fix: sandbox binding + agent sandbox sync + uuid rotation
This commit is contained in:
parent
d1195bbc75
commit
17909d57d2
119
api/admin_assume.go
Normal file
119
api/admin_assume.go
Normal 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
|
||||||
@ -86,14 +86,8 @@ func (h *handler) bindSandboxNode(c *gin.Context) {
|
|||||||
|
|
||||||
// Update the in-memory registry if available
|
// Update the in-memory registry if available
|
||||||
if h.agentRegistry != nil {
|
if h.agentRegistry != nil {
|
||||||
// This should ideally be handled by a more robust event system,
|
// Enforce 1-to-1 binding: clear then set.
|
||||||
// but since it's a single instance (usually), we can just clear and reset.
|
h.agentRegistry.ClearSandboxAgents()
|
||||||
// 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
|
|
||||||
if agentID != "" {
|
if agentID != "" {
|
||||||
h.agentRegistry.SetSandboxAgent(agentID, true)
|
h.agentRegistry.SetSandboxAgent(agentID, true)
|
||||||
}
|
}
|
||||||
|
|||||||
174
api/agent_server.go
Normal file
174
api/agent_server.go
Normal 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{}
|
||||||
30
api/api.go
30
api/api.go
@ -315,6 +315,18 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
|
|||||||
authProtected.POST("/admin/blacklist", h.addToBlacklist)
|
authProtected.POST("/admin/blacklist", h.addToBlacklist)
|
||||||
authProtected.DELETE("/admin/blacklist/:email", h.removeFromBlacklist)
|
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)
|
authProtected.GET("/users", h.listUsers)
|
||||||
|
|
||||||
// Internal routes for service-to-service reads.
|
// 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.
|
// stay outside token middleware to support dashboard session tokens.
|
||||||
agentServerGroup := r.Group("/api/agent-server/v1")
|
agentServerGroup := r.Group("/api/agent-server/v1")
|
||||||
agentServerGroup.GET("/nodes", h.listAgentNodes)
|
agentServerGroup.GET("/nodes", h.listAgentNodes)
|
||||||
|
agentServerGroup.GET("/users", h.listAgentUsers)
|
||||||
|
agentServerGroup.POST("/status", h.reportAgentStatus)
|
||||||
|
|
||||||
// Legacy alias kept for backward compatibility.
|
// Legacy alias kept for backward compatibility.
|
||||||
agentGroup := r.Group("/api/agent")
|
agentGroup := r.Group("/api/agent")
|
||||||
@ -992,6 +1006,13 @@ func (h *handler) login(c *gin.Context) {
|
|||||||
return
|
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 password != "" {
|
||||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
||||||
respondError(c, http.StatusUnauthorized, "invalid_credentials", "invalid credentials")
|
respondError(c, http.StatusUnauthorized, "invalid_credentials", "invalid credentials")
|
||||||
@ -1217,6 +1238,11 @@ func (h *handler) session(c *gin.Context) {
|
|||||||
return
|
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)})
|
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)
|
name := strings.TrimSpace(user.Name)
|
||||||
email := strings.TrimSpace(user.Email)
|
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
|
return true
|
||||||
}
|
}
|
||||||
for _, group := range user.Groups {
|
for _, group := range user.Groups {
|
||||||
|
|||||||
40
api/sandbox_binding_public.go
Normal file
40
api/sandbox_binding_public.go
Normal 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
40
api/sandbox_uuid.go
Normal 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)
|
||||||
|
}
|
||||||
@ -63,11 +63,23 @@ func (h *handler) listAgentNodes(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if user.ProxyUUIDExpiresAt != nil && time.Now().UTC().After(*user.ProxyUUIDExpiresAt) {
|
if user.ProxyUUIDExpiresAt != nil && time.Now().UTC().After(*user.ProxyUUIDExpiresAt) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{
|
// Sandbox rotates hourly; never block it on expiry.
|
||||||
"error": "proxy_uuid_expired",
|
if strings.EqualFold(strings.TrimSpace(user.Email), sandboxUserEmail) {
|
||||||
"message": "proxy access has expired, please renew",
|
if err := h.ensureSandboxProxyUUID(c.Request.Context(), user); err != nil {
|
||||||
})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "sandbox_uuid_rotation_failed"})
|
||||||
return
|
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
|
// Add panic recovery for this handler
|
||||||
|
|||||||
@ -22,6 +22,7 @@ type ClientOptions struct {
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
InsecureSkipVerify bool
|
InsecureSkipVerify bool
|
||||||
UserAgent string
|
UserAgent string
|
||||||
|
AgentID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client issues authenticated requests against the controller.
|
// Client issues authenticated requests against the controller.
|
||||||
@ -30,6 +31,7 @@ type Client struct {
|
|||||||
token string
|
token string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
userAgent string
|
userAgent string
|
||||||
|
agentID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient constructs a client for the provided controller URL and token.
|
// 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 == "" {
|
if userAgent == "" {
|
||||||
userAgent = "xcontrol-agent"
|
userAgent = "xcontrol-agent"
|
||||||
}
|
}
|
||||||
|
agentID := strings.TrimSpace(opts.AgentID)
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: parsed,
|
baseURL: parsed,
|
||||||
token: token,
|
token: token,
|
||||||
http: client,
|
http: client,
|
||||||
userAgent: userAgent,
|
userAgent: userAgent,
|
||||||
|
agentID: agentID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,4 +152,7 @@ func (c *Client) ReportStatus(ctx context.Context, report agentproto.StatusRepor
|
|||||||
func (c *Client) applyHeaders(req *http.Request) {
|
func (c *Client) applyHeaders(req *http.Request) {
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
req.Header.Set("User-Agent", c.userAgent)
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
|
if c.agentID != "" {
|
||||||
|
req.Header.Set("X-Agent-ID", c.agentID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,6 +70,7 @@ func Run(ctx context.Context, opts Options) error {
|
|||||||
Timeout: httpTimeout,
|
Timeout: httpTimeout,
|
||||||
InsecureSkipVerify: opts.Agent.TLS.InsecureSkipVerify,
|
InsecureSkipVerify: opts.Agent.TLS.InsecureSkipVerify,
|
||||||
UserAgent: buildUserAgent(opts.Agent.ID),
|
UserAgent: buildUserAgent(opts.Agent.ID),
|
||||||
|
AgentID: opts.Agent.ID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -146,7 +147,7 @@ func Run(ctx context.Context, opts Options) error {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
runStatusReporter(reporterCtx, client, tracker, statusInterval, syncInterval, logger)
|
runStatusReporter(reporterCtx, client, tracker, statusInterval, syncInterval, opts.Agent.ID, logger)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
@ -163,13 +164,13 @@ func buildUserAgent(id string) string {
|
|||||||
return fmt.Sprintf("xcontrol-agent/%s", id)
|
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)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
send := func() {
|
send := func() {
|
||||||
snapshot := tracker.Snapshot()
|
snapshot := tracker.Snapshot()
|
||||||
report := buildStatusReport(snapshot, syncInterval)
|
report := buildStatusReport(snapshot, syncInterval, agentID)
|
||||||
if err := client.ReportStatus(ctx, report); err != nil {
|
if err := client.ReportStatus(ctx, report); err != nil {
|
||||||
logger.Warn("failed to report agent status", "err", err)
|
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()
|
healthy := snapshot.LastError == "" && !snapshot.LastSuccess.IsZero()
|
||||||
|
|
||||||
running := false
|
running := false
|
||||||
@ -199,6 +200,7 @@ func buildStatusReport(snapshot trackerSnapshot, syncInterval time.Duration) age
|
|||||||
}
|
}
|
||||||
|
|
||||||
report := agentproto.StatusReport{
|
report := agentproto.StatusReport{
|
||||||
|
AgentID: strings.TrimSpace(agentID),
|
||||||
Healthy: healthy,
|
Healthy: healthy,
|
||||||
Message: snapshot.LastError,
|
Message: snapshot.LastError,
|
||||||
Users: snapshot.Clients,
|
Users: snapshot.Clients,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user