From 17909d57d2d213c58baf47d6a26b4bd7e1393cdc Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 6 Feb 2026 18:06:20 +0800 Subject: [PATCH] fix: sandbox binding + agent sandbox sync + uuid rotation --- api/admin_assume.go | 119 +++++++++++++++++++++++ api/admin_sandbox.go | 10 +- api/agent_server.go | 174 ++++++++++++++++++++++++++++++++++ api/api.go | 30 +++++- api/sandbox_binding_public.go | 40 ++++++++ api/sandbox_uuid.go | 40 ++++++++ api/user_agents.go | 22 ++++- internal/agentmode/client.go | 7 ++ internal/agentmode/runner.go | 10 +- 9 files changed, 434 insertions(+), 18 deletions(-) create mode 100644 api/admin_assume.go create mode 100644 api/agent_server.go create mode 100644 api/sandbox_binding_public.go create mode 100644 api/sandbox_uuid.go diff --git a/api/admin_assume.go b/api/admin_assume.go new file mode 100644 index 0000000..a287c85 --- /dev/null +++ b/api/admin_assume.go @@ -0,0 +1,119 @@ +package api + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "account/internal/store" +) + +const ( + assumeSandboxEmail = sandboxUserEmail +) + +func (h *handler) adminAssume(c *gin.Context) { + adminUser, ok := h.requireAdminPermission(c, permissionAdminSettingsWrite) + if !ok { + return + } + if !h.isRootAccount(adminUser) { + respondError(c, http.StatusForbidden, "root_only", "root only") + return + } + + var req struct { + Email string `json:"email"` + } + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload") + return + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if email != assumeSandboxEmail { + respondError(c, http.StatusBadRequest, "invalid_target", "target is not allowed") + return + } + + // Resolve sandbox user. + sandboxUser, err := h.store.GetUserByEmail(c.Request.Context(), assumeSandboxEmail) + if err != nil { + if err == store.ErrUserNotFound { + respondError(c, http.StatusNotFound, "sandbox_missing", "sandbox user not found") + return + } + respondError(c, http.StatusInternalServerError, "sandbox_lookup_failed", "failed to lookup sandbox user") + return + } + + // Rotate sandbox UUID if expired (hourly forced rotation). + if err := h.ensureSandboxProxyUUID(c.Request.Context(), sandboxUser); err != nil { + respondError(c, http.StatusInternalServerError, "sandbox_uuid_rotation_failed", "failed to rotate sandbox uuid") + return + } + + sandboxToken, expiresAt, err := h.createSession(sandboxUser.ID) + if err != nil { + respondError(c, http.StatusInternalServerError, "session_creation_failed", "failed to create sandbox session") + return + } + + slog.Info("admin assume sandbox", + "event", "admin_assume", + "actor_user_id", adminUser.ID, + "actor_email", adminUser.Email, + "target_user_id", sandboxUser.ID, + "target_email", sandboxUser.Email, + ) + + // NOTE: cookies are intentionally NOT set by accounts.svc.plus. + // The console BFF is responsible for setting host-scoped cookies. + c.JSON(http.StatusOK, gin.H{ + "ok": true, + "assumed": assumeSandboxEmail, + "token": sandboxToken, + "expiresAt": expiresAt.UTC(), + }) +} + +func (h *handler) adminAssumeRevert(c *gin.Context) { + adminUser, ok := h.requireAdminPermission(c, permissionAdminSettingsWrite) + if !ok { + return + } + if !h.isRootAccount(adminUser) { + respondError(c, http.StatusForbidden, "root_only", "root only") + return + } + + slog.Info("admin assume revert", + "event", "admin_assume_revert", + "actor_user_id", adminUser.ID, + "actor_email", adminUser.Email, + "target_email", assumeSandboxEmail, + ) + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *handler) adminAssumeStatus(c *gin.Context) { + adminUser, ok := h.requireAdminPermission(c, permissionAdminSettingsRead) + if !ok { + return + } + if !h.isRootAccount(adminUser) { + respondError(c, http.StatusForbidden, "root_only", "root only") + return + } + + // NOTE: assume status is tracked via host-scoped cookies owned by console.svc.plus. + // accounts.svc.plus cannot observe that state safely, so we only expose a stub. + c.JSON(http.StatusOK, gin.H{"isAssuming": false, "target": ""}) +} + +// guard unused imports if build tags change. +var _ = context.Background diff --git a/api/admin_sandbox.go b/api/admin_sandbox.go index 7adfa8b..9e0ed6a 100644 --- a/api/admin_sandbox.go +++ b/api/admin_sandbox.go @@ -86,14 +86,8 @@ func (h *handler) bindSandboxNode(c *gin.Context) { // Update the in-memory registry if available if h.agentRegistry != nil { - // This should ideally be handled by a more robust event system, - // but since it's a single instance (usually), we can just clear and reset. - // For now, let's assume the registry will reload if we trigger it or we just set it here. - // Wait, Registry needs to know ALL sandbox agents. - // I'll update the registry's internal state. - - // First reset all sandbox flags (not supported by current Registry API, let's just set the new one) - // TODO: Implement ClearSandboxAgents in Registry + // Enforce 1-to-1 binding: clear then set. + h.agentRegistry.ClearSandboxAgents() if agentID != "" { h.agentRegistry.SetSandboxAgent(agentID, true) } diff --git a/api/agent_server.go b/api/agent_server.go new file mode 100644 index 0000000..6440c47 --- /dev/null +++ b/api/agent_server.go @@ -0,0 +1,174 @@ +package api + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "account/internal/agentproto" + "account/internal/agentserver" + "account/internal/store" + "account/internal/xrayconfig" +) + +const agentIDHeader = "X-Agent-ID" + +func (h *handler) listAgentUsers(c *gin.Context) { + if h.agentRegistry == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "agent_registry_unavailable"}) + return + } + + token := extractToken(c.GetHeader("Authorization")) + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing_token"}) + return + } + + credIdentity, ok := h.agentRegistry.Authenticate(token) + if !ok || credIdentity == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"}) + return + } + + agentID := strings.TrimSpace(c.GetHeader(agentIDHeader)) + if agentID == "" { + agentID = strings.TrimSpace(c.Query("agentId")) + } + if agentID == "" { + agentID = credIdentity.ID + } + + identity := *credIdentity + if agentID != "" && agentID != identity.ID { + // Shared token scenario: register a concrete agent id so sandbox bindings can target it. + identity = h.agentRegistry.RegisterAgent(agentID, identity.Groups) + } + + now := time.Now().UTC() + clients := make([]xrayconfig.Client, 0, 1) + + if h.agentRegistry.IsSandboxAgent(identity.ID) { + sandboxUser, err := h.store.GetUserByEmail(c.Request.Context(), sandboxUserEmail) + if err != nil { + if err == store.ErrUserNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "sandbox_missing"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "sandbox_lookup_failed"}) + return + } + if err := h.ensureSandboxProxyUUID(c.Request.Context(), sandboxUser); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "sandbox_uuid_rotation_failed"}) + return + } + + uuid := strings.TrimSpace(sandboxUser.ProxyUUID) + if uuid == "" { + uuid = strings.TrimSpace(sandboxUser.ID) + } + if uuid != "" { + clients = append(clients, xrayconfig.Client{ + ID: uuid, + Email: sandboxUserEmail, + Flow: xrayconfig.DefaultFlow, + }) + } + + c.JSON(http.StatusOK, agentproto.ClientListResponse{ + Clients: clients, + Total: len(clients), + GeneratedAt: now, + }) + return + } + + users, err := h.store.ListUsers(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "list_users_failed"}) + return + } + + for _, u := range users { + email := strings.ToLower(strings.TrimSpace(u.Email)) + if email == sandboxUserEmail || email == "demo@svc.plus" { + continue + } + if !u.Active { + continue + } + if !u.EmailVerified { + continue + } + if u.ProxyUUIDExpiresAt != nil && now.After(*u.ProxyUUIDExpiresAt) { + continue + } + + id := strings.TrimSpace(u.ProxyUUID) + if id == "" { + id = strings.TrimSpace(u.ID) + } + if id == "" { + continue + } + clients = append(clients, xrayconfig.Client{ + ID: id, + Email: strings.TrimSpace(u.Email), + Flow: xrayconfig.DefaultFlow, + }) + } + + c.JSON(http.StatusOK, agentproto.ClientListResponse{ + Clients: clients, + Total: len(clients), + GeneratedAt: now, + }) +} + +func (h *handler) reportAgentStatus(c *gin.Context) { + if h.agentRegistry == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "agent_registry_unavailable"}) + return + } + + token := extractToken(c.GetHeader("Authorization")) + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing_token"}) + return + } + + credIdentity, ok := h.agentRegistry.Authenticate(token) + if !ok || credIdentity == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"}) + return + } + + var report agentproto.StatusReport + if err := c.ShouldBindJSON(&report); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request", "message": err.Error()}) + return + } + + agentID := strings.TrimSpace(report.AgentID) + if agentID == "" { + agentID = strings.TrimSpace(c.GetHeader(agentIDHeader)) + } + if agentID == "" { + agentID = credIdentity.ID + } + + identity := *credIdentity + if agentID != "" && agentID != identity.ID { + identity = h.agentRegistry.RegisterAgent(agentID, identity.Groups) + } + + // Ensure report uses the resolved agent id. + report.AgentID = identity.ID + h.agentRegistry.ReportStatus(identity, report) + + c.Status(http.StatusNoContent) +} + +var _ = agentserver.Identity{} diff --git a/api/api.go b/api/api.go index 3574e22..81a8fa3 100644 --- a/api/api.go +++ b/api/api.go @@ -315,6 +315,18 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { authProtected.POST("/admin/blacklist", h.addToBlacklist) authProtected.DELETE("/admin/blacklist/:email", h.removeFromBlacklist) + // Sandbox node binding (root-only via permissions guard). + authProtected.GET("/admin/sandbox/binding", h.getSandboxBinding) + authProtected.POST("/admin/sandbox/bind", h.bindSandboxNode) + + // Public read of sandbox binding for demo/sandbox user experience. + authProtected.GET("/sandbox/binding", h.getSandboxBindingPublic) + + // Root-only identity switch to sandbox@svc.plus (hard-coded allowlist). + authProtected.POST("/admin/assume", h.adminAssume) + authProtected.POST("/admin/assume/revert", h.adminAssumeRevert) + authProtected.GET("/admin/assume/status", h.adminAssumeStatus) + authProtected.GET("/users", h.listUsers) // Internal routes for service-to-service reads. @@ -335,6 +347,8 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { // stay outside token middleware to support dashboard session tokens. agentServerGroup := r.Group("/api/agent-server/v1") agentServerGroup.GET("/nodes", h.listAgentNodes) + agentServerGroup.GET("/users", h.listAgentUsers) + agentServerGroup.POST("/status", h.reportAgentStatus) // Legacy alias kept for backward compatibility. agentGroup := r.Group("/api/agent") @@ -992,6 +1006,13 @@ func (h *handler) login(c *gin.Context) { return } + // Sandbox user is not allowed to login by password/totp. + // Root can only assume into sandbox via the admin assume endpoint. + if strings.EqualFold(strings.TrimSpace(user.Email), sandboxUserEmail) { + respondError(c, http.StatusForbidden, "sandbox_no_login", "sandbox login is disabled") + return + } + if password != "" { if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil { respondError(c, http.StatusUnauthorized, "invalid_credentials", "invalid credentials") @@ -1217,6 +1238,11 @@ func (h *handler) session(c *gin.Context) { return } + // Sandbox UUID rotates hourly; refresh on session reads so the UI always sees a valid UUID. + if err := h.ensureSandboxProxyUUID(c.Request.Context(), user); err != nil { + slog.Warn("failed to rotate sandbox proxy uuid", "err", err, "userID", user.ID) + } + c.JSON(http.StatusOK, gin.H{"user": sanitizeUser(user, nil)}) } @@ -2658,7 +2684,9 @@ func (h *handler) isReadOnlyAccount(user *store.User) bool { } name := strings.TrimSpace(user.Name) email := strings.TrimSpace(user.Email) - if strings.EqualFold(name, "demo") || strings.EqualFold(email, "demo@svc.plus") { + if strings.EqualFold(name, "demo") || + strings.EqualFold(email, "demo@svc.plus") || + strings.EqualFold(email, sandboxUserEmail) { return true } for _, group := range user.Groups { diff --git a/api/sandbox_binding_public.go b/api/sandbox_binding_public.go new file mode 100644 index 0000000..4d0ad29 --- /dev/null +++ b/api/sandbox_binding_public.go @@ -0,0 +1,40 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "account/internal/model" +) + +// getSandboxBindingPublic returns the currently bound sandbox agent (if any). +// It is intentionally readable by any authenticated user so demo/sandbox users +// do not depend on localStorage browser state. +func (h *handler) getSandboxBindingPublic(c *gin.Context) { + if _, ok := h.requireAuthenticatedUser(c); !ok { + return + } + + if h.db == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database_not_configured"}) + return + } + + var binding model.SandboxBinding + if err := h.db.WithContext(c.Request.Context()).First(&binding).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusOK, gin.H{"address": "", "updatedAt": int64(0)}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed_to_query_binding", "message": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "address": binding.AgentID, + "updatedAt": binding.UpdatedAt.UnixMilli(), + }) +} diff --git a/api/sandbox_uuid.go b/api/sandbox_uuid.go new file mode 100644 index 0000000..cb72313 --- /dev/null +++ b/api/sandbox_uuid.go @@ -0,0 +1,40 @@ +package api + +import ( + "context" + "strings" + "time" + + "account/internal/store" +) + +const ( + sandboxUserEmail = "sandbox@svc.plus" + sandboxUUIDRotationWindow = time.Hour +) + +// ensureSandboxProxyUUID enforces hourly rotation of the sandbox user's ProxyUUID. +// It is intentionally strict: only the hard-coded sandbox email is eligible. +func (h *handler) ensureSandboxProxyUUID(ctx context.Context, user *store.User) error { + if h == nil || user == nil { + return nil + } + email := strings.ToLower(strings.TrimSpace(user.Email)) + if email != sandboxUserEmail { + return nil + } + + now := time.Now().UTC() + needsRotation := strings.TrimSpace(user.ProxyUUID) == "" || + user.ProxyUUIDExpiresAt == nil || + !now.Before(*user.ProxyUUIDExpiresAt) + + if !needsRotation { + return nil + } + + exp := now.Add(sandboxUUIDRotationWindow) + user.ProxyUUID = generateRandomUUID() + user.ProxyUUIDExpiresAt = &exp + return h.store.UpdateUser(ctx, user) +} diff --git a/api/user_agents.go b/api/user_agents.go index 964667c..3465b49 100644 --- a/api/user_agents.go +++ b/api/user_agents.go @@ -63,11 +63,23 @@ func (h *handler) listAgentNodes(c *gin.Context) { } if user.ProxyUUIDExpiresAt != nil && time.Now().UTC().After(*user.ProxyUUIDExpiresAt) { - c.JSON(http.StatusForbidden, gin.H{ - "error": "proxy_uuid_expired", - "message": "proxy access has expired, please renew", - }) - return + // Sandbox rotates hourly; never block it on expiry. + if strings.EqualFold(strings.TrimSpace(user.Email), sandboxUserEmail) { + if err := h.ensureSandboxProxyUUID(c.Request.Context(), user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "sandbox_uuid_rotation_failed"}) + return + } + proxyUUID = strings.TrimSpace(user.ProxyUUID) + if proxyUUID == "" { + proxyUUID = user.ID + } + } else { + c.JSON(http.StatusForbidden, gin.H{ + "error": "proxy_uuid_expired", + "message": "proxy access has expired, please renew", + }) + return + } } // Add panic recovery for this handler diff --git a/internal/agentmode/client.go b/internal/agentmode/client.go index 802d5f5..7cf1124 100644 --- a/internal/agentmode/client.go +++ b/internal/agentmode/client.go @@ -22,6 +22,7 @@ type ClientOptions struct { Timeout time.Duration InsecureSkipVerify bool UserAgent string + AgentID string } // Client issues authenticated requests against the controller. @@ -30,6 +31,7 @@ type Client struct { token string http *http.Client userAgent string + agentID string } // NewClient constructs a client for the provided controller URL and token. @@ -73,12 +75,14 @@ func NewClient(baseURL, token string, opts ClientOptions) (*Client, error) { if userAgent == "" { userAgent = "xcontrol-agent" } + agentID := strings.TrimSpace(opts.AgentID) return &Client{ baseURL: parsed, token: token, http: client, userAgent: userAgent, + agentID: agentID, }, nil } @@ -148,4 +152,7 @@ func (c *Client) ReportStatus(ctx context.Context, report agentproto.StatusRepor func (c *Client) applyHeaders(req *http.Request) { req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("User-Agent", c.userAgent) + if c.agentID != "" { + req.Header.Set("X-Agent-ID", c.agentID) + } } diff --git a/internal/agentmode/runner.go b/internal/agentmode/runner.go index d73165e..92e3166 100644 --- a/internal/agentmode/runner.go +++ b/internal/agentmode/runner.go @@ -70,6 +70,7 @@ func Run(ctx context.Context, opts Options) error { Timeout: httpTimeout, InsecureSkipVerify: opts.Agent.TLS.InsecureSkipVerify, UserAgent: buildUserAgent(opts.Agent.ID), + AgentID: opts.Agent.ID, }) if err != nil { return err @@ -146,7 +147,7 @@ func Run(ctx context.Context, opts Options) error { wg.Add(1) go func() { defer wg.Done() - runStatusReporter(reporterCtx, client, tracker, statusInterval, syncInterval, logger) + runStatusReporter(reporterCtx, client, tracker, statusInterval, syncInterval, opts.Agent.ID, logger) }() <-ctx.Done() @@ -163,13 +164,13 @@ func buildUserAgent(id string) string { return fmt.Sprintf("xcontrol-agent/%s", id) } -func runStatusReporter(ctx context.Context, client *Client, tracker *syncTracker, interval, syncInterval time.Duration, logger *slog.Logger) { +func runStatusReporter(ctx context.Context, client *Client, tracker *syncTracker, interval, syncInterval time.Duration, agentID string, logger *slog.Logger) { ticker := time.NewTicker(interval) defer ticker.Stop() send := func() { snapshot := tracker.Snapshot() - report := buildStatusReport(snapshot, syncInterval) + report := buildStatusReport(snapshot, syncInterval, agentID) if err := client.ReportStatus(ctx, report); err != nil { logger.Warn("failed to report agent status", "err", err) } @@ -187,7 +188,7 @@ func runStatusReporter(ctx context.Context, client *Client, tracker *syncTracker } } -func buildStatusReport(snapshot trackerSnapshot, syncInterval time.Duration) agentproto.StatusReport { +func buildStatusReport(snapshot trackerSnapshot, syncInterval time.Duration, agentID string) agentproto.StatusReport { healthy := snapshot.LastError == "" && !snapshot.LastSuccess.IsZero() running := false @@ -199,6 +200,7 @@ func buildStatusReport(snapshot trackerSnapshot, syncInterval time.Duration) age } report := agentproto.StatusReport{ + AgentID: strings.TrimSpace(agentID), Healthy: healthy, Message: snapshot.LastError, Users: snapshot.Clients,