Compare commits

..

245 commits
v0.1.1 ... dev

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

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

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

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

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

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

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

Sorry folks!
2024-12-27 00:10:13 -05:00
d334878599
feature: Fixes all backup and API related things to make everything work in production.
All checks were successful
Release code / build (push) Successful in 14m13s
2024-12-26 22:54:05 -05:00
c2eb2d15aa
fix: Fixes migration Dockerfile. 2024-12-26 21:56:47 -05:00
0bc41c430a
fix: Fixes PostgreSQL database deletion.
All checks were successful
Release code / build (push) Successful in 10m1s
2024-12-26 18:26:26 -05:00
e056911af4
feature: Implements backup code.
All checks were successful
Release code / build (push) Successful in 9m35s
2024-12-26 15:14:26 -05:00
862f307e56
fix: Fixes documentation and migration code.
All checks were successful
Release code / build (push) Successful in 9m48s
2024-12-26 14:59:01 -05:00
84e1a437a4
chore: Adds documentation. 2024-12-26 14:37:38 -05:00
c4c5e1cd16
fix: Change packages server to GitHub.
All checks were successful
Release code / build (push) Successful in 12m43s
2024-12-25 20:19:01 -05:00
51ebfe46d3
fix: Attempts to fix workflow to allow for uploading packages.
Some checks failed
Release code / build (push) Failing after 8m38s
2024-12-25 20:04:45 -05:00
4a46b5aca0
feature: Adds migration Docker image. 2024-12-25 20:00:16 -05:00
217e73d9ec
feature: Adds backup importing support. 2024-12-25 19:21:22 -05:00
ed90f66b2b
fix: Reorders container authentication, and removes custom permissions.
Some checks failed
Release code / build (push) Failing after 6m8s
2024-12-24 13:20:47 -05:00
50281df8d0
fix: Finally fixes TLS.
Some checks failed
Release code / build (push) Failing after 6m18s
2024-12-24 13:02:12 -05:00
0515ffe5da
fix: Patches TLS certificates.
Some checks failed
Release code / build (push) Has been cancelled
2024-12-24 12:41:11 -05:00
6fb4a9b5c1
hack: Reorder steps in action.
Some checks failed
Release code / build (push) Failing after 1m46s
2024-12-24 12:37:32 -05:00
2e6e8d38dd
fix: Switches back to using local network communication inside action.
Some checks failed
Release code / build (push) Failing after 1m49s
2024-12-24 12:27:02 -05:00
65ccd716ff
fix: Try more shenanigans to try to get actions working.
Some checks failed
Release code / build (push) Failing after 1m52s
2024-12-24 12:24:16 -05:00
9fff3be650
chore: Fixes actions.
Some checks failed
Release code / build (push) Failing after 1m51s
2024-12-24 12:20:38 -05:00
d76e32b2c5
fix: Moves away from rootless dind.
Some checks failed
Release code / build (push) Has been cancelled
2024-12-24 12:01:22 -05:00
5efb75d49d
fix: Changes Docker image and switches back to using ports.
Some checks failed
Release code / build (push) Has been cancelled
2024-12-24 11:38:33 -05:00
8527354423
fix: Attempts to use local Docker networking instead of exposed ports.
Some checks failed
Release code / build (push) Failing after 1m44s
2024-12-24 11:18:02 -05:00
fc0adcc663
fix: Changes port in docker context to hopefully finally fix building.
Some checks failed
Release code / build (push) Failing after 1m53s
2024-12-24 11:12:53 -05:00
e69b1cd7db
chore: Change port.
Some checks failed
Release code / build (push) Failing after 1m44s
2024-12-24 11:10:12 -05:00
4014188ad1
fix: Changes /var/run/docker.sock to TCP port 2375 instead.
Some checks failed
Release code / build (push) Failing after 2s
2024-12-24 11:05:43 -05:00
32bf007b60
fix: Fixes actions.
Some checks failed
Release code / build (push) Failing after 1m56s
2024-12-24 10:56:34 -05:00
622e8bd286
fix: Adds docker-in-docker to attempt to get services running correctly.
Some checks failed
Release code / build (push) Failing after 55s
2024-12-24 10:54:25 -05:00
b22fd417b6
fix: Attempts to add Docker installation.
Some checks failed
Release code / build (push) Failing after 2m3s
2024-12-24 10:14:24 -05:00
9d90477ffb
chore: Fix actions.
Some checks failed
Release code / build (push) Failing after 1m57s
2024-12-24 10:08:23 -05:00
899258ef7f
feature: Adds LOM support.
Some checks failed
Release code / build (push) Has been cancelled
2024-12-24 09:54:18 -05:00
7879c65e25
feature: Adds building workflows. 2024-12-24 09:52:06 -05:00
b0b11413fb
feature: Adds production support. 2024-12-23 22:37:28 -05:00
a8bc56ccb5
chore: Delete old backend legacy code. 2024-12-23 22:07:26 -05:00
5c45533371
feature: Adds autostart support. 2024-12-23 22:03:59 -05:00
8ba0424512
fix: Fixes getting inbound connections. 2024-12-23 21:45:26 -05:00
bcf97fde6d
fix: Fixes API routes. 2024-12-23 20:47:01 -05:00
fe8980b265
fix: Updates Lookup route. 2024-12-23 19:59:38 -05:00
imterah
495dff3de1
fix: Fixes API routing for connections. 2024-12-23 19:57:39 -05:00
af6ee6ab66
Merge remote-tracking branch 'origin/dev' into dev 2024-12-23 19:04:59 -05:00
imterah
5495fc4ae2
feature: Implement connections API. 2024-12-23 19:00:46 -05:00
d73380e647
add(api): all forward api endpoints except connections 2024-12-23 19:00:43 -05:00
5ad69f6bbe
connections file (not finished) (stupid) 2024-12-23 17:22:14 -05:00
greysoh
216936fe50
feature: Adds backend lookup support. 2024-12-23 16:47:35 -05:00
greysoh
3484760f41
feature: Adds backend removal support. 2024-12-23 16:18:35 -05:00
greysoh
0b73b4aa47
feature: Adds backend system and basic API.
This adds the backend API, as well as backend infrastructure, including
autostarting and basic communication between the Goroutine + Application.
2024-12-23 15:52:16 -05:00
greysoh
611d7f24f8
feature: Adds user lookup support. 2024-12-22 13:58:18 -05:00
greysoh
cee4e62f53
feature: Adds user deletion support. 2024-12-22 12:36:55 -05:00
greysoh
37d0d41570
feature: Adds API refresh support. 2024-12-22 11:36:15 -05:00
greysoh
7d5db06c7b
fix: Fixes vscode settings. 2024-12-22 11:35:57 -05:00
greysoh
67f67007d9
chore: Switches backend parameters to use pointers instead. 2024-12-22 11:08:09 -05:00
greysoh
bbad26b686
feature: Adds login support. 2024-12-22 11:07:38 -05:00
greysoh
af37abf075
fix: Fixes initializing script for backend-legacy. 2024-12-22 10:31:38 -05:00
imterah
1237a31f8b
chore: Adds user creation route. 2024-12-22 00:56:15 -05:00
c7b71e754f fix: Fixes alt text to be more descriptive and removes extra new line. 2024-12-21 23:34:13 +00:00
imterah
d25da9091e
chore: Restructure files. 2024-12-21 18:27:40 -05:00
imterah
559588f726
chore: Center slogan. 2024-12-21 18:11:05 -05:00
imterah
f9d648256a
chore: Reword and rename things. 2024-12-21 18:07:53 -05:00
greysoh
46178f3482
feature: Adds ExportDBContent tool for NextNet. 2024-12-02 23:58:56 -05:00
greysoh
28ed6ddfd7
feature: Gets initial SSH backend working (this time with the rest of the code). 2024-12-02 19:15:43 -05:00
greysoh
cc31bb2ad5
chore: Gets initial SSH backend working. 2024-12-02 19:11:16 -05:00
greysoh
b30d8150f3
chore: Rename structs to be more clear. 2024-12-02 16:38:41 -05:00
greysoh
0b6e40a944
feature: Adds remaining commands. 2024-12-02 16:06:08 -05:00
imterah
889be65392
feature: Finish up SSH backend code. 2024-12-02 09:09:06 -05:00
imterah
64afe992b8
fix: Fixes package lockfiles being corrupt for some reason. 2024-12-02 07:13:19 -05:00
79389d37b8
ssh backend (mostly) 2024-12-01 23:16:21 -05:00
greysoh
aa16549667
chore: Adds constant definitions. 2024-12-01 22:17:27 -05:00
greysoh
3cb9526716
feature: Adds more commands and adds an example. 2024-12-01 22:07:10 -05:00
greysoh
0d0f16174b
chore: Adds base backend interface. 2024-12-01 19:40:23 -05:00
greysoh
c9fed58c6a
chore: Rename actions directory to forgejo-disabled for the time being. 2024-12-01 19:13:18 -05:00
greysoh
28d25c9698
chore: Adds basic commands and protocol.
Some checks failed
CI Testing (API) / test (push) Has been cancelled
2024-12-01 19:11:07 -05:00
greysoh
7b5ec36e61
chore: Update README with new development information.
Some checks failed
CI Testing (API) / test (push) Waiting to run
CI Testing (LOM) / test (push) Has been cancelled
2024-12-01 13:08:38 -05:00
greysoh
0965c56547
chore: Bumps dependencies, rewrites development environment system. 2024-12-01 13:06:28 -05:00
imterah
c0a12f53d1 chore: Update changelog for tag v1.1.2. 2024-09-29 13:03:38 +00:00
imterah
f4d691a95b
chore: Bump version. 2024-09-29 09:03:06 -04:00
Tera
b3c9d31db5
fix: Fixes LOM release failures. 2024-09-29 13:01:14 +00:00
imterah
398e978c0d
fix: Fixes issues with package lock files. 2024-09-29 09:00:03 -04:00
Tera
74657b6453
fix: Makes LOM Kubernetes capable. 2024-09-29 12:58:38 +00:00
imterah
d03d5d9d85 chore: Update changelog for tag v1.1.1. 2024-09-29 12:32:57 +00:00
Tera
7b8f1eb919
chore: Bumps VERSION. 2024-09-29 12:32:29 +00:00
Tera
5a86b171ec
fix: Fixes Kubernetes support. 2024-09-29 12:32:08 +00:00
imterah
cd2efa026a chore: Update changelog for tag v1.1.0. 2024-09-22 00:13:13 +00:00
Tera
1e2392cca5
Update release.yml 2024-09-22 00:10:41 +00:00
imterah
aa84105cba chore: Update changelog for tag v1.1.0. 2024-09-22 00:08:31 +00:00
Tera
32b4af760f
Update VERSION 2024-09-22 00:08:03 +00:00
Tera
9bf67ab8de
fix: Fixes release failures in GitHub workflows.
Oh my god I'm an idiot
2024-09-22 00:06:47 +00:00
imterah
06d2544f5f
Revert "hack: Disable GitHub release creation for now."
This reverts commit 3c6d7d9cd6.
2024-09-20 15:18:44 -04:00
imterah
37ed37b33f chore: Update changelog for tag v1.1.1. 2024-09-20 19:10:28 +00:00
Tera
d2cf85c1d6
Update VERSION 2024-09-20 19:10:01 +00:00
Tera
ff96cdb428
chore: Attempt to do release again. 2024-09-20 18:58:05 +00:00
imterah
3c6d7d9cd6
hack: Disable GitHub release creation for now. 2024-09-20 14:54:56 -04:00
imterah
c5bfcbaab0 chore: Update changelog for tag v1.1.0. 2024-09-20 18:23:33 +00:00
imterah
9ae7f1001d
chore: Do release of code. 2024-09-20 14:22:55 -04:00
imterah
b9c05e6196
fix: Fixes various issues related to backends.
This fixes not providing the correct parameters on the backend to
create correctly, as well as fixes crashes when the backend class
fails to be instantiated. :3
2024-09-20 14:16:50 -04:00
imterah
3b55ce1207
chore: Add support for manually specifying the IP address to listen on. 2024-09-20 14:15:47 -04:00
imterah
758c92ded8
chore: Reformate base backend. 2024-09-20 14:15:01 -04:00
Tera
e338465871
Merge pull request #16 from imterah/dependabot/npm_and_yarn/lom/axios-1.7.4
chore(deps): bump axios from 1.6.8 to 1.7.4 in /lom
2024-09-18 14:34:27 -04:00
Tera
b7a8f416e9
Merge pull request #17 from imterah/dependabot/npm_and_yarn/api/find-my-way-8.2.2
chore(deps): bump find-my-way from 8.1.0 to 8.2.2 in /api
2024-09-18 14:34:14 -04:00
Tera
5afd5ae3f1
Merge pull request #15 from imterah/dependabot/npm_and_yarn/lom/micromatch-4.0.8
chore(deps): bump micromatch from 4.0.5 to 4.0.8 in /lom
2024-09-18 14:34:06 -04:00
dependabot[bot]
5e3ab8c296
chore(deps): bump find-my-way from 8.1.0 to 8.2.2 in /api
Bumps [find-my-way](https://github.com/delvedor/find-my-way) from 8.1.0 to 8.2.2.
- [Release notes](https://github.com/delvedor/find-my-way/releases)
- [Commits](https://github.com/delvedor/find-my-way/compare/v8.1.0...v8.2.2)

---
updated-dependencies:
- dependency-name: find-my-way
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-18 18:33:37 +00:00
dependabot[bot]
44142b5933
chore(deps): bump axios from 1.6.8 to 1.7.4 in /lom
Bumps [axios](https://github.com/axios/axios) from 1.6.8 to 1.7.4.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.8...v1.7.4)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-18 18:33:35 +00:00
dependabot[bot]
59d27c2488
chore(deps): bump micromatch from 4.0.5 to 4.0.8 in /lom
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

---
updated-dependencies:
- dependency-name: micromatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-18 18:33:32 +00:00
Greyson
c7dbe596ab
Merge pull request #13 from greysoh/dependabot/npm_and_yarn/lom/braces-3.0.3
chore(deps): bump braces from 3.0.2 to 3.0.3 in /lom
2024-06-20 22:49:04 -04:00
dependabot[bot]
40bc5ae336
chore(deps): bump braces from 3.0.2 to 3.0.3 in /lom
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-21 02:48:15 +00:00
Greyson
c25c22e6ee
Merge pull request #11 from greysoh/dependabot/npm_and_yarn/api/braces-3.0.3
chore(deps-dev): bump braces from 3.0.2 to 3.0.3 in /api
2024-06-20 22:40:29 -04:00
dependabot[bot]
255609cb98
chore(deps-dev): bump braces from 3.0.2 to 3.0.3 in /api
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-20 21:23:47 +00:00
Greyson
61fa0d5315
Merge pull request #10 from greysoh/dependabot/npm_and_yarn/api/ws-8.17.1
chore(deps): bump ws from 8.17.0 to 8.17.1 in /api
2024-06-20 17:20:15 -04:00
dependabot[bot]
d832ccca1a
chore(deps): bump ws from 8.17.0 to 8.17.1 in /api
Bumps [ws](https://github.com/websockets/ws) from 8.17.0 to 8.17.1.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.17.0...8.17.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-20 19:51:45 +00:00
greysoh
3986649919
chore: Remove unneccesary extra typing.
I'm Greyson and I disagree with this message. Oh well

Co-authored-by: dev <hi@dessa.dev>
2024-06-17 21:30:21 -04:00
greysoh
3806ce92cb
fix: Fixes eslint issues. 2024-06-02 11:31:09 -04:00
greysoh
436811a0e3
chore: Clean up usage of git commands in git hooks. 2024-06-02 11:24:53 -04:00
greysoh
a5381d0e25
chore: Migrates rest of logging to fastify. 2024-06-02 11:16:59 -04:00
Greyson
a99cf55aa1
feature: Adds depends-on property. 2024-06-01 18:14:46 -04:00
greysoh
ef43007f8e
fix: Fixes LOM CI. 2024-05-30 16:45:33 -04:00
greysoh
431acc505e
feature: Adds code to reinitialize SSH after disconnection. 2024-05-30 16:39:17 -04:00
greysoh
59a1eed141
chore: Changes formatting. 2024-05-30 16:13:35 -04:00
greysoh
f659a6ae83
chore: Remove unneeded entries from .gitignore. 2024-05-30 16:05:03 -04:00
greysoh
604946873f chore: Update changelog for tag v1.0.1. 2024-05-18 17:15:17 +00:00
greysoh
9d7c24a813
chore: Format code. 2024-05-18 13:14:43 -04:00
greysoh
41013f46bb
feature: Adds support for service accounts. 2024-05-18 13:12:00 -04:00
Greyson
0e3335c4c2
chore: Remove remaining Nix shell stuff. 2024-05-11 16:57:40 -04:00
Greyson
9008df5a7b
Merge pull request #6 from greysoh/broken-pubkey-auth
Adds public key authentication
2024-05-10 18:33:57 -04:00
075edcd0ca
feat: prettify logger in development mode 2024-05-10 18:32:07 -04:00
Greyson
ab7c0b00f9
Merge branch 'dev' into broken-pubkey-auth 2024-05-10 18:31:09 -04:00
greysoh
7fa789691c
chore: Fixes final changes. 2024-05-10 18:28:09 -04:00
greysoh
19aadc20c0
feature: Adds (semi-broken) public key authentication. 2024-05-10 18:24:49 -04:00
greysoh
5e0c7af53c
Revert "chore: Experiment with removing pull request CI testing."
This reverts commit bff0e30e6f.
2024-05-10 18:21:58 -04:00
greysoh
60c521bfa7
chore: Experiment with removing pull request CI testing. 2024-05-10 18:21:57 -04:00
greysoh
47a596f7b4
chore: Also format eslint files.
bruh
2024-05-10 18:21:57 -04:00
greysoh
301ad0c458
fix: Fixes automated testing once again. 2024-05-10 18:21:57 -04:00
greysoh
c387e28b41
fix: Fixes workflows. 2024-05-10 18:21:57 -04:00
greysoh
d5596ff38f
chore: Clean up github actions workflows. 2024-05-10 18:21:57 -04:00
greysoh
abc60691c6
feature: Adds eslint rules. 2024-05-10 18:21:57 -04:00
greysoh
57a82a15e9
fix: Fixes invalid parameters in LOM. 2024-05-10 18:21:27 -04:00
greysoh
28ff9e3cf3
feature: Adds CI workflows. 2024-05-10 18:21:27 -04:00
greysoh
ce6298d7bc
chore: Modifies github workflows. 2024-05-10 18:21:27 -04:00
greysoh
ba245d2987
chore: Adds initial support for eslint. 2024-05-10 18:21:27 -04:00
d57a2a4a1d
chore: moving gui to separate repository 2024-05-10 18:21:26 -04:00
greysoh
8c4179209d
fix: fixes typo.
I can't catch a break man
2024-05-10 18:21:26 -04:00
greysoh
4034e6321a
fix: Fixes TypeScript not building correctly.
This works locally but not on GitHub actions?? what
2024-05-10 18:21:26 -04:00
greysoh
69997a6b20
chore: Update changelog for tag v1.0.0. 2024-05-10 18:21:26 -04:00
greysoh
21a9ca2f12
feature: Bump version to 1.0.0, and slightly rename stuff. 2024-05-10 18:21:26 -04:00
greysoh
cc9d0538b4
chore: Prepare for initial release of the LOM. 2024-05-10 18:21:26 -04:00
Greyson
d107fff996
feature: Add support for eslint. 2024-05-10 18:03:21 -04:00
greysoh
a38c0acf5e
Revert "chore: Experiment with removing pull request CI testing."
This reverts commit bff0e30e6f.
2024-05-10 18:01:24 -04:00
4209e01e96
fix: makes pubkey auth work 2024-05-10 18:00:58 -04:00
greysoh
bff0e30e6f
chore: Experiment with removing pull request CI testing. 2024-05-10 18:00:34 -04:00
greysoh
c5efb4c55d
chore: Also format eslint files.
bruh
2024-05-10 17:58:36 -04:00
greysoh
9171e08fcd
fix: Fixes automated testing once again. 2024-05-10 17:55:24 -04:00
greysoh
4235f2215c
fix: Fixes workflows. 2024-05-10 17:52:16 -04:00
greysoh
b5f1bdd063
chore: Clean up github actions workflows. 2024-05-10 17:47:21 -04:00
greysoh
3c95c23369
feature: Adds eslint rules. 2024-05-10 17:37:04 -04:00
a53cebcada
chore: moving gui to separate repository 2024-05-10 17:06:05 -04:00
greysoh
09f7987e75
fix: Fixes invalid parameters in LOM. 2024-05-10 12:37:22 -04:00
greysoh
8eeab0d6b4
feature: Adds CI workflows. 2024-05-10 12:33:13 -04:00
greysoh
07c1f1ca1d
chore: Modifies github workflows. 2024-05-10 11:48:30 -04:00
greysoh
7e80b298a2
chore: Adds initial support for eslint. 2024-05-10 11:46:22 -04:00
greysoh
8b4f3715e9 fix: fixes typo.
I can't catch a break man
2024-05-10 09:13:31 -04:00
greysoh
b1973bdd90 fix: Fixes TypeScript not building correctly.
This works locally but not on GitHub actions?? what
2024-05-10 09:07:43 -04:00
greysoh
9b13c11d28 chore: Update changelog for tag v1.0.0. 2024-05-10 12:59:43 +00:00
greysoh
43153e75a1 feature: Bump version to 1.0.0, and slightly rename stuff. 2024-05-10 08:59:13 -04:00
greysoh
98d5f18fef chore: Prepare for initial release of the LOM. 2024-05-10 08:48:09 -04:00
greysoh
d1da42b7ec
feature: Adds (semi-broken) public key authentication. 2024-05-09 19:39:16 -04:00
greysoh
422ec568e2
fix: Fixes character reading in readFromKeyboard not displaying correctly. 2024-05-09 19:38:30 -04:00
greysoh
47b707e8cd
feature: Adds exec support.
This also lets us prepare for support for ssh-copy-id.
2024-05-09 13:19:56 -04:00
greysoh
95b79ca364
chore: Remove unneeded code from passyfire routes. 2024-05-09 11:18:56 -04:00
greysoh
1ece50ce28
feature: Add support for adding backends. 2024-05-09 11:18:28 -04:00
greysoh
213455b050
feature: Begins work on backend command. 2024-05-08 22:16:26 -04:00
greysoh
99f76d875b
chore: Simplifies code base. 2024-05-08 13:42:24 -04:00
greysoh
3537aecbe1
feature: Finishes connection code. 2024-05-08 13:42:04 -04:00
greysoh
931da90f69
chore: Adds better checking for argv. 2024-05-08 11:53:40 -04:00
greysoh
03a6ecbe02
feature: Implements connection adding and removing. 2024-05-08 11:53:21 -04:00
greysoh
ee6556adb6
feature: Adds user command. 2024-05-08 11:22:51 -04:00
greysoh
2f7f2088cd
feature: Swap out promise system with more native code.
This makes this "less stupid", so to speak, by using our existing propagation system to spread exit signals.

Co-authored-by: dess <devessa@users.noreply.github.com>
2024-05-07 21:30:21 -04:00
greysoh
3ffad37180
feature: Implements tunnel starting and stopping. 2024-05-07 20:58:35 -04:00
greysoh
0e059d6443
chore: Adds option to customize pulling rate. 2024-05-07 18:44:26 -04:00
greysoh
a9fcdea037
feature: Add support for getting inbound connection.
Also fixes any bugs I found.
2024-05-07 18:35:04 -04:00
greysoh
b99d6a89dc
chore: Makes promises not async.
This commit makes readFromKeyboard's internal (what used to be) event loop not async anymore.
We don't use the async portions anymore, and it was bad practice anyways.
2024-05-07 12:52:34 -04:00
greysoh
c5c73ae602
feature: Adds ctrl + c support. 2024-05-07 12:01:46 -04:00
greysoh
f7a6a4356d
fix: Removes extra pulling in readFromKeyboard. 2024-05-07 11:56:53 -04:00
greysoh
32b77b12a2
feature: Adds basic command layout. 2024-05-07 11:45:05 -04:00
greysoh
56338544b5
fix: Fixes random crashing when resizing the window. 2024-05-07 11:44:33 -04:00
greysoh
3a69222ee5
chore: Fix formatting. 2024-05-07 11:44:18 -04:00
greysoh
964938e407
chore: Makes read from keyboard code more clear. 2024-05-07 07:28:16 -04:00
greysoh
cb12cab250
feature: Restructure commander patch code. 2024-05-07 07:27:14 -04:00
greysoh
ebda78fd30
fix: Fixes patch commander to work properly.
This patch makes the patch code also patch nested createCommand calls.

Yo dawg I heard you like patching.
2024-05-06 21:29:00 -04:00
greysoh
ea9279b16d
fix: Fixes backspacing to work fully properly. 2024-05-06 21:28:00 -04:00
greysoh
e85838d438
feature: Adds better command line argument parsing. 2024-05-06 20:21:10 -04:00
greysoh
ea2fd4bff1
chore: Switches println to use console.log like formatting. 2024-05-06 17:12:56 -04:00
greysoh
dd1c1dbb25
feature: Adds basic argument parsing for connections. 2024-05-06 16:22:51 -04:00
greysoh
7f34ddd6c5
chore: Seperates readFromKeyboard into a seperate file. 2024-05-06 16:22:22 -04:00
greysoh
0e60efceee
fix: Adds (slightly broken) fixed backspace support. 2024-05-06 15:46:35 -04:00
greysoh
e447c0f9e1
feature: Add support for disabling echo. 2024-05-06 15:07:15 -04:00
greysoh
41d37048d2
chore: Restructure keyboard reading. 2024-05-06 14:22:57 -04:00
greysoh
c25e7badcd
chore: Adds primitive support for cursor movement. 2024-05-06 14:05:36 -04:00
greysoh
3a773e5b9b
chore: Adds lom with basic commands parsing. 2024-05-06 11:31:51 -04:00
greysoh
7c1e26b150
feature: Adds username support.
LOM eta wen
2024-05-05 21:56:35 -04:00
greysoh
9f1de2b4ec chore: Update changelog for tag v0.1.1. 2024-05-05 22:57:59 +00:00
greysoh
fd24cfe34c
chore: Remove some parts of sparse changelog.
ASdkjasdjkasd
2024-05-05 18:57:13 -04:00
169 changed files with 11438 additions and 5771 deletions

View file

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

View file

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

View file

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

View file

@ -1,6 +0,0 @@
#!/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
View file

@ -1,14 +0,0 @@
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'

View file

@ -1,13 +0,0 @@
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 }}"

View file

@ -1,110 +0,0 @@
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

26
.gitignore vendored
View file

@ -1,20 +1,14 @@
# Rust
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Go artifacts
backend/api/api
backend/sshbackend/sshbackend
backend/dummybackend/dummybackend
backend/sshappbackend/local-code/remote-bin
backend/sshappbackend/local-code/sshappbackend
backend/externalbackendlauncher/externalbackendlauncher
frontend/frontend
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# 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
# Backup artifacts
*.json.gz
# Output
out

View file

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

View file

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

View file

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

11
Dockerfile Normal file
View file

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

View file

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

View file

@ -1,43 +1,46 @@
<h1 align="center">NextNet</h1>
<h1 align="center">Hermes</h1>
<p align="center">
<a href="https://builtwithnix.org"><img src="https://builtwithnix.org/badge.svg" alt="built with nix" height="20"/></a>
<img src="https://img.shields.io/github/license/greysoh/nextnet" alt="License Badge"/>
<img src="https://img.shields.io/badge/built-with_docker-purple" alt="Docker Badge"/>
<img src="https://img.shields.io/badge/built-with_Go-blue" alt="Golang Badge">
<img src="https://img.shields.io/badge/license-BSD--3--Clause-green" alt="License Badge (licensed under BSD-3-Clause)"/>
</p>
<br>
**NextNet is a dashboard to manage portforwarding technologies.**
<p align="center">
<b>Port forwarding across boundaries.</b>
</p>
<h2 align="center">Local Development</h2>
> [!NOTE]
> Using [nix](https://builtwithnix.org) is recommended. If you're not using Nix, install PostgreSQL, Node.JS, and `lsof`.
> Using [Nix](https://builtwithnix.org) is recommended for the development environment. If you're not using it, install Go. For legacy maintence tasks, install NodeJS.
1. First, check if you have a working Nix environment if you're using Nix.
1. Firstly, check if you have a working Nix environment if you're using Nix.
2. Run `nix-shell`, or alternatively `source init.sh` if you're not using Nix.
2. Secondly, Run `nix-shell`, or alternatively `source init.sh` if you're not using Nix.
<h3 align="center">API Development</h3>
1. After that, run the project in development mode: `npm run dev`.
1. After that, run the backend build script: `./build.sh`.
2. If you want to explore your database, run `npx prisma studio` to open the database editor.
2. Then, go into the `api/` directory, and then start it up: `go run . -b ../backends.dev.json`
<h2 align="center">Production Deployment</h2>
> [!WARNING]
> Deploying using docker compose is the only officially supported deployment method. Here be dragons!
> Deploying using [Docker Compose](https://docs.docker.com/compose/) is the only officially supported deployment method.
1. Copy and change the default password (or username & db name too) from the template file `prod-docker.env`:
```bash
sed "s/POSTGRES_PASSWORD=nextnet/POSTGRES_PASSWORD=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" prod-docker.env > .env
```
```bash
sed -e "s/POSTGRES_PASSWORD=hermes/POSTGRES_PASSWORD=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" -e "s/JWT_SECRET=hermes/JWT_SECRET=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" prod-docker.env > .env
```
2. Build the docker stack: `docker compose --env-file .env up -d`
<h2 align="center">Troubleshooting</h2>
* I'm using the SSH tunneling, and I can't reach any of the tunnels publicly.
This has been moved [here.](docs/troubleshooting.md)
- Be sure to enable GatewayPorts in your sshd config (in `/etc/ssh/sshd_config` on most systems). Also, be sure to check your firewall rules on your system and your network.
<h2 align="center">Documentation</h2>
Go to the `docs/` folder.

View file

@ -1 +0,0 @@
0.1.1

View file

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

View file

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

View file

@ -1,6 +0,0 @@
#!/bin/bash
echo "Welcome to NextNet."
echo "Running database migrations..."
npx prisma migrate deploy
echo "Starting application..."
npm start

View file

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

1795
api/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,33 +0,0 @@
{
"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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,18 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +0,0 @@
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

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

View file

@ -1,178 +0,0 @@
import { generateRandomData } from "../../libs/generateRandom.js";
import type { PassyFireBackendProvider } from "./index.js";
export function route(instance: PassyFireBackendProvider) {
const { fastify } = instance;
const proxiedPort: number = instance.options.publicPort ?? 443;
const unsupportedSpoofedRoutes: string[] = [
"/api/v1/tunnels/add",
"/api/v1/tunnels/edit",
"/api/v1/tunnels/remove",
// TODO (greysoh): Should we implement these? We have these for internal reasons. We could expose these /shrug
"/api/v1/tunnels/start",
"/api/v1/tunnels/stop",
// Same scenario for this API.
"/api/v1/users",
"/api/v1/users/add",
"/api/v1/users/remove",
"/api/v1/users/enable",
"/api/v1/users/disable",
];
fastify.get("/api/v1/static/getScopes", () => {
return {
success: true,
data: {
users: {
add: true,
remove: true,
get: true,
getPasswords: true,
},
routes: {
add: true,
remove: true,
start: true,
stop: true,
get: true,
getPasswords: true,
},
},
};
});
for (const spoofedRoute of unsupportedSpoofedRoutes) {
fastify.post(spoofedRoute, (req, res) => {
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

@ -1,140 +0,0 @@
import dgram from "node:dgram";
import net from "node:net";
import type { WebSocket } from "@fastify/websocket";
import type { FastifyRequest } from "fastify";
import type { ConnectedClientExt, PassyFireBackendProvider } from "./index.js";
// This code sucks because this protocol sucks BUUUT it works, and I don't wanna reinvent
// the gosh darn wheel for (almost) no reason
function authenticateSocket(
instance: PassyFireBackendProvider,
ws: WebSocket,
message: string,
state: ConnectedClientExt,
): Boolean {
if (!message.startsWith("Accept: ")) {
ws.send("400 Bad Request");
return false;
}
const type = message.substring(message.indexOf(":") + 1).trim();
if (type == "IsPassedWS") {
ws.send("AcceptResponse IsPassedWS: true");
} else if (type.startsWith("Bearer")) {
const token = type.substring(type.indexOf("Bearer") + 7);
for (const proxy of instance.proxies) {
for (const username of Object.keys(proxy.userConfig)) {
const currentToken = proxy.userConfig[username];
if (token == currentToken) {
state.connectionDetails = proxy;
state.username = username;
}
}
}
if (state.connectionDetails && state.username) {
ws.send("AcceptResponse Bearer: true");
return true;
} else {
ws.send("AcceptResponse Bearer: false");
}
}
return false;
}
export function requestHandler(
instance: PassyFireBackendProvider,
ws: WebSocket,
req: FastifyRequest,
) {
let state: "authentication" | "data" = "authentication";
let socket: dgram.Socket | net.Socket | undefined;
// @ts-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}'`,
);
}
});
}

View file

@ -1,268 +0,0 @@
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,
};
}
}

View file

@ -1,110 +0,0 @@
import process from "node:process";
import { PrismaClient } from "@prisma/client";
import Fastify from "fastify";
import type {
ServerOptions,
SessionToken,
RouteOptions,
} from "./libs/types.js";
import type { BackendBaseClass } from "./backendimpl/base.js";
import { route as getPermissions } from "./routes/getPermissions.js";
import { route as backendCreate } from "./routes/backends/create.js";
import { route as backendRemove } from "./routes/backends/remove.js";
import { route as backendLookup } from "./routes/backends/lookup.js";
import { route as forwardConnections } from "./routes/forward/connections.js";
import { route as forwardCreate } from "./routes/forward/create.js";
import { route as forwardRemove } from "./routes/forward/remove.js";
import { route as forwardLookup } from "./routes/forward/lookup.js";
import { route as forwardStart } from "./routes/forward/start.js";
import { route as forwardStop } from "./routes/forward/stop.js";
import { route as userCreate } from "./routes/user/create.js";
import { route as userRemove } from "./routes/user/remove.js";
import { route as userLookup } from "./routes/user/lookup.js";
import { route as userLogin } from "./routes/user/login.js";
import { backendInit } from "./libs/backendInit.js";
const prisma = new PrismaClient();
const isSignupEnabled: boolean = 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

@ -1,64 +0,0 @@
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

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

View file

@ -1,110 +0,0 @@
import type { PrismaClient } from "@prisma/client";
import type { SessionToken } from "./types.js";
export const permissionListDisabled: Record<string, boolean> = {
"routes.add": false,
"routes.remove": false,
"routes.start": false,
"routes.stop": false,
"routes.edit": false,
"routes.visible": false,
"routes.visibleConn": false,
"backends.add": false,
"backends.remove": false,
"backends.start": false,
"backends.stop": false,
"backends.edit": false,
"backends.visible": false,
"backends.secretVis": false,
"permissions.see": false,
"users.add": false,
"users.remove": false,
"users.lookup": false,
"users.edit": false,
};
// FIXME: This solution fucking sucks.
export 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);
}

View file

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

View file

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

View file

@ -1,99 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
import { backendProviders } from "../../backendimpl/index.js";
import { backendInit } from "../../libs/backendInit.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
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

@ -1,82 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/backends/lookup",
{
schema: {
body: {
type: "object",
required: ["token"],
properties: {
token: { type: "string" },
id: { type: "number" },
name: { type: "string" },
description: { type: "string" },
backend: { type: "string" },
},
},
},
},
async (req, res) => {
// @ts-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

@ -1,71 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/backends/remove",
{
schema: {
body: {
type: "object",
required: ["token", "id"],
properties: {
token: { type: "string" },
id: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-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

@ -1,64 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
fastify.post(
"/api/v1/forward/connections",
{
schema: {
body: {
type: "object",
required: ["token", "id"],
properties: {
token: { type: "string" },
id: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-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

@ -1,119 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/forward/create",
{
schema: {
body: {
type: "object",
required: [
"token",
"name",
"protocol",
"sourceIP",
"sourcePort",
"destinationPort",
"providerID",
],
properties: {
token: { type: "string" },
name: { type: "string" },
description: { type: "string" },
protocol: { type: "string" },
sourceIP: { type: "string" },
sourcePort: { type: "number" },
destinationPort: { type: "number" },
providerID: { type: "number" },
autoStart: { type: "boolean" },
},
},
},
},
async (req, res) => {
// @ts-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

@ -1,113 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/forward/lookup",
{
schema: {
body: {
type: "object",
required: ["token"],
properties: {
token: { type: "string" },
id: { type: "number" },
name: { type: "string" },
protocol: { type: "string" },
description: { type: "string" },
sourceIP: { type: "string" },
sourcePort: { type: "number" },
destPort: { type: "number" },
providerID: { type: "number" },
autoStart: { type: "boolean" },
},
},
},
},
async (req, res) => {
// @ts-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

@ -1,56 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/forward/remove",
{
schema: {
body: {
type: "object",
required: ["token", "id"],
properties: {
token: { type: "string" },
id: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-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

@ -1,77 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/forward/start",
{
schema: {
body: {
type: "object",
required: ["token", "id"],
properties: {
token: { type: "string" },
id: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-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

@ -1,77 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, backends } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new route to use
*/
fastify.post(
"/api/v1/forward/stop",
{
schema: {
body: {
type: "object",
required: ["token", "id"],
properties: {
token: { type: "string" },
id: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-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

@ -1,51 +0,0 @@
import { hasPermission, getUID } from "../libs/permissions.js";
import type { RouteOptions } from "../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
/**
* Logs in to a user account.
*/
fastify.post(
"/api/v1/getPermissions",
{
schema: {
body: {
type: "object",
required: ["token"],
properties: {
token: { type: "string" },
},
},
},
},
async (req, res) => {
// @ts-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

@ -1,121 +0,0 @@
import { hash } from "bcrypt";
import { permissionListEnabled } from "../../libs/permissions.js";
import { generateRandomData } from "../../libs/generateRandom.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens, options } = routeOptions;
/**
* Creates a new user account to use, only if it is enabled.
*/
fastify.post(
"/api/v1/users/create",
{
schema: {
body: {
type: "object",
required: ["name", "email", "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

@ -1,68 +0,0 @@
import { compare } from "bcrypt";
import { generateRandomData } from "../../libs/generateRandom.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
/**
* Logs in to a user account.
*/
fastify.post(
"/api/v1/users/login",
{
schema: {
body: {
type: "object",
required: ["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

@ -1,67 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
fastify.post(
"/api/v1/users/lookup",
{
schema: {
body: {
type: "object",
required: ["token"],
properties: {
token: { type: "string" },
id: { type: "number" },
name: { type: "string" },
email: { type: "string" },
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

@ -1,62 +0,0 @@
import { hasPermissionByToken } from "../../libs/permissions.js";
import type { RouteOptions } from "../../libs/types.js";
export function route(routeOptions: RouteOptions) {
const { fastify, prisma, tokens } = routeOptions;
function hasPermission(
token: string,
permissionList: string[],
): Promise<boolean> {
return hasPermissionByToken(permissionList, token, tokens, prisma);
}
/**
* Creates a new backend to use
*/
fastify.post(
"/api/v1/users/remove",
{
schema: {
body: {
type: "object",
required: ["token", "uid"],
properties: {
token: { type: "string" },
uid: { type: "number" },
},
},
},
},
async (req, res) => {
// @ts-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,
};
},
);
}

View file

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

View file

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

21
apiclient/apiclient.go Normal file
View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

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

423
backend/api/main.go Normal file
View file

@ -0,0 +1,423 @@
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)
}
}

View file

@ -0,0 +1,38 @@
package permissions
import "git.terah.dev/imterah/hermes/backend/api/db"
var DefaultPermissionNodes []string = []string{
"routes.add",
"routes.remove",
"routes.start",
"routes.stop",
"routes.edit",
"routes.visible",
"routes.visibleConn",
"backends.add",
"backends.remove",
"backends.start",
"backends.stop",
"backends.edit",
"backends.visible",
"backends.secretVis",
"permissions.see",
"users.add",
"users.remove",
"users.lookup",
"users.edit",
}
func UserHasPermission(user *db.User, node string) bool {
for _, permission := range user.Permissions {
if permission.PermissionNode == node && permission.HasPermission {
return true
}
}
return false
}

View file

@ -0,0 +1,24 @@
package state
import (
"git.terah.dev/imterah/hermes/backend/api/db"
"git.terah.dev/imterah/hermes/backend/api/jwt"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
type State struct {
DB *db.DB
JWT *jwt.JWTCore
Engine *gin.Engine
Validator *validator.Validate
}
func New(db *db.DB, jwt *jwt.JWTCore, engine *gin.Engine) *State {
return &State{
DB: db,
JWT: jwt,
Engine: engine,
Validator: validator.New(),
}
}

View file

@ -0,0 +1,18 @@
package backendlauncher
import (
"fmt"
"math/rand/v2"
"net"
)
func GetUnixSocket(folder string) (string, net.Listener, error) {
socketPath := fmt.Sprintf("%s/sock-%d.sock", folder, rand.Uint())
listener, err := net.Listen("unix", socketPath)
if err != nil {
return "", nil, err
}
return socketPath, listener, nil
}

14
backend/backends.dev.json Normal file
View file

@ -0,0 +1,14 @@
[
{
"name": "ssh",
"path": "./sshbackend/sshbackend"
},
{
"name": "sshapp",
"path": "./sshappbackend/local-code/sshappbackend"
},
{
"name": "dummy",
"path": "./dummybackend/dummybackend"
}
]

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