Compare commits
119 commits
Author | SHA1 | Date | |
---|---|---|---|
8e9c7f120f | |||
75b12f2053 | |||
b93bf456b5 | |||
d56a8eb7bf | |||
71d53990de | |||
1cefe64f88 | |||
83f80af405 | |||
7dee159d5f | |||
17b10c9b19 | |||
5c503f0421 | |||
959718163e | |||
24b165c9bb | |||
34b605c1b1 | |||
f8a4fe00a0 | |||
15176831e6 | |||
432d457ad7 | |||
cf90ddb104 | |||
62cc8b39ad | |||
17e1491f96 | |||
ede4d528aa | |||
a35602a6f2 | |||
4101ce7007 | |||
737ba2887f | |||
48adfc88db | |||
0efda4b283 | |||
356cfb8dca | |||
ea0a953b0e | |||
3429f2cd37 | |||
a3519220be | |||
4cb648cd66 | |||
7837334361 | |||
e456de9802 | |||
f24daabe45 | |||
f8d32fb1c6 | |||
1e1a330a4b | |||
93f2f9cbee | |||
157e1c8712 | |||
605ad31dd6 | |||
f505ff6605 | |||
843cd34785 | |||
96833b238b | |||
4ca2c809c9 | |||
aaacdfd5f4 | |||
fd4d6bfd65 | |||
49db323e81 | |||
201007f7a0 | |||
be92c5a569 | |||
c55510eb04 | |||
538c5b6c51 | |||
d334878599 | |||
c2eb2d15aa | |||
0bc41c430a | |||
e056911af4 | |||
862f307e56 | |||
84e1a437a4 | |||
c4c5e1cd16 | |||
51ebfe46d3 | |||
4a46b5aca0 | |||
217e73d9ec | |||
ed90f66b2b | |||
50281df8d0 | |||
0515ffe5da | |||
6fb4a9b5c1 | |||
2e6e8d38dd | |||
65ccd716ff | |||
9fff3be650 | |||
d76e32b2c5 | |||
5efb75d49d | |||
8527354423 | |||
fc0adcc663 | |||
e69b1cd7db | |||
4014188ad1 | |||
32bf007b60 | |||
622e8bd286 | |||
b22fd417b6 | |||
9d90477ffb | |||
899258ef7f | |||
7879c65e25 | |||
b0b11413fb | |||
a8bc56ccb5 | |||
5c45533371 | |||
8ba0424512 | |||
bcf97fde6d | |||
fe8980b265 | |||
|
495dff3de1 | ||
af6ee6ab66 | |||
|
5495fc4ae2 | ||
d73380e647 | |||
5ad69f6bbe | |||
|
216936fe50 | ||
|
3484760f41 | ||
|
0b73b4aa47 | ||
|
611d7f24f8 | ||
|
cee4e62f53 | ||
|
37d0d41570 | ||
|
7d5db06c7b | ||
|
67f67007d9 | ||
|
bbad26b686 | ||
|
af37abf075 | ||
|
1237a31f8b | ||
c7b71e754f | |||
|
d25da9091e | ||
|
559588f726 | ||
|
f9d648256a | ||
|
46178f3482 | ||
|
28ed6ddfd7 | ||
|
cc31bb2ad5 | ||
|
b30d8150f3 | ||
|
0b6e40a944 | ||
|
889be65392 | ||
|
64afe992b8 | ||
79389d37b8 | |||
|
aa16549667 | ||
|
3cb9526716 | ||
|
0d0f16174b | ||
|
c9fed58c6a | ||
|
28d25c9698 | ||
|
7b5ec36e61 | ||
|
0965c56547 |
180 changed files with 11432 additions and 11366 deletions
|
@ -13,16 +13,14 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
// run arguments passed to docker
|
// run arguments passed to docker
|
||||||
"runArgs": [
|
"runArgs": ["--security-opt", "label=disable"],
|
||||||
"--security-opt", "label=disable"
|
|
||||||
],
|
|
||||||
|
|
||||||
"containerEnv": {
|
"containerEnv": {
|
||||||
// extensions to preload before other extensions
|
// extensions to preload before other extensions
|
||||||
"PRELOAD_EXTENSIONS": "arrterian.nix-env-selector"
|
"PRELOAD_EXTENSIONS": "arrterian.nix-env-selector"
|
||||||
},
|
},
|
||||||
|
|
||||||
// disable command overriding and updating remote user ID
|
// disable command overriding and updating remote user ID
|
||||||
"overrideCommand": false,
|
"overrideCommand": false,
|
||||||
"userEnvProbe": "loginShell",
|
"userEnvProbe": "loginShell",
|
||||||
"updateRemoteUserUID": false,
|
"updateRemoteUserUID": false,
|
||||||
|
@ -31,18 +29,14 @@
|
||||||
"onCreateCommand": "nix-shell --command 'echo done building nix dev environment'",
|
"onCreateCommand": "nix-shell --command 'echo done building nix dev environment'",
|
||||||
|
|
||||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
"forwardPorts": [
|
"forwardPorts": [8000],
|
||||||
3000
|
|
||||||
],
|
|
||||||
|
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": ["arrterian.nix-env-selector"]
|
||||||
"arrterian.nix-env-selector"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
// "postCreateCommand": "go version",
|
// "postCreateCommand": "go version",
|
||||||
}
|
}
|
||||||
|
|
43
.forgejo/workflows/release.yml
Normal file
43
.forgejo/workflows/release.yml
Normal 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
|
|
@ -1,2 +0,0 @@
|
||||||
[core]
|
|
||||||
hooksPath = .githooks/
|
|
|
@ -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
16
.github/labeler.yml
vendored
|
@ -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'
|
|
61
.github/workflows/api-testing.yml
vendored
61
.github/workflows/api-testing.yml
vendored
|
@ -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
|
|
13
.github/workflows/label.yml
vendored
13
.github/workflows/label.yml
vendored
|
@ -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 }}"
|
|
40
.github/workflows/lom-testing.yml
vendored
40
.github/workflows/lom-testing.yml
vendored
|
@ -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
|
|
110
.github/workflows/release.yml
vendored
110
.github/workflows/release.yml
vendored
|
@ -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
15
.gitignore
vendored
|
@ -1,5 +1,14 @@
|
||||||
# LOM
|
# Go artifacts
|
||||||
lom/keys
|
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
|
# Output
|
||||||
out
|
out
|
||||||
|
@ -135,4 +144,4 @@ dist
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
.tmp
|
.tmp
|
||||||
|
|
16
.prettierrc
16
.prettierrc
|
@ -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
|
|
||||||
}
|
|
11
.vscode/extensions.json
vendored
11
.vscode/extensions.json
vendored
|
@ -1,10 +1,3 @@
|
||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": ["bbenoist.Nix", "Prisma.prisma", "golang.go"]
|
||||||
"bbenoist.Nix",
|
}
|
||||||
"Prisma.prisma",
|
|
||||||
|
|
||||||
"rust-lang.rust-analyzer",
|
|
||||||
"tamasfe.even-better-toml",
|
|
||||||
"dustypomerleau.rust-syntax",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
|
@ -11,5 +11,8 @@
|
||||||
"editor.tabSize": 2
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
|
|
||||||
"rust-analyzer.linkedProjects": ["./gui/Cargo.toml"]
|
"[go]": {
|
||||||
}
|
"editor.insertSpaces": false,
|
||||||
|
"editor.tabSize": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
49
CHANGELOG.md
49
CHANGELOG.md
|
@ -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
11
Dockerfile
Normal 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"]
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
||||||
BSD 3-Clause License
|
BSD 3-Clause License
|
||||||
|
|
||||||
Copyright (c) 2024, Greyson
|
Copyright (c) 2024, Tera
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Redistribution and use in source and binary forms, with or without
|
||||||
modification, are permitted provided that the following conditions are met:
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
41
README.md
41
README.md
|
@ -1,43 +1,46 @@
|
||||||
<h1 align="center">NextNet</h1>
|
<h1 align="center">Hermes</h1>
|
||||||
|
|
||||||
<p align="center">
|
<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/badge/built-with_docker-purple" alt="Docker Badge"/>
|
||||||
<img src="https://img.shields.io/github/license/greysoh/nextnet" alt="License 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>
|
</p>
|
||||||
|
|
||||||
<br>
|
<p align="center">
|
||||||
|
<b>Port forwarding across boundaries.</b>
|
||||||
**NextNet is a dashboard to manage portforwarding technologies.**
|
</p>
|
||||||
|
|
||||||
<h2 align="center">Local Development</h2>
|
<h2 align="center">Local Development</h2>
|
||||||
|
|
||||||
> [!NOTE]
|
> [!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>
|
<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>
|
<h2 align="center">Production Deployment</h2>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Deploying using docker compose is the only officially supported deployment method. Here be dragons!
|
> 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`:
|
1. Copy and change the default password (or username & db name too) from the template file `prod-docker.env`:
|
||||||
```bash
|
```bash
|
||||||
sed "s/POSTGRES_PASSWORD=nextnet/POSTGRES_PASSWORD=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" prod-docker.env > .env
|
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`
|
2. Build the docker stack: `docker compose --env-file .env up -d`
|
||||||
|
|
||||||
<h2 align="center">Troubleshooting</h2>
|
<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.
|
||||||
|
|
1
VERSION
1
VERSION
|
@ -1 +0,0 @@
|
||||||
1.1.2
|
|
|
@ -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
|
|
|
@ -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"
|
|
|
@ -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
|
|
|
@ -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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
32
api/init.sh
32
api/init.sh
|
@ -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
3187
api/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
|
@ -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";
|
|
|
@ -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;
|
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" ADD COLUMN "username" TEXT;
|
|
|
@ -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"
|
|
|
@ -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[]
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"version": "1",
|
|
||||||
"name": "Passyfire Base Routes",
|
|
||||||
"type": "collection",
|
|
||||||
"ignore": [
|
|
||||||
"node_modules",
|
|
||||||
".git"
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
140
api/src/index.ts
140
api/src/index.ts
|
@ -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);
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -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>;
|
|
||||||
};
|
|
|
@ -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
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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
|
|
|
@ -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
21
apiclient/apiclient.go
Normal 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)
|
||||||
|
}
|
102
apiclient/backendstructs/struct.go
Normal file
102
apiclient/backendstructs/struct.go
Normal 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
99
apiclient/users/auth.go
Normal 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
63
apiclient/users/create.go
Normal 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
|
||||||
|
}
|
15
backend/api/backendruntime/core.go
Normal file
15
backend/api/backendruntime/core.go
Normal 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"
|
||||||
|
}
|
396
backend/api/backendruntime/runtime.go
Normal file
396
backend/api/backendruntime/runtime.go
Normal 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
|
||||||
|
}
|
61
backend/api/backendruntime/struct.go
Normal file
61
backend/api/backendruntime/struct.go
Normal 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
|
||||||
|
}
|
270
backend/api/controllers/v1/backends/create.go
Normal file
270
backend/api/controllers/v1/backends/create.go
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
164
backend/api/controllers/v1/backends/lookup.go
Normal file
164
backend/api/controllers/v1/backends/lookup.go
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
124
backend/api/controllers/v1/backends/remove.go
Normal file
124
backend/api/controllers/v1/backends/remove.go
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
165
backend/api/controllers/v1/proxies/connections.go
Normal file
165
backend/api/controllers/v1/proxies/connections.go
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
177
backend/api/controllers/v1/proxies/create.go
Normal file
177
backend/api/controllers/v1/proxies/create.go
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
184
backend/api/controllers/v1/proxies/lookup.go
Normal file
184
backend/api/controllers/v1/proxies/lookup.go
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
150
backend/api/controllers/v1/proxies/remove.go
Normal file
150
backend/api/controllers/v1/proxies/remove.go
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
136
backend/api/controllers/v1/proxies/start.go
Normal file
136
backend/api/controllers/v1/proxies/start.go
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
136
backend/api/controllers/v1/proxies/stop.go
Normal file
136
backend/api/controllers/v1/proxies/stop.go
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
15
backend/api/controllers/v1/users/core.go
Normal file
15
backend/api/controllers/v1/users/core.go
Normal 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") != ""
|
||||||
|
}
|
160
backend/api/controllers/v1/users/create.go
Normal file
160
backend/api/controllers/v1/users/create.go
Normal 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),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
158
backend/api/controllers/v1/users/login.go
Normal file
158
backend/api/controllers/v1/users/login.go
Normal 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),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
137
backend/api/controllers/v1/users/lookup.go
Normal file
137
backend/api/controllers/v1/users/lookup.go
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
118
backend/api/controllers/v1/users/refresh.go
Normal file
118
backend/api/controllers/v1/users/refresh.go
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
106
backend/api/controllers/v1/users/remove.go
Normal file
106
backend/api/controllers/v1/users/remove.go
Normal 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
77
backend/api/db/db.go
Normal 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
66
backend/api/db/models.go
Normal 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
107
backend/api/jwt/jwt.go
Normal 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
Loading…
Add table
Add a link
Reference in a new issue