fix: sandbox binding + agent sandbox sync + uuid rotation

This commit is contained in:
Haitao Pan 2026-02-06 18:06:20 +08:00
parent d1195bbc75
commit 17909d57d2
9 changed files with 434 additions and 18 deletions

119
api/admin_assume.go Normal file
View File

@ -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

View File

@ -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)
}

174
api/agent_server.go Normal file
View File

@ -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{}

View File

@ -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 {

View File

@ -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(),
})
}

40
api/sandbox_uuid.go Normal file
View File

@ -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)
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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,