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
|
||||
"runArgs": [
|
||||
"--security-opt", "label=disable"
|
||||
],
|
||||
"runArgs": ["--security-opt", "label=disable"],
|
||||
|
||||
"containerEnv": {
|
||||
// extensions to preload before other extensions
|
||||
// extensions to preload before other extensions
|
||||
"PRELOAD_EXTENSIONS": "arrterian.nix-env-selector"
|
||||
},
|
||||
|
||||
// disable command overriding and updating remote user ID
|
||||
// disable command overriding and updating remote user ID
|
||||
"overrideCommand": false,
|
||||
"userEnvProbe": "loginShell",
|
||||
"updateRemoteUserUID": false,
|
||||
|
@ -31,18 +29,14 @@
|
|||
"onCreateCommand": "nix-shell --command 'echo done building nix dev environment'",
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
3000
|
||||
],
|
||||
"forwardPorts": [8000],
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"arrterian.nix-env-selector"
|
||||
]
|
||||
"extensions": ["arrterian.nix-env-selector"]
|
||||
}
|
||||
}
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "go version",
|
||||
}
|
||||
}
|
||||
|
|
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
|
||||
lom/keys
|
||||
# Go artifacts
|
||||
backend/api/api
|
||||
backend/sshbackend/sshbackend
|
||||
backend/dummybackend/dummybackend
|
||||
backend/sshappbackend/local-code/remote-bin
|
||||
backend/sshappbackend/local-code/sshappbackend
|
||||
backend/externalbackendlauncher/externalbackendlauncher
|
||||
frontend/frontend
|
||||
|
||||
# Backup artifacts
|
||||
*.json.gz
|
||||
|
||||
# Output
|
||||
out
|
||||
|
@ -135,4 +144,4 @@ dist
|
|||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
.tmp
|
||||
.tmp
|
||||
|
|
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": [
|
||||
"bbenoist.Nix",
|
||||
"Prisma.prisma",
|
||||
|
||||
"rust-lang.rust-analyzer",
|
||||
"tamasfe.even-better-toml",
|
||||
"dustypomerleau.rust-syntax",
|
||||
]
|
||||
}
|
||||
"recommendations": ["bbenoist.Nix", "Prisma.prisma", "golang.go"]
|
||||
}
|
||||
|
|
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
|
@ -11,5 +11,8 @@
|
|||
"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
|
||||
|
||||
Copyright (c) 2024, Greyson
|
||||
Copyright (c) 2024, Tera
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
|
41
README.md
41
README.md
|
@ -1,43 +1,46 @@
|
|||
<h1 align="center">NextNet</h1>
|
||||
<h1 align="center">Hermes</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://builtwithnix.org"><img src="https://builtwithnix.org/badge.svg" alt="built with nix" height="20"/></a>
|
||||
<img src="https://img.shields.io/github/license/greysoh/nextnet" alt="License Badge"/>
|
||||
<img src="https://img.shields.io/badge/built-with_docker-purple" alt="Docker Badge"/>
|
||||
<img src="https://img.shields.io/badge/built-with_Go-blue" alt="Golang Badge">
|
||||
<img src="https://img.shields.io/badge/license-BSD--3--Clause-green" alt="License Badge (licensed under BSD-3-Clause)"/>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
**NextNet is a dashboard to manage portforwarding technologies.**
|
||||
<p align="center">
|
||||
<b>Port forwarding across boundaries.</b>
|
||||
</p>
|
||||
|
||||
<h2 align="center">Local Development</h2>
|
||||
|
||||
> [!NOTE]
|
||||
> Using [nix](https://builtwithnix.org) is recommended. If you're not using Nix, install PostgreSQL, Node.JS, and `lsof`.
|
||||
> Using [Nix](https://builtwithnix.org) is recommended for the development environment. If you're not using it, install Go. For legacy maintence tasks, install NodeJS.
|
||||
|
||||
1. First, check if you have a working Nix environment if you're using Nix.
|
||||
1. Firstly, check if you have a working Nix environment if you're using Nix.
|
||||
|
||||
2. Run `nix-shell`, or alternatively `source init.sh` if you're not using Nix.
|
||||
2. Secondly, Run `nix-shell`, or alternatively `source init.sh` if you're not using Nix.
|
||||
|
||||
<h3 align="center">API Development</h3>
|
||||
|
||||
1. After that, run the project in development mode: `npm run dev`.
|
||||
1. After that, run the backend build script: `./build.sh`.
|
||||
|
||||
2. If you want to explore your database, run `npx prisma studio` to open the database editor.
|
||||
2. Then, go into the `api/` directory, and then start it up: `go run . -b ../backends.dev.json`
|
||||
|
||||
<h2 align="center">Production Deployment</h2>
|
||||
|
||||
> [!WARNING]
|
||||
> Deploying using docker compose is the only officially supported deployment method. Here be dragons!
|
||||
> [!WARNING]
|
||||
> Deploying using [Docker Compose](https://docs.docker.com/compose/) is the only officially supported deployment method.
|
||||
|
||||
1. Copy and change the default password (or username & db name too) from the template file `prod-docker.env`:
|
||||
```bash
|
||||
sed "s/POSTGRES_PASSWORD=nextnet/POSTGRES_PASSWORD=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" prod-docker.env > .env
|
||||
```
|
||||
|
||||
```bash
|
||||
sed -e "s/POSTGRES_PASSWORD=hermes/POSTGRES_PASSWORD=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" -e "s/JWT_SECRET=hermes/JWT_SECRET=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" prod-docker.env > .env
|
||||
```
|
||||
|
||||
2. Build the docker stack: `docker compose --env-file .env up -d`
|
||||
|
||||
<h2 align="center">Troubleshooting</h2>
|
||||
|
||||
* I'm using the SSH tunneling, and I can't reach any of the tunnels publicly.
|
||||
This has been moved [here.](docs/troubleshooting.md)
|
||||
|
||||
- Be sure to enable GatewayPorts in your sshd config (in `/etc/ssh/sshd_config` on most systems). Also, be sure to check your firewall rules on your system and your network.
|
||||
<h2 align="center">Documentation</h2>
|
||||
|
||||
Go to the `docs/` folder.
|
||||
|
|
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