package cluster import ( "context" "errors" "net/http" "net/url" "strings" "time" "github.com/0xJacky/Nginx-UI/api/audit" "github.com/0xJacky/Nginx-UI/internal/analytic" "github.com/0xJacky/Nginx-UI/internal/cache" internalCluster "github.com/0xJacky/Nginx-UI/internal/cluster" "github.com/0xJacky/Nginx-UI/internal/middleware" "github.com/0xJacky/Nginx-UI/internal/nodeauth" "github.com/0xJacky/Nginx-UI/model" "github.com/0xJacky/Nginx-UI/settings" "github.com/gin-gonic/gin" "github.com/spf13/cast" "github.com/uozi-tech/cosy" "gorm.io/gorm" ) type nodeMutationRequest struct { Name string `json:"name" binding:"required"` URL string `json:"url" binding:"required"` Enabled bool `json:"enabled"` LegacySecret *string `json:"legacy_secret"` Token *string `json:"token"` } type nodeResponse struct { ID uint64 `json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Name string `json:"name"` URL string `json:"url"` Enabled bool `json:"enabled"` AuthMethod string `json:"auth_method"` HasCredential bool `json:"has_credential"` CredentialStatus string `json:"credential_status"` LastCredentialUseAt *time.Time `json:"last_credential_use_at,omitempty"` AuthUpgradeStatus string `json:"auth_upgrade_status,omitempty"` AuthUpgradeStep string `json:"auth_upgrade_step,omitempty"` AuthUpgradeAttemptCount uint `json:"auth_upgrade_attempt_count"` AuthUpgradeAttemptedAt *time.Time `json:"auth_upgrade_attempted_at,omitempty"` AuthUpgradeNextRetryAt *time.Time `json:"auth_upgrade_next_retry_at,omitempty"` AuthUpgradeCompletedAt *time.Time `json:"auth_upgrade_completed_at,omitempty"` AuthUpgradeErrorCode string `json:"auth_upgrade_error_code,omitempty"` AuthUpgradeError string `json:"auth_upgrade_error,omitempty"` ConnectionError string `json:"connection_error,omitempty"` ConnectionErrorCode analytic.NodeConnectionErrorCode `json:"connection_error_code,omitempty"` ConnectionErrorAt *time.Time `json:"connection_error_at,omitempty"` // LegacySecret only ever carries the redaction sentinel, which tells the // edit form a secret is stored without putting it in a list response. LegacySecret string `json:"legacy_secret,omitempty"` analytic.NodeStat analytic.NodeInfo } func newNodeResponse(node *model.Node) nodeResponse { analyticNode := analytic.GetNode(node) authUpgradeStatus := node.AuthUpgradeStatus authUpgradeErrorCode := node.AuthUpgradeErrorCode authUpgradeError := node.AuthUpgradeError if node.AuthMethod == model.NodeAuthMethodLegacy && authUpgradeStatus == "" { authUpgradeStatus = model.NodeAuthUpgradeStatusPending } if node.AuthMethod == model.NodeAuthMethodLegacy && len(node.EncryptedLegacySecret) == 0 { authUpgradeStatus = model.NodeAuthUpgradeStatusFailed authUpgradeErrorCode = model.NodeAuthUpgradeErrorMissingLegacySecret authUpgradeError = "The stored legacy node secret is unavailable." } if node.AuthMethod == model.NodeAuthMethodLegacy && !node.Enabled { authUpgradeStatus = model.NodeAuthUpgradeStatusPaused } response := nodeResponse{ ID: node.ID, CreatedAt: node.CreatedAt, UpdatedAt: node.UpdatedAt, Name: node.Name, URL: node.URL, Enabled: node.Enabled, AuthMethod: node.AuthMethod, HasCredential: node.HasCredential(), CredentialStatus: node.CredentialStatus, LastCredentialUseAt: node.LastCredentialUseAt, AuthUpgradeStatus: authUpgradeStatus, AuthUpgradeStep: node.AuthUpgradeStep, AuthUpgradeAttemptCount: node.AuthUpgradeAttemptCount, AuthUpgradeAttemptedAt: node.AuthUpgradeAttemptedAt, AuthUpgradeNextRetryAt: node.AuthUpgradeNextRetryAt, AuthUpgradeCompletedAt: node.AuthUpgradeCompletedAt, AuthUpgradeErrorCode: authUpgradeErrorCode, AuthUpgradeError: authUpgradeError, } if len(node.EncryptedLegacySecret) != 0 { response.LegacySecret = settings.RedactedSensitiveValue } if analyticNode != nil { response.NodeStat = analyticNode.NodeStat response.NodeInfo = analyticNode.NodeInfo response.ConnectionError = analyticNode.ConnectionError response.ConnectionErrorCode = analyticNode.ConnectionErrorCode response.ConnectionErrorAt = analyticNode.ConnectionErrorAt } return response } func GetNode(c *gin.Context) { node, ok := findNode(c, false) if !ok { return } c.JSON(http.StatusOK, newNodeResponse(node)) } func GetNodeList(c *gin.Context) { core := cosy.Core[model.Node](c).SetFussy("name") if c.Query("enabled") != "" { core.GormScope(func(tx *gorm.DB) *gorm.DB { return tx.Where("enabled = ?", cast.ToInt(cast.ToBool(c.Query("enabled")))) }) } core.SetTransformer(func(node *model.Node) any { return newNodeResponse(node) }) core.List() } func AddNode(c *gin.Context) { audit.MarkSensitiveRequest(c) var request nodeMutationRequest if !cosy.BindAndValid(c, &request) { return } normalizedURL, err := validateNodeURL(request.URL) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) return } legacySecret := mutationLegacySecret(request) authMethod := model.NodeAuthMethodPaired credentialStatus := model.NodeCredentialStatusUnpaired authUpgradeStatus := "" authUpgradeStep := "" if legacySecret != "" { authMethod = model.NodeAuthMethodLegacy credentialStatus = model.NodeCredentialStatusActive authUpgradeStatus = model.NodeAuthUpgradeStatusPending authUpgradeStep = model.NodeAuthUpgradeStepQueued } node := &model.Node{ Name: request.Name, URL: normalizedURL, Enabled: request.Enabled, AuthMethod: authMethod, CredentialStatus: credentialStatus, AuthUpgradeStatus: authUpgradeStatus, AuthUpgradeStep: authUpgradeStep, } database := model.UseDB() err = database.Transaction(func(tx *gorm.DB) error { if err := tx.Create(node).Error; err != nil { return err } if legacySecret == "" { return nil } encrypted, err := nodeauth.EncryptPrivateCredential( nodeauth.LegacyCredentialPurpose(node.ID), []byte(legacySecret), ) if err != nil { return err } node.EncryptedLegacySecret = encrypted return tx.Model(node).Update("encrypted_legacy_secret", encrypted).Error }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) return } refreshNodeState() if node.Enabled && legacySecret != "" { nodeauth.QueueLegacyRelationshipUpgrade(node.ID) } c.JSON(http.StatusCreated, newNodeResponse(node)) } func EditNode(c *gin.Context) { audit.MarkSensitiveRequest(c) node, ok := findNode(c, false) if !ok { return } var request nodeMutationRequest if !cosy.BindAndValid(c, &request) { return } normalizedURL, err := validateNodeURL(request.URL) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) return } updates := map[string]any{ "name": request.Name, "url": normalizedURL, "enabled": request.Enabled, } legacySecret := mutationLegacySecret(request) database := model.UseDB() err = database.Transaction(func(tx *gorm.DB) error { if legacySecret != "" { encrypted, err := nodeauth.EncryptPrivateCredential( nodeauth.LegacyCredentialPurpose(node.ID), []byte(legacySecret), ) if err != nil { return err } updates["token"] = "" updates["encrypted_legacy_secret"] = encrypted updates["auth_method"] = model.NodeAuthMethodLegacy updates["credential_status"] = model.NodeCredentialStatusActive updates["auth_upgrade_status"] = model.NodeAuthUpgradeStatusPending updates["auth_upgrade_step"] = model.NodeAuthUpgradeStepQueued updates["auth_upgrade_next_retry_at"] = time.Now() updates["auth_upgrade_completed_at"] = nil updates["auth_upgrade_error_code"] = "" updates["auth_upgrade_error"] = "" if err := tx.Unscoped().Where("node_id = ?", node.ID).Delete(&model.NodeCredential{}).Error; err != nil { return err } } return tx.Model(node).Updates(updates).Error }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) return } if err := database.First(node, node.ID).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) return } refreshNodeState() if node.Enabled && node.AuthMethod == model.NodeAuthMethodLegacy && len(node.EncryptedLegacySecret) != 0 { nodeauth.QueueLegacyRelationshipUpgrade(node.ID) } c.JSON(http.StatusOK, newNodeResponse(node)) } func RetryNodeAuthUpgrade(c *gin.Context) { node, ok := findNode(c, false) if !ok { return } if err := nodeauth.RetryLegacyRelationshipUpgrade(node.ID, time.Now()); err != nil { status := http.StatusInternalServerError if errors.Is(err, nodeauth.ErrRelationshipUpgradeAlreadyRunning) || errors.Is(err, nodeauth.ErrRelationshipUpgradeNotAvailable) { status = http.StatusConflict } c.JSON(status, gin.H{"message": err.Error()}) return } if err := model.UseDB().First(node, node.ID).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) return } c.JSON(http.StatusAccepted, newNodeResponse(node)) } func DeleteNode(c *gin.Context) { permanent := cast.ToBool(c.Query("permanent")) node, ok := findNode(c, permanent) if !ok { return } err := model.UseDB().Transaction(func(tx *gorm.DB) error { if permanent { if err := tx.Unscoped().Where("node_id = ?", node.ID).Delete(&model.NodeCredential{}).Error; err != nil { return err } return tx.Unscoped().Delete(node).Error } return tx.Delete(node).Error }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) return } refreshNodeState() c.Status(http.StatusNoContent) } func RecoverNode(c *gin.Context) { node, ok := findNode(c, true) if !ok { return } if err := model.UseDB().Unscoped().Model(node).Update("deleted_at", nil).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) return } refreshNodeState() c.Status(http.StatusNoContent) } func LoadNodeFromSettings(c *gin.Context) { if err := settings.ReloadCluster(); err != nil { cosy.ErrHandler(c, err) return } internalCluster.RegisterPredefinedNodes(context.Background()) refreshNodeState() c.JSON(http.StatusOK, gin.H{"message": "ok"}) } func findNode(c *gin.Context, includeDeleted bool) (*model.Node, bool) { id := cast.ToUint64(c.Param("id")) if id == 0 || model.UseDB() == nil { c.JSON(http.StatusNotFound, gin.H{"message": "node not found"}) return nil, false } var node model.Node database := model.UseDB() if includeDeleted { database = database.Unscoped() } if err := database.First(&node, id).Error; err != nil { status := http.StatusInternalServerError if errors.Is(err, gorm.ErrRecordNotFound) { status = http.StatusNotFound } c.JSON(status, gin.H{"message": err.Error()}) return nil, false } return &node, true } // GetNodeSecret reveals the shared secret stored for a node so an administrator // can read or copy it back out. Revealing it costs a verified secure session, // the same bar protected settings are held to — the route's own middleware only // demands one from users who have two-factor authentication enabled. func GetNodeSecret(c *gin.Context) { if verified, _ := c.Get(middleware.SecureSessionVerifiedKey); verified != true { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "message": "Two-factor authentication is required to reveal the node secret", }) return } node, ok := findNode(c, false) if !ok { return } if len(node.EncryptedLegacySecret) == 0 { c.JSON(http.StatusOK, gin.H{"value": ""}) return } secret, err := nodeauth.DecryptPrivateCredential( nodeauth.LegacyCredentialPurpose(node.ID), node.EncryptedLegacySecret, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) return } audit.MarkSensitiveResponse(c) c.JSON(http.StatusOK, gin.H{"value": string(secret)}) } func refreshNodeState() { cache.InvalidateNodeCache() analytic.ReloadNodesStatus() } // mutationLegacySecret reads the submitted secret, treating the redaction // sentinel the edit form echoes back as "keep the stored value" rather than as // a literal new secret. func mutationLegacySecret(request nodeMutationRequest) string { if request.LegacySecret != nil { return sanitizeSubmittedSecret(*request.LegacySecret) } if request.Token != nil { return sanitizeSubmittedSecret(*request.Token) } return "" } func sanitizeSubmittedSecret(value string) string { value = strings.TrimSpace(value) if value == settings.RedactedSensitiveValue { return "" } return value } func validateNodeURL(rawURL string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Host == "" { return "", errors.New("invalid node URL") } if parsed.Scheme != "http" && parsed.Scheme != "https" { return "", errors.New("node URL must use HTTP or HTTPS") } if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { return "", errors.New("node URL must not contain credentials, query parameters, or fragments") } parsed.Path = strings.TrimSuffix(parsed.Path, "/") parsed.RawPath = strings.TrimSuffix(parsed.RawPath, "/") return parsed.String(), nil }