feat: Implement sandbox agent functionality with dedicated user, admin API, and agent-side user filtering.

This commit is contained in:
Haitao Pan 2026-02-06 13:03:47 +08:00
parent 508e98504a
commit 4503b053f7
8 changed files with 307 additions and 19 deletions

Binary file not shown.

103
api/admin_sandbox.go Normal file
View File

@ -0,0 +1,103 @@
package api
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"account/internal/model"
)
func (h *handler) getSandboxBinding(c *gin.Context) {
if _, ok := h.requireAdminPermission(c, permissionAdminSettingsRead); !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": "", "name": ""})
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(),
})
}
func (h *handler) bindSandboxNode(c *gin.Context) {
adminUser, ok := h.requireAdminPermission(c, permissionAdminSettingsWrite)
if !ok {
return
}
if h.isReadOnlyAccount(adminUser) {
respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only")
return
}
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database_not_configured"})
return
}
var req struct {
Address string `json:"address"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request", "message": err.Error()})
return
}
agentID := strings.TrimSpace(req.Address)
err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
// Clear existing bindings (enforce 1-to-1 for now as per frontend)
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.SandboxBinding{}).Error; err != nil {
return err
}
if agentID != "" {
newBinding := model.SandboxBinding{
AgentID: agentID,
}
if err := tx.Create(&newBinding).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed_to_save_binding", "message": err.Error()})
return
}
// 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
if agentID != "" {
h.agentRegistry.SetSandboxAgent(agentID, true)
}
}
c.JSON(http.StatusOK, gin.H{"message": "sandbox node bound successfully", "address": agentID})
}

View File

@ -21,6 +21,7 @@ import (
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"account/internal/auth"
"account/internal/service"
@ -66,6 +67,13 @@ type handler struct {
oauthProviders map[string]auth.OAuthProvider
oauthFrontendURL string
publicURL string
agentRegistry agentRegistry
db *gorm.DB
}
type agentRegistry interface {
IsSandboxAgent(agentID string) bool
SetSandboxAgent(agentID string, enabled bool)
}
type mfaChallenge struct {
@ -202,6 +210,20 @@ func WithOAuthFrontendURL(url string) Option {
}
}
// WithAgentRegistry configures the handler with the provided agent registry.
func WithAgentRegistry(registry agentRegistry) Option {
return func(h *handler) {
h.agentRegistry = registry
}
}
// WithGormDB configures the handler with the provided GORM database for admin settings.
func WithGormDB(db *gorm.DB) Option {
return func(h *handler) {
h.db = db
}
}
// RegisterRoutes attaches account service endpoints to the router.
func RegisterRoutes(r *gin.Engine, opts ...Option) {
h := &handler{
@ -286,6 +308,9 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
authProtected.POST("/admin/blacklist", h.addToBlacklist)
authProtected.DELETE("/admin/blacklist/:email", h.removeFromBlacklist)
authProtected.GET("/admin/sandbox/binding", h.getSandboxBinding)
authProtected.POST("/admin/sandbox/bind", h.bindSandboxNode)
authProtected.GET("/users", h.listUsers)
// Internal routes for service-to-service reads.

View File

@ -26,7 +26,7 @@ const (
defaultTCPFlow = "xtls-rprx-vision"
)
type vlessNode struct {
type VlessNode struct {
Name string `json:"name"`
Address string `json:"address"`
Port int `json:"port,omitempty"`
@ -82,7 +82,7 @@ func (h *handler) listAgentNodes(c *gin.Context) {
hosts := parseProxyNodeHosts(h.publicURL, registeredHosts)
if len(hosts) == 0 {
c.JSON(http.StatusOK, []vlessNode{})
c.JSON(http.StatusOK, []VlessNode{})
return
}
@ -95,10 +95,10 @@ func (h *handler) listAgentNodes(c *gin.Context) {
tcpScheme := xrayconfig.VLESSTCPScheme()
users := []string{proxyUUID}
nodes := make([]vlessNode, 0, len(hosts))
nodes := make([]VlessNode, 0, len(hosts))
for _, host := range hosts {
nodeName := resolveNodeName(host, registeredNames)
nodes = append(nodes, vlessNode{
nodes = append(nodes, VlessNode{
Name: nodeName,
Address: host,
Port: xhttpPort,

View File

@ -42,6 +42,11 @@ var (
logLevel string
)
const (
// SandboxEmail is the canonical email for the sandbox account.
SandboxEmail = "Sandbox@svc.plus"
)
const (
demoUsername = "Demo"
demoPassword = "Demo"
@ -202,6 +207,56 @@ func findDemoUser(ctx context.Context, st store.Store) (*store.User, error) {
return nil, nil
}
func ensureSandboxUser(ctx context.Context, st store.Store, logger *slog.Logger) error {
sandboxUser, err := st.GetUserByEmail(ctx, SandboxEmail)
if err != nil && !errors.Is(err, store.ErrUserNotFound) {
return fmt.Errorf("lookup sandbox user: %w", err)
}
if sandboxUser == nil {
hashed, err := bcrypt.GenerateFromPassword([]byte(uuid.NewString()), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash sandbox password: %w", err)
}
user := &store.User{
Name: "Sandbox",
Email: SandboxEmail,
EmailVerified: true,
PasswordHash: string(hashed),
Level: store.LevelUser,
Role: store.RoleUser,
Groups: []string{"User", "Sandbox"},
Permissions: []string{},
Active: true,
ProxyUUID: uuid.NewString(),
}
if err := st.CreateUser(ctx, user); err != nil {
return fmt.Errorf("create sandbox user: %w", err)
}
if logger != nil {
logger.Info("sandbox experience user created", "email", SandboxEmail)
}
} else {
// Ensure sandbox user is active and has a proxy uuid
changed := false
if !sandboxUser.Active {
sandboxUser.Active = true
changed = true
}
if sandboxUser.ProxyUUID == "" {
sandboxUser.ProxyUUID = uuid.NewString()
changed = true
}
if changed {
if err := st.UpdateUser(ctx, sandboxUser); err != nil {
return fmt.Errorf("update sandbox user: %w", err)
}
}
}
return nil
}
func startDemoUUIDRotator(ctx context.Context, st store.Store, logger *slog.Logger) {
go func() {
ticker := time.NewTicker(time.Hour)
@ -603,6 +658,9 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err
if err := ensureDemoUser(ctx, st, logger); err != nil {
return err
}
if err := ensureSandboxUser(ctx, st, logger); err != nil {
logger.Warn("failed to ensure sandbox user", "err", err)
}
startDemoUUIDRotator(ctx, st, logger)
var emailSender api.EmailSender
@ -845,6 +903,16 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err
}
}
options = append(options, api.WithOAuthProviders(oauthProviders))
options = append(options, api.WithAgentRegistry(agentRegistry))
options = append(options, api.WithGormDB(gormDB))
// Pre-load sandbox bindings from database into the registry
var sandboxBindings []model.SandboxBinding
if err := gormDB.Find(&sandboxBindings).Error; err == nil {
for _, b := range sandboxBindings {
agentRegistry.SetSandboxAgent(b.AgentID, true)
}
}
api.RegisterRoutes(r, options...)
@ -1062,8 +1130,28 @@ func registerAgentAPIRoutes(r *gin.Engine, registry *agentserver.Registry, sourc
// Use /api/agent-server/v1 to avoid conflict with /api/agent prefix used by admin/user API
group := r.Group("/api/agent-server/v1")
group.Use(agentAuthMiddleware(registry))
group.GET("/users", agentListUsersHandler(source))
group.GET("/users", agentListUsersHandler(registry, source, logger))
group.POST("/status", agentReportStatusHandler(registry, logger))
group.GET("/nodes", agentListNodesHandler(registry))
}
func agentListNodesHandler(registry *agentserver.Registry) gin.HandlerFunc {
return func(c *gin.Context) {
if registry == nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
agents := registry.Agents()
nodes := make([]api.VlessNode, 0, len(agents))
for _, agent := range agents {
nodes = append(nodes, api.VlessNode{
Name: agent.Name,
Address: agent.ID,
})
}
c.JSON(http.StatusOK, nodes)
}
}
func agentAuthMiddleware(registry *agentserver.Registry) gin.HandlerFunc {
@ -1087,17 +1175,45 @@ func agentAuthMiddleware(registry *agentserver.Registry) gin.HandlerFunc {
}
}
func agentListUsersHandler(source xrayconfig.ClientSource) gin.HandlerFunc {
func agentListUsersHandler(registry *agentserver.Registry, source xrayconfig.ClientSource, logger *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
if source == nil {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "client_source_unavailable", "message": "client source not configured"})
return
}
// Agent identity is already verified by middleware
val, _ := c.Get(agentIdentityContextKey)
identity, _ := val.(agentserver.Identity)
// Determine if this agent is in sandbox mode
isSandbox := false
if registry != nil {
isSandbox = registry.IsSandboxAgent(identity.ID)
}
// Use accounts server logic to decide if sandbox user should be included
// Requirement: default sandbox email is Sandbox@svc.plus
clients, err := source.ListClients(c.Request.Context())
if err != nil {
if logger != nil {
logger.Error("failed to list clients from source", "err", err, "agent", identity.ID)
}
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "list_clients_failed", "message": "failed to list clients"})
return
}
if isSandbox {
// In sandbox mode, only return the Sandbox user
sandboxClients := make([]xrayconfig.Client, 0)
for _, client := range clients {
if strings.EqualFold(client.Email, SandboxEmail) {
sandboxClients = append(sandboxClients, client)
}
}
clients = sandboxClients
}
response := agentproto.ClientListResponse{
Clients: clients,
Total: len(clients),
@ -1247,7 +1363,7 @@ func openAdminSettingsDB(cfg config.Store) (*gorm.DB, func(context.Context) erro
return nil, nil, err
}
if err := db.AutoMigrate(&model.AdminSetting{}); err != nil {
if err := db.AutoMigrate(&model.AdminSetting{}, &model.SandboxBinding{}); err != nil {
return nil, nil, err
}

View File

@ -43,22 +43,24 @@ type StatusSnapshot struct {
// Registry manages agent credentials and status reports in-memory.
type Registry struct {
mu sync.RWMutex
credentials map[[32]byte]Identity
byID map[string]Identity
statuses map[string]StatusSnapshot
store store.Store
logger *slog.Logger
mu sync.RWMutex
credentials map[[32]byte]Identity
byID map[string]Identity
statuses map[string]StatusSnapshot
sandboxAgents map[string]bool
store store.Store
logger *slog.Logger
}
// NewRegistry constructs a registry from configuration, validating credentials
// and normalising their representation.
func NewRegistry(cfg Config) (*Registry, error) {
r := &Registry{
credentials: make(map[[32]byte]Identity),
byID: make(map[string]Identity),
statuses: make(map[string]StatusSnapshot),
logger: slog.Default().With("component", "agent-registry"),
credentials: make(map[[32]byte]Identity),
byID: make(map[string]Identity),
statuses: make(map[string]StatusSnapshot),
sandboxAgents: make(map[string]bool),
logger: slog.Default().With("component", "agent-registry"),
}
for _, cred := range cfg.Credentials {
@ -278,6 +280,24 @@ func (r *Registry) Agents() []Identity {
return agents
}
// IsSandboxAgent reports whether the provided agent ID is bound to sandbox mode.
func (r *Registry) IsSandboxAgent(agentID string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.sandboxAgents[agentID]
}
// SetSandboxAgent marks an agent as a sandbox agent.
func (r *Registry) SetSandboxAgent(agentID string, enabled bool) {
r.mu.Lock()
defer r.mu.Unlock()
if enabled {
r.sandboxAgents[agentID] = true
} else {
delete(r.sandboxAgents, agentID)
}
}
// normalizeStrings trims whitespace and removes duplicates from the provided
// slice while preserving the original order for unique entries.
func normalizeStrings(values []string) []string {

View File

@ -0,0 +1,16 @@
package model
import (
"time"
)
// SandboxBinding represents the binding between the Sandbox mode and a specific agent.
type SandboxBinding struct {
ID uint `gorm:"primaryKey"`
AgentID string `gorm:"column:agent_id;type:text;not null;uniqueIndex"`
CreatedAt time.Time `gorm:"column:created_at;not null;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;autoUpdateTime"`
}
// TableName overrides the default table name used by GORM.
func (SandboxBinding) TableName() string { return "sandbox_bindings" }

View File

@ -3,6 +3,7 @@ package xrayconfig
import (
"context"
"errors"
"log/slog"
"strings"
"gorm.io/gorm"
@ -10,7 +11,8 @@ import (
// GormClientSource reads Xray client credentials from the users table using GORM.
type GormClientSource struct {
DB *gorm.DB
DB *gorm.DB
Logger *slog.Logger
}
// NewGormClientSource constructs a ClientSource backed by the provided GORM instance.
@ -18,7 +20,10 @@ func NewGormClientSource(db *gorm.DB) (*GormClientSource, error) {
if db == nil {
return nil, errors.New("gorm db is required")
}
return &GormClientSource{DB: db}, nil
return &GormClientSource{
DB: db,
Logger: slog.Default().With("component", "xray-gorm-source"),
}, nil
}
// ListClients returns all users ordered by creation time.
@ -38,6 +43,9 @@ func (s *GormClientSource) ListClients(ctx context.Context) ([]Client, error) {
Select("proxy_uuid, email").
Order("created_at ASC, proxy_uuid ASC").
Find(&rows).Error; err != nil {
if s.Logger != nil {
s.Logger.Error("failed to list clients from users table", "err", err)
}
return nil, err
}