diff --git a/backend/api/controllers/v1/forward/connections.go b/backend/api/controllers/v1/proxies/connections.go similarity index 99% rename from backend/api/controllers/v1/forward/connections.go rename to backend/api/controllers/v1/proxies/connections.go index bd089b2..781b1f5 100644 --- a/backend/api/controllers/v1/forward/connections.go +++ b/backend/api/controllers/v1/proxies/connections.go @@ -1,4 +1,4 @@ -package forward +package proxies import ( "fmt" diff --git a/backend/api/controllers/v1/proxies/create.go b/backend/api/controllers/v1/proxies/create.go new file mode 100644 index 0000000..3f1f442 --- /dev/null +++ b/backend/api/controllers/v1/proxies/create.go @@ -0,0 +1,127 @@ +package proxies + +import ( + "fmt" + "net/http" + + "git.terah.dev/imterah/hermes/api/dbcore" + "git.terah.dev/imterah/hermes/api/jwtcore" + "git.terah.dev/imterah/hermes/api/permissions" + "github.com/charmbracelet/log" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +type ProxyCreationRequest struct { + Token string `validate:"required" json:"token"` + Name string `validate:"required" json:"name"` + Description *string `json:"description"` + Protcol string `validate:"required" json:"protcol"` + SourceIP string `validate:"required" json:"source_ip"` + SourcePort uint16 `validate:"required" json:"source_port"` + DestinationPort uint16 `validate:"required" json:"destination_port"` + ProviderID uint `validate:"required" json:"provider_id"` + AutoStart bool `json:"auto_start"` +} + +type ProxyCreationResponse struct { + Success bool `json:"success"` + Id uint `json:"id"` +} + +func CreateProxy(c *gin.Context) { + var req ProxyCreationRequest + + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Failed to parse body: %s", err.Error()), + }) + + return + } + + if err := validator.New().Struct(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Failed to validate body: %s", err.Error()), + }) + + return + } + + user, err := jwtcore.GetUserFromJWT(req.Token) + if err != nil { + if err.Error() == "token is expired" || err.Error() == "user does not exist" { + c.JSON(http.StatusForbidden, gin.H{ + "error": err.Error(), + }) + + return + } else { + log.Warnf("Failed to get user from the provided JWT token: %s", err.Error()) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to parse token", + }) + + return + } + } + + if !permissions.UserHasPermission(user, "routes.add") { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Missing permissions", + }) + + return + } + + if req.Protcol != "tcp" && req.Protcol != "udp" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Body protocol must be either 'tcp' or 'udp'", + }) + + return + } + + var backend dbcore.Backend + backendRequest := dbcore.DB.Where("id = ?", req.ProviderID).First(&backend) + if backendRequest.Error != nil { + log.Warnf("failed to find if backend exists or not: %s", backendRequest.Error) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to find if provider exists", + }) + } + + backendExists := backendRequest.RowsAffected > 0 + if !backendExists { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Could not find provider", + }) + } + + proxy := &dbcore.Proxy{ + UserID: user.ID, + BackendID: req.ProviderID, + Name: req.Name, + Description: req.Description, + Protocol: req.Protcol, + SourceIP: req.SourceIP, + SourcePort: req.SourcePort, + DestinationPort: req.DestinationPort, + AutoStart: req.AutoStart, + } + + if result := dbcore.DB.Create(proxy); result.Error != nil { + log.Warnf("failed to create proxy: %s", result.Error.Error()) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to add forward rule to database", + }) + } + + c.JSON(http.StatusOK, &ProxyCreationResponse{ + Success: true, + Id: proxy.ID, + }) +} diff --git a/backend/api/controllers/v1/proxies/lookup.go b/backend/api/controllers/v1/proxies/lookup.go new file mode 100644 index 0000000..862bcbe --- /dev/null +++ b/backend/api/controllers/v1/proxies/lookup.go @@ -0,0 +1,174 @@ +package proxies + +import ( + "fmt" + "net/http" + "strings" + + "git.terah.dev/imterah/hermes/api/dbcore" + "git.terah.dev/imterah/hermes/api/jwtcore" + "git.terah.dev/imterah/hermes/api/permissions" + "github.com/charmbracelet/log" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +type ProxyLookupRequest struct { + Token string `validate:"required" json:"token"` + Id *uint `json:"id"` + Name *string `json:"name"` + Description *string `json:"description"` + Protocol *string `json:"protocol"` + SourceIP *string `json:"source_ip"` + SourcePort *uint16 `json:"source_port"` + DestinationPort *uint16 `json:"destination_port"` + ProviderID *uint `json:"provider_id"` + AutoStart *bool `json:"auto_start"` +} + +type SanitizedProxy struct { + Id uint `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + Protcol string `json:"protcol"` + SourceIP string `json:"source_ip"` + SourcePort uint16 `json:"source_port"` + DestinationPort uint16 `json:"destination_port"` + ProviderID uint `json:"provider_id"` + AutoStart bool `json:"auto_start"` +} + +type ProxyLookupResponse struct { + Success bool `json:"success"` + Data []*SanitizedProxy `json:"data"` +} + +func LookupProxy(c *gin.Context) { + var req ProxyLookupRequest + + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Failed to parse body: %s", err.Error()), + }) + + return + } + + if err := validator.New().Struct(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Failed to validate body: %s", err.Error()), + }) + + return + } + + user, err := jwtcore.GetUserFromJWT(req.Token) + if err != nil { + if err.Error() == "token is expired" || err.Error() == "user does not exist" { + c.JSON(http.StatusForbidden, gin.H{ + "error": err.Error(), + }) + + return + } else { + log.Warnf("Failed to get user from the provided JWT token: %s", err.Error()) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to parse token", + }) + + return + } + } + + if !permissions.UserHasPermission(user, "routes.visible") { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Missing permissions", + }) + + return + } + + if *req.Protcol != "tcp" && *req.Protcol != "udp" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Protocol specified in body must either be 'tcp' or 'udp'", + }) + } + + proxies := []dbcore.Proxy{} + queryString := []string{} + queryParameters := []interface{}{} + + if req.Id != nil { + queryString = append(queryString, "id = ?") + queryParameters = append(queryParameters, req.Id) + } + if req.Name != nil { + queryString = append(queryString, "name = ?") + queryParameters = append(queryParameters, req.Name) + } + if req.Description != nil { + queryString = append(queryString, "description = ?") + queryParameters = append(queryParameters, req.Description) + } + if req.SourceIP != nil { + queryString = append(queryString, "name = ?") + queryParameters = append(queryParameters, req.Name) + } + if req.SourcePort != nil { + queryString = append(queryString, "sourceport = ?") + queryParameters = append(queryParameters, req.SourcePort) + } + if req.DestinationPort != nil { + queryString = append(queryString, "destinationport = ?") + queryParameters = append(queryParameters, req.DestinationPort) + } + if req.ProviderID != nil { + queryString = append(queryString, "backendid = ?") + queryParameters = append(queryParameters, req.ProviderID) + } + if req.AutoStart != nil { + queryString = append(queryString, "autostart = ?") + queryParameters = append(queryParameters, req.AutoStart) + } + if req.Protocol != nil { + queryString = append(queryString, "protocol = ?") + queryParameters = append(queryParameters, req.Protocol) + } + + if err := dbcore.DB.Where(strings.Join(queryString, " AND "), queryParameters...).Find(&proxies).Error; err != nil { + log.Warnf("failed to get proxies: %s", err.Error()) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to get forward rules", + }) + + return + } + + sanitizedProxies := make([]*SanitizedProxy, len(proxies)) + + for proxyIndex, proxy := range proxies { + description := "" + if proxy.Description != nil { + description = *proxy.Description + } + + sanitizedProxies[proxyIndex] = &SanitizedProxy{ + Id: proxy.ID, + Name: proxy.Name, + Description: &description, + Protcol: proxy.Protocol, + SourceIP: proxy.SourceIP, + SourcePort: proxy.SourcePort, + DestinationPort: proxy.DestinationPort, + ProviderID: proxy.BackendID, + AutoStart: proxy.AutoStart, + } + } + + c.JSON(http.StatusOK, &ProxyLookupResponse{ + Success: true, + Data: sanitizedProxies, + }) +} diff --git a/backend/api/controllers/v1/proxies/remove.go b/backend/api/controllers/v1/proxies/remove.go new file mode 100644 index 0000000..e7b83bb --- /dev/null +++ b/backend/api/controllers/v1/proxies/remove.go @@ -0,0 +1,106 @@ +package proxies + +import ( + "fmt" + "net/http" + + "git.terah.dev/imterah/hermes/api/dbcore" + "git.terah.dev/imterah/hermes/api/jwtcore" + "git.terah.dev/imterah/hermes/api/permissions" + "github.com/charmbracelet/log" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +type ProxyRemovalRequest struct { + Token string `validate:"required" json:"token"` + ID uint `validate:"required" json:"id"` +} + +type ProxyRemovalResponse struct { + Success bool `json:"success"` +} + +func RemoveProxy(c *gin.Context) { + var req ProxyRemovalRequest + + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Failed to parse body: %s", err.Error()), + }) + + return + } + + if err := validator.New().Struct(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Failed to validate body: %s", err.Error()), + }) + + return + } + + user, err := jwtcore.GetUserFromJWT(req.Token) + if err != nil { + if err.Error() == "token is expired" || err.Error() == "user does not exist" { + c.JSON(http.StatusForbidden, gin.H{ + "error": err.Error(), + }) + + return + } else { + log.Warnf("Failed to get user from the provided JWT token: %s", err.Error()) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to parse token", + }) + + return + } + } + + if !permissions.UserHasPermission(user, "routes.remove") { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Missing permissions", + }) + + return + } + + var proxy *dbcore.Proxy + proxyRequest := dbcore.DB.Where("id = ?", req.ID).Find(&proxy) + + if proxyRequest.Error != nil { + log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to find if forward rule exists", + }) + + return + } + + proxyExists := proxyRequest.RowsAffected > 0 + + if !proxyExists { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Forward rule doesn't exist", + }) + + return + } + + if err := dbcore.DB.Delete(proxy).Error; err != nil { + log.Warnf("failed to delete proxy: %s", err.Error()) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to delete forward rule", + }) + + return + } + + c.JSON(http.StatusOK, &ProxyRemovalResponse{ + Success: true, + }) +} diff --git a/backend/api/controllers/v1/proxies/start.go b/backend/api/controllers/v1/proxies/start.go new file mode 100644 index 0000000..d3f36ae --- /dev/null +++ b/backend/api/controllers/v1/proxies/start.go @@ -0,0 +1,133 @@ +package proxies + +import ( + "fmt" + "net/http" + + "git.terah.dev/imterah/hermes/api/backendruntime" + "git.terah.dev/imterah/hermes/api/dbcore" + "git.terah.dev/imterah/hermes/api/jwtcore" + "git.terah.dev/imterah/hermes/api/permissions" + "git.terah.dev/imterah/hermes/commonbackend" + "github.com/charmbracelet/log" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +type ProxyStartRequest struct { + Token string `validate:"required" json:"token"` + ID uint `validate:"required" json:"id"` +} + +type ProxyStartResponse struct { + Success bool `json:"success"` +} + +func StartProxy(c *gin.Context) { + var req ProxyStartRequest + + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Failed to parse body: %s", err.Error()), + }) + + return + } + + if err := validator.New().Struct(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Failed to validate body: %s", err.Error()), + }) + + return + } + + user, err := jwtcore.GetUserFromJWT(req.Token) + if err != nil { + if err.Error() == "token is expired" || err.Error() == "user does not exist" { + c.JSON(http.StatusForbidden, gin.H{ + "error": err.Error(), + }) + + return + } else { + log.Warnf("Failed to get user from the provided JWT token: %s", err.Error()) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to parse token", + }) + + return + } + } + + if !permissions.UserHasPermission(user, "routes.start") { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Missing permissions", + }) + + return + } + + var proxy *dbcore.Proxy + proxyRequest := dbcore.DB.Where("id = ?", req.ID).Find(&proxy) + + if proxyRequest.Error != nil { + log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to find if forward rule exists", + }) + + return + } + + proxyExists := proxyRequest.RowsAffected > 0 + + if !proxyExists { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Forward rule doesn't exist", + }) + + return + } + + backend := backendruntime.RunningBackends[proxy.BackendID] + + backend.RuntimeCommands <- commonbackend.AddProxy{ + Type: "addProxy", + SourceIP: proxy.SourceIP, + SourcePort: proxy.SourcePort, + DestPort: proxy.DestinationPort, + Protocol: proxy.Protocol, + } + + backendResponse := <-backend.RuntimeCommands + + switch responseMessage := backendResponse.(type) { + case error: + log.Warnf("Failed to get response for backend #%d: %s", proxy.BackendID, responseMessage.Error()) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to get response from backend", + }) + + return + case *commonbackend.ProxyStatusResponse: + if !responseMessage.IsActive { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to start proxy", + }) + + return + } + break + default: + log.Errorf("Got illegal response type for backend #%d: %T", proxy.BackendID, responseMessage) + break + } + + c.JSON(http.StatusOK, &ProxyStartResponse{ + Success: true, + }) +} diff --git a/backend/api/controllers/v1/proxies/stop.go b/backend/api/controllers/v1/proxies/stop.go new file mode 100644 index 0000000..cf94f59 --- /dev/null +++ b/backend/api/controllers/v1/proxies/stop.go @@ -0,0 +1,108 @@ +package proxies + +import ( + "fmt" + "net/http" + + "git.terah.dev/imterah/hermes/api/backendruntime" + "git.terah.dev/imterah/hermes/api/dbcore" + "git.terah.dev/imterah/hermes/api/jwtcore" + "git.terah.dev/imterah/hermes/api/permissions" + "git.terah.dev/imterah/hermes/commonbackend" + "github.com/charmbracelet/log" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +type ProxyStopRequest struct { + Token string `validate:"required" json:"token"` + ID uint `validate:"required" json:"id"` +} + +type ProxyStopResponse struct { + Success bool `json:"success"` +} + +func StopProxy(c *gin.Context) { + var req ProxyStopRequest + + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Failed to parse body: %s", err.Error()), + }) + + return + } + + if err := validator.New().Struct(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Failed to validate body: %s", err.Error()), + }) + + return + } + + user, err := jwtcore.GetUserFromJWT(req.Token) + if err != nil { + if err.Error() == "token is expired" || err.Error() == "user does not exist" { + c.JSON(http.StatusForbidden, gin.H{ + "error": err.Error(), + }) + + return + } else { + log.Warnf("Failed to get user from the provided JWT token: %s", err.Error()) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to parse token", + }) + + return + } + } + + if !permissions.UserHasPermission(user, "routes.stop") { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Missing permissions", + }) + + return + } + + var proxy *dbcore.Proxy + proxyRequest := dbcore.DB.Where("id = ?", req.ID).Find(&proxy) + + if proxyRequest.Error != nil { + log.Warnf("failed to find if proxy exists or not: %s", proxyRequest.Error) + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to find if forward rule exists", + }) + + return + } + + proxyExists := proxyRequest.RowsAffected > 0 + + if !proxyExists { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Forward rule doesn't exist", + }) + + return + } + + backend := backendruntime.RunningBackends[proxy.BackendID] + + backend.RuntimeCommands <- commonbackend.RemoveProxy{ + Type: "removeProxy", + SourceIP: proxy.SourceIP, + SourcePort: proxy.SourcePort, + DestPort: proxy.DestinationPort, + Protocol: proxy.Protocol, + } + + c.JSON(http.StatusOK, &ProxyStopResponse{ + Success: true, + }) +} diff --git a/backend/api/main.go b/backend/api/main.go index 885e55c..12c13d3 100644 --- a/backend/api/main.go +++ b/backend/api/main.go @@ -11,6 +11,7 @@ import ( "git.terah.dev/imterah/hermes/api/backendruntime" "git.terah.dev/imterah/hermes/api/controllers/v1/backends" + "git.terah.dev/imterah/hermes/api/controllers/v1/proxies" "git.terah.dev/imterah/hermes/api/controllers/v1/users" "git.terah.dev/imterah/hermes/api/dbcore" "git.terah.dev/imterah/hermes/api/jwtcore" @@ -195,6 +196,12 @@ func entrypoint(cCtx *cli.Context) error { engine.POST("/api/v1/backends/remove", backends.RemoveBackend) engine.POST("/api/v1/backends/lookup", backends.LookupBackend) + engine.POST("/api/v1/forward/create", proxies.CreateProxy) + engine.POST("/api/v1/forward/lookup", proxies.LookupProxy) + engine.POST("/api/v1/forward/remove", proxies.RemoveProxy) + engine.POST("/api/v1/forward/start", proxies.StartProxy) + engine.POST("/api/v1/forward/stop", proxies.StopProxy) + log.Infof("Listening on '%s'", listeningAddress) err = engine.Run(listeningAddress)