Compare commits

..

119 commits
legacy ... dev

Author SHA1 Message Date
8e9c7f120f
fix: Fix regression where Postgres DSN wouldn't be detected
All checks were successful
Release code / build (push) Successful in 5m47s
There was a typo where databaseBackendName == "postgresql" was
"postgres" instead on accident.
2025-03-21 13:39:51 -04:00
75b12f2053
fix: Avoid recreating validator in SSHBackend and SSHAppBackend
All checks were successful
Release code / build (push) Successful in 5m46s
2025-03-21 13:24:59 -04:00
b93bf456b5
fix: Fixes 100% CPU usage in the backend runtime
This makes the backend runtime not constantly search for messages to be
processed. Instead, it only wakes up when it needs to be woken up via
goroutines.
2025-03-21 13:17:08 -04:00
d56a8eb7bf
feature: Change state management from global variables to object passing
This restructures dbcore (now the db package) and jwtcore (now the jwt
package) to use a single struct. There is now a state package, which
contains a struct with the full application state.

After this, instead of initializing the API routes directly in the main
function, the state object gets passed, and the API routes get
initialized with their accompanying code.

One fix done to reduce memory usage and increase speed is that the
validator object is now persistent across requests, instead of
recreating it each time. This should speed things up slightly, and
improve memory usage.

One additional chore done is that the database models have been moved to
be a seperate file from the DB initialization itself.
2025-03-21 12:59:51 -04:00
71d53990de
chore: Fix sample code to remove the deprecated LOM, and add JWT secrets 2025-03-18 20:51:01 -04:00
1cefe64f88
chore: Remove Node.JS from the nix shell 2025-03-18 20:36:20 -04:00
83f80af405 chore: Delete unmaintained CHANGELOG 2025-03-19 00:34:36 +00:00
7dee159d5f
chore: Change license name 2025-03-18 20:33:59 -04:00
17b10c9b19
fix: Add system to detect duplicate running remote processes and kill them accordingly
All checks were successful
Release code / build (push) Successful in 6m3s
2025-03-18 20:31:28 -04:00
5c503f0421
fix: Make logging options more clear for the backend runtime's backend logs 2025-03-18 20:27:40 -04:00
959718163e
fix: Fix disconnect handler not working in production
All checks were successful
Release code / build (push) Successful in 8m28s
2025-03-16 21:34:20 -04:00
24b165c9bb Merge pull request 'Add new backend callled SSHAppBackend.' (#10) from sshappbackend into dev
All checks were successful
Release code / build (push) Successful in 4m46s
Reviewed-on: #10
2025-02-20 14:13:12 +00:00
34b605c1b1
feature: Adds API manifest definitions, and implement GetAllClientConnections()
Some checks failed
Release code / build (push) Has been cancelled
2025-02-20 09:11:28 -05:00
f8a4fe00a0
feature: Adds basic UDP support. 2025-02-19 07:58:42 -05:00
15176831e6
feature: Adds basic TCP support for SSHAppBackend. 2025-02-18 13:15:09 -05:00
432d457ad7
feature: Adds remote implementation of code. 2025-02-16 21:51:33 -05:00
cf90ddb104
chore: Strip unneeded components from code. 2025-02-16 19:12:17 -05:00
62cc8b39ad
chore: Cleanup code by switching to type switching instead of string switching. 2025-02-16 18:11:01 -05:00
17e1491f96
feature: Adds basic data command support. 2025-02-16 15:02:50 -05:00
ede4d528aa
feature: Adds basic backend starting for sshappbackend. 2025-01-27 07:36:29 -05:00
a35602a6f2
chore: Initialize sshappbackend. 2025-01-24 13:26:25 -05:00
4101ce7007
Revert "chore: Delete old commonbackend code."
Wrong branch oops

This reverts commit 737ba2887f.
2025-01-10 20:44:02 -05:00
737ba2887f
chore: Delete old commonbackend code. 2025-01-10 20:34:50 -05:00
48adfc88db
fix: Fixes performance regression introduced in 4cb648cd66 / v2.1.0. (closes #7)
All checks were successful
Release code / build (push) Successful in 5m43s
2025-01-10 16:37:38 -05:00
0efda4b283
feature: Add profiling documentation for backends based on BackendUtil. 2025-01-10 16:23:26 -05:00
356cfb8dca
fix: Fixes action workflows.
All checks were successful
Release code / build (push) Successful in 5m33s
2025-01-09 08:00:37 -05:00
ea0a953b0e
chore: Remove unneeded debug messages.
Some checks failed
Release code / build (push) Failing after 3m20s
2025-01-09 07:50:32 -05:00
3429f2cd37
chore: Reword restart notification message. 2025-01-09 07:39:35 -05:00
a3519220be
fix: Adds automatic restarting upon failure for SSH. 2025-01-09 07:22:12 -05:00
4cb648cd66
fix: Fixes errors showing for no reason. 2025-01-08 12:38:10 -05:00
7837334361
fix: Fixes various crashes. 2025-01-08 11:40:42 -05:00
e456de9802 Merge pull request 'Makes backend infrastructure more stable, concurrent, and fault tolerant' (#6) from backend-runtime-stability-fixes into dev
Reviewed-on: #6
2025-01-08 16:23:35 +00:00
f24daabe45
Reapply "feature: Adds semi-broken stability improvements into the runtime environment."
This reverts commit 157e1c8712.
2025-01-08 11:23:05 -05:00
f8d32fb1c6
fix: Fixes more instability issues. :) 2025-01-08 09:12:48 -05:00
1e1a330a4b
feature: Refactors backend runtime's communication mechanism to be more stable. 2025-01-06 01:24:11 -05:00
93f2f9cbee
fix: Adds missing backend status command implementations. 2025-01-06 00:09:14 -05:00
157e1c8712
Revert "feature: Adds semi-broken stability improvements into the runtime environment."
This wasn't meant to be pushed to dev.

This reverts commit 605ad31dd6.
2025-01-06 00:04:35 -05:00
605ad31dd6
feature: Adds semi-broken stability improvements into the runtime environment. 2025-01-05 23:45:44 -05:00
f505ff6605
fix: Fixes SQLite not working with Docker. 2025-01-05 20:51:30 -05:00
843cd34785
chore: Adds login and user creation support to the API. 2025-01-05 20:51:06 -05:00
96833b238b
chore: Tidies Go module files. 2024-12-28 17:23:25 -05:00
4ca2c809c9
chore: Remove old TypeScript git hooks. 2024-12-28 16:00:37 -05:00
aaacdfd5f4
chore: Remove all legacy code. 2024-12-28 15:43:21 -05:00
fd4d6bfd65
fix: Fixes rare errors regarding Nix shell not finding the initialization script. 2024-12-28 15:41:05 -05:00
49db323e81
chore: Prepare for frontend support by moving the Go module files.
This moves the Go module files to the root of the project and fixes
all of the imports.
2024-12-28 15:37:32 -05:00
201007f7a0
chore: Make lookup fields in the API omit when they're empty.
This makes the API more accurate to the previous API's responses.
2024-12-28 15:26:48 -05:00
be92c5a569
chore: Slims up shell.nix. 2024-12-28 12:11:08 -05:00
c55510eb04
fix: Fixes migration code incorrectly decoding bcrypt basswords as hex.
All checks were successful
Release code / build (push) Successful in 11m56s
2024-12-27 09:10:17 -05:00
538c5b6c51
chore: Adds "day-one"/v2.0.1 bug fixes.
All checks were successful
Release code / build (push) Successful in 11m41s
This fixes database error reporting, as well as majorly fixes users
not being able to authenticate to the API if you used the automated
migration setup, as the password would remain in hex encoding.

We now decode the hexadecimal and then change it to the far more
compact base64 encoding before adding it to the database. This should
fix login, and not cause 500 Interal Server Errors anymore.

Sorry folks!
2024-12-27 00:10:13 -05:00
d334878599
feature: Fixes all backup and API related things to make everything work in production.
All checks were successful
Release code / build (push) Successful in 14m13s
2024-12-26 22:54:05 -05:00
c2eb2d15aa
fix: Fixes migration Dockerfile. 2024-12-26 21:56:47 -05:00
0bc41c430a
fix: Fixes PostgreSQL database deletion.
All checks were successful
Release code / build (push) Successful in 10m1s
2024-12-26 18:26:26 -05:00
e056911af4
feature: Implements backup code.
All checks were successful
Release code / build (push) Successful in 9m35s
2024-12-26 15:14:26 -05:00
862f307e56
fix: Fixes documentation and migration code.
All checks were successful
Release code / build (push) Successful in 9m48s
2024-12-26 14:59:01 -05:00
84e1a437a4
chore: Adds documentation. 2024-12-26 14:37:38 -05:00
c4c5e1cd16
fix: Change packages server to GitHub.
All checks were successful
Release code / build (push) Successful in 12m43s
2024-12-25 20:19:01 -05:00
51ebfe46d3
fix: Attempts to fix workflow to allow for uploading packages.
Some checks failed
Release code / build (push) Failing after 8m38s
2024-12-25 20:04:45 -05:00
4a46b5aca0
feature: Adds migration Docker image. 2024-12-25 20:00:16 -05:00
217e73d9ec
feature: Adds backup importing support. 2024-12-25 19:21:22 -05:00
ed90f66b2b
fix: Reorders container authentication, and removes custom permissions.
Some checks failed
Release code / build (push) Failing after 6m8s
2024-12-24 13:20:47 -05:00
50281df8d0
fix: Finally fixes TLS.
Some checks failed
Release code / build (push) Failing after 6m18s
2024-12-24 13:02:12 -05:00
0515ffe5da
fix: Patches TLS certificates.
Some checks failed
Release code / build (push) Has been cancelled
2024-12-24 12:41:11 -05:00
6fb4a9b5c1
hack: Reorder steps in action.
Some checks failed
Release code / build (push) Failing after 1m46s
2024-12-24 12:37:32 -05:00
2e6e8d38dd
fix: Switches back to using local network communication inside action.
Some checks failed
Release code / build (push) Failing after 1m49s
2024-12-24 12:27:02 -05:00
65ccd716ff
fix: Try more shenanigans to try to get actions working.
Some checks failed
Release code / build (push) Failing after 1m52s
2024-12-24 12:24:16 -05:00
9fff3be650
chore: Fixes actions.
Some checks failed
Release code / build (push) Failing after 1m51s
2024-12-24 12:20:38 -05:00
d76e32b2c5
fix: Moves away from rootless dind.
Some checks failed
Release code / build (push) Has been cancelled
2024-12-24 12:01:22 -05:00
5efb75d49d
fix: Changes Docker image and switches back to using ports.
Some checks failed
Release code / build (push) Has been cancelled
2024-12-24 11:38:33 -05:00
8527354423
fix: Attempts to use local Docker networking instead of exposed ports.
Some checks failed
Release code / build (push) Failing after 1m44s
2024-12-24 11:18:02 -05:00
fc0adcc663
fix: Changes port in docker context to hopefully finally fix building.
Some checks failed
Release code / build (push) Failing after 1m53s
2024-12-24 11:12:53 -05:00
e69b1cd7db
chore: Change port.
Some checks failed
Release code / build (push) Failing after 1m44s
2024-12-24 11:10:12 -05:00
4014188ad1
fix: Changes /var/run/docker.sock to TCP port 2375 instead.
Some checks failed
Release code / build (push) Failing after 2s
2024-12-24 11:05:43 -05:00
32bf007b60
fix: Fixes actions.
Some checks failed
Release code / build (push) Failing after 1m56s
2024-12-24 10:56:34 -05:00
622e8bd286
fix: Adds docker-in-docker to attempt to get services running correctly.
Some checks failed
Release code / build (push) Failing after 55s
2024-12-24 10:54:25 -05:00
b22fd417b6
fix: Attempts to add Docker installation.
Some checks failed
Release code / build (push) Failing after 2m3s
2024-12-24 10:14:24 -05:00
9d90477ffb
chore: Fix actions.
Some checks failed
Release code / build (push) Failing after 1m57s
2024-12-24 10:08:23 -05:00
899258ef7f
feature: Adds LOM support.
Some checks failed
Release code / build (push) Has been cancelled
2024-12-24 09:54:18 -05:00
7879c65e25
feature: Adds building workflows. 2024-12-24 09:52:06 -05:00
b0b11413fb
feature: Adds production support. 2024-12-23 22:37:28 -05:00
a8bc56ccb5
chore: Delete old backend legacy code. 2024-12-23 22:07:26 -05:00
5c45533371
feature: Adds autostart support. 2024-12-23 22:03:59 -05:00
8ba0424512
fix: Fixes getting inbound connections. 2024-12-23 21:45:26 -05:00
bcf97fde6d
fix: Fixes API routes. 2024-12-23 20:47:01 -05:00
fe8980b265
fix: Updates Lookup route. 2024-12-23 19:59:38 -05:00
imterah
495dff3de1
fix: Fixes API routing for connections. 2024-12-23 19:57:39 -05:00
af6ee6ab66
Merge remote-tracking branch 'origin/dev' into dev 2024-12-23 19:04:59 -05:00
imterah
5495fc4ae2
feature: Implement connections API. 2024-12-23 19:00:46 -05:00
d73380e647
add(api): all forward api endpoints except connections 2024-12-23 19:00:43 -05:00
5ad69f6bbe
connections file (not finished) (stupid) 2024-12-23 17:22:14 -05:00
greysoh
216936fe50
feature: Adds backend lookup support. 2024-12-23 16:47:35 -05:00
greysoh
3484760f41
feature: Adds backend removal support. 2024-12-23 16:18:35 -05:00
greysoh
0b73b4aa47
feature: Adds backend system and basic API.
This adds the backend API, as well as backend infrastructure, including
autostarting and basic communication between the Goroutine + Application.
2024-12-23 15:52:16 -05:00
greysoh
611d7f24f8
feature: Adds user lookup support. 2024-12-22 13:58:18 -05:00
greysoh
cee4e62f53
feature: Adds user deletion support. 2024-12-22 12:36:55 -05:00
greysoh
37d0d41570
feature: Adds API refresh support. 2024-12-22 11:36:15 -05:00
greysoh
7d5db06c7b
fix: Fixes vscode settings. 2024-12-22 11:35:57 -05:00
greysoh
67f67007d9
chore: Switches backend parameters to use pointers instead. 2024-12-22 11:08:09 -05:00
greysoh
bbad26b686
feature: Adds login support. 2024-12-22 11:07:38 -05:00
greysoh
af37abf075
fix: Fixes initializing script for backend-legacy. 2024-12-22 10:31:38 -05:00
imterah
1237a31f8b
chore: Adds user creation route. 2024-12-22 00:56:15 -05:00
c7b71e754f fix: Fixes alt text to be more descriptive and removes extra new line. 2024-12-21 23:34:13 +00:00
imterah
d25da9091e
chore: Restructure files. 2024-12-21 18:27:40 -05:00
imterah
559588f726
chore: Center slogan. 2024-12-21 18:11:05 -05:00
imterah
f9d648256a
chore: Reword and rename things. 2024-12-21 18:07:53 -05:00
greysoh
46178f3482
feature: Adds ExportDBContent tool for NextNet. 2024-12-02 23:58:56 -05:00
greysoh
28ed6ddfd7
feature: Gets initial SSH backend working (this time with the rest of the code). 2024-12-02 19:15:43 -05:00
greysoh
cc31bb2ad5
chore: Gets initial SSH backend working. 2024-12-02 19:11:16 -05:00
greysoh
b30d8150f3
chore: Rename structs to be more clear. 2024-12-02 16:38:41 -05:00
greysoh
0b6e40a944
feature: Adds remaining commands. 2024-12-02 16:06:08 -05:00
imterah
889be65392
feature: Finish up SSH backend code. 2024-12-02 09:09:06 -05:00
imterah
64afe992b8
fix: Fixes package lockfiles being corrupt for some reason. 2024-12-02 07:13:19 -05:00
79389d37b8
ssh backend (mostly) 2024-12-01 23:16:21 -05:00
greysoh
aa16549667
chore: Adds constant definitions. 2024-12-01 22:17:27 -05:00
greysoh
3cb9526716
feature: Adds more commands and adds an example. 2024-12-01 22:07:10 -05:00
greysoh
0d0f16174b
chore: Adds base backend interface. 2024-12-01 19:40:23 -05:00
greysoh
c9fed58c6a
chore: Rename actions directory to forgejo-disabled for the time being. 2024-12-01 19:13:18 -05:00
greysoh
28d25c9698
chore: Adds basic commands and protocol.
Some checks failed
CI Testing (API) / test (push) Has been cancelled
2024-12-01 19:11:07 -05:00
greysoh
7b5ec36e61
chore: Update README with new development information.
Some checks failed
CI Testing (API) / test (push) Waiting to run
CI Testing (LOM) / test (push) Has been cancelled
2024-12-01 13:08:38 -05:00
greysoh
0965c56547
chore: Bumps dependencies, rewrites development environment system. 2024-12-01 13:06:28 -05:00
180 changed files with 11432 additions and 11388 deletions

View file

@ -13,16 +13,14 @@
},
// run arguments passed to docker
"runArgs": [
"--security-opt", "label=disable"
],
"runArgs": ["--security-opt", "label=disable"],
"containerEnv": {
// extensions to preload before other extensions
// extensions to preload before other extensions
"PRELOAD_EXTENSIONS": "arrterian.nix-env-selector"
},
// disable command overriding and updating remote user ID
// disable command overriding and updating remote user ID
"overrideCommand": false,
"userEnvProbe": "loginShell",
"updateRemoteUserUID": false,
@ -31,18 +29,14 @@
"onCreateCommand": "nix-shell --command 'echo done building nix dev environment'",
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
3000
],
"forwardPorts": [8000],
"customizations": {
"vscode": {
"extensions": [
"arrterian.nix-env-selector"
]
"extensions": ["arrterian.nix-env-selector"]
}
}
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "go version",
}
}

View file

@ -0,0 +1,43 @@
name: Release code
on:
push:
tags:
- "**"
jobs:
build:
runs-on: docker
services:
dind:
image: docker:dind
env:
DOCKER_TLS_CERTDIR: ""
steps:
- name: Checkout code using Git
uses: actions/checkout@main
- name: Install Docker
run: |
apt update
apt-get install -y docker.io
docker context create forgejo --docker host=tcp://dind:2375
docker context use forgejo
- name: Log in to our container registry
uses: https://github.com/docker/login-action@v3
with:
registry: ghcr.io
username: imterah
password: ${{secrets.ACTIONS_PACKAGES_DEPL_KEY}}
- name: Build Docker image
run: |
docker build . --tag ghcr.io/imterah/hermes:$GITHUB_REF_NAME
- name: Upload Docker image
run: |
docker tag ghcr.io/imterah/hermes:$GITHUB_REF_NAME ghcr.io/imterah/hermes:latest
docker push ghcr.io/imterah/hermes:$GITHUB_REF_NAME
docker push ghcr.io/imterah/hermes:latest

View file

@ -1,2 +0,0 @@
[core]
hooksPath = .githooks/

View file

@ -1,18 +0,0 @@
#!/usr/bin/env bash
shopt -s globstar
set -e
ROOT="$(git rev-parse --show-toplevel)"
pushd $ROOT/api
npx eslint src
popd
pushd $ROOT/lom
npx eslint src
popd
# Formatting step
$ROOT/api/node_modules/.bin/prettier --ignore-unknown --write $ROOT/{api,lom}/{eslint.config.js,src/**/*.ts}
git update-index --again
exit 0

16
.github/labeler.yml vendored
View file

@ -1,16 +0,0 @@
modifies labeler:
- .github/labeler.yml
modifies ci:
- .github/workflows/*.yml
modifies docker:
- '**/Dockerfile'
- '**/docker-compose.yml'
- '**/prod-docker.env'
modifies api:
- api/**/*
modifies lom:
- lom/**/*
modifies gui:
- gui/**/*
modifies nix:
- '**/*.nix'

View file

@ -1,61 +0,0 @@
name: CI Testing (API)
on:
pull_request:
paths:
- "api/**"
push:
paths:
- "api/**"
defaults:
run:
working-directory: api
env:
DATABASE_URL: "postgresql://nextnet:nextnet@localhost:5432/nextnet?schema=nextnet"
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: nextnet
POSTGRES_USER: nextnet
POSTGRES_DB: nextnet
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code using Git
uses: actions/checkout@main
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Install dependencies
run: npm install --save-dev
- name: Install prisma
run: npx prisma migrate dev
- name: Build source
run: npm run build
- name: Run eslint
run: npx eslint src
- name: Run prettier to verify if we're formatted or not
uses: creyD/prettier_action@v4.3
with:
dry: true

View file

@ -1,13 +0,0 @@
name: Label Issues / Pull Requests
on: [pull_request_target]
jobs:
label:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/labeler@v4
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"

View file

@ -1,40 +0,0 @@
name: CI Testing (LOM)
on:
pull_request:
paths:
- "lom/**"
push:
paths:
- "lom/**"
defaults:
run:
working-directory: lom
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code using Git
uses: actions/checkout@main
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Install dependencies
run: npm install --save-dev
- name: Build source
run: npm run build
- name: Run eslint
run: npx eslint src
- name: Run prettier to verify if we're formatted or not
uses: creyD/prettier_action@v4.3
with:
dry: true

View file

@ -1,110 +0,0 @@
name: Release code
on:
pull_request:
types:
- closed
paths:
- VERSION
workflow_dispatch: null
push:
branches: dev
paths:
- VERSION
tags-ignore:
- '*'
jobs:
build:
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
packages: write
contents: write
steps:
- name: Checkout code using Git
uses: actions/checkout@main
- name: Get version information
id: get_version
run: echo "version=v$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Make tag on Git
uses: mathieudutour/github-tag-action@v6.2
with:
github_token: ${{secrets.GITHUB_TOKEN}}
custom_tag: ${{ steps.get_version.outputs.version }}
tag_prefix: ''
- name: Get previous Git tag
id: get_prev_version
run: echo "version=$(git describe --abbrev=0 --tags "$(git describe --abbrev=0 --tags)~") >> $GITHUB_OUTPUT"
- name: Make sparse changelog (1/2)
uses: heinrichreimer/github-changelog-generator-action@v2.1.1
with:
token: '${{secrets.GITHUB_TOKEN}}'
issues: true
issuesWoLabels: true
pullRequests: true
prWoLabels: true
sinceTag: ${{steps.get_prev_version.outputs.version}}
addSections: >-
{"documentation":{"prefix":"**Documentation:**","labels":["documentation"]}}
- name: Make sparse changelog (2/2)
run: |
mv CHANGELOG.md SPARSE_CHANGELOG.md
- name: Make full changelog
uses: heinrichreimer/github-changelog-generator-action@v2.1.1
with:
token: '${{secrets.GITHUB_TOKEN}}'
issues: true
issuesWoLabels: true
pullRequests: true
prWoLabels: true
addSections: >-
{"documentation":{"prefix":"**Documentation:**","labels":["documentation"]}}
- name: Update changelog
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: >-
chore: Update changelog for tag ${{steps.get_version.outputs.version}}.
file_pattern: CHANGELOG.md
- name: Release on GitHub
uses: softprops/action-gh-release@v2
with:
body_path: SPARSE_CHANGELOG.md
files: |
LICENSE
docker-compose.yml
repository: imterah/nextnet
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ steps.get_version.outputs.version }}
- name: Log in to GitHub container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Build all docker images
run: |
docker build ./api --tag ghcr.io/imterah/nextnet:$(cat VERSION)
docker build ./lom --tag ghcr.io/imterah/nextnet-lom:$(cat VERSION)
- name: Publish all docker images
run: |
docker tag ghcr.io/imterah/nextnet:$(cat VERSION) ghcr.io/imterah/nextnet:latest
docker push ghcr.io/imterah/nextnet:$(cat VERSION)
docker push ghcr.io/imterah/nextnet:latest
docker tag ghcr.io/imterah/nextnet-lom:$(cat VERSION) ghcr.io/imterah/nextnet-lom:latest
docker push ghcr.io/imterah/nextnet-lom:$(cat VERSION)
docker push ghcr.io/imterah/nextnet-lom:latest

15
.gitignore vendored
View file

@ -1,5 +1,14 @@
# LOM
lom/keys
# Go artifacts
backend/api/api
backend/sshbackend/sshbackend
backend/dummybackend/dummybackend
backend/sshappbackend/local-code/remote-bin
backend/sshappbackend/local-code/sshappbackend
backend/externalbackendlauncher/externalbackendlauncher
frontend/frontend
# Backup artifacts
*.json.gz
# Output
out
@ -135,4 +144,4 @@ dist
.yarn/install-state.gz
.pnp.*
.tmp
.tmp

View file

@ -1,16 +0,0 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": false,
"printWidth": 80,
"proseWrap": "always",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false
}

View file

@ -1,10 +1,3 @@
{
"recommendations": [
"bbenoist.Nix",
"Prisma.prisma",
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml",
"dustypomerleau.rust-syntax",
]
}
"recommendations": ["bbenoist.Nix", "Prisma.prisma", "golang.go"]
}

View file

@ -11,5 +11,8 @@
"editor.tabSize": 2
},
"rust-analyzer.linkedProjects": ["./gui/Cargo.toml"]
}
"[go]": {
"editor.insertSpaces": false,
"editor.tabSize": 4
}
}

View file

@ -1,49 +0,0 @@
# Changelog
## [v1.1.2](https://github.com/imterah/nextnet/tree/v1.1.2) (2024-09-29)
## [v1.1.1](https://github.com/imterah/nextnet/tree/v1.1.1) (2024-09-29)
## [v1.1.0](https://github.com/imterah/nextnet/tree/v1.1.0) (2024-09-22)
**Fixed bugs:**
- Desktop app fails to build on macOS w/ `nix-shell` [\#1](https://github.com/imterah/nextnet/issues/1)
**Merged pull requests:**
- chore\(deps\): bump find-my-way from 8.1.0 to 8.2.2 in /api [\#17](https://github.com/imterah/nextnet/pull/17)
- chore\(deps\): bump axios from 1.6.8 to 1.7.4 in /lom [\#16](https://github.com/imterah/nextnet/pull/16)
- chore\(deps\): bump micromatch from 4.0.5 to 4.0.8 in /lom [\#15](https://github.com/imterah/nextnet/pull/15)
- chore\(deps\): bump braces from 3.0.2 to 3.0.3 in /lom [\#13](https://github.com/imterah/nextnet/pull/13)
- chore\(deps-dev\): bump braces from 3.0.2 to 3.0.3 in /api [\#11](https://github.com/imterah/nextnet/pull/11)
- chore\(deps\): bump ws from 8.17.0 to 8.17.1 in /api [\#10](https://github.com/imterah/nextnet/pull/10)
## [v1.0.1](https://github.com/imterah/nextnet/tree/v1.0.1) (2024-05-18)
**Merged pull requests:**
- Adds public key authentication [\#6](https://github.com/imterah/nextnet/pull/6)
- Add support for eslint [\#5](https://github.com/imterah/nextnet/pull/5)
## [v1.0.0](https://github.com/imterah/nextnet/tree/v1.0.0) (2024-05-10)
## [v0.1.1](https://github.com/imterah/nextnet/tree/v0.1.1) (2024-05-05)
## [v0.1.0](https://github.com/imterah/nextnet/tree/v0.1.0) (2024-05-05)
**Implemented enhancements:**
- \(potentially\) Migrate nix shell to nix flake [\#2](https://github.com/imterah/nextnet/issues/2)
**Closed issues:**
- add precommit hooks [\#3](https://github.com/imterah/nextnet/issues/3)
**Merged pull requests:**
- Reimplements PassyFire as a possible backend [\#4](https://github.com/imterah/nextnet/pull/4)
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*

11
Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM golang:latest AS build
WORKDIR /build
COPY . /build
RUN cd backend; bash build.sh
FROM busybox:stable-glibc AS run
WORKDIR /app
COPY --from=build /build/backend/backends.prod.json /app/backends.json
COPY --from=build /build/backend/api/api /app/hermes
COPY --from=build /build/backend/sshbackend/sshbackend /app/sshbackend
COPY --from=build /build/backend/sshappbackend/local-code/sshappbackend /app/sshappbackend
ENTRYPOINT ["/app/hermes", "--backends-path", "/app/backends.json"]

View file

@ -1,6 +1,6 @@
BSD 3-Clause License
Copyright (c) 2024, Greyson
Copyright (c) 2024, Tera
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

View file

@ -1,65 +1,46 @@
<h1 align="center">NextNet</h1>
<h1 align="center">Hermes</h1>
<p align="center">
<a href="https://builtwithnix.org"><img src="https://builtwithnix.org/badge.svg" alt="built with nix" height="20"/></a>
<img src="https://img.shields.io/github/license/greysoh/nextnet" alt="License Badge"/>
<img src="https://img.shields.io/badge/built-with_docker-purple" alt="Docker Badge"/>
<img src="https://img.shields.io/badge/built-with_Go-blue" alt="Golang Badge">
<img src="https://img.shields.io/badge/license-BSD--3--Clause-green" alt="License Badge (licensed under BSD-3-Clause)"/>
</p>
<br>
**NextNet is a dashboard to manage portforwarding technologies.**
<h2 align="center">⚠️ Deprecation Warning ⚠️</h2>
NextNet in its current state is going to be deprecated and slowly rewritten into Go, with a more modular approach that's
similar to Tor's modular pluggable transports system.
**What will change for end users?** There will be an export feature added to the legacy codebase, and an import feature
for the new codebase. You will need to upgrade to intermediate versions that allow for this. After this one-time process
is done, you won't have to run it ever again.
This will also lead for performance benefits (hopefully). The flagship backend for now is SSH. The implementation for that
is node-ssh, which... isn't the fastest thing ever, since it reimplements the SSH protocol in pure JS.
**What will change for developers?** The LOM and API will be rewritten in Go slowly. See issue [#1](https://git.greysoh.dev/imterah/nextnet/issues/1) on my Git server as a tracking issue for this. Except for new Go code and migration code, this project is
on a feature freeze effective *immediately*.
The code for this is on branch `dev`, like usual. However, this warning and all the old code is on the `legacy` branch,
which is currently the default branch. If you're a developer, be careful and make sure you're commiting to the right branch,
*especially* if you've just cloned the code.
Additionally, the Git server has moved from `https://github.com/imterah/nextnet.git` to
`https://git.greysoh.dev/imterah/nextnet.git`. Be sure to update your remotes. All PRs and issues on GitHub *will* be ignored.
<p align="center">
<b>Port forwarding across boundaries.</b>
</p>
<h2 align="center">Local Development</h2>
> [!NOTE]
> Using [nix](https://builtwithnix.org) is recommended. If you're not using Nix, install PostgreSQL, Node.JS, and `lsof`.
> Using [Nix](https://builtwithnix.org) is recommended for the development environment. If you're not using it, install Go. For legacy maintence tasks, install NodeJS.
1. First, check if you have a working Nix environment if you're using Nix.
1. Firstly, check if you have a working Nix environment if you're using Nix.
2. Run `nix-shell`, or alternatively `source init.sh` if you're not using Nix.
2. Secondly, Run `nix-shell`, or alternatively `source init.sh` if you're not using Nix.
<h3 align="center">API Development</h3>
1. After that, run the project in development mode: `npm run dev`.
1. After that, run the backend build script: `./build.sh`.
2. If you want to explore your database, run `npx prisma studio` to open the database editor.
2. Then, go into the `api/` directory, and then start it up: `go run . -b ../backends.dev.json`
<h2 align="center">Production Deployment</h2>
> [!WARNING]
> Deploying using docker compose is the only officially supported deployment method. Here be dragons!
> [!WARNING]
> Deploying using [Docker Compose](https://docs.docker.com/compose/) is the only officially supported deployment method.
1. Copy and change the default password (or username & db name too) from the template file `prod-docker.env`:
```bash
sed "s/POSTGRES_PASSWORD=nextnet/POSTGRES_PASSWORD=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" prod-docker.env > .env
```
```bash
sed -e "s/POSTGRES_PASSWORD=hermes/POSTGRES_PASSWORD=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" -e "s/JWT_SECRET=hermes/JWT_SECRET=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" prod-docker.env > .env
```
2. Build the docker stack: `docker compose --env-file .env up -d`
<h2 align="center">Troubleshooting</h2>
* I'm using the SSH tunneling, and I can't reach any of the tunnels publicly.
This has been moved [here.](docs/troubleshooting.md)
- Be sure to enable GatewayPorts in your sshd config (in `/etc/ssh/sshd_config` on most systems). Also, be sure to check your firewall rules on your system and your network.
<h2 align="center">Documentation</h2>
Go to the `docs/` folder.

View file

@ -1 +0,0 @@
1.1.2

View file

@ -1,17 +0,0 @@
FROM node:20.11.1-bookworm
LABEL org.opencontainers.image.source="https://github.com/greysoh/nextnet"
WORKDIR /app/
COPY src /app/src
COPY prisma /app/prisma
COPY docker-entrypoint.sh /app/
COPY tsconfig.json /app/
COPY package.json /app/
COPY package-lock.json /app/
COPY srcpatch.sh /app/
RUN sh srcpatch.sh
RUN npm install --save-dev
RUN npm run build
RUN rm srcpatch.sh out/**/*.ts out/**/*.map
RUN rm -rf src
RUN npm prune --production
ENTRYPOINT sh docker-entrypoint.sh

View file

@ -1,7 +0,0 @@
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
DATABASE_URL="postgresql://nextnet:nextnet@localhost:5432/nextnet?schema=nextnet"

View file

@ -1,12 +0,0 @@
#!/bin/bash
export NODE_ENV="production"
if [[ "$DATABASE_URL" == "" ]]; then
export DATABASE_URL="postgresql://$POSTGRES_USERNAME:$POSTGRES_PASSWORD@nextnet-postgres:5432/$POSTGRES_DB?schema=nextnet"
fi
echo "Welcome to NextNet."
echo "Running database migrations..."
npx prisma migrate deploy
echo "Starting application..."
npm start

View file

@ -1,19 +0,0 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
globals: globals.node,
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "off",
},
},
];

View file

@ -1,32 +0,0 @@
if [ ! -d ".tmp" ]; then
echo "Please wait while I initialize the backend source for you..."
cp dev.env .env
mkdir .tmp
fi
lsof -i:5432 | grep postgres 2> /dev/null > /dev/null
IS_PG_RUNNING=$?
if [ ! -f ".tmp/ispginit" ]; then
if [[ "$IS_PG_RUNNING" == 0 ]]; then
kill -9 $(lsof -t -i:5432) > /dev/null 2> /dev/null
fi
echo " - Database not initialized! Initializing database..."
mkdir .tmp/pglock
initdb -D .tmp/db
pg_ctl -D .tmp/db -l .tmp/logfile -o "--unix_socket_directories='$PWD/.tmp/pglock/'" start
createdb -h localhost -p 5432 nextnet
psql -h localhost -p 5432 nextnet -c "CREATE ROLE nextnet WITH LOGIN SUPERUSER PASSWORD 'nextnet';"
npm install --save-dev
npx prisma migrate dev
touch .tmp/ispginit
elif [[ "$IS_PG_RUNNING" == 1 ]]; then
pg_ctl -D .tmp/db -l .tmp/logfile -o "--unix_socket_directories='$PWD/.tmp/pglock/'" start
fi
source .env # Make sure we actually load correctly

3187
api/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,38 +0,0 @@
{
"name": "nextnet",
"version": "1.1.2",
"description": "Yet another dashboard to manage portforwarding technologies",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
"start": "cd out && node --enable-source-maps index.js",
"dev": "nodemon --watch src --ext ts,js,mjs,json --exec \"tsc && cd out && node --enable-source-maps index.js\""
},
"keywords": [],
"author": "greysoh",
"license": "BSD-3-Clause",
"devDependencies": {
"@eslint/js": "^9.2.0",
"@types/bcrypt": "^5.0.2",
"@types/node": "^20.12.7",
"@types/ssh2": "^1.15.0",
"@types/ws": "^8.5.10",
"eslint": "^8.57.0",
"globals": "^15.2.0",
"nodemon": "^3.0.3",
"pino-pretty": "^11.0.0",
"prettier": "^3.2.5",
"prisma": "^5.13.0",
"typescript": "^5.3.3",
"typescript-eslint": "^7.8.0"
},
"dependencies": {
"@fastify/websocket": "^10.0.1",
"@prisma/client": "^5.13.0",
"bcrypt": "^5.1.1",
"fastify": "^4.26.2",
"node-ssh": "^13.2.0"
}
}

View file

@ -1,53 +0,0 @@
-- CreateTable
CREATE TABLE "DesinationProvider" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"backend" TEXT NOT NULL,
"connectionDetails" TEXT NOT NULL,
CONSTRAINT "DesinationProvider_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ForwardRule" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"sourceIP" TEXT NOT NULL,
"sourcePort" INTEGER NOT NULL,
"destIP" TEXT NOT NULL,
"destPort" INTEGER NOT NULL,
"destProviderID" INTEGER NOT NULL,
"enabled" BOOLEAN NOT NULL,
CONSTRAINT "ForwardRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Permission" (
"id" SERIAL NOT NULL,
"permission" TEXT NOT NULL,
"has" BOOLEAN NOT NULL,
"userID" INTEGER NOT NULL,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"password" TEXT NOT NULL,
"rootToken" TEXT,
"isRootServiceAccount" BOOLEAN,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- AddForeignKey
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userID_fkey" FOREIGN KEY ("userID") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -1,8 +0,0 @@
/*
Warnings:
- You are about to drop the column `destIP` on the `ForwardRule` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "ForwardRule" DROP COLUMN "destIP";

View file

@ -1,8 +0,0 @@
/*
Warnings:
- Added the required column `protocol` to the `ForwardRule` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "ForwardRule" ADD COLUMN "protocol" TEXT NOT NULL;

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "username" TEXT;

View file

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View file

@ -1,54 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model DesinationProvider {
id Int @id @default(autoincrement())
name String
description String?
backend String
connectionDetails String
}
model ForwardRule {
id Int @id @default(autoincrement())
name String
description String?
protocol String
sourceIP String
sourcePort Int
destPort Int
destProviderID Int
enabled Boolean
}
model Permission {
id Int @id @default(autoincrement())
permission String
has Boolean
user User @relation(fields: [userID], references: [id])
userID Int
}
model User {
id Int @id @default(autoincrement())
email String @unique
username String? // NOT optional in the API, but just for backwards compat
name String
password String // Will be hashed using bcrypt
rootToken String?
isRootServiceAccount Boolean?
permissions Permission[]
}

View file

@ -1,28 +0,0 @@
meta {
name: Create
type: http
seq: 1
}
post {
url: http://127.0.0.1:3000/api/v1/backends/create
body: json
auth: none
}
body:json {
{
"token": "9d99397be36747b9e6f1858f1efded4756ea5b479fd5c47a6388041eecb44b4958858c6fe15f23a9cf5e9d67f48443c65342e3a69bfde231114df4bb2ab457",
"name": "Passyfire Reimpl",
"description": "PassyFire never dies",
"backend": "passyfire",
"connectionDetails": {
"ip": "127.0.0.1",
"port": 22,
"users": {
"g"
}
}
}
}

View file

@ -1,17 +0,0 @@
meta {
name: Lookup
type: http
seq: 3
}
post {
url: http://127.0.0.1:3000/api/v1/backends/lookup
body: json
auth: none
}
body:json {
{
"token": "7d69814cdada551dd22521ad97b23b22a106278826a2b4e87dd76246594b56f973894e8265437a5d520ed7258d7c856d0d294e89b1de1a98db7fa4a"
}
}

View file

@ -1,23 +0,0 @@
meta {
name: Remove
type: http
seq: 2
}
post {
url: http://127.0.0.1:3000/api/v1/backends/create
body: json
auth: none
}
body:json {
{
"token": "f1b89cc337073476289ade17ffbe7a6419b4bd52aa7ede26114bffd76fa263b5cb1bcaf389462e1d9e7acb7f4b6a7c28152a9cc9af83e3ec862f1892b1",
"name": "PortCopier Route",
"description": "This is a test route for portcopier.",
"backend": "PortCopier",
"connectionDetails": {
"funny": true
}
}
}

View file

@ -1,28 +0,0 @@
meta {
name: Create
type: http
seq: 1
}
post {
url: http://127.0.0.1:3000/api/v1/forward/create
body: json
auth: none
}
body:json {
{
"token": "914abf2223f84375eed884671bfaefd7755d378af496b345f322214e75b51ed4465f11e26c944914c9b4fcc35c53250325fbc6530853ddfed8f72976d6fc5",
"name": "Test Route",
"description": "This is a test route for SSH",
"protocol": "tcp",
"sourceIP": "127.0.0.1",
"sourcePort": "8000",
"destinationPort": "9000",
"providerID": "1"
}
}

View file

@ -1,18 +0,0 @@
meta {
name: Get Inbound Connections
type: http
seq: 6
}
post {
url: http://127.0.0.1:3000/api/v1/forward/connections
body: json
auth: none
}
body:json {
{
"token": "914abf2223f84375eed884671bfaefd7755d378af496b345f322214e75b51ed4465f11e26c944914c9b4fcc35c53250325fbc6530853ddfed8f72976d6fc5",
"id": "1"
}
}

View file

@ -1,18 +0,0 @@
meta {
name: Lookup
type: http
seq: 3
}
post {
url: http://127.0.0.1:3000/api/v1/forward/lookup
body: json
auth: none
}
body:json {
{
"token": "535c80825631c04b9add7a8682e06799d62ba57b5089b557f5bab2183fc9926b187b3b8d96da8ef16c67ec80f2917cf81bc21337f47728534f58ac9c4ed5f3fe",
"protocol": "tcp"
}
}

View file

@ -1,26 +0,0 @@
meta {
name: Remove
type: http
seq: 2
}
post {
url: http://127.0.0.1:3000/api/v1/forward/remove
body: json
auth: none
}
body:json {
{
"token": "f1b89cc337073476289ade17ffbe7a6419b4bd52aa7ede26114bffd76fa263b5cb1bcaf389462e1d9e7acb7f4b6a7c28152a9cc9af83e3ec862f1892b1",
"name": "Test Route",
"description": "This is a test route for portcopier.",
"sourceIP": "127.0.0.1",
"sourcePort": "8000",
"destinationPort": "9000",
"providerID": "1"
}
}

View file

@ -1,18 +0,0 @@
meta {
name: Start
type: http
seq: 4
}
post {
url: http://127.0.0.1:3000/api/v1/forward/start
body: json
auth: none
}
body:json {
{
"token": "914abf2223f84375eed884671bfaefd7755d378af496b345f322214e75b51ed4465f11e26c944914c9b4fcc35c53250325fbc6530853ddfed8f72976d6fc5",
"id": "1"
}
}

View file

@ -1,18 +0,0 @@
meta {
name: Stop
type: http
seq: 5
}
post {
url: http://127.0.0.1:3000/api/v1/forward/stop
body: json
auth: none
}
body:json {
{
"token": "914abf2223f84375eed884671bfaefd7755d378af496b345f322214e75b51ed4465f11e26c944914c9b4fcc35c53250325fbc6530853ddfed8f72976d6fc5",
"id": "1"
}
}

View file

@ -1,18 +0,0 @@
meta {
name: Log In
type: http
seq: 2
}
post {
url: http://127.0.0.1:3000/api/v1/users/login
body: json
auth: none
}
body:json {
{
"email": "me@greysoh.dev",
"password": "password"
}
}

View file

@ -1,18 +0,0 @@
meta {
name: Lookup
type: http
seq: 4
}
post {
url: http://127.0.0.1:3000/api/v1/users/lookup
body: json
auth: none
}
body:json {
{
"token": "5e2cb92a338a832d385790861312eb85d69f46f82317bfa984ac5e3517368ab5a827897b0f9775a9181b02fa3b9cffed7e59e5b3111d5bdc37f729156caf5f",
"name": "Greyson Hofer"
}
}

View file

@ -1,18 +0,0 @@
meta {
name: Remove
type: http
seq: 3
}
post {
url: http://127.0.0.1:3000/api/v1/users/remove
body: json
auth: inherit
}
body:json {
{
"token": "5e2cb92a338a832d385790861312eb85d69f46f82317bfa984ac5e3517368ab5a827897b0f9775a9181b02fa3b9cffed7e59e5b3111d5bdc37f729156caf5f",
"uid": "2"
}
}

View file

@ -1,11 +0,0 @@
meta {
name: Get All Scopes
type: http
seq: 1
}
get {
url: http://127.0.0.1:8080/api/v1/static/getScopes
body: none
auth: none
}

View file

@ -1,17 +0,0 @@
meta {
name: Get Tunnels
type: http
seq: 3
}
post {
url: http://127.0.0.1:8080/api/v1/tunnels
body: json
auth: none
}
body:json {
{
"token": "641d968c3bfdf78f2df86cae106349c4c95a8dd73512ee34b296379b6cd908c87b078f1f674b43c9e3394c8b233840512d88efdecf47dc63be93276f56c"
}
}

View file

@ -1,18 +0,0 @@
meta {
name: Log In
type: http
seq: 2
}
post {
url: http://127.0.0.1:8080/api/v1/users/login
body: json
auth: none
}
body:json {
{
"username": "guest",
"password": "guest"
}
}

View file

@ -1,9 +0,0 @@
{
"version": "1",
"name": "Passyfire Base Routes",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View file

@ -1,77 +0,0 @@
// @eslint-ignore-file
export type ParameterReturnedValue = {
success: boolean;
message?: string;
};
export type ForwardRule = {
sourceIP: string;
sourcePort: number;
destPort: number;
};
export type ConnectedClient = {
ip: string;
port: number;
connectionDetails: ForwardRule;
};
export class BackendBaseClass {
state: "stopped" | "stopping" | "started" | "starting";
clients?: ConnectedClient[]; // Not required to be implemented, but more consistency
logs: string[];
constructor(parameters: string) {
this.logs = [];
this.clients = [];
this.state = "stopped";
}
addConnection(
sourceIP: string,
sourcePort: number,
destPort: number,
protocol: "tcp" | "udp",
): void {}
removeConnection(
sourceIP: string,
sourcePort: number,
destPort: number,
protocol: "tcp" | "udp",
): void {}
async start(): Promise<boolean> {
return true;
}
async stop(): Promise<boolean> {
return true;
}
getAllConnections(): ConnectedClient[] {
if (this.clients == null) return [];
return this.clients;
}
static checkParametersConnection(
sourceIP: string,
sourcePort: number,
destPort: number,
protocol: "tcp" | "udp",
): ParameterReturnedValue {
return {
success: true,
};
}
static checkParametersBackendInstance(data: string): ParameterReturnedValue {
return {
success: true,
};
}
}

View file

@ -1,13 +0,0 @@
import { BackendBaseClass } from "./base.js";
import { PassyFireBackendProvider } from "./passyfire-reimpl/index.js";
import { SSHBackendProvider } from "./ssh.js";
export const backendProviders: Record<string, typeof BackendBaseClass> = {
ssh: SSHBackendProvider,
passyfire: PassyFireBackendProvider,
};
if (process.env.NODE_ENV != "production") {
backendProviders["dummy"] = BackendBaseClass;
}

View file

@ -1,231 +0,0 @@
import fastifyWebsocket from "@fastify/websocket";
import type { FastifyInstance } from "fastify";
import Fastify from "fastify";
import type {
ForwardRule,
ConnectedClient,
ParameterReturnedValue,
BackendBaseClass,
} from "../base.js";
import { generateRandomData } from "../../libs/generateRandom.js";
import { requestHandler } from "./socket.js";
import { route } from "./routes.js";
type BackendProviderUser = {
username: string;
password: string;
};
export type ForwardRuleExt = ForwardRule & {
protocol: "tcp" | "udp";
userConfig: Record<string, string>;
};
export type ConnectedClientExt = ConnectedClient & {
connectionDetails: ForwardRuleExt;
username: string;
};
// Fight me (for better naming)
type BackendParsedProviderString = {
ip: string;
port: number;
publicPort?: number;
isProxied?: boolean;
users: BackendProviderUser[];
};
type LoggedInUser = {
username: string;
token: string;
};
function parseBackendProviderString(data: string): BackendParsedProviderString {
try {
JSON.parse(data);
} catch (e) {
throw new Error("Payload body is not JSON");
}
const jsonData = JSON.parse(data);
if (typeof jsonData.ip != "string")
throw new Error("IP field is not a string");
if (typeof jsonData.port != "number") throw new Error("Port is not a number");
if (
typeof jsonData.publicPort != "undefined" &&
typeof jsonData.publicPort != "number"
)
throw new Error("(optional field) Proxied port is not a number");
if (
typeof jsonData.isProxied != "undefined" &&
typeof jsonData.isProxied != "boolean"
)
throw new Error("(optional field) 'Is proxied' is not a boolean");
if (!Array.isArray(jsonData.users)) throw new Error("Users is not an array");
for (const userIndex in jsonData.users) {
const user = jsonData.users[userIndex];
if (typeof user.username != "string")
throw new Error("Username is not a string, in users array");
if (typeof user.password != "string")
throw new Error("Password is not a string, in users array");
}
return {
ip: jsonData.ip,
port: jsonData.port,
publicPort: jsonData.publicPort,
isProxied: jsonData.isProxied,
users: jsonData.users,
};
}
export class PassyFireBackendProvider implements BackendBaseClass {
state: "stopped" | "stopping" | "started" | "starting";
clients: ConnectedClientExt[];
proxies: ForwardRuleExt[];
users: LoggedInUser[];
logs: string[];
options: BackendParsedProviderString;
fastify: FastifyInstance;
constructor(parameters: string) {
this.logs = [];
this.clients = [];
this.proxies = [];
this.state = "stopped";
this.options = parseBackendProviderString(parameters);
this.users = [];
}
async start(): Promise<boolean> {
this.state = "starting";
this.fastify = Fastify({
logger: true,
trustProxy: this.options.isProxied,
});
await this.fastify.register(fastifyWebsocket);
route(this);
this.fastify.get("/", { websocket: true }, (ws, req) =>
requestHandler(this, ws, req),
);
await this.fastify.listen({
port: this.options.port,
host: this.options.ip,
});
this.state = "started";
return true;
}
async stop(): Promise<boolean> {
await this.fastify.close();
this.users.splice(0, this.users.length);
this.proxies.splice(0, this.proxies.length);
this.clients.splice(0, this.clients.length);
return true;
}
addConnection(
sourceIP: string,
sourcePort: number,
destPort: number,
protocol: "tcp" | "udp",
): void {
const proxy: ForwardRuleExt = {
sourceIP,
sourcePort,
destPort,
protocol,
userConfig: {},
};
for (const user of this.options.users) {
proxy.userConfig[user.username] = generateRandomData();
}
this.proxies.push(proxy);
}
removeConnection(
sourceIP: string,
sourcePort: number,
destPort: number,
protocol: "tcp" | "udp",
): void {
const connectionCheck = PassyFireBackendProvider.checkParametersConnection(
sourceIP,
sourcePort,
destPort,
protocol,
);
if (!connectionCheck.success) throw new Error(connectionCheck.message);
const foundProxyEntry = this.proxies.find(
i =>
i.sourceIP == sourceIP &&
i.sourcePort == sourcePort &&
i.destPort == destPort,
);
if (!foundProxyEntry) return;
this.proxies.splice(this.proxies.indexOf(foundProxyEntry), 1);
return;
}
getAllConnections(): ConnectedClient[] {
if (this.clients == null) return [];
return this.clients;
}
static checkParametersConnection(
sourceIP: string,
sourcePort: number,
destPort: number,
protocol: "tcp" | "udp",
): ParameterReturnedValue {
return {
success: true,
};
}
static checkParametersBackendInstance(data: string): ParameterReturnedValue {
try {
parseBackendProviderString(data);
// @ts-expect-error: We write the function, and we know we're returning an error
} catch (e: Error) {
return {
success: false,
message: e.toString(),
};
}
return {
success: true,
};
}
}

View file

@ -1,158 +0,0 @@
import { generateRandomData } from "../../libs/generateRandom.js";
import type { PassyFireBackendProvider } from "./index.js";
export function route(instance: PassyFireBackendProvider) {
const { fastify } = instance;
const proxiedPort: number = instance.options.publicPort ?? 443;
const unsupportedSpoofedRoutes: string[] = [
"/api/v1/tunnels/add",
"/api/v1/tunnels/edit",
"/api/v1/tunnels/remove",
// TODO (greysoh): Should we implement these? We have these for internal reasons. We could expose these /shrug
"/api/v1/tunnels/start",
"/api/v1/tunnels/stop",
// Same scenario for this API.
"/api/v1/users",
"/api/v1/users/add",
"/api/v1/users/remove",
"/api/v1/users/enable",
"/api/v1/users/disable",
];
fastify.get("/api/v1/static/getScopes", () => {
return {
success: true,
data: {
users: {
add: true,
remove: true,
get: true,
getPasswords: true,
},
routes: {
add: true,
remove: true,
start: true,
stop: true,
get: true,
getPasswords: true,
},
},
};
});
for (const spoofedRoute of unsupportedSpoofedRoutes) {
fastify.post(spoofedRoute, (req, res) => {
return res.status(403).send({
error: "Invalid scope(s)",
});
});
}
fastify.post(
"/api/v1/users/login",
{
schema: {
body: {
type: "object",
required: ["username", "password"],
properties: {
username: { type: "string" },
password: { type: "string" },
},
},
},
},
(req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
username: string;
password: string;
} = req.body;
if (
!instance.options.users.find(
i => i.username == body.username && i.password == body.password,
)
) {
return res.status(403).send({
error: "Invalid username/password.",
});
}
const token = generateRandomData();
instance.users.push({
username: body.username,
token,
});
return {
success: true,
data: {
token,
},
};
},
);
fastify.post(
"/api/v1/tunnels",
{
schema: {
body: {
type: "object",
required: ["token"],
properties: {
token: { type: "string" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
} = req.body;
const userData = instance.users.find(user => user.token == body.token);
if (!userData)
return res.status(403).send({
error: "Invalid token",
});
// const host = req.hostname.substring(0, req.hostname.indexOf(":"));
const unparsedPort = req.hostname.substring(
req.hostname.indexOf(":") + 1,
);
// @ts-expect-error: parseInt(...) can take a number just fine, at least in Node.JS
const port = parseInt(unparsedPort == "" ? proxiedPort : unparsedPort);
// This protocol is so confusing. I'm sorry.
res.send({
success: true,
data: instance.proxies.map(proxy => ({
proxyUrlSettings: {
host: "sameAs", // Makes pfC work (this is by design apparently)
port,
protocol: proxy.protocol.toUpperCase(),
},
dest: `${proxy.sourceIP}:${proxy.destPort}`,
name: `${proxy.protocol.toUpperCase()} on ::${proxy.sourcePort} -> ::${proxy.destPort}`,
passwords: [proxy.userConfig[userData.username]],
running: true,
})),
});
},
);
}

View file

@ -1,140 +0,0 @@
import dgram from "node:dgram";
import net from "node:net";
import type { WebSocket } from "@fastify/websocket";
import type { FastifyRequest } from "fastify";
import type { ConnectedClientExt, PassyFireBackendProvider } from "./index.js";
// This code sucks because this protocol sucks BUUUT it works, and I don't wanna reinvent
// the gosh darn wheel for (almost) no reason
function authenticateSocket(
instance: PassyFireBackendProvider,
ws: WebSocket,
message: string,
state: ConnectedClientExt,
): boolean {
if (!message.startsWith("Accept: ")) {
ws.send("400 Bad Request");
return false;
}
const type = message.substring(message.indexOf(":") + 1).trim();
if (type == "IsPassedWS") {
ws.send("AcceptResponse IsPassedWS: true");
} else if (type.startsWith("Bearer")) {
const token = type.substring(type.indexOf("Bearer") + 7);
for (const proxy of instance.proxies) {
for (const username of Object.keys(proxy.userConfig)) {
const currentToken = proxy.userConfig[username];
if (token == currentToken) {
state.connectionDetails = proxy;
state.username = username;
}
}
}
if (state.connectionDetails && state.username) {
ws.send("AcceptResponse Bearer: true");
return true;
} else {
ws.send("AcceptResponse Bearer: false");
}
}
return false;
}
export function requestHandler(
instance: PassyFireBackendProvider,
ws: WebSocket,
req: FastifyRequest,
) {
let state: "authentication" | "data" = "authentication";
let socket: dgram.Socket | net.Socket | undefined;
// @ts-expect-error: FIXME because this is a mess
const connectedClient: ConnectedClientExt = {};
ws.on("close", () => {
instance.clients.splice(
instance.clients.indexOf(connectedClient as ConnectedClientExt),
1,
);
});
ws.on("message", (rawData: ArrayBuffer) => {
if (state == "authentication") {
const data = rawData.toString();
if (authenticateSocket(instance, ws, data, connectedClient)) {
ws.send("AcceptResponse Bearer: true");
connectedClient.ip = req.ip;
connectedClient.port = req.socket.remotePort ?? -1;
instance.clients.push(connectedClient);
if (connectedClient.connectionDetails.protocol == "tcp") {
socket = new net.Socket();
socket.connect(
connectedClient.connectionDetails.sourcePort,
connectedClient.connectionDetails.sourceIP,
);
socket.on("connect", () => {
state = "data";
ws.send("InitProxy: Attempting to connect");
ws.send("InitProxy: Connected");
});
socket.on("data", data => {
ws.send(data);
});
} else if (connectedClient.connectionDetails.protocol == "udp") {
socket = dgram.createSocket("udp4");
state = "data";
ws.send("InitProxy: Attempting to connect");
ws.send("InitProxy: Connected");
socket.on("message", (data, rinfo) => {
if (
rinfo.address != connectedClient.connectionDetails.sourceIP ||
rinfo.port != connectedClient.connectionDetails.sourcePort
)
return;
ws.send(data);
});
}
}
} else if (state == "data") {
if (socket instanceof dgram.Socket) {
const array = new Uint8Array(rawData);
socket.send(
array,
connectedClient.connectionDetails.sourcePort,
connectedClient.connectionDetails.sourceIP,
err => {
if (err) throw err;
},
);
} else if (socket instanceof net.Socket) {
const array = new Uint8Array(rawData);
socket.write(array);
}
} else {
throw new Error(
`Whooops, our WebSocket reached an unsupported state: '${state}'`,
);
}
});
}

View file

@ -1,331 +0,0 @@
import { NodeSSH } from "node-ssh";
import { Socket } from "node:net";
import type {
BackendBaseClass,
ForwardRule,
ConnectedClient,
ParameterReturnedValue,
} from "./base.js";
import {
TcpConnectionDetails,
AcceptConnection,
ClientChannel,
RejectConnection,
} from "ssh2";
type ForwardRuleExt = ForwardRule & {
enabled: boolean;
};
// Fight me (for better naming)
type BackendParsedProviderString = {
ip: string;
port: number;
username: string;
privateKey: string;
listenOnIPs: string[];
};
function parseBackendProviderString(data: string): BackendParsedProviderString {
try {
JSON.parse(data);
} catch (e) {
throw new Error("Payload body is not JSON");
}
const jsonData = JSON.parse(data);
if (typeof jsonData.ip != "string") {
throw new Error("IP field is not a string");
}
if (typeof jsonData.port != "number") {
throw new Error("Port is not a number");
}
if (typeof jsonData.username != "string") {
throw new Error("Username is not a string");
}
if (typeof jsonData.privateKey != "string") {
throw new Error("Private key is not a string");
}
let listenOnIPs: string[] = [];
if (!Array.isArray(jsonData.listenOnIPs)) {
listenOnIPs.push("0.0.0.0");
} else {
listenOnIPs = jsonData.listenOnIPs;
}
return {
ip: jsonData.ip,
port: jsonData.port,
username: jsonData.username,
privateKey: jsonData.privateKey,
listenOnIPs,
};
}
export class SSHBackendProvider implements BackendBaseClass {
state: "stopped" | "stopping" | "started" | "starting";
clients: ConnectedClient[];
proxies: ForwardRuleExt[];
logs: string[];
sshInstance: NodeSSH;
options: BackendParsedProviderString;
constructor(parameters: string) {
this.logs = [];
this.proxies = [];
this.clients = [];
this.options = parseBackendProviderString(parameters);
this.state = "stopped";
}
async start(): Promise<boolean> {
this.state = "starting";
this.logs.push("Starting SSHBackendProvider...");
if (this.sshInstance) {
this.sshInstance.dispose();
}
this.sshInstance = new NodeSSH();
try {
await this.sshInstance.connect({
host: this.options.ip,
port: this.options.port,
username: this.options.username,
privateKey: this.options.privateKey,
});
} catch (e) {
this.logs.push(`Failed to start SSHBackendProvider! Error: '${e}'`);
this.state = "stopped";
// @ts-expect-error: We know that stuff will be initialized in order, so this will be safe
this.sshInstance = null;
return false;
}
if (this.sshInstance.connection) {
this.sshInstance.connection.on("end", async () => {
if (this.state != "started") return;
this.logs.push("We disconnected from the SSH server. Restarting...");
// Create a new array from the existing list of proxies, so we have a backup of the proxy list before
// we wipe the list of all proxies and clients (as we're disconnected anyways)
const proxies = Array.from(this.proxies);
this.proxies.splice(0, this.proxies.length);
this.clients.splice(0, this.clients.length);
await this.start();
if (this.state != "started") return;
for (const proxy of proxies) {
if (!proxy.enabled) continue;
this.addConnection(
proxy.sourceIP,
proxy.sourcePort,
proxy.destPort,
"tcp",
);
}
});
}
this.state = "started";
this.logs.push("Successfully started SSHBackendProvider.");
return true;
}
async stop(): Promise<boolean> {
this.state = "stopping";
this.logs.push("Stopping SSHBackendProvider...");
this.proxies.splice(0, this.proxies.length);
this.sshInstance.dispose();
// @ts-expect-error: We know that stuff will be initialized in order, so this will be safe
this.sshInstance = null;
this.logs.push("Successfully stopped SSHBackendProvider.");
this.state = "stopped";
return true;
}
addConnection(
sourceIP: string,
sourcePort: number,
destPort: number,
protocol: "tcp" | "udp",
): void {
const connectionCheck = SSHBackendProvider.checkParametersConnection(
sourceIP,
sourcePort,
destPort,
protocol,
);
if (!connectionCheck.success) throw new Error(connectionCheck.message);
const foundProxyEntry = this.proxies.find(
i =>
i.sourceIP == sourceIP &&
i.sourcePort == sourcePort &&
i.destPort == destPort,
);
if (foundProxyEntry) return;
const connCallback = (
info: TcpConnectionDetails,
accept: AcceptConnection<ClientChannel>,
reject: RejectConnection,
) => {
const foundProxyEntry = this.proxies.find(
i =>
i.sourceIP == sourceIP &&
i.sourcePort == sourcePort &&
i.destPort == destPort,
);
if (!foundProxyEntry || !foundProxyEntry.enabled) return reject();
const client: ConnectedClient = {
ip: info.srcIP,
port: info.srcPort,
connectionDetails: foundProxyEntry,
};
this.clients.push(client);
const srcConn = new Socket();
srcConn.connect({
host: sourceIP,
port: sourcePort,
});
// Why is this so confusing
const destConn = accept();
destConn.addListener("data", (chunk: Uint8Array) => {
srcConn.write(chunk);
});
destConn.addListener("end", () => {
this.clients.splice(this.clients.indexOf(client), 1);
srcConn.end();
});
srcConn.on("data", data => {
destConn.write(data);
});
srcConn.on("end", () => {
this.clients.splice(this.clients.indexOf(client), 1);
destConn.end();
});
};
for (const ip of this.options.listenOnIPs) {
this.sshInstance.forwardIn(ip, destPort, connCallback);
}
this.proxies.push({
sourceIP,
sourcePort,
destPort,
enabled: true,
});
}
removeConnection(
sourceIP: string,
sourcePort: number,
destPort: number,
protocol: "tcp" | "udp",
): void {
const connectionCheck = SSHBackendProvider.checkParametersConnection(
sourceIP,
sourcePort,
destPort,
protocol,
);
if (!connectionCheck.success) throw new Error(connectionCheck.message);
const foundProxyEntry = this.proxies.find(
i =>
i.sourceIP == sourceIP &&
i.sourcePort == sourcePort &&
i.destPort == destPort,
);
if (!foundProxyEntry) return;
foundProxyEntry.enabled = false;
}
getAllConnections(): ConnectedClient[] {
return this.clients;
}
static checkParametersConnection(
sourceIP: string,
sourcePort: number,
destPort: number,
protocol: "tcp" | "udp",
): ParameterReturnedValue {
if (protocol == "udp") {
return {
success: false,
message:
"SSH does not support UDP tunneling! Please use something like PortCopier instead (if it gets done)",
};
}
return {
success: true,
};
}
static checkParametersBackendInstance(data: string): ParameterReturnedValue {
try {
parseBackendProviderString(data);
// @ts-expect-error: We write the function, and we know we're returning an error
} catch (e: Error) {
return {
success: false,
message: e.toString(),
};
}
return {
success: true,
};
}
}

View file

@ -1,140 +0,0 @@
import process from "node:process";
import { PrismaClient } from "@prisma/client";
import Fastify from "fastify";
import type {
ServerOptions,
SessionToken,
RouteOptions,
} from "./libs/types.js";
import type { BackendBaseClass } from "./backendimpl/base.js";
import { route as getPermissions } from "./routes/getPermissions.js";
import { route as backendCreate } from "./routes/backends/create.js";
import { route as backendRemove } from "./routes/backends/remove.js";
import { route as backendLookup } from "./routes/backends/lookup.js";
import { route as forwardConnections } from "./routes/forward/connections.js";
import { route as forwardCreate } from "./routes/forward/create.js";
import { route as forwardRemove } from "./routes/forward/remove.js";
import { route as forwardLookup } from "./routes/forward/lookup.js";
import { route as forwardStart } from "./routes/forward/start.js";
import { route as forwardStop } from "./routes/forward/stop.js";
import { route as userCreate } from "./routes/user/create.js";
import { route as userRemove } from "./routes/user/remove.js";
import { route as userLookup } from "./routes/user/lookup.js";
import { route as userLogin } from "./routes/user/login.js";
import { backendInit } from "./libs/backendInit.js";
const prisma = new PrismaClient();
const isSignupEnabled = Boolean(process.env.IS_SIGNUP_ENABLED);
const unsafeAdminSignup = Boolean(process.env.UNSAFE_ADMIN_SIGNUP);
const noUsersCheck = (await prisma.user.count()) == 0;
if (unsafeAdminSignup) {
console.error(
"WARNING: You have admin sign up on! This means that anyone that signs up will have admin rights!",
);
}
const serverOptions: ServerOptions = {
isSignupEnabled: isSignupEnabled ? true : noUsersCheck,
isSignupAsAdminEnabled: unsafeAdminSignup ? true : noUsersCheck,
allowUnsafeGlobalTokens: process.env.NODE_ENV != "production",
};
const sessionTokens: Record<number, SessionToken[]> = {};
const backends: Record<number, BackendBaseClass> = {};
const loggerEnv = {
development: {
transport: {
target: "pino-pretty",
options: {
translateTime: "HH:MM:ss Z",
ignore: "pid,hostname,time",
},
},
},
production: true,
test: false,
};
const fastify = Fastify({
logger:
process.env.NODE_ENV == "production"
? loggerEnv.production
: loggerEnv.development,
trustProxy: Boolean(process.env.IS_BEHIND_PROXY),
});
const routeOptions: RouteOptions = {
fastify: fastify,
prisma: prisma,
tokens: sessionTokens,
options: serverOptions,
backends: backends,
};
fastify.log.info("Initializing forwarding rules...");
const createdBackends = await prisma.desinationProvider.findMany();
const logWrapper = (arg: string) => fastify.log.info(arg);
const errorWrapper = (arg: string) => fastify.log.error(arg);
for (const backend of createdBackends) {
fastify.log.info(
`Running init steps for ID '${backend.id}' (${backend.name})`,
);
const init = await backendInit(
backend,
backends,
prisma,
logWrapper,
errorWrapper,
);
if (init) fastify.log.info("Init successful.");
}
fastify.log.info("Done.");
getPermissions(routeOptions);
backendCreate(routeOptions);
backendRemove(routeOptions);
backendLookup(routeOptions);
forwardConnections(routeOptions);
forwardCreate(routeOptions);
forwardRemove(routeOptions);
forwardLookup(routeOptions);
forwardStart(routeOptions);
forwardStop(routeOptions);
userCreate(routeOptions);
userRemove(routeOptions);
userLookup(routeOptions);
userLogin(routeOptions);
// Run the server!
try {
await fastify.listen({
port: 3000,
host: process.env.NODE_ENV == "production" ? "0.0.0.0" : "127.0.0.1",
});
} catch (err) {
fastify.log.error(err);
process.exit(1);
}

View file

@ -1,84 +0,0 @@
import { format } from "node:util";
import type { PrismaClient } from "@prisma/client";
import { backendProviders } from "../backendimpl/index.js";
import { BackendBaseClass } from "../backendimpl/base.js";
type Backend = {
id: number;
name: string;
description: string | null;
backend: string;
connectionDetails: string;
};
export async function backendInit(
backend: Backend,
backends: Record<number, BackendBaseClass>,
prisma: PrismaClient,
logger?: (arg: string) => void,
errorOut?: (arg: string) => void,
): Promise<boolean> {
const log = (...args: string[]) =>
logger ? logger(format(...args)) : console.log(...args);
const error = (...args: string[]) =>
errorOut ? errorOut(format(...args)) : log(...args);
const ourProvider = backendProviders[backend.backend];
if (!ourProvider) {
error(" - Error: Invalid backend recieved!");
// Prevent crashes when we don't recieve a backend
backends[backend.id] = new BackendBaseClass("");
backends[backend.id].logs.push("** Failed To Create Backend **");
backends[backend.id].logs.push(
"Reason: Invalid backend recieved (couldn't find the backend to use!)",
);
return false;
}
log(" - Initializing backend...");
backends[backend.id] = new ourProvider(backend.connectionDetails);
const ourBackend = backends[backend.id];
if (!(await ourBackend.start())) {
error(" - Error initializing backend!");
error(" - " + ourBackend.logs.join("\n - "));
return false;
}
log(" - Initializing clients...");
const clients = await prisma.forwardRule.findMany({
where: {
destProviderID: backend.id,
enabled: true,
},
});
for (const client of clients) {
if (client.protocol != "tcp" && client.protocol != "udp") {
error(
` - Error: Client with ID of '${client.id}' has an invalid protocol! (must be either TCP or UDP)`,
);
continue;
}
ourBackend.addConnection(
client.sourceIP,
client.sourcePort,
client.destPort,
client.protocol,
);
}
return true;
}

View file

@ -1,22 +0,0 @@
function getRandomInt(min: number, max: number): number {
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); // The maximum is exclusive and the minimum is inclusive
}
export function generateRandomData(length: number = 128): string {
let newString = "";
for (let i = 0; i < length; i += 2) {
const randomNumber = getRandomInt(0, 255);
if (randomNumber == 0) {
i -= 2;
continue;
}
newString += randomNumber.toString(16);
}
return newString;
}

View file

@ -1,110 +0,0 @@
import type { PrismaClient } from "@prisma/client";
import type { SessionToken } from "./types.js";
export const permissionListDisabled: Record<string, boolean> = {
"routes.add": false,
"routes.remove": false,
"routes.start": false,
"routes.stop": false,
"routes.edit": false,
"routes.visible": false,
"routes.visibleConn": false,
"backends.add": false,
"backends.remove": false,
"backends.start": false,
"backends.stop": false,
"backends.edit": false,
"backends.visible": false,
"backends.secretVis": false,
"permissions.see": false,
"users.add": false,
"users.remove": false,
"users.lookup": false,
"users.edit": false,
};
// FIXME: This solution fucking sucks.
export const permissionListEnabled: Record<string, boolean> = JSON.parse(
JSON.stringify(permissionListDisabled),
);
for (const index of Object.keys(permissionListEnabled)) {
permissionListEnabled[index] = true;
}
export async function hasPermission(
permissionList: string[],
uid: number,
prisma: PrismaClient,
): Promise<boolean> {
for (const permission of permissionList) {
const permissionNode = await prisma.permission.findFirst({
where: {
userID: uid,
permission,
},
});
if (!permissionNode || !permissionNode.has) return false;
}
return true;
}
export async function getUID(
token: string,
tokens: Record<number, SessionToken[]>,
prisma: PrismaClient,
): Promise<number> {
let userID = -1;
// Look up in our currently authenticated users
for (const otherTokenKey of Object.keys(tokens)) {
const otherTokenList = tokens[parseInt(otherTokenKey)];
for (const otherTokenIndex in otherTokenList) {
const otherToken = otherTokenList[otherTokenIndex];
if (otherToken.token == token) {
if (
otherToken.expiresAt <
otherToken.createdAt + (otherToken.createdAt - Date.now())
) {
otherTokenList.splice(parseInt(otherTokenIndex), 1);
continue;
} else {
userID = parseInt(otherTokenKey);
}
}
}
}
// Fine, we'll look up for global tokens...
// FIXME: Could this be more efficient? IDs are sequential in SQL I think
if (userID == -1) {
const allUsers = await prisma.user.findMany({
where: {
isRootServiceAccount: true,
},
});
for (const user of allUsers) {
if (user.rootToken == token) userID = user.id;
}
}
return userID;
}
export async function hasPermissionByToken(
permissionList: string[],
token: string,
tokens: Record<number, SessionToken[]>,
prisma: PrismaClient,
): Promise<boolean> {
const userID = await getUID(token, tokens, prisma);
return await hasPermission(permissionList, userID, prisma);
}

View file

@ -1,28 +0,0 @@
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import type { BackendBaseClass } from "../backendimpl/base.js";
export type ServerOptions = {
isSignupEnabled: boolean;
isSignupAsAdminEnabled: boolean;
allowUnsafeGlobalTokens: boolean;
};
// NOTE: Someone should probably use Redis for this, but this is fine...
export type SessionToken = {
createdAt: number;
expiresAt: number; // Should be (createdAt + (30 minutes))
token: string;
};
export type RouteOptions = {
fastify: FastifyInstance;
prisma: PrismaClient;
tokens: Record<number, SessionToken[]>;
options: ServerOptions;
backends: Record<number, BackendBaseClass>;
};

View file

@ -1,17 +0,0 @@
# Route Plan
- [x] /api/v1/users/create
- [x] /api/v1/users/login
- [x] /api/v1/users/remove
- [ ] /api/v1/users/modify
- [x] /api/v1/users/lookup
- [x] /api/v1/backends/create
- [x] /api/v1/backends/remove
- [ ] /api/v1/backends/modify
- [x] /api/v1/backends/lookup
- [x] /api/v1/routes/create
- [x] /api/v1/routes/remove
- [ ] /api/v1/routes/modify
- [x] /api/v1/routes/lookup
- [ ] /api/v1/routes/start
- [ ] /api/v1/routes/stop
- [x] /api/v1/getPermissions

View file

@ -1,107 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
import { backendProviders } from "../../backendimpl/index.js";
import { backendInit } from "../../libs/backendInit.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
const logWrapper = (arg: string) => fastify.log.info(arg);
const errorWrapper = (arg: string) => fastify.log.error(arg);
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new backend to use
*/
fastify.post(
"/api/v1/backends/create",
{
schema: {
body: {
type: "object",
required: ["token", "name", "backend", "connectionDetails"],
properties: {
token: { type: "string" },
name: { type: "string" },
description: { type: "string" },
backend: { type: "string" },
connectionDetails: { type: "string" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
name: string;
description?: string;
connectionDetails: string;
backend: string;
} = req.body;
if (!(await hasPermission(body.token, ["backends.add"]))) {
return res.status(403).send({
error: "Unauthorized",
});
}
if (!backendProviders[body.backend]) {
return res.status(400).send({
error: "Unsupported backend!",
});
}
const connectionDetailsValidityCheck = backendProviders[
body.backend
].checkParametersBackendInstance(body.connectionDetails);
if (!connectionDetailsValidityCheck.success) {
return res.status(400).send({
error:
connectionDetailsValidityCheck.message ??
"Unknown error while attempting to parse connectionDetails (it's on your side)",
});
}
const backend = await prisma.desinationProvider.create({
data: {
name: body.name,
description: body.description,
backend: body.backend,
connectionDetails: body.connectionDetails,
},
});
const init = await backendInit(
backend,
backends,
prisma,
logWrapper,
errorWrapper,
);
if (!init) {
// TODO: better error code
return res.status(504).send({
error: "Backend is created, but failed to initalize correctly",
id: backend.id,
});
}
return {
success: true,
id: backend.id,
};
},
);
}

View file

@ -1,84 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/backends/lookup",
{
schema: {
body: {
type: "object",
required: ["token"],
properties: {
token: { type: "string" },
id: { type: "number" },
name: { type: "string" },
description: { type: "string" },
backend: { type: "string" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
id?: number;
name?: string;
description?: string;
backend?: string;
} = req.body;
if (
!(await hasPermission(body.token, [
"backends.visible", // wtf?
]))
) {
return res.status(403).send({
error: "Unauthorized",
});
}
const canSeeSecrets = await hasPermission(body.token, [
"backends.secretVis",
]);
const prismaBackends = await prisma.desinationProvider.findMany({
where: {
id: body.id,
name: body.name,
description: body.description,
backend: body.backend,
},
});
return {
success: true,
data: prismaBackends.map(i => ({
id: i.id,
name: i.name,
description: i.description,
backend: i.backend,
connectionDetails: canSeeSecrets ? i.connectionDetails : "",
logs: backends[i.id].logs,
})),
};
},
);
}

View file

@ -1,71 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/backends/remove",
{
schema: {
body: {
type: "object",
required: ["token", "id"],
properties: {
token: { type: "string" },
id: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
id: number;
} = req.body;
if (!(await hasPermission(body.token, ["backends.remove"]))) {
return res.status(403).send({
error: "Unauthorized",
});
}
if (!backends[body.id]) {
return res.status(400).send({
error: "Backend not found",
});
}
// Unload the backend
if (!(await backends[body.id].stop())) {
return res.status(400).send({
error: "Failed to stop backend! Please report this issue.",
});
}
delete backends[body.id];
await prisma.desinationProvider.delete({
where: {
id: body.id,
},
});
return {
success: true,
};
},
);
}

View file

@ -1,72 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
fastify.post(
"/api/v1/forward/connections",
{
schema: {
body: {
type: "object",
required: ["token", "id"],
properties: {
token: { type: "string" },
id: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
id: number;
} = req.body;
if (!(await hasPermission(body.token, ["routes.visibleConn"]))) {
return res.status(403).send({
error: "Unauthorized",
});
}
const forward = await prisma.forwardRule.findUnique({
where: {
id: body.id,
},
});
if (!forward) {
return res.status(400).send({
error: "Could not find forward entry",
});
}
if (!backends[forward.destProviderID]) {
return res.status(400).send({
error: "Backend not found",
});
}
return {
success: true,
data: backends[forward.destProviderID].getAllConnections().filter(i => {
return (
i.connectionDetails.sourceIP == forward.sourceIP &&
i.connectionDetails.sourcePort == forward.sourcePort &&
i.connectionDetails.destPort == forward.destPort
);
}),
};
},
);
}

View file

@ -1,119 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/forward/create",
{
schema: {
body: {
type: "object",
required: [
"token",
"name",
"protocol",
"sourceIP",
"sourcePort",
"destinationPort",
"providerID",
],
properties: {
token: { type: "string" },
name: { type: "string" },
description: { type: "string" },
protocol: { type: "string" },
sourceIP: { type: "string" },
sourcePort: { type: "number" },
destinationPort: { type: "number" },
providerID: { type: "number" },
autoStart: { type: "boolean" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
name: string;
description?: string;
protocol: "tcp" | "udp";
sourceIP: string;
sourcePort: number;
destinationPort: number;
providerID: number;
autoStart?: boolean;
} = req.body;
if (body.protocol != "tcp" && body.protocol != "udp") {
return res.status(400).send({
error: "Body protocol field must be either tcp or udp",
});
}
if (!(await hasPermission(body.token, ["routes.add"]))) {
return res.status(403).send({
error: "Unauthorized",
});
}
const lookupIDForDestProvider =
await prisma.desinationProvider.findUnique({
where: {
id: body.providerID,
},
});
if (!lookupIDForDestProvider)
return res.status(400).send({
error: "Could not find provider",
});
const forwardRule = await prisma.forwardRule.create({
data: {
name: body.name,
description: body.description,
protocol: body.protocol,
sourceIP: body.sourceIP,
sourcePort: body.sourcePort,
destPort: body.destinationPort,
destProviderID: body.providerID,
enabled: Boolean(body.autoStart),
},
});
return {
success: true,
id: forwardRule.id,
};
},
);
}

View file

@ -1,113 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/forward/lookup",
{
schema: {
body: {
type: "object",
required: ["token"],
properties: {
token: { type: "string" },
id: { type: "number" },
name: { type: "string" },
protocol: { type: "string" },
description: { type: "string" },
sourceIP: { type: "string" },
sourcePort: { type: "number" },
destPort: { type: "number" },
providerID: { type: "number" },
autoStart: { type: "boolean" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
id?: number;
name?: string;
description?: string;
protocol?: "tcp" | "udp";
sourceIP?: string;
sourcePort?: number;
destinationPort?: number;
providerID?: number;
autoStart?: boolean;
} = req.body;
if (body.protocol && body.protocol != "tcp" && body.protocol != "udp") {
return res.status(400).send({
error: "Protocol specified in body must be either 'tcp' or 'udp'",
});
}
if (
!(await hasPermission(body.token, [
"routes.visible", // wtf?
]))
) {
return res.status(403).send({
error: "Unauthorized",
});
}
const forwardRules = await prisma.forwardRule.findMany({
where: {
id: body.id,
name: body.name,
description: body.description,
sourceIP: body.sourceIP,
sourcePort: body.sourcePort,
destPort: body.destinationPort,
destProviderID: body.providerID,
enabled: body.autoStart,
},
});
return {
success: true,
data: forwardRules.map(i => ({
id: i.id,
name: i.name,
description: i.description,
sourceIP: i.sourceIP,
sourcePort: i.sourcePort,
destPort: i.destPort,
providerID: i.destProviderID,
autoStart: i.enabled, // TODO: Add enabled flag in here to see if we're running or not
})),
};
},
);
}

View file

@ -1,56 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/forward/remove",
{
schema: {
body: {
type: "object",
required: ["token", "id"],
properties: {
token: { type: "string" },
id: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
id: number;
} = req.body;
if (!(await hasPermission(body.token, ["routes.remove"]))) {
return res.status(403).send({
error: "Unauthorized",
});
}
await prisma.forwardRule.delete({
where: {
id: body.id,
},
});
return {
success: true,
};
},
);
}

View file

@ -1,76 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/forward/start",
{
schema: {
body: {
type: "object",
required: ["token", "id"],
properties: {
token: { type: "string" },
id: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
id: number;
} = req.body;
if (!(await hasPermission(body.token, ["routes.start"]))) {
return res.status(403).send({
error: "Unauthorized",
});
}
const forward = await prisma.forwardRule.findUnique({
where: {
id: body.id,
},
});
if (!forward)
return res.status(400).send({
error: "Could not find forward entry",
});
if (!backends[forward.destProviderID])
return res.status(400).send({
error: "Backend not found",
});
// @ts-expect-error: Other restrictions in place make it so that it MUST be either TCP or UDP
const protocol: "tcp" | "udp" = forward.protocol;
backends[forward.destProviderID].addConnection(
forward.sourceIP,
forward.sourcePort,
forward.destPort,
protocol,
);
return {
success: true,
};
},
);
}

View file

@ -1,76 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/forward/stop",
{
schema: {
body: {
type: "object",
required: ["token", "id"],
properties: {
token: { type: "string" },
id: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
id: number;
} = req.body;
if (!(await hasPermission(body.token, ["routes.stop"]))) {
return res.status(403).send({
error: "Unauthorized",
});
}
const forward = await prisma.forwardRule.findUnique({
where: {
id: body.id,
},
});
if (!forward)
return res.status(400).send({
error: "Could not find forward entry",
});
if (!backends[forward.destProviderID])
return res.status(400).send({
error: "Backend not found",
});
// @ts-expect-error: Other restrictions in place make it so that it MUST be either TCP or UDP
const protocol: "tcp" | "udp" = forward.protocol;
backends[forward.destProviderID].removeConnection(
forward.sourceIP,
forward.sourcePort,
forward.destPort,
protocol,
);
return {
success: true,
};
},
);
}

View file

@ -1,51 +0,0 @@
import { hasPermission, getUID } from "../libs/permissions.js";
import type { RouteOptions } from "../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
/**
* Logs in to a user account.
*/
fastify.post(
"/api/v1/getPermissions",
{
schema: {
body: {
type: "object",
required: ["token"],
properties: {
token: { type: "string" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
} = req.body;
const uid = await getUID(body.token, tokens, prisma);
if (!(await hasPermission(["permissions.see"], uid, prisma))) {
return res.status(403).send({
error: "Unauthorized",
});
}
const permissionsRaw = await prisma.permission.findMany({
where: {
userID: uid,
},
});
return {
success: true,
// Get the ones that we have, and transform them into just their name
data: permissionsRaw.filter(i => i.has).map(i => i.permission),
};
},
);
}

View file

@ -1,125 +0,0 @@
import { hash } from "bcrypt";
import { permissionListEnabled } from "../../libs/permissions.js";
import { generateRandomData } from "../../libs/generateRandom.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, options } = routeOptions;
/**
* Creates a new user account to use, only if it is enabled.
*/
fastify.post(
"/api/v1/users/create",
{
schema: {
body: {
type: "object",
required: ["name", "email", "username", "password"],
properties: {
name: { type: "string" },
username: { type: "string" },
email: { type: "string" },
password: { type: "string" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
name: string;
email: string;
password: string;
username: string;
} = req.body;
if (!options.isSignupEnabled) {
return res.status(403).send({
error: "Signing up is not enabled at this time.",
});
}
const userSearch = await prisma.user.findFirst({
where: {
email: body.email,
},
});
if (userSearch) {
return res.status(400).send({
error: "User already exists",
});
}
const saltedPassword: string = await hash(body.password, 15);
const userData = {
name: body.name,
email: body.email,
password: saltedPassword,
username: body.username,
permissions: {
create: [] as {
permission: string;
has: boolean;
}[],
},
};
// TODO: There's probably a faster way to pull this off, but I'm lazy
for (const permissionKey of Object.keys(permissionListEnabled)) {
if (
options.isSignupAsAdminEnabled ||
permissionKey.startsWith("routes") ||
permissionKey == "permissions.see"
) {
userData.permissions.create.push({
permission: permissionKey,
has: permissionListEnabled[permissionKey],
});
}
}
if (options.allowUnsafeGlobalTokens) {
// @ts-expect-error: Setting this correctly is a goddamn mess, but this is safe to an extent. It won't crash at least
userData.rootToken = generateRandomData();
// @ts-expect-error: Read above.
userData.isRootServiceAccount = true;
}
const userCreateResults = await prisma.user.create({
data: userData,
});
// FIXME(?): Redundant checks
if (options.allowUnsafeGlobalTokens) {
return {
success: true,
token: userCreateResults.rootToken,
};
} else {
const generatedToken = generateRandomData();
tokens[userCreateResults.id] = [];
tokens[userCreateResults.id].push({
createdAt: Date.now(),
expiresAt: Date.now() + 30 * 60_000,
token: generatedToken,
});
return {
success: true,
token: generatedToken,
};
}
},
);
}

View file

@ -1,76 +0,0 @@
import { compare } from "bcrypt";
import { generateRandomData } from "../../libs/generateRandom.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
/**
* Logs in to a user account.
*/
fastify.post(
"/api/v1/users/login",
{
schema: {
body: {
type: "object",
required: ["password"],
properties: {
email: { type: "string" },
username: { type: "string" },
password: { type: "string" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
email?: string;
username?: string;
password: string;
} = req.body;
if (!body.email && !body.username)
return res.status(400).send({
error: "missing both email and username. please supply at least one.",
});
const userSearch = await prisma.user.findFirst({
where: {
email: body.email,
username: body.username,
},
});
if (!userSearch)
return res.status(403).send({
error: "Email or password is incorrect",
});
const passwordIsValid = await compare(body.password, userSearch.password);
if (!passwordIsValid)
return res.status(403).send({
error: "Email or password is incorrect",
});
const token = generateRandomData();
if (!tokens[userSearch.id]) tokens[userSearch.id] = [];
tokens[userSearch.id].push({
createdAt: Date.now(),
expiresAt: Date.now() + 30 * 60_000,
token,
});
return {
success: true,
token,
};
},
);
}

View file

@ -1,72 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
fastify.post(
"/api/v1/users/lookup",
{
schema: {
body: {
type: "object",
required: ["token"],
properties: {
token: { type: "string" },
id: { type: "number" },
name: { type: "string" },
email: { type: "string" },
username: { type: "string" },
isServiceAccount: { type: "boolean" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
id?: number;
name?: string;
email?: string;
username?: string;
isServiceAccount?: boolean;
} = req.body;
if (!(await hasPermission(body.token, ["users.lookup"]))) {
return res.status(403).send({
error: "Unauthorized",
});
}
const users = await prisma.user.findMany({
where: {
id: body.id,
name: body.name,
email: body.email,
username: body.username,
isRootServiceAccount: body.isServiceAccount,
},
});
return {
success: true,
data: users.map(i => ({
id: i.id,
name: i.name,
email: i.email,
isServiceAccount: i.isRootServiceAccount,
username: i.username,
})),
};
},
);
}

View file

@ -1,62 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new backend to use
*/
fastify.post(
"/api/v1/users/remove",
{
schema: {
body: {
type: "object",
required: ["token", "uid"],
properties: {
token: { type: "string" },
uid: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-expect-error: Fastify routes schema parsing is trustworthy, so we can "assume" invalid types
const body: {
token: string;
uid: number;
} = req.body;
if (!(await hasPermission(body.token, ["users.remove"]))) {
return res.status(403).send({
error: "Unauthorized",
});
}
await prisma.permission.deleteMany({
where: {
userID: body.uid,
},
});
await prisma.user.delete({
where: {
id: body.uid,
},
});
return {
success: true,
};
},
);
}

View file

@ -1,6 +0,0 @@
# !-- DO NOT USE THIS FOR DEVELOPMENT --!
# This is only to source patch files in production deployments, if prisma isn't configured already.
printf "//@ts-nocheck\n$(cat src/routes/backends/lookup.ts)" > src/routes/backends/lookup.ts
printf "//@ts-nocheck\n$(cat src/routes/forward/lookup.ts)" > src/routes/forward/lookup.ts
printf "//@ts-nocheck\n$(cat src/routes/user/lookup.ts)" > src/routes/user/lookup.ts
printf "//@ts-nocheck\n$(cat src/routes/getPermissions.ts)" > src/routes/getPermissions.ts

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "es2020",
"module": "es2022",
"moduleResolution": "node",
"outDir": "./out",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"strictPropertyInitialization": false,
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

21
apiclient/apiclient.go Normal file
View file

@ -0,0 +1,21 @@
package apiclient
import "git.terah.dev/imterah/hermes/apiclient/users"
type HermesAPIClient struct {
URL string
}
/// Users
func (api *HermesAPIClient) UserGetRefreshToken(username *string, email *string, password string) (string, error) {
return users.GetRefreshToken(api.URL, username, email, password)
}
func (api *HermesAPIClient) UserGetJWTFromToken(refreshToken string) (string, error) {
return users.GetJWTFromToken(api.URL, refreshToken)
}
func (api *HermesAPIClient) UserCreate(fullName, username, email, password string, isBot bool) (string, error) {
return users.CreateUser(api.URL, fullName, username, email, password, isBot)
}

View file

@ -0,0 +1,102 @@
package backendstructs
type BackendCreationRequest struct {
Token string `validate:"required"`
Name string `validate:"required"`
Description *string `json:"description"`
Backend string `validate:"required"`
BackendParameters interface{} `json:"connectionDetails" validate:"required"`
}
type BackendLookupRequest struct {
Token string `validate:"required"`
BackendID *uint `json:"id"`
Name *string `json:"name"`
Description *string `json:"description"`
Backend *string `json:"backend"`
}
type BackendRemovalRequest struct {
Token string `validate:"required"`
BackendID uint `json:"id" validate:"required"`
}
type ConnectionsRequest struct {
Token string `validate:"required" json:"token"`
Id uint `validate:"required" json:"id"`
}
type ProxyCreationRequest struct {
Token string `validate:"required" json:"token"`
Name string `validate:"required" json:"name"`
Description *string `json:"description"`
Protocol string `validate:"required" json:"protocol"`
SourceIP string `validate:"required" json:"sourceIP"`
SourcePort uint16 `validate:"required" json:"sourcePort"`
DestinationPort uint16 `validate:"required" json:"destinationPort"`
ProviderID uint `validate:"required" json:"providerID"`
AutoStart *bool `json:"autoStart"`
}
type ProxyLookupRequest struct {
Token string `validate:"required" json:"token"`
Id *uint `json:"id"`
Name *string `json:"name"`
Description *string `json:"description"`
Protocol *string `json:"protocol"`
SourceIP *string `json:"sourceIP"`
SourcePort *uint16 `json:"sourcePort"`
DestinationPort *uint16 `json:"destPort"`
ProviderID *uint `json:"providerID"`
AutoStart *bool `json:"autoStart"`
}
type ProxyRemovalRequest struct {
Token string `validate:"required" json:"token"`
ID uint `validate:"required" json:"id"`
}
type ProxyStartRequest struct {
Token string `validate:"required" json:"token"`
ID uint `validate:"required" json:"id"`
}
type ProxyStopRequest struct {
Token string `validate:"required" json:"token"`
ID uint `validate:"required" json:"id"`
}
type UserCreationRequest struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required"`
Password string `json:"password" validate:"required"`
Username string `json:"username" validate:"required"`
ExistingUserToken string `json:"token"`
IsBot bool `json:"isBot"`
}
type UserLoginRequest struct {
Username *string `json:"username"`
Email *string `json:"email"`
Password string `json:"password" validate:"required"`
}
type UserLookupRequest struct {
Token string `validate:"required"`
UID *uint `json:"id"`
Name *string `json:"name"`
Email *string `json:"email"`
Username *string `json:"username"`
IsBot *bool `json:"isServiceAccount"`
}
type UserRefreshRequest struct {
Token string `json:"token" validate:"required"`
}
type UserRemovalRequest struct {
Token string `json:"token" validate:"required"`
UID *uint `json:"uid"`
}

99
apiclient/users/auth.go Normal file
View file

@ -0,0 +1,99 @@
package users
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"git.terah.dev/imterah/hermes/apiclient/backendstructs"
)
type refreshTokenResponse struct {
Success bool `json:"success"`
RefreshToken string `json:"refreshToken"`
}
type jwtTokenResponse struct {
Success bool `json:"success"`
JWT string `json:"token"`
}
func GetRefreshToken(url string, username, email *string, password string) (string, error) {
body, err := json.Marshal(&backendstructs.UserLoginRequest{
Username: username,
Email: email,
Password: password,
})
if err != nil {
return "", err
}
res, err := http.Post(fmt.Sprintf("%s/api/v1/users/login", url), "application/json", bytes.NewBuffer(body))
if err != nil {
return "", err
}
bodyContents, err := io.ReadAll(res.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %s", err.Error())
}
response := &refreshTokenResponse{}
if err := json.Unmarshal(bodyContents, response); err != nil {
return "", err
}
if !response.Success {
return "", fmt.Errorf("failed to get refresh token")
}
if response.RefreshToken == "" {
return "", fmt.Errorf("refresh token is empty")
}
return response.RefreshToken, nil
}
func GetJWTFromToken(url, refreshToken string) (string, error) {
body, err := json.Marshal(&backendstructs.UserRefreshRequest{
Token: refreshToken,
})
if err != nil {
return "", err
}
res, err := http.Post(fmt.Sprintf("%s/api/v1/users/refresh", url), "application/json", bytes.NewBuffer(body))
if err != nil {
return "", err
}
bodyContents, err := io.ReadAll(res.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %s", err.Error())
}
response := &jwtTokenResponse{}
if err := json.Unmarshal(bodyContents, response); err != nil {
return "", err
}
if !response.Success {
return "", fmt.Errorf("failed to get JWT token")
}
if response.JWT == "" {
return "", fmt.Errorf("JWT token is empty")
}
return response.JWT, nil
}

63
apiclient/users/create.go Normal file
View file

@ -0,0 +1,63 @@
package users
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"git.terah.dev/imterah/hermes/apiclient/backendstructs"
)
type createUserResponse struct {
Error string `json:"error"`
Success bool `json:"success"`
RefreshToken string `json:"refreshToken"`
}
func CreateUser(url, fullName, username, email, password string, isBot bool) (string, error) {
body, err := json.Marshal(&backendstructs.UserCreationRequest{
Username: username,
Name: fullName,
Email: email,
Password: password,
IsBot: isBot,
})
if err != nil {
return "", err
}
res, err := http.Post(fmt.Sprintf("%s/api/v1/users/create", url), "application/json", bytes.NewBuffer(body))
if err != nil {
return "", err
}
bodyContents, err := io.ReadAll(res.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %s", err.Error())
}
response := &createUserResponse{}
if err := json.Unmarshal(bodyContents, response); err != nil {
return "", err
}
if response.Error != "" {
return "", fmt.Errorf("error from server: %s", response.Error)
}
if !response.Success {
return "", fmt.Errorf("failed to get refresh token")
}
if response.RefreshToken == "" {
return "", fmt.Errorf("refresh token is empty")
}
return response.RefreshToken, nil
}

View file

@ -0,0 +1,15 @@
package backendruntime
import "os"
var (
AvailableBackends []*Backend
RunningBackends map[uint]*Runtime
TempDir string
shouldLog bool
)
func init() {
RunningBackends = make(map[uint]*Runtime)
shouldLog = os.Getenv("HERMES_DEVELOPMENT_MODE") != "" || os.Getenv("HERMES_BACKEND_LOGGING_ENABLED") != "" || os.Getenv("HERMES_LOG_LEVEL") == "debug"
}

View file

@ -0,0 +1,396 @@
package backendruntime
import (
"context"
"fmt"
"net"
"os"
"os/exec"
"strings"
"sync"
"time"
"git.terah.dev/imterah/hermes/backend/backendlauncher"
"git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log"
)
// TODO TODO TODO(imterah):
// This code is a mess. This NEEDS to be rearchitected and refactored to work better. Or at the very least, this code needs to be documented heavily.
func handleCommand(command interface{}, sock net.Conn, rtcChan chan interface{}) error {
bytes, err := commonbackend.Marshal(command)
if err != nil {
log.Warnf("Failed to marshal message: %s", err.Error())
rtcChan <- fmt.Errorf("failed to marshal message: %s", err.Error())
return fmt.Errorf("failed to marshal message: %s", err.Error())
}
if _, err := sock.Write(bytes); err != nil {
log.Warnf("Failed to write message: %s", err.Error())
rtcChan <- fmt.Errorf("failed to write message: %s", err.Error())
return fmt.Errorf("failed to write message: %s", err.Error())
}
data, err := commonbackend.Unmarshal(sock)
if err != nil {
log.Warnf("Failed to unmarshal message: %s", err.Error())
rtcChan <- fmt.Errorf("failed to unmarshal message: %s", err.Error())
return fmt.Errorf("failed to unmarshal message: %s", err.Error())
}
rtcChan <- data
return nil
}
func (runtime *Runtime) goRoutineHandler() error {
log.Debug("Starting up backend runtime")
log.Debug("Running socket acquisition")
logLevel := os.Getenv("HERMES_LOG_LEVEL")
sockPath, sockListener, err := backendlauncher.GetUnixSocket(TempDir)
if err != nil {
return err
}
runtime.currentListener = sockListener
log.Debugf("Acquired unix socket at: %s", sockPath)
go func() {
log.Debug("Created new Goroutine for socket connection handling")
for {
log.Debug("Waiting for Unix socket connections...")
sock, err := runtime.currentListener.Accept()
if err != nil {
log.Warnf("Failed to accept Unix socket connection in a backend runtime instance: %s", err.Error())
return
}
log.Debug("Recieved connection. Attempting to figure out backend state...")
timeoutChannel := time.After(500 * time.Millisecond)
select {
case <-timeoutChannel:
log.Debug("Timeout reached. Assuming backend is running.")
case hasRestarted, ok := <-runtime.processRestartNotification:
if !ok {
log.Warnf("Failed to get the process restart notification state!")
}
if hasRestarted {
if runtime.OnCrashCallback == nil {
log.Warn("The backend has restarted for some reason, but we could not run the on crash callback as the callback is not set!")
} else {
log.Debug("We have restarted. Running the restart callback...")
runtime.OnCrashCallback(sock)
}
log.Debug("Clearing caches...")
runtime.cleanUpPendingCommandProcessingJobs()
runtime.messageBufferLock = sync.Mutex{}
} else {
log.Debug("We have not restarted.")
}
}
go func() {
log.Debug("Setting up Hermes keepalive Goroutine")
hasFailedBackendRunningCheckAlready := false
for {
if !runtime.isRuntimeRunning {
return
}
// Asking for the backend status seems to be a "good-enough" keepalive system. Plus, it provides useful telemetry.
// There isn't a ping command in the backend API, so we have to make do with what we have.
//
// To be safe here, we have to use the proper (yet annoying) facilities to prevent cross-talk, since we're in
// a goroutine, and can't talk directly. This actually has benefits, as the OuterLoop should exit on its own, if we
// encounter a critical error.
statusResponse, err := runtime.ProcessCommand(&commonbackend.BackendStatusRequest{})
if err != nil {
log.Warnf("Failed to get response for backend (in backend runtime keep alive): %s", err.Error())
log.Debugf("Attempting to close socket...")
err := sock.Close()
if err != nil {
log.Debugf("Failed to close socket: %s", err.Error())
}
continue
}
switch responseMessage := statusResponse.(type) {
case *commonbackend.BackendStatusResponse:
if !responseMessage.IsRunning {
if hasFailedBackendRunningCheckAlready {
if responseMessage.Message != "" {
log.Warnf("Backend (in backend keepalive) is up but not active: %s", responseMessage.Message)
} else {
log.Warnf("Backend (in backend keepalive) is up but not active")
}
}
hasFailedBackendRunningCheckAlready = true
}
default:
log.Errorf("Got illegal response type for backend (in backend keepalive): %T", responseMessage)
log.Debugf("Attempting to close socket...")
err := sock.Close()
if err != nil {
log.Debugf("Failed to close socket: %s", err.Error())
}
}
time.Sleep(5 * time.Second)
}
}()
OuterLoop:
for {
_ = <-runtime.startProcessingNotification
runtime.isRuntimeCurrentlyProcessing = true
for chanIndex, messageData := range runtime.messageBuffer {
if messageData == nil {
continue
}
err := handleCommand(messageData.Message, sock, messageData.Channel)
if err != nil {
log.Warnf("failed to handle command in backend runtime instance: %s", err.Error())
if strings.HasPrefix(err.Error(), "failed to write message") {
break OuterLoop
}
}
runtime.messageBuffer[chanIndex] = nil
}
runtime.isRuntimeCurrentlyProcessing = false
}
sock.Close()
}
}()
runtime.processRestartNotification <- false
for {
log.Debug("Starting process...")
ctx := context.Background()
runtime.currentProcess = exec.CommandContext(ctx, runtime.ProcessPath)
runtime.currentProcess.Env = append(runtime.currentProcess.Env, fmt.Sprintf("HERMES_API_SOCK=%s", sockPath), fmt.Sprintf("HERMES_LOG_LEVEL=%s", logLevel))
runtime.currentProcess.Stdout = runtime.logger
runtime.currentProcess.Stderr = runtime.logger
err := runtime.currentProcess.Run()
if err != nil {
if err, ok := err.(*exec.ExitError); ok {
if err.ExitCode() != -1 && err.ExitCode() != 0 {
log.Warnf("A backend process died with exit code '%d' and with error '%s'", err.ExitCode(), err.Error())
}
} else {
log.Warnf("A backend process died with error: %s", err.Error())
}
} else {
log.Debug("Process exited gracefully.")
}
if !runtime.isRuntimeRunning {
return nil
}
log.Debug("Sleeping 5 seconds, and then restarting process")
time.Sleep(5 * time.Second)
// NOTE(imterah): This could cause hangs if we're not careful. If the process dies so much that we can't keep up, it should deserve to be hung, really.
// There's probably a better way to do this, but this works.
//
// If this does turn out to be a problem, just increase the Goroutine buffer size.
runtime.processRestartNotification <- true
log.Debug("Sent off notification.")
}
}
func (runtime *Runtime) Start() error {
if runtime.isRuntimeRunning {
return fmt.Errorf("runtime already running")
}
runtime.messageBuffer = make([]*messageForBuf, 10)
runtime.messageBufferLock = sync.Mutex{}
runtime.startProcessingNotification = make(chan bool)
runtime.processRestartNotification = make(chan bool, 1)
runtime.logger = &writeLogger{
Runtime: runtime,
}
go func() {
err := runtime.goRoutineHandler()
if err != nil {
log.Errorf("Failed during execution of runtime: %s", err.Error())
}
}()
runtime.isRuntimeRunning = true
return nil
}
func (runtime *Runtime) Stop() error {
if !runtime.isRuntimeRunning {
return fmt.Errorf("runtime not running")
}
runtime.isRuntimeRunning = false
if runtime.currentProcess != nil && runtime.currentProcess.Cancel != nil {
err := runtime.currentProcess.Cancel()
if err != nil {
return fmt.Errorf("failed to stop process: %s", err.Error())
}
} else {
log.Warn("Failed to kill process (Stop recieved), currentProcess or currentProcess.Cancel is nil")
}
if runtime.currentListener != nil {
err := runtime.currentListener.Close()
if err != nil {
return fmt.Errorf("failed to stop listener: %s", err.Error())
}
} else {
log.Warn("Failed to kill listener, as the listener is nil")
}
return nil
}
func (runtime *Runtime) ProcessCommand(command interface{}) (interface{}, error) {
schedulingAttempts := 0
var commandChannel chan interface{}
SchedulingLoop:
for {
if !runtime.isRuntimeRunning {
time.Sleep(10 * time.Millisecond)
}
if schedulingAttempts > 50 {
return nil, fmt.Errorf("failed to schedule message transmission after 50 tries (REPORT THIS ISSUE)")
}
runtime.messageBufferLock.Lock()
// Attempt to find spot in buffer to schedule message transmission
for i, message := range runtime.messageBuffer {
if message != nil {
continue
}
commandChannel = make(chan interface{})
runtime.messageBuffer[i] = &messageForBuf{
Channel: commandChannel,
Message: command,
}
runtime.messageBufferLock.Unlock()
break SchedulingLoop
}
runtime.messageBufferLock.Unlock()
time.Sleep(100 * time.Millisecond)
schedulingAttempts++
}
if !runtime.isRuntimeCurrentlyProcessing {
runtime.startProcessingNotification <- true
}
// Fetch response and close Channel
response, ok := <-commandChannel
if !ok {
return nil, fmt.Errorf("failed to read from command channel: recieved signal that is not OK")
}
close(commandChannel)
err, ok := response.(error)
if ok {
return nil, err
}
return response, nil
}
func (runtime *Runtime) cleanUpPendingCommandProcessingJobs() {
for messageIndex, message := range runtime.messageBuffer {
if message == nil {
continue
}
timeoutChannel := time.After(100 * time.Millisecond)
select {
case <-timeoutChannel:
log.Warn("Message channel is likely running (timed out reading from it without an error)")
close(message.Channel)
case _, ok := <-message.Channel:
if ok {
log.Warn("Message channel is running, but should be stopped (since message is NOT nil!)")
close(message.Channel)
}
}
runtime.messageBuffer[messageIndex] = nil
}
}
func NewBackend(path string) *Runtime {
return &Runtime{
ProcessPath: path,
}
}
func Init(backends []*Backend) error {
var err error
TempDir, err = os.MkdirTemp("", "hermes-sockets-")
if err != nil {
return err
}
AvailableBackends = backends
return nil
}

View file

@ -0,0 +1,61 @@
package backendruntime
import (
"net"
"os/exec"
"strings"
"sync"
"github.com/charmbracelet/log"
)
type Backend struct {
Name string `validate:"required"`
Path string `validate:"required"`
}
type messageForBuf struct {
Channel chan interface{}
// TODO(imterah): could this be refactored to just be a []byte instead? Look into this
Message interface{}
}
type Runtime struct {
isRuntimeRunning bool
isRuntimeCurrentlyProcessing bool
startProcessingNotification chan bool
logger *writeLogger
currentProcess *exec.Cmd
currentListener net.Listener
processRestartNotification chan bool
messageBufferLock sync.Mutex
messageBuffer []*messageForBuf
ProcessPath string
Logs []string
OnCrashCallback func(sock net.Conn)
}
type writeLogger struct {
Runtime *Runtime
}
func (writer writeLogger) Write(p []byte) (n int, err error) {
logSplit := strings.Split(string(p), "\n")
if shouldLog {
for _, logLine := range logSplit {
if logLine == "" {
continue
}
log.Debug("spawned backend logs: " + logLine)
}
}
writer.Runtime.Logs = append(writer.Runtime.Logs, logSplit...)
return len(p), err
}

View file

@ -0,0 +1,270 @@
package backends
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type BackendCreationRequest struct {
Token string `validate:"required"`
Name string `validate:"required"`
Description *string
Backend string `validate:"required"`
BackendParameters interface{} `json:"connectionDetails" validate:"required"`
}
func SetupCreateBackend(state *state.State) {
state.Engine.POST("/api/v1/backends/create", func(c *gin.Context) {
var req BackendCreationRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
user, err := state.JWT.GetUserFromJWT(req.Token)
if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
log.Warnf("Failed to get user from the provided JWT token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse token",
})
return
}
}
if !permissions.UserHasPermission(user, "backends.add") {
c.JSON(http.StatusForbidden, gin.H{
"error": "Missing permissions",
})
return
}
var backendParameters []byte
switch parameters := req.BackendParameters.(type) {
case string:
backendParameters = []byte(parameters)
case map[string]interface{}:
backendParameters, err = json.Marshal(parameters)
if err != nil {
log.Warnf("Failed to marshal JSON recieved as BackendParameters: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to prepare parameters",
})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Invalid type for connectionDetails (recieved %T)", parameters),
})
return
}
var backendRuntimeFilePath string
for _, runtime := range backendruntime.AvailableBackends {
if runtime.Name == req.Backend {
backendRuntimeFilePath = runtime.Path
}
}
if backendRuntimeFilePath == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Unsupported backend recieved",
})
return
}
backend := backendruntime.NewBackend(backendRuntimeFilePath)
err = backend.Start()
if err != nil {
log.Warnf("Failed to start backend: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to start backend",
})
return
}
backendParamCheckResponse, err := backend.ProcessCommand(&commonbackend.CheckServerParameters{
Arguments: backendParameters,
})
if err != nil {
log.Warnf("Failed to get response for backend: %s", err.Error())
err = backend.Stop()
if err != nil {
log.Warnf("Failed to stop backend: %s", err.Error())
}
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get status response from backend",
})
return
}
switch responseMessage := backendParamCheckResponse.(type) {
case *commonbackend.CheckParametersResponse:
if responseMessage.InResponseTo != "checkServerParameters" {
log.Errorf("Got illegal response to CheckServerParameters: %s", responseMessage.InResponseTo)
err = backend.Stop()
if err != nil {
log.Warnf("Failed to stop backend: %s", err.Error())
}
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get status response from backend",
})
return
}
if !responseMessage.IsValid {
err = backend.Stop()
if err != nil {
log.Warnf("Failed to stop backend: %s", err.Error())
}
var errorMessage string
if responseMessage.Message == "" {
errorMessage = "Unkown error while trying to parse connectionDetails"
} else {
errorMessage = fmt.Sprintf("Invalid backend parameters: %s", responseMessage.Message)
}
c.JSON(http.StatusBadRequest, gin.H{
"error": errorMessage,
})
return
}
default:
log.Warnf("Got illegal response type for backend: %T", responseMessage)
}
log.Info("Passed backend checks successfully")
backendInDatabase := &db.Backend{
UserID: user.ID,
Name: req.Name,
Description: req.Description,
Backend: req.Backend,
BackendParameters: base64.StdEncoding.EncodeToString(backendParameters),
}
if result := state.DB.DB.Create(&backendInDatabase); result.Error != nil {
log.Warnf("Failed to create backend: %s", result.Error.Error())
err = backend.Stop()
if err != nil {
log.Warnf("Failed to stop backend: %s", err.Error())
}
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to add backend into database",
})
return
}
backendStartResponse, err := backend.ProcessCommand(&commonbackend.Start{
Arguments: backendParameters,
})
if err != nil {
log.Warnf("Failed to get response for backend: %s", err.Error())
err = backend.Stop()
if err != nil {
log.Warnf("Failed to stop backend: %s", err.Error())
}
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get status response from backend",
})
return
}
switch responseMessage := backendStartResponse.(type) {
case *commonbackend.BackendStatusResponse:
if !responseMessage.IsRunning {
err = backend.Stop()
if err != nil {
log.Warnf("Failed to start backend: %s", err.Error())
}
var errorMessage string
if responseMessage.Message == "" {
errorMessage = "Unkown error while trying to start the backend"
} else {
errorMessage = fmt.Sprintf("Failed to start backend: %s", responseMessage.Message)
}
c.JSON(http.StatusBadRequest, gin.H{
"error": errorMessage,
})
return
}
default:
log.Warnf("Got illegal response type for backend: %T", responseMessage)
}
backendruntime.RunningBackends[backendInDatabase.ID] = backend
c.JSON(http.StatusOK, gin.H{
"success": true,
})
})
}

View file

@ -0,0 +1,164 @@
package backends
import (
"encoding/base64"
"fmt"
"net/http"
"strings"
"git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type BackendLookupRequest struct {
Token string `validate:"required"`
BackendID *uint `json:"id"`
Name *string
Description *string
Backend *string
}
type SanitizedBackend struct {
Name string `json:"name"`
BackendID uint `json:"id"`
OwnerID uint `json:"ownerID"`
Description *string `json:"description,omitempty"`
Backend string `json:"backend"`
BackendParameters *string `json:"connectionDetails,omitempty"`
Logs []string `json:"logs"`
}
type LookupResponse struct {
Success bool `json:"success"`
Data []*SanitizedBackend `json:"data"`
}
func SetupLookupBackend(state *state.State) {
state.Engine.POST("/api/v1/backends/lookup", func(c *gin.Context) {
var req BackendLookupRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
user, err := state.JWT.GetUserFromJWT(req.Token)
if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
log.Warnf("Failed to get user from the provided JWT token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse token",
})
return
}
}
if !permissions.UserHasPermission(user, "backends.visible") {
c.JSON(http.StatusForbidden, gin.H{
"error": "Missing permissions",
})
return
}
backends := []db.Backend{}
queryString := []string{}
queryParameters := []interface{}{}
if req.BackendID != nil {
queryString = append(queryString, "id = ?")
queryParameters = append(queryParameters, req.BackendID)
}
if req.Name != nil {
queryString = append(queryString, "name = ?")
queryParameters = append(queryParameters, req.Name)
}
if req.Description != nil {
queryString = append(queryString, "description = ?")
queryParameters = append(queryParameters, req.Description)
}
if req.Backend != nil {
queryString = append(queryString, "is_bot = ?")
queryParameters = append(queryParameters, req.Backend)
}
if err := state.DB.DB.Where(strings.Join(queryString, " AND "), queryParameters...).Find(&backends).Error; err != nil {
log.Warnf("Failed to get backends: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get backends",
})
return
}
sanitizedBackends := make([]*SanitizedBackend, len(backends))
hasSecretVisibility := permissions.UserHasPermission(user, "backends.secretVis")
for backendIndex, backend := range backends {
foundBackend, ok := backendruntime.RunningBackends[backend.ID]
if !ok {
log.Warnf("Failed to get backend #%d controller", backend.ID)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get backends",
})
return
}
sanitizedBackends[backendIndex] = &SanitizedBackend{
BackendID: backend.ID,
OwnerID: backend.UserID,
Name: backend.Name,
Description: backend.Description,
Backend: backend.Backend,
Logs: foundBackend.Logs,
}
if backend.UserID == user.ID || hasSecretVisibility {
backendParametersBytes, err := base64.StdEncoding.DecodeString(backend.BackendParameters)
if err != nil {
log.Warnf("Failed to decode base64 backend parameters: %s", err.Error())
}
backendParameters := string(backendParametersBytes)
sanitizedBackends[backendIndex].BackendParameters = &backendParameters
}
}
c.JSON(http.StatusOK, &LookupResponse{
Success: true,
Data: sanitizedBackends,
})
})
}

View file

@ -0,0 +1,124 @@
package backends
import (
"fmt"
"net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type BackendRemovalRequest struct {
Token string `validate:"required"`
BackendID uint `json:"id" validate:"required"`
}
func SetupRemoveBackend(state *state.State) {
state.Engine.POST("/api/v1/backends/remove", func(c *gin.Context) {
var req BackendRemovalRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
user, err := state.JWT.GetUserFromJWT(req.Token)
if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
log.Warnf("Failed to get user from the provided JWT token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse token",
})
return
}
}
if !permissions.UserHasPermission(user, "backends.remove") {
c.JSON(http.StatusForbidden, gin.H{
"error": "Missing permissions",
})
return
}
var backend *db.Backend
backendRequest := state.DB.DB.Where("id = ?", req.BackendID).Find(&backend)
if backendRequest.Error != nil {
log.Warnf("failed to find if backend exists or not: %s", backendRequest.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find if backend exists",
})
return
}
backendExists := backendRequest.RowsAffected > 0
if !backendExists {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Backend doesn't exist",
})
return
}
if err := state.DB.DB.Delete(backend).Error; err != nil {
log.Warnf("failed to delete backend: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to delete backend",
})
return
}
backendInstance, ok := backendruntime.RunningBackends[req.BackendID]
if ok {
err = backendInstance.Stop()
if err != nil {
log.Warnf("Failed to stop backend: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Backend deleted, but failed to stop",
})
delete(backendruntime.RunningBackends, req.BackendID)
return
}
delete(backendruntime.RunningBackends, req.BackendID)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
})
})
}

View file

@ -0,0 +1,165 @@
package proxies
import (
"fmt"
"net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type ConnectionsRequest struct {
Token string `validate:"required" json:"token"`
Id uint `validate:"required" json:"id"`
}
type ConnectionDetailsForConnection struct {
SourceIP string `json:"sourceIP"`
SourcePort uint16 `json:"sourcePort"`
DestPort uint16 `json:"destPort"`
}
type SanitizedConnection struct {
ClientIP string `json:"ip"`
Port uint16 `json:"port"`
ConnectionDetails *ConnectionDetailsForConnection `json:"connectionDetails"`
}
type ConnectionsResponse struct {
Success bool `json:"success"`
Data []*SanitizedConnection `json:"data"`
}
func SetupGetConnections(state *state.State) {
state.Engine.POST("/api/v1/forward/connections", func(c *gin.Context) {
var req ConnectionsRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
user, err := state.JWT.GetUserFromJWT(req.Token)
if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
log.Warnf("Failed to get user from the provided JWT token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse token",
})
return
}
}
if !permissions.UserHasPermission(user, "routes.visibleConn") {
c.JSON(http.StatusForbidden, gin.H{
"error": "Missing permissions",
})
return
}
var proxy db.Proxy
proxyRequest := state.DB.DB.Where("id = ?", req.Id).First(&proxy)
if proxyRequest.Error != nil {
log.Warnf("failed to find proxy: %s", proxyRequest.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find forward entry",
})
return
}
proxyExists := proxyRequest.RowsAffected > 0
if !proxyExists {
c.JSON(http.StatusBadRequest, gin.H{
"error": "No forward entry found",
})
return
}
backendRuntime, ok := backendruntime.RunningBackends[proxy.BackendID]
if !ok {
log.Warnf("Couldn't fetch backend runtime from backend ID #%d", proxy.BackendID)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Couldn't fetch backend runtime",
})
return
}
backendResponse, err := backendRuntime.ProcessCommand(&commonbackend.ProxyConnectionsRequest{})
if err != nil {
log.Warnf("Failed to get response for backend: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get status response from backend",
})
return
}
switch responseMessage := backendResponse.(type) {
case *commonbackend.ProxyConnectionsResponse:
sanitizedConnections := []*SanitizedConnection{}
for _, connection := range responseMessage.Connections {
if connection.SourceIP == proxy.SourceIP && connection.SourcePort == proxy.SourcePort && proxy.DestinationPort == proxy.DestinationPort {
sanitizedConnections = append(sanitizedConnections, &SanitizedConnection{
ClientIP: connection.ClientIP,
Port: connection.ClientPort,
ConnectionDetails: &ConnectionDetailsForConnection{
SourceIP: proxy.SourceIP,
SourcePort: proxy.SourcePort,
DestPort: proxy.DestinationPort,
},
})
}
}
c.JSON(http.StatusOK, &ConnectionsResponse{
Success: true,
Data: sanitizedConnections,
})
default:
log.Warnf("Got illegal response type for backend: %T", responseMessage)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Got illegal response type",
})
}
})
}

View file

@ -0,0 +1,177 @@
package proxies
import (
"fmt"
"net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type ProxyCreationRequest struct {
Token string `validate:"required" json:"token"`
Name string `validate:"required" json:"name"`
Description *string `json:"description"`
Protocol string `validate:"required" json:"protocol"`
SourceIP string `validate:"required" json:"sourceIP"`
SourcePort uint16 `validate:"required" json:"sourcePort"`
DestinationPort uint16 `validate:"required" json:"destinationPort"`
ProviderID uint `validate:"required" json:"providerID"`
AutoStart *bool `json:"autoStart"`
}
func SetupCreateProxy(state *state.State) {
state.Engine.POST("/api/v1/forward/create", func(c *gin.Context) {
var req ProxyCreationRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
user, err := state.JWT.GetUserFromJWT(req.Token)
if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
log.Warnf("Failed to get user from the provided JWT token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse token",
})
return
}
}
if !permissions.UserHasPermission(user, "routes.add") {
c.JSON(http.StatusForbidden, gin.H{
"error": "Missing permissions",
})
return
}
if req.Protocol != "tcp" && req.Protocol != "udp" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Protocol must be either 'tcp' or 'udp'",
})
return
}
var backend db.Backend
backendRequest := state.DB.DB.Where("id = ?", req.ProviderID).First(&backend)
if backendRequest.Error != nil {
log.Warnf("failed to find if backend exists or not: %s", backendRequest.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find if backend exists",
})
}
backendExists := backendRequest.RowsAffected > 0
if !backendExists {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Could not find backend",
})
}
autoStart := false
if req.AutoStart != nil {
autoStart = *req.AutoStart
}
proxy := &db.Proxy{
UserID: user.ID,
BackendID: req.ProviderID,
Name: req.Name,
Description: req.Description,
Protocol: req.Protocol,
SourceIP: req.SourceIP,
SourcePort: req.SourcePort,
DestinationPort: req.DestinationPort,
AutoStart: autoStart,
}
if result := state.DB.DB.Create(proxy); result.Error != nil {
log.Warnf("failed to create proxy: %s", result.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to add forward rule to database",
})
return
}
if autoStart {
backend, ok := backendruntime.RunningBackends[proxy.BackendID]
if !ok {
log.Warnf("Couldn't fetch backend runtime from backend ID #%d", proxy.BackendID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"id": proxy.ID,
})
return
}
backendResponse, err := backend.ProcessCommand(&commonbackend.AddProxy{
SourceIP: proxy.SourceIP,
SourcePort: proxy.SourcePort,
DestPort: proxy.DestinationPort,
Protocol: proxy.Protocol,
})
if err != nil {
log.Warnf("Failed to get response for backend #%d: %s", proxy.BackendID, err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to get response from backend",
})
return
}
switch responseMessage := backendResponse.(type) {
case *commonbackend.ProxyStatusResponse:
if !responseMessage.IsActive {
log.Warnf("Failed to start proxy for backend #%d", proxy.BackendID)
}
default:
log.Errorf("Got illegal response type for backend #%d: %T", proxy.BackendID, responseMessage)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"id": proxy.ID,
})
})
}

View file

@ -0,0 +1,184 @@
package proxies
import (
"fmt"
"net/http"
"strings"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type ProxyLookupRequest struct {
Token string `validate:"required" json:"token"`
Id *uint `json:"id"`
Name *string `json:"name"`
Description *string `json:"description"`
Protocol *string `json:"protocol"`
SourceIP *string `json:"sourceIP"`
SourcePort *uint16 `json:"sourcePort"`
DestinationPort *uint16 `json:"destPort"`
ProviderID *uint `json:"providerID"`
AutoStart *bool `json:"autoStart"`
}
type SanitizedProxy struct {
Id uint `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Protcol string `json:"protocol"`
SourceIP string `json:"sourceIP"`
SourcePort uint16 `json:"sourcePort"`
DestinationPort uint16 `json:"destPort"`
ProviderID uint `json:"providerID"`
AutoStart bool `json:"autoStart"`
}
type ProxyLookupResponse struct {
Success bool `json:"success"`
Data []*SanitizedProxy `json:"data"`
}
func SetupLookupProxy(state *state.State) {
state.Engine.POST("/api/v1/forward/lookup", func(c *gin.Context) {
var req ProxyLookupRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
user, err := state.JWT.GetUserFromJWT(req.Token)
if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
log.Warnf("Failed to get user from the provided JWT token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse token",
})
return
}
}
if !permissions.UserHasPermission(user, "routes.visible") {
c.JSON(http.StatusForbidden, gin.H{
"error": "Missing permissions",
})
return
}
if req.Protocol != nil {
if *req.Protocol != "tcp" && *req.Protocol != "udp" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Protocol specified in body must either be 'tcp' or 'udp'",
})
return
}
}
proxies := []db.Proxy{}
queryString := []string{}
queryParameters := []interface{}{}
if req.Id != nil {
queryString = append(queryString, "id = ?")
queryParameters = append(queryParameters, req.Id)
}
if req.Name != nil {
queryString = append(queryString, "name = ?")
queryParameters = append(queryParameters, req.Name)
}
if req.Description != nil {
queryString = append(queryString, "description = ?")
queryParameters = append(queryParameters, req.Description)
}
if req.SourceIP != nil {
queryString = append(queryString, "name = ?")
queryParameters = append(queryParameters, req.Name)
}
if req.SourcePort != nil {
queryString = append(queryString, "source_port = ?")
queryParameters = append(queryParameters, req.SourcePort)
}
if req.DestinationPort != nil {
queryString = append(queryString, "destination_port = ?")
queryParameters = append(queryParameters, req.DestinationPort)
}
if req.ProviderID != nil {
queryString = append(queryString, "backend_id = ?")
queryParameters = append(queryParameters, req.ProviderID)
}
if req.AutoStart != nil {
queryString = append(queryString, "auto_start = ?")
queryParameters = append(queryParameters, req.AutoStart)
}
if req.Protocol != nil {
queryString = append(queryString, "protocol = ?")
queryParameters = append(queryParameters, req.Protocol)
}
if err := state.DB.DB.Where(strings.Join(queryString, " AND "), queryParameters...).Find(&proxies).Error; err != nil {
log.Warnf("failed to get proxies: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get proxies",
})
return
}
sanitizedProxies := make([]*SanitizedProxy, len(proxies))
for proxyIndex, proxy := range proxies {
sanitizedProxies[proxyIndex] = &SanitizedProxy{
Id: proxy.ID,
Name: proxy.Name,
Description: proxy.Description,
Protcol: proxy.Protocol,
SourceIP: proxy.SourceIP,
SourcePort: proxy.SourcePort,
DestinationPort: proxy.DestinationPort,
ProviderID: proxy.BackendID,
AutoStart: proxy.AutoStart,
}
}
c.JSON(http.StatusOK, &ProxyLookupResponse{
Success: true,
Data: sanitizedProxies,
})
})
}

View file

@ -0,0 +1,150 @@
package proxies
import (
"fmt"
"net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type ProxyRemovalRequest struct {
Token string `validate:"required" json:"token"`
ID uint `validate:"required" json:"id"`
}
func SetupRemoveProxy(state *state.State) {
state.Engine.POST("/api/v1/forward/remove", func(c *gin.Context) {
var req ProxyRemovalRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
user, err := state.JWT.GetUserFromJWT(req.Token)
if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
log.Warnf("Failed to get user from the provided JWT token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse token",
})
return
}
}
if !permissions.UserHasPermission(user, "routes.remove") {
c.JSON(http.StatusForbidden, gin.H{
"error": "Missing permissions",
})
return
}
var proxy *db.Proxy
proxyRequest := state.DB.DB.Where("id = ?", req.ID).Find(&proxy)
if proxyRequest.Error != nil {
log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find if forward rule exists",
})
return
}
proxyExists := proxyRequest.RowsAffected > 0
if !proxyExists {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Forward rule doesn't exist",
})
return
}
if err := state.DB.DB.Delete(proxy).Error; err != nil {
log.Warnf("failed to delete proxy: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to delete forward rule",
})
return
}
backend, ok := backendruntime.RunningBackends[proxy.BackendID]
if !ok {
log.Warnf("Couldn't fetch backend runtime from backend ID #%d", proxy.BackendID)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Couldn't fetch backend runtime",
})
return
}
backendResponse, err := backend.ProcessCommand(&commonbackend.RemoveProxy{
SourceIP: proxy.SourceIP,
SourcePort: proxy.SourcePort,
DestPort: proxy.DestinationPort,
Protocol: proxy.Protocol,
})
if err != nil {
log.Warnf("Failed to get response for backend #%d: %s", proxy.BackendID, err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get response from backend. Proxy was still successfully deleted",
})
return
}
switch responseMessage := backendResponse.(type) {
case *commonbackend.ProxyStatusResponse:
if responseMessage.IsActive {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to stop proxy. Proxy was still successfully deleted",
})
} else {
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}
default:
log.Errorf("Got illegal response type for backend #%d: %T", proxy.BackendID, responseMessage)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Got invalid response from backend. Proxy was still successfully deleted",
})
}
})
}

View file

@ -0,0 +1,136 @@
package proxies
import (
"fmt"
"net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type ProxyStartRequest struct {
Token string `validate:"required" json:"token"`
ID uint `validate:"required" json:"id"`
}
func SetupStartProxy(state *state.State) {
state.Engine.POST("/api/v1/forward/start", func(c *gin.Context) {
var req ProxyStartRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
user, err := state.JWT.GetUserFromJWT(req.Token)
if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
log.Warnf("Failed to get user from the provided JWT token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse token",
})
return
}
}
if !permissions.UserHasPermission(user, "routes.start") {
c.JSON(http.StatusForbidden, gin.H{
"error": "Missing permissions",
})
return
}
var proxy *db.Proxy
proxyRequest := state.DB.DB.Where("id = ?", req.ID).Find(&proxy)
if proxyRequest.Error != nil {
log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find if forward rule exists",
})
return
}
proxyExists := proxyRequest.RowsAffected > 0
if !proxyExists {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Forward rule doesn't exist",
})
return
}
backend, ok := backendruntime.RunningBackends[proxy.BackendID]
if !ok {
log.Warnf("Couldn't fetch backend runtime from backend ID #%d", proxy.BackendID)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Couldn't fetch backend runtime",
})
return
}
backendResponse, err := backend.ProcessCommand(&commonbackend.AddProxy{
SourceIP: proxy.SourceIP,
SourcePort: proxy.SourcePort,
DestPort: proxy.DestinationPort,
Protocol: proxy.Protocol,
})
switch responseMessage := backendResponse.(type) {
case error:
log.Warnf("Failed to get response for backend #%d: %s", proxy.BackendID, responseMessage.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to get response from backend",
})
case *commonbackend.ProxyStatusResponse:
if !responseMessage.IsActive {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to start proxy",
})
} else {
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}
default:
log.Errorf("Got illegal response type for backend #%d: %T", proxy.BackendID, responseMessage)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Got invalid response from backend. Proxy was likely still successfully started",
})
}
})
}

View file

@ -0,0 +1,136 @@
package proxies
import (
"fmt"
"net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type ProxyStopRequest struct {
Token string `validate:"required" json:"token"`
ID uint `validate:"required" json:"id"`
}
func SetupStopProxy(state *state.State) {
state.Engine.POST("/api/v1/forward/stop", func(c *gin.Context) {
var req ProxyStartRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
user, err := state.JWT.GetUserFromJWT(req.Token)
if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
log.Warnf("Failed to get user from the provided JWT token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse token",
})
return
}
}
if !permissions.UserHasPermission(user, "routes.stop") {
c.JSON(http.StatusForbidden, gin.H{
"error": "Missing permissions",
})
return
}
var proxy *db.Proxy
proxyRequest := state.DB.DB.Where("id = ?", req.ID).Find(&proxy)
if proxyRequest.Error != nil {
log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find if forward rule exists",
})
return
}
proxyExists := proxyRequest.RowsAffected > 0
if !proxyExists {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Forward rule doesn't exist",
})
return
}
backend, ok := backendruntime.RunningBackends[proxy.BackendID]
if !ok {
log.Warnf("Couldn't fetch backend runtime from backend ID #%d", proxy.BackendID)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Couldn't fetch backend runtime",
})
return
}
backendResponse, err := backend.ProcessCommand(&commonbackend.RemoveProxy{
SourceIP: proxy.SourceIP,
SourcePort: proxy.SourcePort,
DestPort: proxy.DestinationPort,
Protocol: proxy.Protocol,
})
switch responseMessage := backendResponse.(type) {
case error:
log.Warnf("Failed to get response for backend #%d: %s", proxy.BackendID, responseMessage.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to get response from backend",
})
case *commonbackend.ProxyStatusResponse:
if responseMessage.IsActive {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to stop proxy",
})
} else {
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}
default:
log.Errorf("Got illegal response type for backend #%d: %T", proxy.BackendID, responseMessage)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Got invalid response from backend. Proxy was likely still successfully stopped",
})
}
})
}

View file

@ -0,0 +1,15 @@
package users
import "os"
var (
signupEnabled bool
unsafeSignup bool
forceNoExpiryTokens bool
)
func init() {
signupEnabled = os.Getenv("HERMES_SIGNUP_ENABLED") != ""
unsafeSignup = os.Getenv("HERMES_UNSAFE_ADMIN_SIGNUP_ENABLED") != ""
forceNoExpiryTokens = os.Getenv("HERMES_FORCE_DISABLE_REFRESH_TOKEN_EXPIRY") != ""
}

View file

@ -0,0 +1,160 @@
package users
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"strings"
"git.terah.dev/imterah/hermes/backend/api/db"
permissionHelper "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
type UserCreationRequest struct {
Name string `validate:"required"`
Email string `validate:"required"`
Password string `validate:"required"`
Username string `validate:"required"`
IsBot bool
}
func SetupCreateUser(state *state.State) {
state.Engine.POST("/api/v1/users/create", func(c *gin.Context) {
if !signupEnabled && !unsafeSignup {
c.JSON(http.StatusForbidden, gin.H{
"error": "Signing up is not enabled at this time.",
})
return
}
var req UserCreationRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
var user *db.User
userRequest := state.DB.DB.Where("email = ? OR username = ?", req.Email, req.Username).Find(&user)
if userRequest.Error != nil {
log.Warnf("failed to find if user exists or not: %s", userRequest.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find if user exists",
})
return
}
userExists := userRequest.RowsAffected > 0
if userExists {
c.JSON(http.StatusBadRequest, gin.H{
"error": "User already exists",
})
return
}
passwordHashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Warnf("Failed to generate password for client upon signup: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate password hash",
})
return
}
permissions := []db.Permission{}
for _, permission := range permissionHelper.DefaultPermissionNodes {
permissionEnabledState := false
if unsafeSignup || strings.HasPrefix(permission, "routes.") || permission == "permissions.see" {
permissionEnabledState = true
}
permissions = append(permissions, db.Permission{
PermissionNode: permission,
HasPermission: permissionEnabledState,
})
}
tokenRandomData := make([]byte, 80)
if _, err := rand.Read(tokenRandomData); err != nil {
log.Warnf("Failed to read random data to use as token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate refresh token",
})
return
}
user = &db.User{
Email: req.Email,
Username: req.Username,
Name: req.Name,
IsBot: &req.IsBot,
Password: base64.StdEncoding.EncodeToString(passwordHashed),
Permissions: permissions,
Tokens: []db.Token{
{
Token: base64.StdEncoding.EncodeToString(tokenRandomData),
DisableExpiry: forceNoExpiryTokens,
CreationIPAddr: c.ClientIP(),
},
},
}
if result := state.DB.DB.Create(&user); result.Error != nil {
log.Warnf("Failed to create user: %s", result.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to add user into database",
})
return
}
jwt, err := state.JWT.Generate(user.ID)
if err != nil {
log.Warnf("Failed to generate JWT: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate refresh token",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"token": jwt,
"refreshToken": base64.StdEncoding.EncodeToString(tokenRandomData),
})
})
}

View file

@ -0,0 +1,158 @@
package users
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
type UserLoginRequest struct {
Username *string
Email *string
Password string `validate:"required"`
}
func SetupLoginUser(state *state.State) {
state.Engine.POST("/api/v1/users/login", func(c *gin.Context) {
var req UserLoginRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
if req.Email == nil && req.Username == nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Missing both email and username in body",
})
return
}
userFindRequestArguments := make([]interface{}, 1)
userFindRequest := ""
if req.Email != nil {
userFindRequestArguments[0] = &req.Email
userFindRequest += "email = ?"
}
if req.Username != nil {
userFindRequestArguments[0] = &req.Username
userFindRequest += "username = ?"
}
var user *db.User
userRequest := state.DB.DB.Where(userFindRequest, userFindRequestArguments...).Find(&user)
if userRequest.Error != nil {
log.Warnf("failed to find if user exists or not: %s", userRequest.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find if user exists",
})
return
}
userExists := userRequest.RowsAffected > 0
if !userExists {
c.JSON(http.StatusBadRequest, gin.H{
"error": "User not found",
})
return
}
decodedPassword := make([]byte, base64.StdEncoding.DecodedLen(len(user.Password)))
_, err := base64.StdEncoding.Decode(decodedPassword, []byte(user.Password))
if err != nil {
log.Warnf("failed to decode password in database: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse database result for password",
})
return
}
err = bcrypt.CompareHashAndPassword(decodedPassword, []byte(req.Password))
if err != nil {
c.JSON(http.StatusForbidden, gin.H{
"error": "Invalid password",
})
return
}
tokenRandomData := make([]byte, 80)
if _, err := rand.Read(tokenRandomData); err != nil {
log.Warnf("Failed to read random data to use as token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate refresh token",
})
return
}
token := &db.Token{
UserID: user.ID,
Token: base64.StdEncoding.EncodeToString(tokenRandomData),
DisableExpiry: forceNoExpiryTokens,
CreationIPAddr: c.ClientIP(),
}
if result := state.DB.DB.Create(&token); result.Error != nil {
log.Warnf("Failed to create user: %s", result.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to add refresh token into database",
})
return
}
jwt, err := state.JWT.Generate(user.ID)
if err != nil {
log.Warnf("Failed to generate JWT: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate refresh token",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"token": jwt,
"refreshToken": base64.StdEncoding.EncodeToString(tokenRandomData),
})
})
}

View file

@ -0,0 +1,137 @@
package users
import (
"fmt"
"net/http"
"strings"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type UserLookupRequest struct {
Token string `validate:"required"`
UID *uint `json:"id"`
Name *string `json:"name"`
Email *string `json:"email"`
Username *string `json:"username"`
IsBot *bool `json:"isServiceAccount"`
}
type SanitizedUsers struct {
UID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Username string `json:"username"`
IsBot bool `json:"isServiceAccount"`
}
type LookupResponse struct {
Success bool `json:"success"`
Data []*SanitizedUsers `json:"data"`
}
func SetupLookupUser(state *state.State) {
state.Engine.POST("/api/v1/users/lookup", func(c *gin.Context) {
var req UserLookupRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
user, err := state.JWT.GetUserFromJWT(req.Token)
if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
log.Warnf("Failed to get user from the provided JWT token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse token",
})
return
}
}
users := []db.User{}
queryString := []string{}
queryParameters := []interface{}{}
if !permissions.UserHasPermission(user, "users.lookup") {
queryString = append(queryString, "id = ?")
queryParameters = append(queryParameters, user.ID)
} else if permissions.UserHasPermission(user, "users.lookup") && req.UID != nil {
queryString = append(queryString, "id = ?")
queryParameters = append(queryParameters, req.UID)
}
if req.Name != nil {
queryString = append(queryString, "name = ?")
queryParameters = append(queryParameters, req.Name)
}
if req.Email != nil {
queryString = append(queryString, "email = ?")
queryParameters = append(queryParameters, req.Email)
}
if req.IsBot != nil {
queryString = append(queryString, "is_bot = ?")
queryParameters = append(queryParameters, req.IsBot)
}
if err := state.DB.DB.Where(strings.Join(queryString, " AND "), queryParameters...).Find(&users).Error; err != nil {
log.Warnf("Failed to get users: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get users",
})
return
}
sanitizedUsers := make([]*SanitizedUsers, len(users))
for userIndex, user := range users {
isBot := false
if user.IsBot != nil {
isBot = *user.IsBot
}
sanitizedUsers[userIndex] = &SanitizedUsers{
UID: user.ID,
Name: user.Name,
Email: user.Email,
Username: user.Username,
IsBot: isBot,
}
}
c.JSON(http.StatusOK, &LookupResponse{
Success: true,
Data: sanitizedUsers,
})
})
}

View file

@ -0,0 +1,118 @@
package users
import (
"fmt"
"net/http"
"time"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type UserRefreshRequest struct {
Token string `validate:"required"`
}
func SetupRefreshUserToken(state *state.State) {
state.Engine.POST("/api/v1/users/refresh", func(c *gin.Context) {
var req UserRefreshRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
var tokenInDatabase *db.Token
tokenRequest := state.DB.DB.Where("token = ?", req.Token).Find(&tokenInDatabase)
if tokenRequest.Error != nil {
log.Warnf("failed to find if token exists or not: %s", tokenRequest.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find if token exists",
})
return
}
tokenExists := tokenRequest.RowsAffected > 0
if !tokenExists {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Token not found",
})
return
}
// First, we check to make sure that the key expiry is disabled before checking if the key is expired.
// Then, we check if the IP addresses differ, or if it has been 7 days since the token has been created.
if !tokenInDatabase.DisableExpiry && (c.ClientIP() != tokenInDatabase.CreationIPAddr || time.Now().Before(tokenInDatabase.CreatedAt.Add((24*7)*time.Hour))) {
c.JSON(http.StatusForbidden, gin.H{
"error": "Token has expired",
})
tx := state.DB.DB.Delete(tokenInDatabase)
if tx.Error != nil {
log.Warnf("Failed to delete expired token from database: %s", tx.Error.Error())
}
return
}
// Get the user to check if the user exists before doing anything
var user *db.User
userRequest := state.DB.DB.Where("id = ?", tokenInDatabase.UserID).Find(&user)
if tokenRequest.Error != nil {
log.Warnf("failed to find if token user or not: %s", userRequest.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find user",
})
return
}
userExists := userRequest.RowsAffected > 0
if !userExists {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "User not found",
})
return
}
jwt, err := state.JWT.Generate(user.ID)
if err != nil {
log.Warnf("Failed to generate JWT: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate refresh token",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"token": jwt,
})
})
}

View file

@ -0,0 +1,106 @@
package users
import (
"fmt"
"net/http"
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log"
"github.com/gin-gonic/gin"
)
type UserRemovalRequest struct {
Token string `validate:"required"`
UID *uint `json:"uid"`
}
func SetupRemoveUser(state *state.State) {
state.Engine.POST("/api/v1/users/remove", func(c *gin.Context) {
var req UserRemovalRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to parse body: %s", err.Error()),
})
return
}
if err := state.Validator.Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
})
return
}
user, err := state.JWT.GetUserFromJWT(req.Token)
if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
log.Warnf("Failed to get user from the provided JWT token: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to parse token",
})
return
}
}
uid := user.ID
if req.UID != nil {
uid = *req.UID
if uid != user.ID && !permissions.UserHasPermission(user, "users.remove") {
c.JSON(http.StatusForbidden, gin.H{
"error": "Missing permissions",
})
return
}
}
// Make sure the user exists first if we have a custom UserID
if uid != user.ID {
var customUser *db.User
userRequest := state.DB.DB.Where("id = ?", uid).Find(customUser)
if userRequest.Error != nil {
log.Warnf("failed to find if user exists or not: %s", userRequest.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to find if user exists",
})
return
}
userExists := userRequest.RowsAffected > 0
if !userExists {
c.JSON(http.StatusBadRequest, gin.H{
"error": "User doesn't exist",
})
return
}
}
state.DB.DB.Select("Tokens", "Permissions", "Proxys", "Backends").Where("id = ?", uid).Delete(user)
c.JSON(http.StatusOK, gin.H{
"success": true,
})
})
}

77
backend/api/db/db.go Normal file
View file

@ -0,0 +1,77 @@
package db
import (
"fmt"
"os"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type DB struct {
DB *gorm.DB
}
func New(backend, params string) (*DB, error) {
var err error
dialector, err := initDialector(backend, params)
if err != nil {
return nil, fmt.Errorf("failed to initialize physical database: %s", err)
}
database, err := gorm.Open(dialector)
if err != nil {
return nil, fmt.Errorf("failed to open database: %s", err)
}
return &DB{DB: database}, nil
}
func (db *DB) DoMigrations() error {
if err := db.DB.AutoMigrate(&Proxy{}); err != nil {
return err
}
if err := db.DB.AutoMigrate(&Backend{}); err != nil {
return err
}
if err := db.DB.AutoMigrate(&Permission{}); err != nil {
return err
}
if err := db.DB.AutoMigrate(&Token{}); err != nil {
return err
}
if err := db.DB.AutoMigrate(&User{}); err != nil {
return err
}
return nil
}
func initDialector(backend, params string) (gorm.Dialector, error) {
switch backend {
case "sqlite":
if params == "" {
return nil, fmt.Errorf("sqlite database file not specified")
}
return sqlite.Open(params), nil
case "postgresql":
if params == "" {
return nil, fmt.Errorf("postgres DSN not specified")
}
return postgres.Open(params), nil
case "":
return nil, fmt.Errorf("no database backend specified in environment variables")
default:
return nil, fmt.Errorf("unknown database backend specified: %s", os.Getenv(backend))
}
}

66
backend/api/db/models.go Normal file
View file

@ -0,0 +1,66 @@
package db
import (
"gorm.io/gorm"
)
type Backend struct {
gorm.Model
UserID uint
Name string
Description *string
Backend string
BackendParameters string
Proxies []Proxy
}
type Proxy struct {
gorm.Model
BackendID uint
UserID uint
Name string
Description *string
Protocol string
SourceIP string
SourcePort uint16
DestinationPort uint16
AutoStart bool
}
type Permission struct {
gorm.Model
PermissionNode string
HasPermission bool
UserID uint
}
type Token struct {
gorm.Model
UserID uint
Token string
DisableExpiry bool
CreationIPAddr string
}
type User struct {
gorm.Model
Email string `gorm:"unique"`
Username string `gorm:"unique"`
Name string
Password string
IsBot *bool
Permissions []Permission
OwnedProxies []Proxy
OwnedBackends []Backend
Tokens []Token
}

107
backend/api/jwt/jwt.go Normal file
View file

@ -0,0 +1,107 @@
package jwt
import (
"errors"
"fmt"
"strconv"
"time"
"git.terah.dev/imterah/hermes/backend/api/db"
"github.com/golang-jwt/jwt/v5"
)
var (
DevelopmentModeTimings = time.Duration(60*24) * time.Minute
NormalModeTimings = time.Duration(3) * time.Minute
)
type JWTCore struct {
Key []byte
Database *db.DB
TimeMultiplier time.Duration
}
func New(key []byte, database *db.DB, timeMultiplier time.Duration) *JWTCore {
jwtCore := &JWTCore{
Key: key,
Database: database,
TimeMultiplier: timeMultiplier,
}
return jwtCore
}
func (jwtCore *JWTCore) Parse(tokenString string, options ...jwt.ParserOption) (*jwt.Token, error) {
return jwt.Parse(tokenString, jwtCore.jwtKeyCallback, options...)
}
func (jwtCore *JWTCore) GetUserFromJWT(token string) (*db.User, error) {
if jwtCore.Database == nil {
return nil, fmt.Errorf("database is not initialized")
}
parsedJWT, err := jwtCore.Parse(token)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, fmt.Errorf("token is expired")
} else {
return nil, err
}
}
audience, err := parsedJWT.Claims.GetAudience()
if err != nil {
return nil, err
}
if len(audience) < 1 {
return nil, fmt.Errorf("audience is too small")
}
uid, err := strconv.Atoi(audience[0])
if err != nil {
return nil, err
}
user := &db.User{}
userRequest := jwtCore.Database.DB.Preload("Permissions").Where("id = ?", uint(uid)).Find(&user)
if userRequest.Error != nil {
return user, fmt.Errorf("failed to find if user exists or not: %s", userRequest.Error.Error())
}
userExists := userRequest.RowsAffected > 0
if !userExists {
return user, fmt.Errorf("user does not exist")
}
return user, nil
}
func (jwtCore *JWTCore) Generate(uid uint) (string, error) {
currentJWTTime := jwt.NewNumericDate(time.Now())
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(jwtCore.TimeMultiplier)),
IssuedAt: currentJWTTime,
NotBefore: currentJWTTime,
// Convert the user ID to a string, and then set it as the audience parameters only value (there's only 1 user per key)
Audience: []string{strconv.Itoa(int(uid))},
})
signedToken, err := token.SignedString(jwtCore.Key)
if err != nil {
return "", err
}
return signedToken, nil
}
func (jwtCore *JWTCore) jwtKeyCallback(*jwt.Token) (any, error) {
return jwtCore.Key, nil
}

Some files were not shown because too many files have changed in this diff Show more