Compare commits

..

No commits in common. "dev" and "v0.1.1" have entirely different histories.
dev ... v0.1.1

169 changed files with 5771 additions and 11438 deletions

View file

@ -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",
} }

View file

@ -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
View file

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

6
.githooks/pre-commit Executable file
View 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
View 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
View 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
View 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
View file

@ -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
View 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
}

View file

@ -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",
]
}

View file

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

View file

@ -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"]

View file

@ -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:

View file

@ -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
View file

@ -0,0 +1 @@
0.1.1

17
api/Dockerfile Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

33
api/package.json Normal file
View 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"
}
}

View 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;

View file

@ -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";

View file

@ -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;

View 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
View 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[]
}

View 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"
}
}
}
}

View 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"
}
}

View 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
}
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View file

@ -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"
} }
} }

View 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"
}
}

View 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"
}
}

View 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"
}
}

View file

@ -1,5 +1,5 @@
{ {
"version": "1", "version": "1",
"name": "Hermes", "name": "NextNet API",
"type": "collection" "type": "collection"
} }

View file

@ -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
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

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

View 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,
};
}
}

View 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,
};

View 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,
};
}
}

View 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,
})),
});
},
);
}

View 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
View 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
View 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);
}

View 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;
}

View 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
View 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
View 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>;
};

View 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

View 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,
};
},
);
}

View 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,
})),
};
},
);
}

View 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,
};
},
);
}

View 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(),
};
},
);
}

View 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,
};
},
);
}

View 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
})),
};
},
);
}

View 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,
};
},
);
}

View 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,
};
},
);
}

View 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,
};
},
);
}

View 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),
};
},
);
}

View 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,
};
}
},
);
}

View 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,
};
},
);
}

View 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,
})),
};
},
);
}

View 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
View 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
View 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"]
}

View file

@ -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)
}

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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"
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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,
})
})
}

View file

@ -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,
})
})
}

View file

@ -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,
})
})
}

View file

@ -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",
})
}
})
}

View file

@ -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,
})
})
}

View file

@ -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,
})
})
}

View file

@ -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",
})
}
})
}

View file

@ -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",
})
}
})
}

View file

@ -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",
})
}
})
}

View file

@ -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") != ""
}

View file

@ -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),
})
})
}

View file

@ -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),
})
})
}

View file

@ -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,
})
})
}

View file

@ -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,
})
})
}

View file

@ -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,
})
})
}

View file

@ -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))
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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