feat: Implement sandbox agent functionality with dedicated user, admin API, and agent-side user filtering.
This commit is contained in:
parent
508e98504a
commit
4503b053f7
BIN
accountsvc
BIN
accountsvc
Binary file not shown.
103
api/admin_sandbox.go
Normal file
103
api/admin_sandbox.go
Normal 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})
|
||||
}
|
||||
25
api/api.go
25
api/api.go
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
16
internal/model/sandbox_binding.go
Normal file
16
internal/model/sandbox_binding.go
Normal 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" }
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user