Compare commits

..

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

35 changed files with 2326 additions and 2186 deletions

16
.prettierrc Normal file
View file

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

49
CHANGELOG.md Normal file
View file

@ -0,0 +1,49 @@
# Changelog
## [v1.1.2](https://github.com/imterah/nextnet/tree/v1.1.2) (2024-09-29)
## [v1.1.1](https://github.com/imterah/nextnet/tree/v1.1.1) (2024-09-29)
## [v1.1.0](https://github.com/imterah/nextnet/tree/v1.1.0) (2024-09-22)
**Fixed bugs:**
- Desktop app fails to build on macOS w/ `nix-shell` [\#1](https://github.com/imterah/nextnet/issues/1)
**Merged pull requests:**
- chore\(deps\): bump find-my-way from 8.1.0 to 8.2.2 in /api [\#17](https://github.com/imterah/nextnet/pull/17)
- chore\(deps\): bump axios from 1.6.8 to 1.7.4 in /lom [\#16](https://github.com/imterah/nextnet/pull/16)
- chore\(deps\): bump micromatch from 4.0.5 to 4.0.8 in /lom [\#15](https://github.com/imterah/nextnet/pull/15)
- chore\(deps\): bump braces from 3.0.2 to 3.0.3 in /lom [\#13](https://github.com/imterah/nextnet/pull/13)
- chore\(deps-dev\): bump braces from 3.0.2 to 3.0.3 in /api [\#11](https://github.com/imterah/nextnet/pull/11)
- chore\(deps\): bump ws from 8.17.0 to 8.17.1 in /api [\#10](https://github.com/imterah/nextnet/pull/10)
## [v1.0.1](https://github.com/imterah/nextnet/tree/v1.0.1) (2024-05-18)
**Merged pull requests:**
- Adds public key authentication [\#6](https://github.com/imterah/nextnet/pull/6)
- Add support for eslint [\#5](https://github.com/imterah/nextnet/pull/5)
## [v1.0.0](https://github.com/imterah/nextnet/tree/v1.0.0) (2024-05-10)
## [v0.1.1](https://github.com/imterah/nextnet/tree/v0.1.1) (2024-05-05)
## [v0.1.0](https://github.com/imterah/nextnet/tree/v0.1.0) (2024-05-05)
**Implemented enhancements:**
- \(potentially\) Migrate nix shell to nix flake [\#2](https://github.com/imterah/nextnet/issues/2)
**Closed issues:**
- add precommit hooks [\#3](https://github.com/imterah/nextnet/issues/3)
**Merged pull requests:**
- Reimplements PassyFire as a possible backend [\#4](https://github.com/imterah/nextnet/pull/4)
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*

View file

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

View file

@ -32,7 +32,7 @@
1. Copy and change the default password (or username & db name too) from the template file `prod-docker.env`: 1. Copy and change the default password (or username & db name too) from the template file `prod-docker.env`:
```bash ```bash
sed -e "s/POSTGRES_PASSWORD=hermes/POSTGRES_PASSWORD=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" -e "s/JWT_SECRET=hermes/JWT_SECRET=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" prod-docker.env > .env sed "s/POSTGRES_PASSWORD=hermes/POSTGRES_PASSWORD=$(head -c 500 /dev/random | sha512sum | cut -d " " -f 1)/g" prod-docker.env > .env
``` ```
2. Build the docker stack: `docker compose --env-file .env up -d` 2. Build the docker stack: `docker compose --env-file .env up -d`

View file

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

View file

@ -15,9 +15,6 @@ import (
"github.com/charmbracelet/log" "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 { func handleCommand(command interface{}, sock net.Conn, rtcChan chan interface{}) error {
bytes, err := commonbackend.Marshal(command) bytes, err := commonbackend.Marshal(command)
@ -163,9 +160,6 @@ func (runtime *Runtime) goRoutineHandler() error {
OuterLoop: OuterLoop:
for { for {
_ = <-runtime.startProcessingNotification
runtime.isRuntimeCurrentlyProcessing = true
for chanIndex, messageData := range runtime.messageBuffer { for chanIndex, messageData := range runtime.messageBuffer {
if messageData == nil { if messageData == nil {
continue continue
@ -183,8 +177,6 @@ func (runtime *Runtime) goRoutineHandler() error {
runtime.messageBuffer[chanIndex] = nil runtime.messageBuffer[chanIndex] = nil
} }
runtime.isRuntimeCurrentlyProcessing = false
} }
sock.Close() sock.Close()
@ -243,7 +235,6 @@ func (runtime *Runtime) Start() error {
runtime.messageBuffer = make([]*messageForBuf, 10) runtime.messageBuffer = make([]*messageForBuf, 10)
runtime.messageBufferLock = sync.Mutex{} runtime.messageBufferLock = sync.Mutex{}
runtime.startProcessingNotification = make(chan bool)
runtime.processRestartNotification = make(chan bool, 1) runtime.processRestartNotification = make(chan bool, 1)
runtime.logger = &writeLogger{ runtime.logger = &writeLogger{
@ -331,10 +322,6 @@ SchedulingLoop:
schedulingAttempts++ schedulingAttempts++
} }
if !runtime.isRuntimeCurrentlyProcessing {
runtime.startProcessingNotification <- true
}
// Fetch response and close Channel // Fetch response and close Channel
response, ok := <-commandChannel response, ok := <-commandChannel

View file

@ -16,14 +16,11 @@ type Backend struct {
type messageForBuf struct { type messageForBuf struct {
Channel chan interface{} Channel chan interface{}
// TODO(imterah): could this be refactored to just be a []byte instead? Look into this
Message interface{} Message interface{}
} }
type Runtime struct { type Runtime struct {
isRuntimeRunning bool isRuntimeRunning bool
isRuntimeCurrentlyProcessing bool
startProcessingNotification chan bool
logger *writeLogger logger *writeLogger
currentProcess *exec.Cmd currentProcess *exec.Cmd
currentListener net.Listener currentListener net.Listener
@ -45,7 +42,7 @@ type writeLogger struct {
func (writer writeLogger) Write(p []byte) (n int, err error) { func (writer writeLogger) Write(p []byte) (n int, err error) {
logSplit := strings.Split(string(p), "\n") logSplit := strings.Split(string(p), "\n")
if shouldLog { if isDevelopmentMode {
for _, logLine := range logSplit { for _, logLine := range logSplit {
if logLine == "" { if logLine == "" {
continue continue

298
backend/api/backup.go Normal file
View file

@ -0,0 +1,298 @@
package main
import (
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"git.terah.dev/imterah/hermes/backend/api/dbcore"
"github.com/charmbracelet/log"
"github.com/go-playground/validator/v10"
"github.com/urfave/cli/v2"
"gorm.io/gorm"
)
// Data structures
type BackupBackend struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Description *string `json:"description"`
Backend string `json:"backend" validate:"required"`
BackendParameters string `json:"connectionDetails" validate:"required"`
}
type BackupProxy struct {
ID uint `json:"id" validate:"required"`
BackendID uint `json:"destProviderID" validate:"required"`
Name string `json:"name" validate:"required"`
Description *string `json:"description"`
Protocol string `json:"protocol" validate:"required"`
SourceIP string `json:"sourceIP" validate:"required"`
SourcePort uint16 `json:"sourcePort" validate:"required"`
DestinationPort uint16 `json:"destPort" validate:"required"`
AutoStart bool `json:"enabled" validate:"required"`
}
type BackupPermission struct {
ID uint `json:"id" validate:"required"`
PermissionNode string `json:"permission" validate:"required"`
HasPermission bool `json:"has" validate:"required"`
UserID uint `json:"userID" validate:"required"`
}
type BackupUser struct {
ID uint `json:"id" validate:"required"`
Email string `json:"email" validate:"required"`
Username *string `json:"username"`
Name string `json:"name" validate:"required"`
Password string `json:"password" validate:"required"`
IsBot *bool `json:"isRootServiceAccount"`
Token *string `json:"rootToken" validate:"required"`
}
type BackupData struct {
Backends []*BackupBackend `json:"destinationProviders" validate:"required"`
Proxies []*BackupProxy `json:"forwardRules" validate:"required"`
Permissions []*BackupPermission `json:"allPermissions" validate:"required"`
Users []*BackupUser `json:"users" validate:"required"`
}
// From https://stackoverflow.com/questions/54461423/efficient-way-to-remove-all-non-alphanumeric-characters-from-large-text
// Strips all alphanumeric characters from a string
func stripAllAlphanumeric(s string) string {
var result strings.Builder
for i := 0; i < len(s); i++ {
b := s[i]
if ('a' <= b && b <= 'z') ||
('A' <= b && b <= 'Z') ||
('0' <= b && b <= '9') {
result.WriteByte(b)
} else {
result.WriteByte('_')
}
}
return result.String()
}
func backupRestoreEntrypoint(cCtx *cli.Context) error {
log.Info("Decompressing backup...")
backupFile, err := os.Open(cCtx.String("backup-path"))
if err != nil {
return fmt.Errorf("failed to open backup: %s", err.Error())
}
reader, err := gzip.NewReader(backupFile)
if err != nil {
return fmt.Errorf("failed to initialize Gzip (compression) reader: %s", err.Error())
}
backupDataBytes, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("failed to read backup contents: %s", err.Error())
}
log.Info("Decompressed backup. Cleaning up...")
err = reader.Close()
if err != nil {
return fmt.Errorf("failed to close Gzip reader: %s", err.Error())
}
err = backupFile.Close()
if err != nil {
return fmt.Errorf("failed to close backup: %s", err.Error())
}
log.Info("Parsing backup into internal structures...")
backupData := &BackupData{}
err = json.Unmarshal(backupDataBytes, backupData)
if err != nil {
return fmt.Errorf("failed to parse backup: %s", err.Error())
}
if err := validator.New().Struct(backupData); err != nil {
return fmt.Errorf("failed to validate backup: %s", err.Error())
}
log.Info("Initializing database and opening it...")
err = dbcore.InitializeDatabase(&gorm.Config{})
if err != nil {
log.Fatalf("Failed to initialize database: %s", err)
}
log.Info("Running database migrations...")
if err := dbcore.DoDatabaseMigrations(dbcore.DB); err != nil {
return fmt.Errorf("Failed to run database migrations: %s", err)
}
log.Info("Restoring database...")
bestEffortOwnerUIDFromBackup := -1
log.Info("Attempting to find user to use as owner of resources...")
for _, user := range backupData.Users {
foundUser := false
failedAdministrationCheck := false
for _, permission := range backupData.Permissions {
if permission.UserID != user.ID {
continue
}
foundUser = true
if !strings.HasPrefix(permission.PermissionNode, "routes.") && permission.PermissionNode != "permissions.see" && !permission.HasPermission {
log.Infof("User with email '%s' and ID of '%d' failed administration check (lacks all permissions required). Attempting to find better user", user.Email, user.ID)
failedAdministrationCheck = true
break
}
}
if !foundUser {
log.Warnf("User with email '%s' and ID of '%d' lacks any permissions!", user.Email, user.ID)
continue
}
if failedAdministrationCheck {
continue
}
log.Infof("Using user with email '%s', and ID of '%d'", user.Email, user.ID)
bestEffortOwnerUIDFromBackup = int(user.ID)
break
}
if bestEffortOwnerUIDFromBackup == -1 {
log.Warnf("Could not find Administrative level user to use as the owner of resources. Using user with email '%s', and ID of '%d'", backupData.Users[0].Email, backupData.Users[0].ID)
bestEffortOwnerUIDFromBackup = int(backupData.Users[0].ID)
}
var bestEffortOwnerUID uint
for _, user := range backupData.Users {
log.Infof("Migrating user with email '%s' and ID of '%d'", user.Email, user.ID)
tokens := make([]dbcore.Token, 0)
permissions := make([]dbcore.Permission, 0)
if user.Token != nil {
tokens = append(tokens, dbcore.Token{
Token: *user.Token,
DisableExpiry: true,
CreationIPAddr: "127.0.0.1", // We don't know the creation IP address...
})
}
for _, permission := range backupData.Permissions {
if permission.UserID != user.ID {
continue
}
permissions = append(permissions, dbcore.Permission{
PermissionNode: permission.PermissionNode,
HasPermission: permission.HasPermission,
})
}
username := ""
if user.Username == nil {
username = strings.ToLower(stripAllAlphanumeric(user.Email))
log.Warnf("User with ID of '%d' doesn't have a username. Derived username from email is '%s' (email is '%s')", user.ID, username, user.Email)
} else {
username = *user.Username
}
userDatabase := &dbcore.User{
Email: user.Email,
Username: username,
Name: user.Name,
Password: base64.StdEncoding.EncodeToString([]byte(user.Password)),
IsBot: user.IsBot,
Tokens: tokens,
Permissions: permissions,
}
if err := dbcore.DB.Create(userDatabase).Error; err != nil {
log.Errorf("Failed to create user: %s", err.Error())
continue
}
if uint(bestEffortOwnerUIDFromBackup) == user.ID {
bestEffortOwnerUID = userDatabase.ID
}
}
for _, backend := range backupData.Backends {
log.Infof("Migrating backend ID '%d' with name '%s'", backend.ID, backend.Name)
backendDatabase := &dbcore.Backend{
UserID: bestEffortOwnerUID,
Name: backend.Name,
Description: backend.Description,
Backend: backend.Backend,
BackendParameters: base64.StdEncoding.EncodeToString([]byte(backend.BackendParameters)),
}
if err := dbcore.DB.Create(backendDatabase).Error; err != nil {
log.Errorf("Failed to create backend: %s", err.Error())
continue
}
log.Infof("Migrating proxies for backend ID '%d'", backend.ID)
for _, proxy := range backupData.Proxies {
if proxy.BackendID != backend.ID {
continue
}
log.Infof("Migrating proxy ID '%d' with name '%s'", proxy.ID, proxy.Name)
proxyDatabase := &dbcore.Proxy{
BackendID: backendDatabase.ID,
UserID: bestEffortOwnerUID,
Name: proxy.Name,
Description: proxy.Description,
Protocol: proxy.Protocol,
SourceIP: proxy.SourceIP,
SourcePort: proxy.SourcePort,
DestinationPort: proxy.DestinationPort,
AutoStart: proxy.AutoStart,
}
if err := dbcore.DB.Create(proxyDatabase).Error; err != nil {
log.Errorf("Failed to create proxy: %s", err.Error())
}
}
}
log.Info("Successfully upgraded to Hermes from NextNet.")
return nil
}

View file

@ -7,12 +7,13 @@ import (
"net/http" "net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime" "git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/permissions" "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend" "git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type BackendCreationRequest struct { type BackendCreationRequest struct {
@ -23,8 +24,7 @@ type BackendCreationRequest struct {
BackendParameters interface{} `json:"connectionDetails" validate:"required"` BackendParameters interface{} `json:"connectionDetails" validate:"required"`
} }
func SetupCreateBackend(state *state.State) { func CreateBackend(c *gin.Context) {
state.Engine.POST("/api/v1/backends/create", func(c *gin.Context) {
var req BackendCreationRequest var req BackendCreationRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -35,7 +35,7 @@ func SetupCreateBackend(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -43,7 +43,7 @@ func SetupCreateBackend(state *state.State) {
return return
} }
user, err := state.JWT.GetUserFromJWT(req.Token) user, err := jwtcore.GetUserFromJWT(req.Token)
if err != nil { if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" { if err.Error() == "token is expired" || err.Error() == "user does not exist" {
@ -190,7 +190,7 @@ func SetupCreateBackend(state *state.State) {
log.Info("Passed backend checks successfully") log.Info("Passed backend checks successfully")
backendInDatabase := &db.Backend{ backendInDatabase := &dbcore.Backend{
UserID: user.ID, UserID: user.ID,
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
@ -198,7 +198,7 @@ func SetupCreateBackend(state *state.State) {
BackendParameters: base64.StdEncoding.EncodeToString(backendParameters), BackendParameters: base64.StdEncoding.EncodeToString(backendParameters),
} }
if result := state.DB.DB.Create(&backendInDatabase); result.Error != nil { if result := dbcore.DB.Create(&backendInDatabase); result.Error != nil {
log.Warnf("Failed to create backend: %s", result.Error.Error()) log.Warnf("Failed to create backend: %s", result.Error.Error())
err = backend.Stop() err = backend.Stop()
@ -266,5 +266,4 @@ func SetupCreateBackend(state *state.State) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
}) })
})
} }

View file

@ -7,11 +7,12 @@ import (
"strings" "strings"
"git.terah.dev/imterah/hermes/backend/api/backendruntime" "git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/permissions" "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type BackendLookupRequest struct { type BackendLookupRequest struct {
@ -37,8 +38,7 @@ type LookupResponse struct {
Data []*SanitizedBackend `json:"data"` Data []*SanitizedBackend `json:"data"`
} }
func SetupLookupBackend(state *state.State) { func LookupBackend(c *gin.Context) {
state.Engine.POST("/api/v1/backends/lookup", func(c *gin.Context) {
var req BackendLookupRequest var req BackendLookupRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -49,7 +49,7 @@ func SetupLookupBackend(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -57,7 +57,7 @@ func SetupLookupBackend(state *state.State) {
return return
} }
user, err := state.JWT.GetUserFromJWT(req.Token) user, err := jwtcore.GetUserFromJWT(req.Token)
if err != nil { if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" { if err.Error() == "token is expired" || err.Error() == "user does not exist" {
@ -85,7 +85,7 @@ func SetupLookupBackend(state *state.State) {
return return
} }
backends := []db.Backend{} backends := []dbcore.Backend{}
queryString := []string{} queryString := []string{}
queryParameters := []interface{}{} queryParameters := []interface{}{}
@ -109,7 +109,7 @@ func SetupLookupBackend(state *state.State) {
queryParameters = append(queryParameters, req.Backend) queryParameters = append(queryParameters, req.Backend)
} }
if err := state.DB.DB.Where(strings.Join(queryString, " AND "), queryParameters...).Find(&backends).Error; err != nil { if err := dbcore.DB.Where(strings.Join(queryString, " AND "), queryParameters...).Find(&backends).Error; err != nil {
log.Warnf("Failed to get backends: %s", err.Error()) log.Warnf("Failed to get backends: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@ -160,5 +160,4 @@ func SetupLookupBackend(state *state.State) {
Success: true, Success: true,
Data: sanitizedBackends, Data: sanitizedBackends,
}) })
})
} }

View file

@ -5,11 +5,12 @@ import (
"net/http" "net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime" "git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/permissions" "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type BackendRemovalRequest struct { type BackendRemovalRequest struct {
@ -17,8 +18,7 @@ type BackendRemovalRequest struct {
BackendID uint `json:"id" validate:"required"` BackendID uint `json:"id" validate:"required"`
} }
func SetupRemoveBackend(state *state.State) { func RemoveBackend(c *gin.Context) {
state.Engine.POST("/api/v1/backends/remove", func(c *gin.Context) {
var req BackendRemovalRequest var req BackendRemovalRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -29,7 +29,7 @@ func SetupRemoveBackend(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -37,7 +37,7 @@ func SetupRemoveBackend(state *state.State) {
return return
} }
user, err := state.JWT.GetUserFromJWT(req.Token) user, err := jwtcore.GetUserFromJWT(req.Token)
if err != nil { if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" { if err.Error() == "token is expired" || err.Error() == "user does not exist" {
@ -65,8 +65,8 @@ func SetupRemoveBackend(state *state.State) {
return return
} }
var backend *db.Backend var backend *dbcore.Backend
backendRequest := state.DB.DB.Where("id = ?", req.BackendID).Find(&backend) backendRequest := dbcore.DB.Where("id = ?", req.BackendID).Find(&backend)
if backendRequest.Error != nil { if backendRequest.Error != nil {
log.Warnf("failed to find if backend exists or not: %s", backendRequest.Error.Error()) log.Warnf("failed to find if backend exists or not: %s", backendRequest.Error.Error())
@ -88,7 +88,7 @@ func SetupRemoveBackend(state *state.State) {
return return
} }
if err := state.DB.DB.Delete(backend).Error; err != nil { if err := dbcore.DB.Delete(backend).Error; err != nil {
log.Warnf("failed to delete backend: %s", err.Error()) log.Warnf("failed to delete backend: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@ -120,5 +120,4 @@ func SetupRemoveBackend(state *state.State) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
}) })
})
} }

View file

@ -5,12 +5,13 @@ import (
"net/http" "net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime" "git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/permissions" "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend" "git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type ConnectionsRequest struct { type ConnectionsRequest struct {
@ -36,8 +37,7 @@ type ConnectionsResponse struct {
Data []*SanitizedConnection `json:"data"` Data []*SanitizedConnection `json:"data"`
} }
func SetupGetConnections(state *state.State) { func GetConnections(c *gin.Context) {
state.Engine.POST("/api/v1/forward/connections", func(c *gin.Context) {
var req ConnectionsRequest var req ConnectionsRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -48,7 +48,7 @@ func SetupGetConnections(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -56,8 +56,7 @@ func SetupGetConnections(state *state.State) {
return return
} }
user, err := state.JWT.GetUserFromJWT(req.Token) user, err := jwtcore.GetUserFromJWT(req.Token)
if err != nil { if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" { if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
@ -84,8 +83,8 @@ func SetupGetConnections(state *state.State) {
return return
} }
var proxy db.Proxy var proxy dbcore.Proxy
proxyRequest := state.DB.DB.Where("id = ?", req.Id).First(&proxy) proxyRequest := dbcore.DB.Where("id = ?", req.Id).First(&proxy)
if proxyRequest.Error != nil { if proxyRequest.Error != nil {
log.Warnf("failed to find proxy: %s", proxyRequest.Error.Error()) log.Warnf("failed to find proxy: %s", proxyRequest.Error.Error())
@ -161,5 +160,4 @@ func SetupGetConnections(state *state.State) {
"error": "Got illegal response type", "error": "Got illegal response type",
}) })
} }
})
} }

View file

@ -5,12 +5,13 @@ import (
"net/http" "net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime" "git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/permissions" "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend" "git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type ProxyCreationRequest struct { type ProxyCreationRequest struct {
@ -25,8 +26,7 @@ type ProxyCreationRequest struct {
AutoStart *bool `json:"autoStart"` AutoStart *bool `json:"autoStart"`
} }
func SetupCreateProxy(state *state.State) { func CreateProxy(c *gin.Context) {
state.Engine.POST("/api/v1/forward/create", func(c *gin.Context) {
var req ProxyCreationRequest var req ProxyCreationRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -37,7 +37,7 @@ func SetupCreateProxy(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -45,8 +45,7 @@ func SetupCreateProxy(state *state.State) {
return return
} }
user, err := state.JWT.GetUserFromJWT(req.Token) user, err := jwtcore.GetUserFromJWT(req.Token)
if err != nil { if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" { if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
@ -81,8 +80,8 @@ func SetupCreateProxy(state *state.State) {
return return
} }
var backend db.Backend var backend dbcore.Backend
backendRequest := state.DB.DB.Where("id = ?", req.ProviderID).First(&backend) backendRequest := dbcore.DB.Where("id = ?", req.ProviderID).First(&backend)
if backendRequest.Error != nil { if backendRequest.Error != nil {
log.Warnf("failed to find if backend exists or not: %s", backendRequest.Error.Error()) log.Warnf("failed to find if backend exists or not: %s", backendRequest.Error.Error())
@ -106,7 +105,7 @@ func SetupCreateProxy(state *state.State) {
autoStart = *req.AutoStart autoStart = *req.AutoStart
} }
proxy := &db.Proxy{ proxy := &dbcore.Proxy{
UserID: user.ID, UserID: user.ID,
BackendID: req.ProviderID, BackendID: req.ProviderID,
Name: req.Name, Name: req.Name,
@ -118,7 +117,7 @@ func SetupCreateProxy(state *state.State) {
AutoStart: autoStart, AutoStart: autoStart,
} }
if result := state.DB.DB.Create(proxy); result.Error != nil { if result := dbcore.DB.Create(proxy); result.Error != nil {
log.Warnf("failed to create proxy: %s", result.Error.Error()) log.Warnf("failed to create proxy: %s", result.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@ -173,5 +172,4 @@ func SetupCreateProxy(state *state.State) {
"success": true, "success": true,
"id": proxy.ID, "id": proxy.ID,
}) })
})
} }

View file

@ -5,11 +5,12 @@ import (
"net/http" "net/http"
"strings" "strings"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/permissions" "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type ProxyLookupRequest struct { type ProxyLookupRequest struct {
@ -42,8 +43,7 @@ type ProxyLookupResponse struct {
Data []*SanitizedProxy `json:"data"` Data []*SanitizedProxy `json:"data"`
} }
func SetupLookupProxy(state *state.State) { func LookupProxy(c *gin.Context) {
state.Engine.POST("/api/v1/forward/lookup", func(c *gin.Context) {
var req ProxyLookupRequest var req ProxyLookupRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -54,7 +54,7 @@ func SetupLookupProxy(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -62,7 +62,7 @@ func SetupLookupProxy(state *state.State) {
return return
} }
user, err := state.JWT.GetUserFromJWT(req.Token) user, err := jwtcore.GetUserFromJWT(req.Token)
if err != nil { if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" { if err.Error() == "token is expired" || err.Error() == "user does not exist" {
@ -100,7 +100,7 @@ func SetupLookupProxy(state *state.State) {
} }
} }
proxies := []db.Proxy{} proxies := []dbcore.Proxy{}
queryString := []string{} queryString := []string{}
queryParameters := []interface{}{} queryParameters := []interface{}{}
@ -150,7 +150,7 @@ func SetupLookupProxy(state *state.State) {
queryParameters = append(queryParameters, req.Protocol) queryParameters = append(queryParameters, req.Protocol)
} }
if err := state.DB.DB.Where(strings.Join(queryString, " AND "), queryParameters...).Find(&proxies).Error; err != nil { if err := dbcore.DB.Where(strings.Join(queryString, " AND "), queryParameters...).Find(&proxies).Error; err != nil {
log.Warnf("failed to get proxies: %s", err.Error()) log.Warnf("failed to get proxies: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@ -180,5 +180,4 @@ func SetupLookupProxy(state *state.State) {
Success: true, Success: true,
Data: sanitizedProxies, Data: sanitizedProxies,
}) })
})
} }

View file

@ -5,12 +5,13 @@ import (
"net/http" "net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime" "git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/permissions" "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend" "git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type ProxyRemovalRequest struct { type ProxyRemovalRequest struct {
@ -18,8 +19,7 @@ type ProxyRemovalRequest struct {
ID uint `validate:"required" json:"id"` ID uint `validate:"required" json:"id"`
} }
func SetupRemoveProxy(state *state.State) { func RemoveProxy(c *gin.Context) {
state.Engine.POST("/api/v1/forward/remove", func(c *gin.Context) {
var req ProxyRemovalRequest var req ProxyRemovalRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -30,7 +30,7 @@ func SetupRemoveProxy(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -38,8 +38,7 @@ func SetupRemoveProxy(state *state.State) {
return return
} }
user, err := state.JWT.GetUserFromJWT(req.Token) user, err := jwtcore.GetUserFromJWT(req.Token)
if err != nil { if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" { if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
@ -66,8 +65,8 @@ func SetupRemoveProxy(state *state.State) {
return return
} }
var proxy *db.Proxy var proxy *dbcore.Proxy
proxyRequest := state.DB.DB.Where("id = ?", req.ID).Find(&proxy) proxyRequest := dbcore.DB.Where("id = ?", req.ID).Find(&proxy)
if proxyRequest.Error != nil { if proxyRequest.Error != nil {
log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error.Error()) log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error.Error())
@ -89,7 +88,7 @@ func SetupRemoveProxy(state *state.State) {
return return
} }
if err := state.DB.DB.Delete(proxy).Error; err != nil { if err := dbcore.DB.Delete(proxy).Error; err != nil {
log.Warnf("failed to delete proxy: %s", err.Error()) log.Warnf("failed to delete proxy: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@ -134,10 +133,8 @@ func SetupRemoveProxy(state *state.State) {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to stop proxy. Proxy was still successfully deleted", "error": "Failed to stop proxy. Proxy was still successfully deleted",
}) })
} else {
c.JSON(http.StatusOK, gin.H{ return
"success": true,
})
} }
default: default:
log.Errorf("Got illegal response type for backend #%d: %T", proxy.BackendID, responseMessage) log.Errorf("Got illegal response type for backend #%d: %T", proxy.BackendID, responseMessage)
@ -145,6 +142,11 @@ func SetupRemoveProxy(state *state.State) {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "Got invalid response from backend. Proxy was still successfully deleted", "error": "Got invalid response from backend. Proxy was still successfully deleted",
}) })
return
} }
c.JSON(http.StatusOK, gin.H{
"success": true,
}) })
} }

View file

@ -5,12 +5,13 @@ import (
"net/http" "net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime" "git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/permissions" "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend" "git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type ProxyStartRequest struct { type ProxyStartRequest struct {
@ -18,8 +19,7 @@ type ProxyStartRequest struct {
ID uint `validate:"required" json:"id"` ID uint `validate:"required" json:"id"`
} }
func SetupStartProxy(state *state.State) { func StartProxy(c *gin.Context) {
state.Engine.POST("/api/v1/forward/start", func(c *gin.Context) {
var req ProxyStartRequest var req ProxyStartRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -30,7 +30,7 @@ func SetupStartProxy(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -38,8 +38,7 @@ func SetupStartProxy(state *state.State) {
return return
} }
user, err := state.JWT.GetUserFromJWT(req.Token) user, err := jwtcore.GetUserFromJWT(req.Token)
if err != nil { if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" { if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
@ -66,8 +65,8 @@ func SetupStartProxy(state *state.State) {
return return
} }
var proxy *db.Proxy var proxy *dbcore.Proxy
proxyRequest := state.DB.DB.Where("id = ?", req.ID).Find(&proxy) proxyRequest := dbcore.DB.Where("id = ?", req.ID).Find(&proxy)
if proxyRequest.Error != nil { if proxyRequest.Error != nil {
log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error.Error()) log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error.Error())
@ -115,22 +114,29 @@ func SetupStartProxy(state *state.State) {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to get response from backend", "error": "failed to get response from backend",
}) })
return
case *commonbackend.ProxyStatusResponse: case *commonbackend.ProxyStatusResponse:
if !responseMessage.IsActive { if !responseMessage.IsActive {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to start proxy", "error": "failed to start proxy",
}) })
} else {
c.JSON(http.StatusOK, gin.H{ return
"success": true,
})
} }
break
default: default:
log.Errorf("Got illegal response type for backend #%d: %T", proxy.BackendID, responseMessage) log.Errorf("Got illegal response type for backend #%d: %T", proxy.BackendID, responseMessage)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "Got invalid response from backend. Proxy was likely still successfully started", "error": "Got invalid response from backend. Proxy was still successfully deleted",
}) })
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
}) })
} }

View file

@ -5,12 +5,13 @@ import (
"net/http" "net/http"
"git.terah.dev/imterah/hermes/backend/api/backendruntime" "git.terah.dev/imterah/hermes/backend/api/backendruntime"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/permissions" "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend" "git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type ProxyStopRequest struct { type ProxyStopRequest struct {
@ -18,9 +19,8 @@ type ProxyStopRequest struct {
ID uint `validate:"required" json:"id"` ID uint `validate:"required" json:"id"`
} }
func SetupStopProxy(state *state.State) { func StopProxy(c *gin.Context) {
state.Engine.POST("/api/v1/forward/stop", func(c *gin.Context) { var req ProxyStopRequest
var req ProxyStartRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@ -30,7 +30,7 @@ func SetupStopProxy(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -38,8 +38,7 @@ func SetupStopProxy(state *state.State) {
return return
} }
user, err := state.JWT.GetUserFromJWT(req.Token) user, err := jwtcore.GetUserFromJWT(req.Token)
if err != nil { if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" { if err.Error() == "token is expired" || err.Error() == "user does not exist" {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
@ -66,8 +65,8 @@ func SetupStopProxy(state *state.State) {
return return
} }
var proxy *db.Proxy var proxy *dbcore.Proxy
proxyRequest := state.DB.DB.Where("id = ?", req.ID).Find(&proxy) proxyRequest := dbcore.DB.Where("id = ?", req.ID).Find(&proxy)
if proxyRequest.Error != nil { if proxyRequest.Error != nil {
log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error.Error()) log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error.Error())
@ -108,29 +107,36 @@ func SetupStopProxy(state *state.State) {
Protocol: proxy.Protocol, Protocol: proxy.Protocol,
}) })
switch responseMessage := backendResponse.(type) { if err != nil {
case error: log.Warnf("Failed to get response for backend #%d: %s", proxy.BackendID, err.Error())
log.Warnf("Failed to get response for backend #%d: %s", proxy.BackendID, responseMessage.Error())
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to get response from backend", "error": "failed to get response from backend",
}) })
return
}
switch responseMessage := backendResponse.(type) {
case *commonbackend.ProxyStatusResponse: case *commonbackend.ProxyStatusResponse:
if responseMessage.IsActive { if responseMessage.IsActive {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to stop proxy", "error": "failed to stop proxy",
}) })
} else {
c.JSON(http.StatusOK, gin.H{ return
"success": true,
})
} }
default: default:
log.Errorf("Got illegal response type for backend #%d: %T", proxy.BackendID, responseMessage) log.Errorf("Got illegal response type for backend #%d: %T", proxy.BackendID, responseMessage)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "Got invalid response from backend. Proxy was likely still successfully stopped", "error": "Got invalid response from backend. Proxy was still successfully deleted",
}) })
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
}) })
} }

View file

@ -7,9 +7,11 @@ import (
"net/http" "net/http"
"strings" "strings"
"git.terah.dev/imterah/hermes/backend/api/db" "github.com/go-playground/validator/v10"
"git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
permissionHelper "git.terah.dev/imterah/hermes/backend/api/permissions" permissionHelper "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -20,11 +22,13 @@ type UserCreationRequest struct {
Email string `validate:"required"` Email string `validate:"required"`
Password string `validate:"required"` Password string `validate:"required"`
Username string `validate:"required"` Username string `validate:"required"`
// TODO: implement support
ExistingUserToken string `json:"token"`
IsBot bool IsBot bool
} }
func SetupCreateUser(state *state.State) { func CreateUser(c *gin.Context) {
state.Engine.POST("/api/v1/users/create", func(c *gin.Context) {
if !signupEnabled && !unsafeSignup { if !signupEnabled && !unsafeSignup {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
"error": "Signing up is not enabled at this time.", "error": "Signing up is not enabled at this time.",
@ -43,7 +47,7 @@ func SetupCreateUser(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -51,8 +55,8 @@ func SetupCreateUser(state *state.State) {
return return
} }
var user *db.User var user *dbcore.User
userRequest := state.DB.DB.Where("email = ? OR username = ?", req.Email, req.Username).Find(&user) userRequest := dbcore.DB.Where("email = ? OR username = ?", req.Email, req.Username).Find(&user)
if userRequest.Error != nil { if userRequest.Error != nil {
log.Warnf("failed to find if user exists or not: %s", userRequest.Error.Error()) log.Warnf("failed to find if user exists or not: %s", userRequest.Error.Error())
@ -86,7 +90,7 @@ func SetupCreateUser(state *state.State) {
return return
} }
permissions := []db.Permission{} permissions := []dbcore.Permission{}
for _, permission := range permissionHelper.DefaultPermissionNodes { for _, permission := range permissionHelper.DefaultPermissionNodes {
permissionEnabledState := false permissionEnabledState := false
@ -95,7 +99,7 @@ func SetupCreateUser(state *state.State) {
permissionEnabledState = true permissionEnabledState = true
} }
permissions = append(permissions, db.Permission{ permissions = append(permissions, dbcore.Permission{
PermissionNode: permission, PermissionNode: permission,
HasPermission: permissionEnabledState, HasPermission: permissionEnabledState,
}) })
@ -113,14 +117,14 @@ func SetupCreateUser(state *state.State) {
return return
} }
user = &db.User{ user = &dbcore.User{
Email: req.Email, Email: req.Email,
Username: req.Username, Username: req.Username,
Name: req.Name, Name: req.Name,
IsBot: &req.IsBot, IsBot: &req.IsBot,
Password: base64.StdEncoding.EncodeToString(passwordHashed), Password: base64.StdEncoding.EncodeToString(passwordHashed),
Permissions: permissions, Permissions: permissions,
Tokens: []db.Token{ Tokens: []dbcore.Token{
{ {
Token: base64.StdEncoding.EncodeToString(tokenRandomData), Token: base64.StdEncoding.EncodeToString(tokenRandomData),
DisableExpiry: forceNoExpiryTokens, DisableExpiry: forceNoExpiryTokens,
@ -129,7 +133,7 @@ func SetupCreateUser(state *state.State) {
}, },
} }
if result := state.DB.DB.Create(&user); result.Error != nil { if result := dbcore.DB.Create(&user); result.Error != nil {
log.Warnf("Failed to create user: %s", result.Error.Error()) log.Warnf("Failed to create user: %s", result.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@ -139,7 +143,7 @@ func SetupCreateUser(state *state.State) {
return return
} }
jwt, err := state.JWT.Generate(user.ID) jwt, err := jwtcore.Generate(user.ID)
if err != nil { if err != nil {
log.Warnf("Failed to generate JWT: %s", err.Error()) log.Warnf("Failed to generate JWT: %s", err.Error())
@ -156,5 +160,4 @@ func SetupCreateUser(state *state.State) {
"token": jwt, "token": jwt,
"refreshToken": base64.StdEncoding.EncodeToString(tokenRandomData), "refreshToken": base64.StdEncoding.EncodeToString(tokenRandomData),
}) })
})
} }

View file

@ -6,10 +6,11 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/state" "git.terah.dev/imterah/hermes/backend/api/jwtcore"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -20,8 +21,7 @@ type UserLoginRequest struct {
Password string `validate:"required"` Password string `validate:"required"`
} }
func SetupLoginUser(state *state.State) { func LoginUser(c *gin.Context) {
state.Engine.POST("/api/v1/users/login", func(c *gin.Context) {
var req UserLoginRequest var req UserLoginRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -32,7 +32,7 @@ func SetupLoginUser(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -61,8 +61,8 @@ func SetupLoginUser(state *state.State) {
userFindRequest += "username = ?" userFindRequest += "username = ?"
} }
var user *db.User var user *dbcore.User
userRequest := state.DB.DB.Where(userFindRequest, userFindRequestArguments...).Find(&user) userRequest := dbcore.DB.Where(userFindRequest, userFindRequestArguments...).Find(&user)
if userRequest.Error != nil { if userRequest.Error != nil {
log.Warnf("failed to find if user exists or not: %s", userRequest.Error.Error()) log.Warnf("failed to find if user exists or not: %s", userRequest.Error.Error())
@ -119,7 +119,7 @@ func SetupLoginUser(state *state.State) {
return return
} }
token := &db.Token{ token := &dbcore.Token{
UserID: user.ID, UserID: user.ID,
Token: base64.StdEncoding.EncodeToString(tokenRandomData), Token: base64.StdEncoding.EncodeToString(tokenRandomData),
@ -127,7 +127,7 @@ func SetupLoginUser(state *state.State) {
CreationIPAddr: c.ClientIP(), CreationIPAddr: c.ClientIP(),
} }
if result := state.DB.DB.Create(&token); result.Error != nil { if result := dbcore.DB.Create(&token); result.Error != nil {
log.Warnf("Failed to create user: %s", result.Error.Error()) log.Warnf("Failed to create user: %s", result.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@ -137,7 +137,7 @@ func SetupLoginUser(state *state.State) {
return return
} }
jwt, err := state.JWT.Generate(user.ID) jwt, err := jwtcore.Generate(user.ID)
if err != nil { if err != nil {
log.Warnf("Failed to generate JWT: %s", err.Error()) log.Warnf("Failed to generate JWT: %s", err.Error())
@ -154,5 +154,4 @@ func SetupLoginUser(state *state.State) {
"token": jwt, "token": jwt,
"refreshToken": base64.StdEncoding.EncodeToString(tokenRandomData), "refreshToken": base64.StdEncoding.EncodeToString(tokenRandomData),
}) })
})
} }

View file

@ -5,11 +5,12 @@ import (
"net/http" "net/http"
"strings" "strings"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/permissions" "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type UserLookupRequest struct { type UserLookupRequest struct {
@ -34,8 +35,7 @@ type LookupResponse struct {
Data []*SanitizedUsers `json:"data"` Data []*SanitizedUsers `json:"data"`
} }
func SetupLookupUser(state *state.State) { func LookupUser(c *gin.Context) {
state.Engine.POST("/api/v1/users/lookup", func(c *gin.Context) {
var req UserLookupRequest var req UserLookupRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -46,7 +46,7 @@ func SetupLookupUser(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -54,7 +54,7 @@ func SetupLookupUser(state *state.State) {
return return
} }
user, err := state.JWT.GetUserFromJWT(req.Token) user, err := jwtcore.GetUserFromJWT(req.Token)
if err != nil { if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" { if err.Error() == "token is expired" || err.Error() == "user does not exist" {
@ -74,7 +74,7 @@ func SetupLookupUser(state *state.State) {
} }
} }
users := []db.User{} users := []dbcore.User{}
queryString := []string{} queryString := []string{}
queryParameters := []interface{}{} queryParameters := []interface{}{}
@ -101,7 +101,7 @@ func SetupLookupUser(state *state.State) {
queryParameters = append(queryParameters, req.IsBot) queryParameters = append(queryParameters, req.IsBot)
} }
if err := state.DB.DB.Where(strings.Join(queryString, " AND "), queryParameters...).Find(&users).Error; err != nil { if err := dbcore.DB.Where(strings.Join(queryString, " AND "), queryParameters...).Find(&users).Error; err != nil {
log.Warnf("Failed to get users: %s", err.Error()) log.Warnf("Failed to get users: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@ -133,5 +133,4 @@ func SetupLookupUser(state *state.State) {
Success: true, Success: true,
Data: sanitizedUsers, Data: sanitizedUsers,
}) })
})
} }

View file

@ -5,18 +5,18 @@ import (
"net/http" "net/http"
"time" "time"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/state" "git.terah.dev/imterah/hermes/backend/api/jwtcore"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type UserRefreshRequest struct { type UserRefreshRequest struct {
Token string `validate:"required"` Token string `validate:"required"`
} }
func SetupRefreshUserToken(state *state.State) { func RefreshUserToken(c *gin.Context) {
state.Engine.POST("/api/v1/users/refresh", func(c *gin.Context) {
var req UserRefreshRequest var req UserRefreshRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -27,7 +27,7 @@ func SetupRefreshUserToken(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -35,8 +35,8 @@ func SetupRefreshUserToken(state *state.State) {
return return
} }
var tokenInDatabase *db.Token var tokenInDatabase *dbcore.Token
tokenRequest := state.DB.DB.Where("token = ?", req.Token).Find(&tokenInDatabase) tokenRequest := dbcore.DB.Where("token = ?", req.Token).Find(&tokenInDatabase)
if tokenRequest.Error != nil { if tokenRequest.Error != nil {
log.Warnf("failed to find if token exists or not: %s", tokenRequest.Error.Error()) log.Warnf("failed to find if token exists or not: %s", tokenRequest.Error.Error())
@ -65,7 +65,7 @@ func SetupRefreshUserToken(state *state.State) {
"error": "Token has expired", "error": "Token has expired",
}) })
tx := state.DB.DB.Delete(tokenInDatabase) tx := dbcore.DB.Delete(tokenInDatabase)
if tx.Error != nil { if tx.Error != nil {
log.Warnf("Failed to delete expired token from database: %s", tx.Error.Error()) log.Warnf("Failed to delete expired token from database: %s", tx.Error.Error())
@ -75,8 +75,8 @@ func SetupRefreshUserToken(state *state.State) {
} }
// Get the user to check if the user exists before doing anything // Get the user to check if the user exists before doing anything
var user *db.User var user *dbcore.User
userRequest := state.DB.DB.Where("id = ?", tokenInDatabase.UserID).Find(&user) userRequest := dbcore.DB.Where("id = ?", tokenInDatabase.UserID).Find(&user)
if tokenRequest.Error != nil { if tokenRequest.Error != nil {
log.Warnf("failed to find if token user or not: %s", userRequest.Error.Error()) log.Warnf("failed to find if token user or not: %s", userRequest.Error.Error())
@ -98,7 +98,7 @@ func SetupRefreshUserToken(state *state.State) {
return return
} }
jwt, err := state.JWT.Generate(user.ID) jwt, err := jwtcore.Generate(user.ID)
if err != nil { if err != nil {
log.Warnf("Failed to generate JWT: %s", err.Error()) log.Warnf("Failed to generate JWT: %s", err.Error())
@ -114,5 +114,4 @@ func SetupRefreshUserToken(state *state.State) {
"success": true, "success": true,
"token": jwt, "token": jwt,
}) })
})
} }

View file

@ -4,11 +4,12 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/permissions" "git.terah.dev/imterah/hermes/backend/api/permissions"
"git.terah.dev/imterah/hermes/backend/api/state"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
) )
type UserRemovalRequest struct { type UserRemovalRequest struct {
@ -16,8 +17,7 @@ type UserRemovalRequest struct {
UID *uint `json:"uid"` UID *uint `json:"uid"`
} }
func SetupRemoveUser(state *state.State) { func RemoveUser(c *gin.Context) {
state.Engine.POST("/api/v1/users/remove", func(c *gin.Context) {
var req UserRemovalRequest var req UserRemovalRequest
if err := c.BindJSON(&req); err != nil { if err := c.BindJSON(&req); err != nil {
@ -28,7 +28,7 @@ func SetupRemoveUser(state *state.State) {
return return
} }
if err := state.Validator.Struct(&req); err != nil { if err := validator.New().Struct(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Failed to validate body: %s", err.Error()), "error": fmt.Sprintf("Failed to validate body: %s", err.Error()),
}) })
@ -36,7 +36,7 @@ func SetupRemoveUser(state *state.State) {
return return
} }
user, err := state.JWT.GetUserFromJWT(req.Token) user, err := jwtcore.GetUserFromJWT(req.Token)
if err != nil { if err != nil {
if err.Error() == "token is expired" || err.Error() == "user does not exist" { if err.Error() == "token is expired" || err.Error() == "user does not exist" {
@ -73,8 +73,8 @@ func SetupRemoveUser(state *state.State) {
// Make sure the user exists first if we have a custom UserID // Make sure the user exists first if we have a custom UserID
if uid != user.ID { if uid != user.ID {
var customUser *db.User var customUser *dbcore.User
userRequest := state.DB.DB.Where("id = ?", uid).Find(customUser) userRequest := dbcore.DB.Where("id = ?", uid).Find(customUser)
if userRequest.Error != nil { if userRequest.Error != nil {
log.Warnf("failed to find if user exists or not: %s", userRequest.Error.Error()) log.Warnf("failed to find if user exists or not: %s", userRequest.Error.Error())
@ -97,10 +97,9 @@ func SetupRemoveUser(state *state.State) {
} }
} }
state.DB.DB.Select("Tokens", "Permissions", "Proxys", "Backends").Where("id = ?", uid).Delete(user) dbcore.DB.Select("Tokens", "Permissions", "Proxys", "Backends").Where("id = ?", uid).Delete(user)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
}) })
})
} }

View file

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

View file

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

142
backend/api/dbcore/db.go Normal file
View file

@ -0,0 +1,142 @@
package dbcore
import (
"fmt"
"os"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"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
}
var DB *gorm.DB
func InitializeDatabaseDialector() (gorm.Dialector, error) {
databaseBackend := os.Getenv("HERMES_DATABASE_BACKEND")
switch databaseBackend {
case "sqlite":
filePath := os.Getenv("HERMES_SQLITE_FILEPATH")
if filePath == "" {
return nil, fmt.Errorf("sqlite database file not specified (missing HERMES_SQLITE_FILEPATH)")
}
return sqlite.Open(filePath), nil
case "postgresql":
postgresDSN := os.Getenv("HERMES_POSTGRES_DSN")
if postgresDSN == "" {
return nil, fmt.Errorf("postgres DSN not specified (missing HERMES_POSTGRES_DSN)")
}
return postgres.Open(postgresDSN), nil
case "":
return nil, fmt.Errorf("no database backend specified in environment variables (missing HERMES_DATABASE_BACKEND)")
default:
return nil, fmt.Errorf("unknown database backend specified: %s", os.Getenv(databaseBackend))
}
}
func InitializeDatabase(config *gorm.Config) error {
var err error
dialector, err := InitializeDatabaseDialector()
if err != nil {
return fmt.Errorf("failed to initialize physical database: %s", err)
}
DB, err = gorm.Open(dialector, config)
if err != nil {
return fmt.Errorf("failed to open database: %s", err)
}
return nil
}
func DoDatabaseMigrations(db *gorm.DB) error {
if err := db.AutoMigrate(&Proxy{}); err != nil {
return err
}
if err := db.AutoMigrate(&Backend{}); err != nil {
return err
}
if err := db.AutoMigrate(&Permission{}); err != nil {
return err
}
if err := db.AutoMigrate(&Token{}); err != nil {
return err
}
if err := db.AutoMigrate(&User{}); err != nil {
return err
}
return nil
}

View file

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

117
backend/api/jwtcore/jwt.go Normal file
View file

@ -0,0 +1,117 @@
package jwtcore
import (
"encoding/base64"
"errors"
"fmt"
"os"
"strconv"
"time"
"git.terah.dev/imterah/hermes/backend/api/dbcore"
"github.com/golang-jwt/jwt/v5"
)
var (
JWTKey []byte
developmentMode bool
)
func SetupJWT() error {
var err error
jwtDataString := os.Getenv("HERMES_JWT_SECRET")
if jwtDataString == "" {
return fmt.Errorf("JWT secret isn't set (missing HERMES_JWT_SECRET)")
}
if os.Getenv("HERMES_JWT_BASE64_ENCODED") != "" {
JWTKey, err = base64.StdEncoding.DecodeString(jwtDataString)
if err != nil {
return fmt.Errorf("failed to decode base64 JWT: %s", err.Error())
}
} else {
JWTKey = []byte(jwtDataString)
}
if os.Getenv("HERMES_DEVELOPMENT_MODE") != "" {
developmentMode = true
}
return nil
}
func Parse(tokenString string, options ...jwt.ParserOption) (*jwt.Token, error) {
return jwt.Parse(tokenString, JWTKeyCallback, options...)
}
func GetUserFromJWT(token string) (*dbcore.User, error) {
parsedJWT, err := 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 := &dbcore.User{}
userRequest := dbcore.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 Generate(uid uint) (string, error) {
timeMultiplier := 3
if developmentMode {
timeMultiplier = 60 * 24
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(timeMultiplier) * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Audience: []string{strconv.Itoa(int(uid))},
})
signedToken, err := token.SignedString(JWTKey)
if err != nil {
return "", err
}
return signedToken, nil
}
func JWTKeyCallback(*jwt.Token) (interface{}, error) {
return JWTKey, nil
}

View file

@ -9,19 +9,18 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"git.terah.dev/imterah/hermes/backend/api/backendruntime" "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/backends"
"git.terah.dev/imterah/hermes/backend/api/controllers/v1/proxies" "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/controllers/v1/users"
"git.terah.dev/imterah/hermes/backend/api/db" "git.terah.dev/imterah/hermes/backend/api/dbcore"
"git.terah.dev/imterah/hermes/backend/api/jwt" "git.terah.dev/imterah/hermes/backend/api/jwtcore"
"git.terah.dev/imterah/hermes/backend/api/state"
"git.terah.dev/imterah/hermes/backend/commonbackend" "git.terah.dev/imterah/hermes/backend/commonbackend"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gorm.io/gorm"
) )
func apiEntrypoint(cCtx *cli.Context) error { func apiEntrypoint(cCtx *cli.Context) error {
@ -35,26 +34,7 @@ func apiEntrypoint(cCtx *cli.Context) error {
log.Info("Hermes is initializing...") log.Info("Hermes is initializing...")
log.Debug("Initializing database and opening it...") log.Debug("Initializing database and opening it...")
databaseBackendName := os.Getenv("HERMES_DATABASE_BACKEND") err := dbcore.InitializeDatabase(&gorm.Config{})
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 { if err != nil {
log.Fatalf("Failed to initialize database: %s", err) log.Fatalf("Failed to initialize database: %s", err)
@ -62,38 +42,16 @@ func apiEntrypoint(cCtx *cli.Context) error {
log.Debug("Running database migrations...") log.Debug("Running database migrations...")
if err := dbInstance.DoMigrations(); err != nil { if err := dbcore.DoDatabaseMigrations(dbcore.DB); err != nil {
return fmt.Errorf("Failed to run database migrations: %s", err) return fmt.Errorf("Failed to run database migrations: %s", err)
} }
log.Debug("Initializing the JWT subsystem...") log.Debug("Initializing the JWT subsystem...")
jwtDataString := os.Getenv("HERMES_JWT_SECRET") if err := jwtcore.SetupJWT(); err != nil {
var jwtKey []byte return fmt.Errorf("Failed to initialize the JWT subsystem: %s", err.Error())
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...") log.Debug("Initializing the backend subsystem...")
backendMetadataPath := cCtx.String("backends-path") backendMetadataPath := cCtx.String("backends-path")
@ -118,9 +76,9 @@ func apiEntrypoint(cCtx *cli.Context) error {
log.Debug("Enumerating backends...") log.Debug("Enumerating backends...")
backendList := []db.Backend{} backendList := []dbcore.Backend{}
if err := dbInstance.DB.Find(&backendList).Error; err != nil { if err := dbcore.DB.Find(&backendList).Error; err != nil {
return fmt.Errorf("Failed to enumerate backends: %s", err.Error()) return fmt.Errorf("Failed to enumerate backends: %s", err.Error())
} }
@ -183,9 +141,9 @@ func apiEntrypoint(cCtx *cli.Context) error {
log.Warnf("Backend #%d has reinitialized! Starting up auto-starting proxies...", backend.ID) log.Warnf("Backend #%d has reinitialized! Starting up auto-starting proxies...", backend.ID)
autoStartProxies := []db.Proxy{} autoStartProxies := []dbcore.Proxy{}
if err := dbInstance.DB.Where("backend_id = ? AND auto_start = true", backend.ID).Find(&autoStartProxies).Error; err != nil { if err := dbcore.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()) log.Errorf("Failed to query proxies to autostart: %s", err.Error())
return return
} }
@ -285,9 +243,9 @@ func apiEntrypoint(cCtx *cli.Context) error {
log.Infof("Successfully initialized backend #%d", backend.ID) log.Infof("Successfully initialized backend #%d", backend.ID)
autoStartProxies := []db.Proxy{} autoStartProxies := []dbcore.Proxy{}
if err := dbInstance.DB.Where("backend_id = ? AND auto_start = true", backend.ID).Find(&autoStartProxies).Error; err != nil { if err := dbcore.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()) log.Errorf("Failed to query proxies to autostart: %s", err.Error())
continue continue
} }
@ -351,25 +309,23 @@ func apiEntrypoint(cCtx *cli.Context) error {
engine.SetTrustedProxies(nil) engine.SetTrustedProxies(nil)
} }
state := state.New(dbInstance, jwtInstance, engine)
// Initialize routes // Initialize routes
users.SetupCreateUser(state) engine.POST("/api/v1/users/create", users.CreateUser)
users.SetupLoginUser(state) engine.POST("/api/v1/users/login", users.LoginUser)
users.SetupRefreshUserToken(state) engine.POST("/api/v1/users/refresh", users.RefreshUserToken)
users.SetupRemoveUser(state) engine.POST("/api/v1/users/remove", users.RemoveUser)
users.SetupLookupUser(state) engine.POST("/api/v1/users/lookup", users.LookupUser)
backends.SetupCreateBackend(state) engine.POST("/api/v1/backends/create", backends.CreateBackend)
backends.SetupRemoveBackend(state) engine.POST("/api/v1/backends/remove", backends.RemoveBackend)
backends.SetupLookupBackend(state) engine.POST("/api/v1/backends/lookup", backends.LookupBackend)
proxies.SetupCreateProxy(state) engine.POST("/api/v1/forward/create", proxies.CreateProxy)
proxies.SetupRemoveProxy(state) engine.POST("/api/v1/forward/lookup", proxies.LookupProxy)
proxies.SetupLookupProxy(state) engine.POST("/api/v1/forward/remove", proxies.RemoveProxy)
proxies.SetupStartProxy(state) engine.POST("/api/v1/forward/start", proxies.StartProxy)
proxies.SetupStopProxy(state) engine.POST("/api/v1/forward/stop", proxies.StopProxy)
proxies.SetupGetConnections(state) engine.POST("/api/v1/forward/connections", proxies.GetConnections)
log.Infof("Listening on '%s'", listeningAddress) log.Infof("Listening on '%s'", listeningAddress)
err = engine.Run(listeningAddress) err = engine.Run(listeningAddress)
@ -406,6 +362,22 @@ func main() {
app := &cli.App{ app := &cli.App{
Name: "hermes", Name: "hermes",
Usage: "port forwarding across boundaries", Usage: "port forwarding across boundaries",
Commands: []*cli.Command{
{
Name: "import",
Usage: "imports from legacy NextNet/Hermes source",
Aliases: []string{"i"},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "backup-path",
Aliases: []string{"bp"},
Usage: "path to the backup file",
Required: true,
},
},
Action: backupRestoreEntrypoint,
},
},
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
Name: "backends-path", Name: "backends-path",

View file

@ -1,6 +1,6 @@
package permissions package permissions
import "git.terah.dev/imterah/hermes/backend/api/db" import "git.terah.dev/imterah/hermes/backend/api/dbcore"
var DefaultPermissionNodes []string = []string{ var DefaultPermissionNodes []string = []string{
"routes.add", "routes.add",
@ -27,7 +27,7 @@ var DefaultPermissionNodes []string = []string{
"users.edit", "users.edit",
} }
func UserHasPermission(user *db.User, node string) bool { func UserHasPermission(user *dbcore.User, node string) bool {
for _, permission := range user.Permissions { for _, permission := range user.Permissions {
if permission.PermissionNode == node && permission.HasPermission { if permission.PermissionNode == node && permission.HasPermission {
return true return true

View file

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

@ -26,8 +26,6 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
var validatorInstance *validator.Validate
type TCPProxy struct { type TCPProxy struct {
proxyInformation *commonbackend.AddProxy proxyInformation *commonbackend.AddProxy
connections map[uint16]net.Conn connections map[uint16]net.Conn
@ -64,11 +62,6 @@ type SSHAppBackend struct {
func (backend *SSHAppBackend) StartBackend(configBytes []byte) (bool, error) { func (backend *SSHAppBackend) StartBackend(configBytes []byte) (bool, error) {
log.Info("SSHAppBackend is initializing...") log.Info("SSHAppBackend is initializing...")
if validatorInstance == nil {
validatorInstance = validator.New()
}
backend.globalNonCriticalMessageChan = make(chan interface{}) backend.globalNonCriticalMessageChan = make(chan interface{})
backend.tcpProxies = map[uint16]*TCPProxy{} backend.tcpProxies = map[uint16]*TCPProxy{}
backend.udpProxies = map[uint16]*UDPProxy{} backend.udpProxies = map[uint16]*UDPProxy{}
@ -79,7 +72,7 @@ func (backend *SSHAppBackend) StartBackend(configBytes []byte) (bool, error) {
return false, err return false, err
} }
if err := validatorInstance.Struct(&backendData); err != nil { if err := validator.New().Struct(&backendData); err != nil {
return false, err return false, err
} }
@ -592,10 +585,6 @@ func (backend *SSHAppBackend) CheckParametersForConnections(clientParameters *co
func (backend *SSHAppBackend) CheckParametersForBackend(arguments []byte) *commonbackend.CheckParametersResponse { func (backend *SSHAppBackend) CheckParametersForBackend(arguments []byte) *commonbackend.CheckParametersResponse {
var backendData SSHAppBackendData var backendData SSHAppBackendData
if validatorInstance == nil {
validatorInstance = validator.New()
}
if err := json.Unmarshal(arguments, &backendData); err != nil { if err := json.Unmarshal(arguments, &backendData); err != nil {
return &commonbackend.CheckParametersResponse{ return &commonbackend.CheckParametersResponse{
IsValid: false, IsValid: false,
@ -603,7 +592,7 @@ func (backend *SSHAppBackend) CheckParametersForBackend(arguments []byte) *commo
} }
} }
if err := validatorInstance.Struct(&backendData); err != nil { if err := validator.New().Struct(&backendData); err != nil {
return &commonbackend.CheckParametersResponse{ return &commonbackend.CheckParametersResponse{
IsValid: false, IsValid: false,
Message: fmt.Sprintf("failed validation of parameters: %s", err.Error()), Message: fmt.Sprintf("failed validation of parameters: %s", err.Error()),

View file

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -19,34 +18,6 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
var validatorInstance *validator.Validate
type ConnWithTimeout struct {
net.Conn
ReadTimeout time.Duration
WriteTimeout time.Duration
}
func (c *ConnWithTimeout) Read(b []byte) (int, error) {
err := c.Conn.SetReadDeadline(time.Now().Add(c.ReadTimeout))
if err != nil {
return 0, err
}
return c.Conn.Read(b)
}
func (c *ConnWithTimeout) Write(b []byte) (int, error) {
err := c.Conn.SetWriteDeadline(time.Now().Add(c.WriteTimeout))
if err != nil {
return 0, err
}
return c.Conn.Write(b)
}
type SSHListener struct { type SSHListener struct {
SourceIP string SourceIP string
SourcePort uint16 SourcePort uint16
@ -55,46 +26,31 @@ type SSHListener struct {
Listeners []net.Listener Listeners []net.Listener
} }
type SSHBackendData struct {
IP string `json:"ip" validate:"required"`
Port uint16 `json:"port" validate:"required"`
Username string `json:"username" validate:"required"`
PrivateKey string `json:"privateKey" validate:"required"`
DisablePIDCheck bool `json:"disablePIDCheck"`
ListenOnIPs []string `json:"listenOnIPs"`
}
type SSHBackend struct { type SSHBackend struct {
config *SSHBackendData config *SSHBackendData
conn *ssh.Client conn *ssh.Client
clients []*commonbackend.ProxyClientConnection clients []*commonbackend.ProxyClientConnection
proxies []*SSHListener proxies []*SSHListener
arrayPropMutex sync.Mutex arrayPropMutex sync.Mutex
pid int }
isReady bool
inReinitLoop bool type SSHBackendData struct {
IP string `json:"ip" validate:"required"`
Port uint16 `json:"port" validate:"required"`
Username string `json:"username" validate:"required"`
PrivateKey string `json:"privateKey" validate:"required"`
ListenOnIPs []string `json:"listenOnIPs"`
} }
func (backend *SSHBackend) StartBackend(bytes []byte) (bool, error) { func (backend *SSHBackend) StartBackend(bytes []byte) (bool, error) {
log.Info("SSHBackend is initializing...") log.Info("SSHBackend is initializing...")
if validatorInstance == nil {
validatorInstance = validator.New()
}
if backend.inReinitLoop {
for !backend.isReady {
time.Sleep(100 * time.Millisecond)
}
}
var backendData SSHBackendData var backendData SSHBackendData
if err := json.Unmarshal(bytes, &backendData); err != nil { if err := json.Unmarshal(bytes, &backendData); err != nil {
return false, err return false, err
} }
if err := validatorInstance.Struct(&backendData); err != nil { if err := validator.New().Struct(&backendData); err != nil {
return false, err return false, err
} }
@ -120,70 +76,16 @@ func (backend *SSHBackend) StartBackend(bytes []byte) (bool, error) {
}, },
} }
addr := fmt.Sprintf("%s:%d", backendData.IP, backendData.Port) conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", backendData.IP, backendData.Port), config)
timeout := time.Duration(10 * time.Second)
rawTCPConn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil { if err != nil {
return false, err return false, err
} }
connWithTimeout := &ConnWithTimeout{ backend.conn = conn
Conn: rawTCPConn,
ReadTimeout: timeout,
WriteTimeout: timeout,
}
c, chans, reqs, err := ssh.NewClientConn(connWithTimeout, addr, config)
if err != nil {
return false, err
}
client := ssh.NewClient(c, chans, reqs)
backend.conn = client
if !backendData.DisablePIDCheck {
if backend.pid != 0 {
session, err := client.NewSession()
if err != nil {
return false, err
}
err = session.Run(fmt.Sprintf("kill -9 %d", backend.pid))
if err != nil {
log.Warnf("Failed to kill process: %s", err.Error())
}
}
session, err := client.NewSession()
if err != nil {
return false, err
}
// Get the parent PID of the shell so we can kill it if we disconnect
output, err := session.Output("ps --no-headers -fp $$ | awk '{print $3}'")
if err != nil {
return false, err
}
// Strip the new line and convert to int
backend.pid, err = strconv.Atoi(string(output)[:len(output)-1])
if err != nil {
return false, err
}
}
go backend.backendDisconnectHandler()
go backend.backendKeepaliveHandler()
log.Info("SSHBackend has initialized successfully.") log.Info("SSHBackend has initialized successfully.")
go backend.backendDisconnectHandler()
return true, nil return true, nil
} }
@ -301,7 +203,8 @@ func (backend *SSHBackend) StartProxy(command *commonbackend.AddProxy) (bool, er
// Splice out the clientInstance by clientIndex // Splice out the clientInstance by clientIndex
// TODO: change approach. It works but it's a bit wonky imho // TODO: change approach. It works but it's a bit wonky imho
backend.clients = slices.Delete(backend.clients, clientIndex, clientIndex+1) // I asked AI to do this as it's a relatively simple task and I forgot how to do this effectively
backend.clients = append(backend.clients[:clientIndex], backend.clients[clientIndex+1:]...)
return return
} }
} }
@ -385,8 +288,10 @@ func (backend *SSHBackend) StopProxy(command *commonbackend.RemoveProxy) (bool,
} }
// Splice out the proxy instance by proxyIndex // Splice out the proxy instance by proxyIndex
// TODO: change approach. It works but it's a bit wonky imho // TODO: change approach. It works but it's a bit wonky imho
backend.proxies = slices.Delete(backend.proxies, proxyIndex, proxyIndex+1) // I asked AI to do this as it's a relatively simple task and I forgot how to do this effectively
backend.proxies = append(backend.proxies[:proxyIndex], backend.proxies[proxyIndex+1:]...)
return true, nil return true, nil
} }
} }
@ -417,10 +322,6 @@ func (backend *SSHBackend) CheckParametersForConnections(clientParameters *commo
func (backend *SSHBackend) CheckParametersForBackend(arguments []byte) *commonbackend.CheckParametersResponse { func (backend *SSHBackend) CheckParametersForBackend(arguments []byte) *commonbackend.CheckParametersResponse {
var backendData SSHBackendData var backendData SSHBackendData
if validatorInstance == nil {
validatorInstance = validator.New()
}
if err := json.Unmarshal(arguments, &backendData); err != nil { if err := json.Unmarshal(arguments, &backendData); err != nil {
return &commonbackend.CheckParametersResponse{ return &commonbackend.CheckParametersResponse{
IsValid: false, IsValid: false,
@ -428,7 +329,7 @@ func (backend *SSHBackend) CheckParametersForBackend(arguments []byte) *commonba
} }
} }
if err := validatorInstance.Struct(&backendData); err != nil { if err := validator.New().Struct(&backendData); err != nil {
return &commonbackend.CheckParametersResponse{ return &commonbackend.CheckParametersResponse{
IsValid: false, IsValid: false,
Message: fmt.Sprintf("failed validation of parameters: %s", err.Error()), Message: fmt.Sprintf("failed validation of parameters: %s", err.Error()),
@ -440,34 +341,17 @@ func (backend *SSHBackend) CheckParametersForBackend(arguments []byte) *commonba
} }
} }
func (backend *SSHBackend) backendKeepaliveHandler() {
for {
if backend.conn != nil {
_, _, err := backend.conn.SendRequest("keepalive@openssh.com", true, nil)
if err != nil {
log.Warn("Keepalive message failed!")
return
}
}
time.Sleep(5 * time.Second)
}
}
func (backend *SSHBackend) backendDisconnectHandler() { func (backend *SSHBackend) backendDisconnectHandler() {
for { for {
if backend.conn != nil { if backend.conn != nil {
backend.conn.Wait() err := backend.conn.Wait()
backend.conn.Close()
backend.isReady = false if err == nil || err.Error() != "EOF" {
backend.inReinitLoop = true continue
}
}
log.Info("Disconnected from the remote SSH server. Attempting to reconnect in 5 seconds...") log.Info("Disconnected from the remote SSH server. Attempting to reconnect in 5 seconds...")
} else {
log.Info("Retrying reconnection in 5 seconds...")
}
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
@ -492,74 +376,14 @@ func (backend *SSHBackend) backendDisconnectHandler() {
}, },
} }
addr := fmt.Sprintf("%s:%d", backend.config.IP, backend.config.Port) conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", backend.config.IP, backend.config.Port), config)
timeout := time.Duration(10 * time.Second)
rawTCPConn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil { if err != nil {
log.Errorf("Failed to establish connection to the server: %s", err.Error()) log.Errorf("Failed to connect to the server: %s", err.Error())
continue
}
connWithTimeout := &ConnWithTimeout{
Conn: rawTCPConn,
ReadTimeout: timeout,
WriteTimeout: timeout,
}
c, chans, reqs, err := ssh.NewClientConn(connWithTimeout, addr, config)
if err != nil {
log.Errorf("Failed to create SSH client connection: %s", err.Error())
rawTCPConn.Close()
continue
}
client := ssh.NewClient(c, chans, reqs)
backend.conn = client
if !backend.config.DisablePIDCheck {
if backend.pid != 0 {
session, err := client.NewSession()
if err != nil {
log.Warnf("Failed to create SSH command session: %s", err.Error())
return return
} }
err = session.Run(fmt.Sprintf("kill -9 %d", backend.pid)) backend.conn = conn
if err != nil {
log.Warnf("Failed to kill process: %s", err.Error())
}
}
session, err := client.NewSession()
if err != nil {
log.Warnf("Failed to create SSH command session: %s", err.Error())
return
}
// Get the parent PID of the shell so we can kill it if we disconnect
output, err := session.Output("ps --no-headers -fp $$ | awk '{print $3}'")
if err != nil {
log.Warnf("Failed to execute command to fetch PID: %s", err.Error())
return
}
// Strip the new line and convert to int
backend.pid, err = strconv.Atoi(string(output)[:len(output)-1])
if err != nil {
log.Warnf("Failed to parse PID: %s", err.Error())
return
}
}
go backend.backendKeepaliveHandler()
log.Info("SSHBackend has reconnected successfully. Attempting to set up proxies again...") log.Info("SSHBackend has reconnected successfully. Attempting to set up proxies again...")

View file

@ -6,12 +6,27 @@ services:
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@nextnet-postgres:5432/${POSTGRES_DB}?schema=nextnet DATABASE_URL: postgresql://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@nextnet-postgres:5432/${POSTGRES_DB}?schema=nextnet
HERMES_POSTGRES_DSN: postgres://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@nextnet-postgres:5432/${POSTGRES_DB} HERMES_POSTGRES_DSN: postgres://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@nextnet-postgres:5432/${POSTGRES_DB}
HERMES_JWT_SECRET: ${JWT_SECRET}
HERMES_DATABASE_BACKEND: postgresql HERMES_DATABASE_BACKEND: postgresql
depends_on: depends_on:
- db - db
ports: ports:
- 3000:3000 - 3000:3000
# WARN: The LOM is deprecated and likely broken currently.
#
# NOTE: For this to work correctly, the nextnet-api must be version > 0.1.1
# or have a version with backported username support, incl. logins
lom:
image: ghcr.io/imterah/hermes-lom:latest
container_name: hermes-lom
restart: always
ports:
- 2222:2222
depends_on:
- api
volumes:
- ssh_key_data:/app/keys
db: db:
image: postgres:17.2 image: postgres:17.2
container_name: nextnet-postgres container_name: nextnet-postgres
@ -22,6 +37,7 @@ services:
POSTGRES_USER: ${POSTGRES_USERNAME} POSTGRES_USER: ${POSTGRES_USERNAME}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
volumes: volumes:
postgres_data: postgres_data:
ssh_key_data: ssh_key_data:

View file

@ -1,4 +1,5 @@
# These are default values, please change these!
POSTGRES_USERNAME=hermes POSTGRES_USERNAME=hermes
POSTGRES_PASSWORD=hermes POSTGRES_PASSWORD=hermes
POSTGRES_DB=hermes POSTGRES_DB=hermes
JWT_SECRET=hermes

View file

@ -3,6 +3,7 @@
}: pkgs.mkShell { }: pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
# api/ # api/
nodejs
go go
gopls gopls
]; ];