Compare commits
No commits in common. "dev" and "v0.1.1" have entirely different histories.
169 changed files with 5771 additions and 11438 deletions
|
@ -13,14 +13,16 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
// run arguments passed to docker
|
// run arguments passed to docker
|
||||||
"runArgs": ["--security-opt", "label=disable"],
|
"runArgs": [
|
||||||
|
"--security-opt", "label=disable"
|
||||||
|
],
|
||||||
|
|
||||||
"containerEnv": {
|
"containerEnv": {
|
||||||
// extensions to preload before other extensions
|
// extensions to preload before other extensions
|
||||||
"PRELOAD_EXTENSIONS": "arrterian.nix-env-selector"
|
"PRELOAD_EXTENSIONS": "arrterian.nix-env-selector"
|
||||||
},
|
},
|
||||||
|
|
||||||
// disable command overriding and updating remote user ID
|
// disable command overriding and updating remote user ID
|
||||||
"overrideCommand": false,
|
"overrideCommand": false,
|
||||||
"userEnvProbe": "loginShell",
|
"userEnvProbe": "loginShell",
|
||||||
"updateRemoteUserUID": false,
|
"updateRemoteUserUID": false,
|
||||||
|
@ -29,14 +31,18 @@
|
||||||
"onCreateCommand": "nix-shell --command 'echo done building nix dev environment'",
|
"onCreateCommand": "nix-shell --command 'echo done building nix dev environment'",
|
||||||
|
|
||||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
"forwardPorts": [8000],
|
"forwardPorts": [
|
||||||
|
3000
|
||||||
|
],
|
||||||
|
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": ["arrterian.nix-env-selector"]
|
"extensions": [
|
||||||
|
"arrterian.nix-env-selector"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
// "postCreateCommand": "go version",
|
// "postCreateCommand": "go version",
|
||||||
}
|
}
|
|
@ -1,43 +0,0 @@
|
||||||
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
|
|
2
.gitconfig
Normal file
2
.gitconfig
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[core]
|
||||||
|
hooksPath = .githooks/
|
6
.githooks/pre-commit
Executable file
6
.githooks/pre-commit
Executable file
|
@ -0,0 +1,6 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
shopt -s globstar
|
||||||
|
"$(git rev-parse --show-toplevel)"/api/node_modules/.bin/prettier --ignore-unknown --write $(git rev-parse --show-toplevel)/{api,lom}/src/**/*.ts
|
||||||
|
rustfmt $(git rev-parse --show-toplevel)/gui/src/**/*.rs
|
||||||
|
git update-index --again
|
||||||
|
exit 0
|
14
.github/labeler.yml
vendored
Normal file
14
.github/labeler.yml
vendored
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
modifies labeler:
|
||||||
|
- .github/labeler.yml
|
||||||
|
modifies ci:
|
||||||
|
- .github/workflows/*.yml
|
||||||
|
modifies docker:
|
||||||
|
- '**/Dockerfile'
|
||||||
|
- '**/docker-compose.yml'
|
||||||
|
- '**/*.env'
|
||||||
|
modifies api:
|
||||||
|
- api/**/*
|
||||||
|
modifies gui:
|
||||||
|
- gui/**/*
|
||||||
|
modifies nix:
|
||||||
|
- '**/*.nix'
|
13
.github/workflows/label.yml
vendored
Normal file
13
.github/workflows/label.yml
vendored
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
name: Labeler
|
||||||
|
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 }}"
|
110
.github/workflows/release.yml
vendored
Normal file
110
.github/workflows/release.yml
vendored
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
name: release
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
# FIXME FIXME FIXME: this could probably be sparser, but I wanna be on the safe side
|
||||||
|
- name: Make sparse changelog (1/3)
|
||||||
|
run: mv CHANGELOG.md TEMP_CHANGELOG.md && exit 0
|
||||||
|
|
||||||
|
- name: Make sparse changelog (2/3)
|
||||||
|
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 (3/3)
|
||||||
|
run: |
|
||||||
|
mv CHANGELOG.md SPARSE_CHANGELOG.md
|
||||||
|
mv TEMP_CHANGELOG.md 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: greysoh/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/greysoh/api:$(cat VERSION)
|
||||||
|
|
||||||
|
- name: Publish all docker images
|
||||||
|
run: |
|
||||||
|
docker tag ghcr.io/greysoh/api:$(cat VERSION) ghcr.io/greysoh/api:latest
|
||||||
|
docker push ghcr.io/greysoh/api:$(cat VERSION)
|
||||||
|
docker push ghcr.io/greysoh/api:latest
|
28
.gitignore
vendored
28
.gitignore
vendored
|
@ -1,14 +1,20 @@
|
||||||
# Go artifacts
|
# Rust
|
||||||
backend/api/api
|
# Generated by Cargo
|
||||||
backend/sshbackend/sshbackend
|
# will have compiled files and executables
|
||||||
backend/dummybackend/dummybackend
|
debug/
|
||||||
backend/sshappbackend/local-code/remote-bin
|
target/
|
||||||
backend/sshappbackend/local-code/sshappbackend
|
|
||||||
backend/externalbackendlauncher/externalbackendlauncher
|
|
||||||
frontend/frontend
|
|
||||||
|
|
||||||
# Backup artifacts
|
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||||
*.json.gz
|
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# NodeJS
|
||||||
|
|
||||||
# Output
|
# Output
|
||||||
out
|
out
|
||||||
|
@ -144,4 +150,4 @@ dist
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
.tmp
|
.tmp
|
16
.prettierrc
Normal file
16
.prettierrc
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"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,3 +1,10 @@
|
||||||
{
|
{
|
||||||
"recommendations": ["bbenoist.Nix", "Prisma.prisma", "golang.go"]
|
"recommendations": [
|
||||||
}
|
"bbenoist.Nix",
|
||||||
|
"Prisma.prisma",
|
||||||
|
|
||||||
|
"rust-lang.rust-analyzer",
|
||||||
|
"tamasfe.even-better-toml",
|
||||||
|
"dustypomerleau.rust-syntax",
|
||||||
|
]
|
||||||
|
}
|
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
|
@ -11,8 +11,5 @@
|
||||||
"editor.tabSize": 2
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
|
|
||||||
"[go]": {
|
"rust-analyzer.linkedProjects": ["./gui/Cargo.toml"]
|
||||||
"editor.insertSpaces": false,
|
}
|
||||||
"editor.tabSize": 4
|
|
||||||
}
|
|
||||||
}
|
|
11
Dockerfile
11
Dockerfile
|
@ -1,11 +0,0 @@
|
||||||
FROM golang:latest AS build
|
|
||||||
WORKDIR /build
|
|
||||||
COPY . /build
|
|
||||||
RUN cd backend; bash build.sh
|
|
||||||
FROM busybox:stable-glibc AS run
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=build /build/backend/backends.prod.json /app/backends.json
|
|
||||||
COPY --from=build /build/backend/api/api /app/hermes
|
|
||||||
COPY --from=build /build/backend/sshbackend/sshbackend /app/sshbackend
|
|
||||||
COPY --from=build /build/backend/sshappbackend/local-code/sshappbackend /app/sshappbackend
|
|
||||||
ENTRYPOINT ["/app/hermes", "--backends-path", "/app/backends.json"]
|
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
||||||
BSD 3-Clause License
|
BSD 3-Clause License
|
||||||
|
|
||||||
Copyright (c) 2024, Tera
|
Copyright (c) 2024, Greyson
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Redistribution and use in source and binary forms, with or without
|
||||||
modification, are permitted provided that the following conditions are met:
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
41
README.md
41
README.md
|
@ -1,46 +1,43 @@
|
||||||
<h1 align="center">Hermes</h1>
|
<h1 align="center">NextNet</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/built-with_docker-purple" alt="Docker Badge"/>
|
<a href="https://builtwithnix.org"><img src="https://builtwithnix.org/badge.svg" alt="built with nix" height="20"/></a>
|
||||||
<img src="https://img.shields.io/badge/built-with_Go-blue" alt="Golang Badge">
|
<img src="https://img.shields.io/github/license/greysoh/nextnet" alt="License Badge"/>
|
||||||
<img src="https://img.shields.io/badge/license-BSD--3--Clause-green" alt="License Badge (licensed under BSD-3-Clause)"/>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<br>
|
||||||
<b>Port forwarding across boundaries.</b>
|
|
||||||
</p>
|
**NextNet is a dashboard to manage portforwarding technologies.**
|
||||||
|
|
||||||
<h2 align="center">Local Development</h2>
|
<h2 align="center">Local Development</h2>
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 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.
|
> Using [nix](https://builtwithnix.org) is recommended. If you're not using Nix, install PostgreSQL, Node.JS, and `lsof`.
|
||||||
|
|
||||||
1. Firstly, check if you have a working Nix environment if you're using Nix.
|
1. First, check if you have a working Nix environment if you're using Nix.
|
||||||
|
|
||||||
2. Secondly, Run `nix-shell`, or alternatively `source init.sh` if you're not using Nix.
|
2. Run `nix-shell`, or alternatively `source init.sh` if you're not using Nix.
|
||||||
|
|
||||||
<h3 align="center">API Development</h3>
|
<h3 align="center">API Development</h3>
|
||||||
|
|
||||||
1. After that, run the backend build script: `./build.sh`.
|
1. After that, run the project in development mode: `npm run dev`.
|
||||||
|
|
||||||
2. Then, go into the `api/` directory, and then start it up: `go run . -b ../backends.dev.json`
|
2. If you want to explore your database, run `npx prisma studio` to open the database editor.
|
||||||
|
|
||||||
<h2 align="center">Production Deployment</h2>
|
<h2 align="center">Production Deployment</h2>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Deploying using [Docker Compose](https://docs.docker.com/compose/) is the only officially supported deployment method.
|
> Deploying using docker compose is the only officially supported deployment method. Here be dragons!
|
||||||
|
|
||||||
1. Copy and change the default password (or username & db name too) from the template file `prod-docker.env`:
|
1. Copy and change the default password (or username & db name too) from the template file `prod-docker.env`:
|
||||||
```bash
|
```bash
|
||||||
sed -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
|
sed "s/POSTGRES_PASSWORD=nextnet/POSTGRES_PASSWORD=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" prod-docker.env > .env
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Build the docker stack: `docker compose --env-file .env up -d`
|
2. Build the docker stack: `docker compose --env-file .env up -d`
|
||||||
|
|
||||||
<h2 align="center">Troubleshooting</h2>
|
<h2 align="center">Troubleshooting</h2>
|
||||||
|
|
||||||
This has been moved [here.](docs/troubleshooting.md)
|
* I'm using the SSH tunneling, and I can't reach any of the tunnels publicly.
|
||||||
|
|
||||||
<h2 align="center">Documentation</h2>
|
- 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.
|
||||||
|
|
||||||
Go to the `docs/` folder.
|
|
1
VERSION
Normal file
1
VERSION
Normal file
|
@ -0,0 +1 @@
|
||||||
|
0.1.1
|
17
api/Dockerfile
Normal file
17
api/Dockerfile
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
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
|
7
api/dev.env
Normal file
7
api/dev.env
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# 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"
|
6
api/docker-entrypoint.sh
Normal file
6
api/docker-entrypoint.sh
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
#!/bin/bash
|
||||||
|
echo "Welcome to NextNet."
|
||||||
|
echo "Running database migrations..."
|
||||||
|
npx prisma migrate deploy
|
||||||
|
echo "Starting application..."
|
||||||
|
npm start
|
32
api/init.sh
Executable file
32
api/init.sh
Executable file
|
@ -0,0 +1,32 @@
|
||||||
|
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
|
1795
api/package-lock.json
generated
Normal file
1795
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
33
api/package.json
Normal file
33
api/package.json
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"name": "nextnet",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"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": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/node": "^20.12.7",
|
||||||
|
"@types/ssh2": "^1.15.0",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"nodemon": "^3.0.3",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"prisma": "^5.13.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/websocket": "^10.0.1",
|
||||||
|
"@prisma/client": "^5.13.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"fastify": "^4.26.2",
|
||||||
|
"node-ssh": "^13.2.0"
|
||||||
|
}
|
||||||
|
}
|
53
api/prisma/migrations/20240421200334_init/migration.sql
Normal file
53
api/prisma/migrations/20240421200334_init/migration.sql
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
-- 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;
|
|
@ -0,0 +1,8 @@
|
||||||
|
/*
|
||||||
|
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";
|
|
@ -0,0 +1,8 @@
|
||||||
|
/*
|
||||||
|
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;
|
3
api/prisma/migrations/migration_lock.toml
Normal file
3
api/prisma/migrations/migration_lock.toml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
53
api/prisma/schema.prisma
Normal file
53
api/prisma/schema.prisma
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
// 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
|
||||||
|
name String
|
||||||
|
password String // Will be hashed using bcrypt
|
||||||
|
rootToken String?
|
||||||
|
isRootServiceAccount Boolean?
|
||||||
|
permissions Permission[]
|
||||||
|
}
|
28
api/routes/NextNet API/Backend/Create.bru
Normal file
28
api/routes/NextNet API/Backend/Create.bru
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
api/routes/NextNet API/Backend/Lookup.bru
Normal file
18
api/routes/NextNet API/Backend/Lookup.bru
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
meta {
|
||||||
|
name: Lookup
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://127.0.0.1:3000/api/v1/backends/remove
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"token": "f1b89cc337073476289ade17ffbe7a6419b4bd52aa7ede26114bffd76fa263b5cb1bcaf389462e1d9e7acb7f4b6a7c28152a9cc9af83e3ec862f1892b1",
|
||||||
|
"id": "2"
|
||||||
|
}
|
||||||
|
}
|
23
api/routes/NextNet API/Backend/Remove.bru
Normal file
23
api/routes/NextNet API/Backend/Remove.bru
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
api/routes/NextNet API/Forward/Create.bru
Normal file
28
api/routes/NextNet API/Forward/Create.bru
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
18
api/routes/NextNet API/Forward/Get Inbound Connections.bru
Normal file
18
api/routes/NextNet API/Forward/Get Inbound Connections.bru
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
18
api/routes/NextNet API/Forward/Lookup.bru
Normal file
18
api/routes/NextNet API/Forward/Lookup.bru
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
26
api/routes/NextNet API/Forward/Remove.bru
Normal file
26
api/routes/NextNet API/Forward/Remove.bru
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
18
api/routes/NextNet API/Forward/Start.bru
Normal file
18
api/routes/NextNet API/Forward/Start.bru
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
18
api/routes/NextNet API/Forward/Stop.bru
Normal file
18
api/routes/NextNet API/Forward/Stop.bru
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,16 +5,15 @@ meta {
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
url: http://127.0.0.1:8000/api/v1/users/create
|
url: http://127.0.0.1:3000/api/v1/users/create
|
||||||
body: json
|
body: json
|
||||||
auth: inherit
|
auth: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
body:json {
|
body:json {
|
||||||
{
|
{
|
||||||
"name": "Test User",
|
"name": "Greysoh Hofuh",
|
||||||
"email": "test@example.com",
|
"email": "greyson@hofers.cloud",
|
||||||
"username": "testuser",
|
|
||||||
"password": "hunter123"
|
"password": "hunter123"
|
||||||
}
|
}
|
||||||
}
|
}
|
18
api/routes/NextNet API/Users/Log In.bru
Normal file
18
api/routes/NextNet API/Users/Log In.bru
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
18
api/routes/NextNet API/Users/Lookup.bru
Normal file
18
api/routes/NextNet API/Users/Lookup.bru
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
18
api/routes/NextNet API/Users/Remove.bru
Normal file
18
api/routes/NextNet API/Users/Remove.bru
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
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,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"name": "Hermes",
|
"name": "NextNet API",
|
||||||
"type": "collection"
|
"type": "collection"
|
||||||
}
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"name": "Passyfire Base Routes",
|
||||||
|
"type": "collection",
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
".git"
|
||||||
|
]
|
||||||
|
}
|
74
api/src/backendimpl/base.ts
Normal file
74
api/src/backendimpl/base.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
9
api/src/backendimpl/index.ts
Normal file
9
api/src/backendimpl/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import type { 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,
|
||||||
|
};
|
228
api/src/backendimpl/passyfire-reimpl/index.ts
Normal file
228
api/src/backendimpl/passyfire-reimpl/index.ts
Normal file
|
@ -0,0 +1,228 @@
|
||||||
|
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-ignore
|
||||||
|
} catch (e: Error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: e.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
178
api/src/backendimpl/passyfire-reimpl/routes.ts
Normal file
178
api/src/backendimpl/passyfire-reimpl/routes.ts
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
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) => {
|
||||||
|
if (typeof req.body != "string")
|
||||||
|
return res.status(400).send({
|
||||||
|
error: "Invalid token",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSON.parse(req.body);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: "Invalid token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (!req.body.token)
|
||||||
|
return res.status(400).send({
|
||||||
|
error: "Invalid token",
|
||||||
|
});
|
||||||
|
|
||||||
|
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-ignore
|
||||||
|
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-ignore
|
||||||
|
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-ignore
|
||||||
|
// 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,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
140
api/src/backendimpl/passyfire-reimpl/socket.ts
Normal file
140
api/src/backendimpl/passyfire-reimpl/socket.ts
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
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-ignore
|
||||||
|
let 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}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
268
api/src/backendimpl/ssh.ts
Normal file
268
api/src/backendimpl/ssh.ts
Normal file
|
@ -0,0 +1,268 @@
|
||||||
|
import { NodeSSH } from "node-ssh";
|
||||||
|
import { Socket } from "node:net";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BackendBaseClass,
|
||||||
|
ForwardRule,
|
||||||
|
ConnectedClient,
|
||||||
|
ParameterReturnedValue,
|
||||||
|
} from "./base.js";
|
||||||
|
|
||||||
|
type ForwardRuleExt = ForwardRule & {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fight me (for better naming)
|
||||||
|
type BackendParsedProviderString = {
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
|
||||||
|
username: string;
|
||||||
|
privateKey: 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");
|
||||||
|
|
||||||
|
return {
|
||||||
|
ip: jsonData.ip,
|
||||||
|
port: jsonData.port,
|
||||||
|
|
||||||
|
username: jsonData.username,
|
||||||
|
privateKey: jsonData.privateKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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-ignore
|
||||||
|
this.sshInstance = null;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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-ignore
|
||||||
|
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;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await this.sshInstance.forwardIn(
|
||||||
|
"0.0.0.0",
|
||||||
|
destPort,
|
||||||
|
(info, accept, reject) => {
|
||||||
|
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("close", () => {
|
||||||
|
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.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
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-ignore
|
||||||
|
} catch (e: Error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: e.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
110
api/src/index.ts
Normal file
110
api/src/index.ts
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
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 = Boolean(process.env.IS_SIGNUP_ENABLED);
|
||||||
|
const unsafeAdminSignup: boolean = Boolean(process.env.UNSAFE_ADMIN_SIGNUP);
|
||||||
|
|
||||||
|
const noUsersCheck: boolean = (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 fastify = Fastify({
|
||||||
|
logger: true,
|
||||||
|
trustProxy: Boolean(process.env.IS_BEHIND_PROXY),
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeOptions: RouteOptions = {
|
||||||
|
fastify: fastify,
|
||||||
|
prisma: prisma,
|
||||||
|
tokens: sessionTokens,
|
||||||
|
options: serverOptions,
|
||||||
|
|
||||||
|
backends: backends,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Initializing forwarding rules...");
|
||||||
|
|
||||||
|
const createdBackends = await prisma.desinationProvider.findMany();
|
||||||
|
|
||||||
|
for (const backend of createdBackends) {
|
||||||
|
console.log(`Running init steps for ID '${backend.id}' (${backend.name})`);
|
||||||
|
const init = await backendInit(backend, backends, prisma);
|
||||||
|
|
||||||
|
if (init) console.log("Init successful.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("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);
|
||||||
|
}
|
64
api/src/libs/backendInit.ts
Normal file
64
api/src/libs/backendInit.ts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
import type { BackendBaseClass } from "../backendimpl/base.js";
|
||||||
|
import { backendProviders } from "../backendimpl/index.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,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const ourProvider = backendProviders[backend.backend];
|
||||||
|
|
||||||
|
if (!ourProvider) {
|
||||||
|
console.log(" - Error: Invalid backend recieved!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(" - Initializing backend...");
|
||||||
|
|
||||||
|
backends[backend.id] = new ourProvider(backend.connectionDetails);
|
||||||
|
const ourBackend = backends[backend.id];
|
||||||
|
|
||||||
|
if (!(await ourBackend.start())) {
|
||||||
|
console.log(" - Error initializing backend!");
|
||||||
|
console.log(" - " + ourBackend.logs.join("\n - "));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.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") {
|
||||||
|
console.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;
|
||||||
|
}
|
22
api/src/libs/generateRandom.ts
Normal file
22
api/src/libs/generateRandom.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
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;
|
||||||
|
}
|
110
api/src/libs/permissions.ts
Normal file
110
api/src/libs/permissions.ts
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
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 let 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);
|
||||||
|
}
|
28
api/src/libs/types.ts
Normal file
28
api/src/libs/types.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
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>;
|
||||||
|
};
|
17
api/src/routes/ROUTE_PLAN.md
Normal file
17
api/src/routes/ROUTE_PLAN.md
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# 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
|
99
api/src/routes/backends/create.ts
Normal file
99
api/src/routes/backends/create.ts
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const body: {
|
||||||
|
token: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
connectionDetails: any;
|
||||||
|
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: "Unknown/unsupported/deprecated backend!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionDetails = JSON.stringify(body.connectionDetails);
|
||||||
|
const connectionDetailsValidityCheck =
|
||||||
|
backendProviders[body.backend].checkParametersBackendInstance(
|
||||||
|
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: JSON.stringify(body.connectionDetails),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const init = await backendInit(backend, backends, prisma);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
82
api/src/routes/backends/lookup.ts
Normal file
82
api/src/routes/backends/lookup.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
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-ignore
|
||||||
|
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 => ({
|
||||||
|
name: i.name,
|
||||||
|
description: i.description,
|
||||||
|
|
||||||
|
backend: i.backend,
|
||||||
|
connectionDetails: canSeeSecrets ? i.connectionDetails : "",
|
||||||
|
|
||||||
|
logs: backends[i.id].logs,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
71
api/src/routes/backends/remove.ts
Normal file
71
api/src/routes/backends/remove.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
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-ignore
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
64
api/src/routes/forward/connections.ts
Normal file
64
api/src/routes/forward/connections.ts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
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-ignore
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
119
api/src/routes/forward/create.ts
Normal file
119
api/src/routes/forward/create.ts
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
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-ignore
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
113
api/src/routes/forward/lookup.ts
Normal file
113
api/src/routes/forward/lookup.ts
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
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-ignore
|
||||||
|
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
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
56
api/src/routes/forward/remove.ts
Normal file
56
api/src/routes/forward/remove.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
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-ignore
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
77
api/src/routes/forward/start.ts
Normal file
77
api/src/routes/forward/start.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
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-ignore
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Other restrictions in place make it so that it MUST be either TCP or UDP
|
||||||
|
// @ts-ignore
|
||||||
|
const protocol: "tcp" | "udp" = forward.protocol;
|
||||||
|
|
||||||
|
backends[forward.destProviderID].addConnection(
|
||||||
|
forward.sourceIP,
|
||||||
|
forward.sourcePort,
|
||||||
|
forward.destPort,
|
||||||
|
protocol,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
77
api/src/routes/forward/stop.ts
Normal file
77
api/src/routes/forward/stop.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
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-ignore
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Other restrictions in place make it so that it MUST be either TCP or UDP
|
||||||
|
// @ts-ignore
|
||||||
|
const protocol: "tcp" | "udp" = forward.protocol;
|
||||||
|
|
||||||
|
backends[forward.destProviderID].removeConnection(
|
||||||
|
forward.sourceIP,
|
||||||
|
forward.sourcePort,
|
||||||
|
forward.destPort,
|
||||||
|
protocol,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
51
api/src/routes/getPermissions.ts
Normal file
51
api/src/routes/getPermissions.ts
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
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-ignore
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
121
api/src/routes/user/create.ts
Normal file
121
api/src/routes/user/create.ts
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
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", "password"],
|
||||||
|
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
email: { type: "string" },
|
||||||
|
password: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const body: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: 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,
|
||||||
|
|
||||||
|
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-ignore
|
||||||
|
userData.rootToken = generateRandomData();
|
||||||
|
// @ts-ignore
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
68
api/src/routes/user/login.ts
Normal file
68
api/src/routes/user/login.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
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: ["email", "password"],
|
||||||
|
|
||||||
|
properties: {
|
||||||
|
email: { type: "string" },
|
||||||
|
password: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const body: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const userSearch = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: body.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
67
api/src/routes/user/lookup.ts
Normal file
67
api/src/routes/user/lookup.ts
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
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" },
|
||||||
|
isServiceAccount: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const body: {
|
||||||
|
token: string;
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
email?: 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,
|
||||||
|
isRootServiceAccount: body.isServiceAccount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: users.map(i => ({
|
||||||
|
name: i.name,
|
||||||
|
email: i.email,
|
||||||
|
isServiceAccount: i.isRootServiceAccount,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
62
api/src/routes/user/remove.ts
Normal file
62
api/src/routes/user/remove.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
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-ignore
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
6
api/srcpatch.sh
Executable file
6
api/srcpatch.sh
Executable file
|
@ -0,0 +1,6 @@
|
||||||
|
# !-- 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
|
22
api/tsconfig.json
Normal file
22
api/tsconfig.json
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
|
@ -1,21 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,102 +0,0 @@
|
||||||
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"`
|
|
||||||
}
|
|
|
@ -1,99 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,63 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
|
@ -1,396 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,61 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,270 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,164 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,124 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,165 +0,0 @@
|
||||||
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",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,177 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,184 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,150 +0,0 @@
|
||||||
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",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,136 +0,0 @@
|
||||||
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",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,136 +0,0 @@
|
||||||
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",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
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") != ""
|
|
||||||
}
|
|
|
@ -1,160 +0,0 @@
|
||||||
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),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,158 +0,0 @@
|
||||||
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),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,137 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,118 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,106 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,77 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,107 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,423 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.terah.dev/imterah/hermes/backend/api/backendruntime"
|
|
||||||
"git.terah.dev/imterah/hermes/backend/api/controllers/v1/backends"
|
|
||||||
"git.terah.dev/imterah/hermes/backend/api/controllers/v1/proxies"
|
|
||||||
"git.terah.dev/imterah/hermes/backend/api/controllers/v1/users"
|
|
||||||
"git.terah.dev/imterah/hermes/backend/api/db"
|
|
||||||
"git.terah.dev/imterah/hermes/backend/api/jwt"
|
|
||||||
"git.terah.dev/imterah/hermes/backend/api/state"
|
|
||||||
"git.terah.dev/imterah/hermes/backend/commonbackend"
|
|
||||||
"github.com/charmbracelet/log"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func apiEntrypoint(cCtx *cli.Context) error {
|
|
||||||
developmentMode := false
|
|
||||||
|
|
||||||
if os.Getenv("HERMES_DEVELOPMENT_MODE") != "" {
|
|
||||||
log.Warn("You have development mode enabled. This may weaken security.")
|
|
||||||
developmentMode = true
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Hermes is initializing...")
|
|
||||||
log.Debug("Initializing database and opening it...")
|
|
||||||
|
|
||||||
databaseBackendName := os.Getenv("HERMES_DATABASE_BACKEND")
|
|
||||||
var databaseBackendParams string
|
|
||||||
|
|
||||||
if databaseBackendName == "sqlite" {
|
|
||||||
databaseBackendParams = os.Getenv("HERMES_SQLITE_FILEPATH")
|
|
||||||
|
|
||||||
if databaseBackendParams == "" {
|
|
||||||
log.Fatal("HERMES_SQLITE_FILEPATH is not set")
|
|
||||||
}
|
|
||||||
} else if databaseBackendName == "postgresql" {
|
|
||||||
databaseBackendParams = os.Getenv("HERMES_POSTGRES_DSN")
|
|
||||||
|
|
||||||
if databaseBackendParams == "" {
|
|
||||||
log.Fatal("HERMES_POSTGRES_DSN is not set")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Fatalf("Unsupported database backend: %s", databaseBackendName)
|
|
||||||
}
|
|
||||||
|
|
||||||
dbInstance, err := db.New(databaseBackendName, databaseBackendParams)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to initialize database: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug("Running database migrations...")
|
|
||||||
|
|
||||||
if err := dbInstance.DoMigrations(); err != nil {
|
|
||||||
return fmt.Errorf("Failed to run database migrations: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug("Initializing the JWT subsystem...")
|
|
||||||
|
|
||||||
jwtDataString := os.Getenv("HERMES_JWT_SECRET")
|
|
||||||
var jwtKey []byte
|
|
||||||
var jwtValidityTimeDuration time.Duration
|
|
||||||
|
|
||||||
if jwtDataString == "" {
|
|
||||||
log.Fatalf("HERMES_JWT_SECRET is not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv("HERMES_JWT_BASE64_ENCODED") != "" {
|
|
||||||
jwtKey, err = base64.StdEncoding.DecodeString(jwtDataString)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to decode base64 JWT: %s", err.Error())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
jwtKey = []byte(jwtDataString)
|
|
||||||
}
|
|
||||||
|
|
||||||
if developmentMode {
|
|
||||||
jwtValidityTimeDuration = jwt.DevelopmentModeTimings
|
|
||||||
} else {
|
|
||||||
jwtValidityTimeDuration = jwt.NormalModeTimings
|
|
||||||
}
|
|
||||||
|
|
||||||
jwtInstance := jwt.New(jwtKey, dbInstance, jwtValidityTimeDuration)
|
|
||||||
|
|
||||||
log.Debug("Initializing the backend subsystem...")
|
|
||||||
|
|
||||||
backendMetadataPath := cCtx.String("backends-path")
|
|
||||||
backendMetadata, err := os.ReadFile(backendMetadataPath)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to read backends: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
availableBackends := []*backendruntime.Backend{}
|
|
||||||
err = json.Unmarshal(backendMetadata, &availableBackends)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse backends: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, backend := range availableBackends {
|
|
||||||
backend.Path = path.Join(filepath.Dir(backendMetadataPath), backend.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
backendruntime.Init(availableBackends)
|
|
||||||
|
|
||||||
log.Debug("Enumerating backends...")
|
|
||||||
|
|
||||||
backendList := []db.Backend{}
|
|
||||||
|
|
||||||
if err := dbInstance.DB.Find(&backendList).Error; err != nil {
|
|
||||||
return fmt.Errorf("Failed to enumerate backends: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, backend := range backendList {
|
|
||||||
log.Infof("Starting up backend #%d: %s", backend.ID, backend.Name)
|
|
||||||
|
|
||||||
var backendRuntimeFilePath string
|
|
||||||
|
|
||||||
for _, runtime := range backendruntime.AvailableBackends {
|
|
||||||
if runtime.Name == backend.Backend {
|
|
||||||
backendRuntimeFilePath = runtime.Path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if backendRuntimeFilePath == "" {
|
|
||||||
log.Errorf("Unsupported backend recieved for ID %d: %s", backend.ID, backend.Backend)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
backendInstance := backendruntime.NewBackend(backendRuntimeFilePath)
|
|
||||||
|
|
||||||
backendInstance.OnCrashCallback = func(conn net.Conn) {
|
|
||||||
backendParameters, err := base64.StdEncoding.DecodeString(backend.BackendParameters)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to decode backend parameters for backend #%d: %s", backend.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
marshalledStartCommand, err := commonbackend.Marshal(&commonbackend.Start{
|
|
||||||
Arguments: backendParameters,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to marshal start command for backend #%d: %s", backend.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := conn.Write(marshalledStartCommand); err != nil {
|
|
||||||
log.Errorf("Failed to send start command for backend #%d: %s", backend.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
backendResponse, err := commonbackend.Unmarshal(conn)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to get start command response for backend #%d: %s", backend.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch responseMessage := backendResponse.(type) {
|
|
||||||
case *commonbackend.BackendStatusResponse:
|
|
||||||
if !responseMessage.IsRunning {
|
|
||||||
log.Errorf("Failed to start backend #%d: %s", backend.ID, responseMessage.Message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("Backend #%d has been reinitialized successfully", backend.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Warnf("Backend #%d has reinitialized! Starting up auto-starting proxies...", backend.ID)
|
|
||||||
|
|
||||||
autoStartProxies := []db.Proxy{}
|
|
||||||
|
|
||||||
if err := dbInstance.DB.Where("backend_id = ? AND auto_start = true", backend.ID).Find(&autoStartProxies).Error; err != nil {
|
|
||||||
log.Errorf("Failed to query proxies to autostart: %s", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, proxy := range autoStartProxies {
|
|
||||||
log.Infof("Starting up route #%d for backend #%d: %s", proxy.ID, backend.ID, proxy.Name)
|
|
||||||
|
|
||||||
marhalledCommand, err := commonbackend.Marshal(&commonbackend.AddProxy{
|
|
||||||
SourceIP: proxy.SourceIP,
|
|
||||||
SourcePort: proxy.SourcePort,
|
|
||||||
DestPort: proxy.DestinationPort,
|
|
||||||
Protocol: proxy.Protocol,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to marshal proxy adding request for backend #%d and route #%d: %s", proxy.BackendID, proxy.ID, err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := conn.Write(marhalledCommand); err != nil {
|
|
||||||
log.Errorf("Failed to send proxy adding request for backend #%d and route #%d: %s", proxy.BackendID, proxy.ID, err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
backendResponse, err := commonbackend.Unmarshal(conn)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to get response for backend #%d and route #%d: %s", proxy.BackendID, proxy.ID, err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch responseMessage := backendResponse.(type) {
|
|
||||||
case *commonbackend.ProxyStatusResponse:
|
|
||||||
if !responseMessage.IsActive {
|
|
||||||
log.Warnf("Failed to start proxy for backend #%d and route #%d", proxy.BackendID, proxy.ID)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
log.Errorf("Got illegal response type for backend #%d and proxy #%d: %T", proxy.BackendID, proxy.ID, responseMessage)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = backendInstance.Start()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to start backend #%d: %s", backend.ID, err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
backendParameters, err := base64.StdEncoding.DecodeString(backend.BackendParameters)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to decode backend parameters for backend #%d: %s", backend.ID, err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
backendStartResponse, err := backendInstance.ProcessCommand(&commonbackend.Start{
|
|
||||||
Arguments: backendParameters,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("Failed to get response for backend #%d: %s", backend.ID, err.Error())
|
|
||||||
|
|
||||||
err = backendInstance.Stop()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("Failed to stop backend: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch responseMessage := backendStartResponse.(type) {
|
|
||||||
case *commonbackend.BackendStatusResponse:
|
|
||||||
if !responseMessage.IsRunning {
|
|
||||||
err = backendInstance.Stop()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("Failed to start backend: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
if responseMessage.Message == "" {
|
|
||||||
log.Errorf("Unkown error while trying to start the backend #%d", backend.ID)
|
|
||||||
} else {
|
|
||||||
log.Errorf("Failed to start backend: %s", responseMessage.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
log.Errorf("Got illegal response type for backend #%d: %T", backend.ID, responseMessage)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
backendruntime.RunningBackends[backend.ID] = backendInstance
|
|
||||||
|
|
||||||
log.Infof("Successfully initialized backend #%d", backend.ID)
|
|
||||||
|
|
||||||
autoStartProxies := []db.Proxy{}
|
|
||||||
|
|
||||||
if err := dbInstance.DB.Where("backend_id = ? AND auto_start = true", backend.ID).Find(&autoStartProxies).Error; err != nil {
|
|
||||||
log.Errorf("Failed to query proxies to autostart: %s", err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, proxy := range autoStartProxies {
|
|
||||||
log.Infof("Starting up route #%d for backend #%d: %s", proxy.ID, backend.ID, proxy.Name)
|
|
||||||
|
|
||||||
backendResponse, err := backendInstance.ProcessCommand(&commonbackend.AddProxy{
|
|
||||||
SourceIP: proxy.SourceIP,
|
|
||||||
SourcePort: proxy.SourcePort,
|
|
||||||
DestPort: proxy.DestinationPort,
|
|
||||||
Protocol: proxy.Protocol,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to get response for backend #%d and route #%d: %s", proxy.BackendID, proxy.ID, err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch responseMessage := backendResponse.(type) {
|
|
||||||
case *commonbackend.ProxyStatusResponse:
|
|
||||||
if !responseMessage.IsActive {
|
|
||||||
log.Warnf("Failed to start proxy for backend #%d and route #%d", proxy.BackendID, proxy.ID)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
log.Errorf("Got illegal response type for backend #%d and proxy #%d: %T", proxy.BackendID, proxy.ID, responseMessage)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("Successfully started backend #%d", backend.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug("Initializing API...")
|
|
||||||
|
|
||||||
if !developmentMode {
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
engine := gin.Default()
|
|
||||||
|
|
||||||
listeningAddress := os.Getenv("HERMES_LISTENING_ADDRESS")
|
|
||||||
|
|
||||||
if listeningAddress == "" {
|
|
||||||
if developmentMode {
|
|
||||||
listeningAddress = "localhost:8000"
|
|
||||||
} else {
|
|
||||||
listeningAddress = "0.0.0.0:8000"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trustedProxiesString := os.Getenv("HERMES_TRUSTED_HTTP_PROXIES")
|
|
||||||
|
|
||||||
if trustedProxiesString != "" {
|
|
||||||
trustedProxies := strings.Split(trustedProxiesString, ",")
|
|
||||||
|
|
||||||
engine.ForwardedByClientIP = true
|
|
||||||
engine.SetTrustedProxies(trustedProxies)
|
|
||||||
} else {
|
|
||||||
engine.ForwardedByClientIP = false
|
|
||||||
engine.SetTrustedProxies(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
state := state.New(dbInstance, jwtInstance, engine)
|
|
||||||
|
|
||||||
// Initialize routes
|
|
||||||
users.SetupCreateUser(state)
|
|
||||||
users.SetupLoginUser(state)
|
|
||||||
users.SetupRefreshUserToken(state)
|
|
||||||
users.SetupRemoveUser(state)
|
|
||||||
users.SetupLookupUser(state)
|
|
||||||
|
|
||||||
backends.SetupCreateBackend(state)
|
|
||||||
backends.SetupRemoveBackend(state)
|
|
||||||
backends.SetupLookupBackend(state)
|
|
||||||
|
|
||||||
proxies.SetupCreateProxy(state)
|
|
||||||
proxies.SetupRemoveProxy(state)
|
|
||||||
proxies.SetupLookupProxy(state)
|
|
||||||
proxies.SetupStartProxy(state)
|
|
||||||
proxies.SetupStopProxy(state)
|
|
||||||
proxies.SetupGetConnections(state)
|
|
||||||
|
|
||||||
log.Infof("Listening on '%s'", listeningAddress)
|
|
||||||
err = engine.Run(listeningAddress)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Error running web server: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
logLevel := os.Getenv("HERMES_LOG_LEVEL")
|
|
||||||
|
|
||||||
if logLevel != "" {
|
|
||||||
switch logLevel {
|
|
||||||
case "debug":
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
|
|
||||||
case "info":
|
|
||||||
log.SetLevel(log.InfoLevel)
|
|
||||||
|
|
||||||
case "warn":
|
|
||||||
log.SetLevel(log.WarnLevel)
|
|
||||||
|
|
||||||
case "error":
|
|
||||||
log.SetLevel(log.ErrorLevel)
|
|
||||||
|
|
||||||
case "fatal":
|
|
||||||
log.SetLevel(log.FatalLevel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app := &cli.App{
|
|
||||||
Name: "hermes",
|
|
||||||
Usage: "port forwarding across boundaries",
|
|
||||||
Flags: []cli.Flag{
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "backends-path",
|
|
||||||
Aliases: []string{"b"},
|
|
||||||
Usage: "path to the backend manifest file",
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Action: apiEntrypoint,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := app.Run(os.Args); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
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