diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index cbc269b..35ca805 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -67,6 +67,27 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) { return nil, false } claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, false + } + + // For sub-user tokens, check token_version against stored version (password rotation invalidation) + if subUser, _ := claims["sub_user"].(string); subUser != "" { + tokenVersionFloat, hasVersion := claims["token_version"].(float64) + tokenVersion := int(tokenVersionFloat) + for i := range config.AppConfig.SubUsers { + if config.AppConfig.SubUsers[i].Username == subUser { + stored := config.AppConfig.SubUsers[i].TokenVersion + // If stored version > 0, require token_version to match exactly. + // This also rejects legacy tokens that lack token_version entirely. + if stored > 0 && (!hasVersion || tokenVersion != stored) { + return nil, false + } + break + } + } + } + return claims, ok } diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 8cb6bbc..46b9707 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -31,16 +31,35 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/containers/") parts := strings.SplitN(path, "/", 2) c := containerByIdentifier(parts[0]) - if c == nil { - jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) - return + id := 0 + if c != nil { + id = c.ID } - id := c.ID action := "" if len(parts) > 1 { action = parts[1] } + // Snapshot delete/restore operations: allow even if the container was deleted + isSnapshotDelete := strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete + isSnapshotRestore := strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost + isSnapshotAction := isSnapshotDelete || isSnapshotRestore + if !isSnapshotAction && c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + if isSnapshotAction && id == 0 { + // For orphaned snapshots, resolve containerID from the snapshot itself + snapshotID := strings.TrimPrefix(action, "snapshots/") + snapshotID = strings.TrimSuffix(snapshotID, "/restore") + snapshot := config.FindSnapshot(snapshotID) + if snapshot == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"}) + return + } + id = snapshot.ContainerID + } + switch { case action == "start" && r.Method == http.MethodPost: HandleSingleTaskAction(w, r, id, "start") diff --git a/backend/internal/api/subuser.go b/backend/internal/api/subuser.go index 71c894f..29108bb 100644 --- a/backend/internal/api/subuser.go +++ b/backend/internal/api/subuser.go @@ -66,7 +66,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { } containerName := c.Name - // Check if sub-user already exists for this container + // Check if sub-user already exists and return the same management password. for i := range config.AppConfig.SubUsers { su := &config.AppConfig.SubUsers[i] for _, uuid := range su.ContainerUUIDs { @@ -74,18 +74,27 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { if su.AccessCode == "" { su.AccessCode = generateRandomStr(8) } - password := generateRandomStr(16) - if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil { + password := su.Password + message := "Sub-user link returned" + if password == "" { + password = generateRandomStr(16) + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"}) + return + } su.PassHash = string(hash) + su.Password = password + su.Token = "" + su.TokenVersion++ + message = "Sub-user password generated" } - su.Password = "" - su.Token = "" su.ContainerNames = appendUniqueString(su.ContainerNames, containerName) su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID) config.SaveConfig() jsonResponse(w, http.StatusOK, APIResponse{ Success: true, - Message: "Sub-user password rotated", + Message: message, Data: newSubUserResponse(*su, password), }) return @@ -104,6 +113,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { subUser := config.SubUser{ ID: "sub-" + generateRandomStr(8), Username: username, + Password: password, PassHash: string(hash), ContainerNames: []string{containerName}, ContainerUUIDs: []string{c.UUID}, @@ -134,17 +144,24 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) { return } + clientIP := r.Header.Get("X-Forwarded-For") + if clientIP == "" { + clientIP = r.RemoteAddr + } + clientUA := r.Header.Get("User-Agent") + // Find sub-user for _, su := range config.AppConfig.SubUsers { if su.Username == req.Username { if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil { - // Generate fresh token containerUUIDs := activeSubUserContainerUUIDs(&su) if len(containerUUIDs) == 0 { + config.AddLoginLog(su.Username, clientIP, clientUA, false) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"}) return } - tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour)) + tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion) + config.AddLoginLog(su.Username, clientIP, clientUA, true) jsonResponse(w, http.StatusOK, APIResponse{ Success: true, @@ -155,6 +172,8 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) { }, }) return + } else { + config.AddLoginLog(su.Username, clientIP, clientUA, false) } } } @@ -179,19 +198,28 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) { } // Find sub-user by access code + clientIP := r.Header.Get("X-Forwarded-For") + if clientIP == "" { + clientIP = r.RemoteAddr + } + clientUA := r.Header.Get("User-Agent") + for _, su := range config.AppConfig.SubUsers { if su.AccessCode == req.Code { if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil { + config.AddLoginLog(su.Username, clientIP, clientUA, false) jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"}) return } containerUUIDs := activeSubUserContainerUUIDs(&su) if len(containerUUIDs) == 0 { + config.AddLoginLog(su.Username, clientIP, clientUA, false) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"}) return } - tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour)) + tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion) + config.AddLoginLog(su.Username, clientIP, clientUA, true) jsonResponse(w, http.StatusOK, APIResponse{ Success: true, @@ -208,10 +236,11 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"}) } -func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time) string { +func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time, tokenVersion int) string { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub_user": username, "container_uuids": containerUUIDs, + "token_version": tokenVersion, "exp": expiresAt.Unix(), "iat": time.Now().Unix(), }) @@ -461,3 +490,156 @@ func splitBy(s, sep string) []string { result = append(result, current) return result } + +// SubUserListItem is the enriched sub-user info returned by the list API +type SubUserListItem struct { + ID string `json:"id"` + Username string `json:"username"` + ContainerNames []string `json:"container_names"` + ContainerUUIDs []string `json:"container_uuids"` + ContainerName string `json:"container_name"` + ContainerUUID string `json:"container_uuid"` + AccessCode string `json:"access_code"` + Password string `json:"password,omitempty"` + CreatedAt string `json:"created_at"` + LastLogin string `json:"last_login"` + LastLoginIP string `json:"last_login_ip"` + LastLoginUA string `json:"last_login_ua"` +} + +// HandleSubUserList returns the list of all sub-users with container info +func HandleSubUserList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + + result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers)) + for _, su := range config.AppConfig.SubUsers { + item := SubUserListItem{ + ID: su.ID, + Username: su.Username, + ContainerNames: su.ContainerNames, + ContainerUUIDs: su.ContainerUUIDs, + AccessCode: su.AccessCode, + Password: su.Password, + CreatedAt: su.CreatedAt, + } + + // Resolve container name from first active UUID + for _, uuid := range su.ContainerUUIDs { + if c := config.FindContainerByUUID(uuid); c != nil { + item.ContainerName = c.Name + item.ContainerUUID = c.UUID + break + } + } + if item.ContainerName == "" && len(su.ContainerNames) > 0 { + item.ContainerName = su.ContainerNames[0] + } + + // Find last login time + for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- { + log := config.AppConfig.LoginLogs[i] + if log.Username == su.Username { + item.LastLogin = log.Time + item.LastLoginIP = log.IP + item.LastLoginUA = log.UserAgent + break + } + } + + // Skip orphaned sub-users with no active containers + if item.ContainerName == "" && item.ContainerUUID == "" { + continue + } + + result = append(result, item) + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result}) +} + +// HandleSubUserAction handles actions on a specific sub-user +func HandleSubUserAction(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/") + parts := strings.SplitN(path, "/", 2) + subUserID := parts[0] + action := "" + if len(parts) > 1 { + action = parts[1] + } + + // Find sub-user + var target *config.SubUser + for i := range config.AppConfig.SubUsers { + if config.AppConfig.SubUsers[i].ID == subUserID { + target = &config.AppConfig.SubUsers[i] + break + } + } + if target == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Sub-user not found"}) + return + } + + switch { + case action == "rotate-password" && r.Method == http.MethodPost: + password := generateRandomStr(16) + if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil { + target.PassHash = string(hash) + target.Password = password + target.Token = "" + target.TokenVersion++ // invalidate all existing tokens + config.SaveConfig() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{ + "password": password, + "access_code": target.AccessCode, + "username": target.Username, + }}) + return + } + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"}) + + case action == "audit-logs" && r.Method == http.MethodGet: + // Filter audit logs for this sub-user + logs := filterSubUserAuditLogs(target.Username) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs}) + + case action == "login-logs" && r.Method == http.MethodGet: + // Filter login logs for this sub-user + logs := filterSubUserLoginLogs(target.Username) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs}) + + default: + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"}) + } +} + +func filterSubUserAuditLogs(username string) []config.AuditLog { + result := make([]config.AuditLog, 0) + for i := len(config.AppConfig.AuditLogs) - 1; i >= 0; i-- { + log := config.AppConfig.AuditLogs[i] + if log.User == username || strings.HasPrefix(log.User, "user:") && strings.Contains(log.User, username) { + result = append(result, log) + } + } + if result == nil { + result = []config.AuditLog{} + } + return result +} + +func filterSubUserLoginLogs(username string) []config.SavedLoginLog { + result := make([]config.SavedLoginLog, 0) + for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- { + log := config.AppConfig.LoginLogs[i] + if log.Username == username { + result = append(result, log) + } + } + if result == nil { + result = []config.SavedLoginLog{} + } + return result +} diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index c6917f0..d460cc8 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -35,6 +35,8 @@ type Task struct { Config lxc.ContainerConfig `json:"config,omitempty"` Name string `json:"name,omitempty"` User string `json:"user,omitempty"` // who created this task + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` } type TaskQueue struct { @@ -100,6 +102,10 @@ func (q *TaskQueue) EnqueueBatch(taskType TaskType, ids []int, templateID string } func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string { + return q.EnqueueBatchWithAudit(taskType, ids, templateID, user, "", "") +} + +func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, templateID string, user string, ip string, userAgent string) []string { q.mu.Lock() defer q.mu.Unlock() var result []string @@ -109,7 +115,7 @@ func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateI if c != nil { name = c.Name } - result = append(result, q.enqueueSingleWithUser(id, name, taskType, templateID, user)) + result = append(result, q.enqueueSingleWithAudit(id, name, taskType, templateID, user, ip, userAgent)) } q.persistTasks() return result @@ -168,6 +174,10 @@ func (q *TaskQueue) enqueueSingle(containerID int, containerName string, taskTyp } func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string { + return q.enqueueSingleWithAudit(containerID, containerName, taskType, templateID, user, "", "") +} + +func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string, taskType TaskType, templateID string, user string, ip string, userAgent string) string { id := q.nextID q.nextID++ task := &Task{ @@ -179,6 +189,8 @@ func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, CreatedAt: time.Now().Format("2006-01-02 15:04:05"), TemplateID: templateID, User: user, + IP: ip, + UserAgent: userAgent, } q.enqueueTask(task) return task.ID @@ -318,10 +330,10 @@ func (q *TaskQueue) opWorker() { if err != nil { task.Status = "failed" task.Error = err.Error() - config.AddAuditLog(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser) + config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error()) } else { task.Status = "done" - config.AddAuditLog(string(task.Type), task.ContainerName, "成功", auditUser) + config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "") switch task.Type { case TaskStart: config.UpdateContainerStatus(task.ContainerID, "running") @@ -418,6 +430,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti user = "user:" + subUser } } + ip := clientIP(r) + userAgent := r.Header.Get("User-Agent") var taskType TaskType var templateID string @@ -452,7 +466,7 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti return } - ids := globalQueue.EnqueueBatchWithUser(taskType, []int{id}, templateID, user) + ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent) jsonResponse(w, http.StatusAccepted, APIResponse{ Success: true, Message: "Task queued", diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2d184f2..8753389 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -47,11 +47,15 @@ type SavedLoginLog struct { // AuditLog represents an operation log entry type AuditLog struct { - Time string `json:"time"` - Action string `json:"action"` - Target string `json:"target"` - Detail string `json:"detail"` - User string `json:"user"` + Time string `json:"time"` + Action string `json:"action"` + Target string `json:"target"` + Detail string `json:"detail"` + User string `json:"user"` + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Success *bool `json:"success,omitempty"` + Error string `json:"error,omitempty"` } // OversellConfig controls host-level overselling behavior @@ -139,13 +143,14 @@ func DeleteApiKey(id string) { type SubUser struct { ID string `json:"id"` Username string `json:"username"` - Password string `json:"-"` + Password string `json:"password,omitempty"` PassHash string `json:"pass_hash"` ContainerNames []string `json:"container_names"` ContainerUUIDs []string `json:"container_uuids,omitempty"` Token string `json:"-"` AccessCode string `json:"access_code"` CreatedAt string `json:"created_at"` + TokenVersion int `json:"token_version"` } type Snapshot struct { @@ -437,10 +442,6 @@ func migrateSubUsers() bool { changed = true } } - if su.Password != "" { - su.Password = "" - changed = true - } if su.Token != "" { su.Token = "" changed = true @@ -536,6 +537,8 @@ func RemoveContainer(id int) bool { if c.ID == id { removeSubUserContainerAccess(c.Name, c.UUID) removeContainerSnapshotMetadata(id) + // Clear snapshot schedule for this container + clearContainerSnapshotSchedule(&AppConfig.Containers[i]) AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...) SaveConfig() return true @@ -544,6 +547,15 @@ func RemoveContainer(id int) bool { return false } +func clearContainerSnapshotSchedule(c *Container) { + c.SnapshotScheduleEnabled = false + c.SnapshotScheduleIntervalHours = 0 + c.SnapshotScheduleTime = "" + c.SnapshotScheduleLastRun = "" + c.SnapshotScheduleNextRun = "" + c.SnapshotScheduleCreatedBy = "" +} + func AddSnapshot(snapshot Snapshot) { AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot) SaveConfig() @@ -723,6 +735,26 @@ func AddAuditLog(action, target, detail, user string) { SaveConfig() } +func AddAuditLogFull(action, target, detail, user, ip, userAgent string, success bool, errMsg string) { + s := success + log := AuditLog{ + Time: time.Now().Format("2006-01-02 15:04:05"), + Action: action, + Target: target, + Detail: detail, + User: user, + IP: ip, + UserAgent: userAgent, + Success: &s, + Error: errMsg, + } + AppConfig.AuditLogs = append(AppConfig.AuditLogs, log) + if len(AppConfig.AuditLogs) > 500 { + AppConfig.AuditLogs = AppConfig.AuditLogs[len(AppConfig.AuditLogs)-500:] + } + SaveConfig() +} + // SaveTasks persists the task queue to config func SaveTasks(tasks []SavedTask) { AppConfig.Tasks = tasks diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 03c0e58..a27e068 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -1479,11 +1479,20 @@ func (m *Manager) DestroyContainer(id int) error { } return fmt.Errorf("container still exists after cleanup with status %s", status) } - snapshotDir := filepath.Join(snapshotBaseDir(), lxcName) + // Remove snapshot physical files (by container ID, not lxcName) + snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id)) if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil { os.RemoveAll(snapshotDir) } + // Also remove any legacy snapshot dir that used lxcName + legacySnapshotDir := filepath.Join(snapshotBaseDir(), lxcName) + if legacySnapshotDir != snapshotDir { + if err := safePathUnder(legacySnapshotDir, snapshotBaseDir()); err == nil { + os.RemoveAll(legacySnapshotDir) + } + } + if !config.RemoveContainer(id) { return fmt.Errorf("container destroyed but config entry was not removed: %d", id) } diff --git a/backend/internal/lxc/snapshot.go b/backend/internal/lxc/snapshot.go index 842c5e3..e1c40c8 100644 --- a/backend/internal/lxc/snapshot.go +++ b/backend/internal/lxc/snapshot.go @@ -45,7 +45,8 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat now := time.Now() snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000")) - snapshotDir := filepath.Join(snapshotBaseDir(), lxcName, snapshotID) + // Use container ID instead of lxcName to avoid collision when containers are recreated + snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID) if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil { return config.Snapshot{}, err } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 5d0f1b5..f5fab42 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -98,6 +98,8 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate))) mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin)) mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode)) + mux.HandleFunc("/api/sub-users", corsMiddleware(api.AdminMiddleware(api.HandleSubUserList))) + mux.HandleFunc("/api/sub-users/", corsMiddleware(api.AdminMiddleware(api.HandleSubUserAction))) mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs))) mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts))) mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck))) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index df9e9a4..fc31aad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import Settings from './pages/Settings' import ImageManagement from './pages/ImageManagement' import Snapshots from './pages/Snapshots' import Routing from './pages/Routing' +import SubUserManagement from './pages/SubUserManagement' import Layout from './components/Layout' function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -63,6 +64,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index 81ea80a..7a236d3 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -7,6 +7,7 @@ interface CreateContainerModalProps { isOpen: boolean onClose: () => void onSuccess: (containers: CreateContainerRequest[]) => void | Promise + existingNames?: string[] } const defaultForm: CreateContainerRequest = { @@ -29,7 +30,7 @@ const defaultForm: CreateContainerRequest = { expires_at: '', } -export default function CreateContainerModal({ isOpen, onClose, onSuccess }: CreateContainerModalProps) { +export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) { const dialog = useDialog() const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(false) @@ -37,6 +38,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre const [form, setForm] = useState(defaultForm) const [hostInfo, setHostInfo] = useState(null) const [ipv6Status, setIPv6Status] = useState(null) + const [nameError, setNameError] = useState('') useEffect(() => { if (!isOpen) return @@ -83,6 +85,34 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre // SSH port preview (will be allocated sequentially, starting around 22000+) const sshPortPreview = 22000 + // Find next available batch index to avoid name conflicts + const batchStartIndex = useMemo(() => { + if (batchCount <= 1 || !form.name) return 1 + const prefix = `${form.name}-` + let maxIdx = 0 + for (const existing of existingNames) { + if (existing.startsWith(prefix)) { + const suffix = existing.slice(prefix.length) + const idx = parseInt(suffix, 10) + if (!isNaN(idx) && idx > maxIdx) { + maxIdx = idx + } + } + } + return maxIdx + 1 + }, [form.name, batchCount, existingNames]) + + const handleNameChange = (value: string) => { + setForm({ ...form, name: value }) + if (/\s/.test(value)) { + setNameError('容器名称不能包含空格') + } else if (value && existingNames.includes(value) && batchCount === 1) { + setNameError('该容器名称已存在') + } else { + setNameError('') + } + } + const handleSubmit = async () => { if (!form.name || !form.template_id) { dialog.alert('提示', '请填写容器名称并选择系统模板') @@ -93,8 +123,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre // Build batch of containers const containers: CreateContainerRequest[] = [] + const startIndex = batchStartIndex for (let i = 0; i < batchCount; i++) { - const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name + const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name containers.push({ ...boundedForm, name, @@ -137,17 +168,18 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre setForm({ ...form, name: event.target.value })} - className={inputClass} + onChange={(event) => handleNameChange(event.target.value)} + className={`${inputClass} ${nameError ? 'border-red-400 focus:ring-red-400 focus:border-red-400' : ''}`} placeholder="my-container" required /> + {nameError &&

{nameError}

} setBatchCount(Math.max(1, value || 1))} /> - {batchCount > 1 &&

将创建 {batchCount} 个容器:{form.name}-1 至 {form.name}-{batchCount}

} + {batchCount > 1 &&

将创建 {batchCount} 个容器:{form.name}-{batchStartIndex} 至 {form.name}-{batchStartIndex + batchCount - 1}

} {templates.length === 0 ? ( diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9d49792..a9f6273 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -193,6 +193,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { {!collapsed && 操作日志} + + - - -
- 用户名 -
- {subUser?.username} - -
-
-
- 密码 -
- {managementPassword} - -
-
- -

打开管理地址,输入用户名和密码即可管理该容器。链接不含 token,无法被截获后直接使用。

- - - )} - {showSubUser && subUser && ( setShowSubUser(false)}> -
+
- 地址 + 地址
- {managementUrl} - + {managementUrl} +
- 密码 + 密码
- {managementPassword} - + {subUser.password || ''} +
diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index 6c3303a..d87cd6f 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -401,7 +401,7 @@ export default function Containers() {
)} - setShowCreate(false)} onSuccess={handleCreateQueued} /> + setShowCreate(false)} onSuccess={handleCreateQueued} existingNames={containers.map(c => c.name)} /> {showTasks && ( { try { @@ -25,6 +27,39 @@ export default function Routing() { useEffect(() => { fetchData() }, [fetchData]) + const nat4Mappings = routing?.nat4_mappings || [] + const ipv6Assignments = routing?.ipv6_assignments || [] + const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-' + + // Filter helpers + const matchesNat4Search = (m: NAT4Route, query: string) => { + if (!query) return true + const q = query.toLowerCase() + return ( + String(m.host_port).includes(q) || + String(m.container_port).includes(q) || + m.container_name.toLowerCase().includes(q) || + m.lxc_name.toLowerCase().includes(q) || + (m.ip || '').toLowerCase().includes(q) + ) + } + const matchesIPv6Search = (item: IPv6Route, query: string) => { + if (!query) return true + const q = query.toLowerCase() + return ( + (item.address || '').toLowerCase().includes(q) || + item.container_name.toLowerCase().includes(q) || + item.lxc_name.toLowerCase().includes(q) + ) + } + + const filteredNat4 = useMemo(() => nat4Mappings.filter(m => matchesNat4Search(m, nat4Search)), [nat4Mappings, nat4Search]) + const filteredIPv6 = useMemo(() => ipv6Assignments.filter(m => matchesIPv6Search(m, ipv6Search)), [ipv6Assignments, ipv6Search]) + + // Reset page on search change + useEffect(() => { setNat4Page(1) }, [nat4Search]) + useEffect(() => { setIPv6Page(1) }, [ipv6Search]) + if (loading) { return (
@@ -33,16 +68,13 @@ export default function Routing() { ) } - const nat4Mappings = routing?.nat4_mappings || [] - const ipv6Assignments = routing?.ipv6_assignments || [] - const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-' const pageSize = 10 - const nat4TotalPages = Math.max(1, Math.ceil(nat4Mappings.length / pageSize)) - const ipv6TotalPages = Math.max(1, Math.ceil(ipv6Assignments.length / pageSize)) + const nat4TotalPages = Math.max(1, Math.ceil(filteredNat4.length / pageSize)) + const ipv6TotalPages = Math.max(1, Math.ceil(filteredIPv6.length / pageSize)) const currentNat4Page = Math.min(nat4Page, nat4TotalPages) const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages) - const pagedNat4Mappings = nat4Mappings.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize) - const pagedIPv6Assignments = ipv6Assignments.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize) + const pagedNat4Mappings = filteredNat4.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize) + const pagedIPv6Assignments = filteredIPv6.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize) return (
@@ -80,10 +112,29 @@ export default function Routing() { />
-
-
-
NAT4 端口分配
-
共 {nat4Mappings.length} 条映射
+
+
+
+
NAT4 端口分配
+
+ {nat4Search ? `搜索 "${nat4Search}" 结果 ${filteredNat4.length} 条,` : ''}共 {nat4Mappings.length} 条映射 +
+
+
+ + setNat4Search(e.target.value)} + placeholder="搜索端口/容器..." + className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white" + /> + {nat4Search && ( + + )} +
{nat4Mappings.length === 0 ? ( } text="暂无 NAT4 端口映射" /> @@ -130,7 +181,7 @@ export default function Routing() { @@ -138,10 +189,29 @@ export default function Routing() { )}
-
-
-
IPv6 地址分配
-
共 {ipv6Assignments.length} 个地址
+
+
+
+
IPv6 地址分配
+
+ {ipv6Search ? `搜索 "${ipv6Search}" 结果 ${filteredIPv6.length} 条,` : ''}共 {ipv6Assignments.length} 个地址 +
+
+
+ + setIPv6Search(e.target.value)} + placeholder="搜索地址/容器..." + className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white" + /> + {ipv6Search && ( + + )} +
{ipv6Assignments.length === 0 ? ( } text="暂无 IPv6 地址分配" /> @@ -184,7 +254,7 @@ export default function Routing() { diff --git a/frontend/src/pages/Snapshots.tsx b/frontend/src/pages/Snapshots.tsx index 4b3935b..d61aec4 100644 --- a/frontend/src/pages/Snapshots.tsx +++ b/frontend/src/pages/Snapshots.tsx @@ -1,13 +1,16 @@ import { useCallback, useEffect, useState } from 'react' -import { Camera, RefreshCw, Server } from 'lucide-react' +import { Camera, RefreshCw, Server, Trash2 } from 'lucide-react' import { useNavigate } from 'react-router-dom' -import { getSnapshots, Snapshot } from '../services/api' +import { deleteContainerSnapshot, getSnapshots, Snapshot } from '../services/api' +import { useDialog } from '../components/Dialog' export default function Snapshots() { const navigate = useNavigate() + const dialog = useDialog() const [snapshots, setSnapshots] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) + const [deleting, setDeleting] = useState(null) const fetchData = useCallback(async () => { try { @@ -23,6 +26,25 @@ export default function Snapshots() { useEffect(() => { fetchData() }, [fetchData]) + const handleDelete = async (snapshot: Snapshot) => { + const confirmed = await dialog.confirm( + '删除快照', + `确认删除容器 ${snapshot.container_name} 的快照吗?此操作不可恢复。` + ) + if (!confirmed) return + + setDeleting(snapshot.id) + try { + await deleteContainerSnapshot(snapshot.container_id, snapshot.id) + setSnapshots(prev => prev.filter(s => s.id !== snapshot.id)) + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } } + dialog.alert('删除失败', error.response?.data?.message || '请稍后重试') + } finally { + setDeleting(null) + } + } + if (loading) { return (
@@ -66,6 +88,7 @@ export default function Snapshots() { 类型 创建者 大小 + 操作 @@ -89,6 +112,16 @@ export default function Snapshots() { {snapshot.created_by || '-'} {formatBytes(snapshot.size_bytes || 0)} + + + ))} diff --git a/frontend/src/pages/SubUserManagement.tsx b/frontend/src/pages/SubUserManagement.tsx new file mode 100644 index 0000000..91a02c9 --- /dev/null +++ b/frontend/src/pages/SubUserManagement.tsx @@ -0,0 +1,328 @@ +import { useCallback, useEffect, useState } from 'react' +import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react' +import { useDialog } from '../components/Dialog' +import api, { AuditLog, LoginLog } from '../services/api' +import { copyToClipboard } from '../utils/clipboard' + +interface SubUserItem { + id: string + username: string + container_names: string[] + container_uuids: string[] + container_name: string + container_uuid: string + access_code: string + password?: string + created_at: string + last_login: string + last_login_ip: string + last_login_ua: string +} + +interface AuditLogExt extends AuditLog { + ip?: string + user_agent?: string + success?: boolean + error?: string +} + +export default function SubUserManagement() { + const dialog = useDialog() + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [auditLogs, setAuditLogs] = useState(null) + const [loginLogs, setLoginLogs] = useState(null) + const [modalTitle, setModalTitle] = useState('') + const [passwordUser, setPasswordUser] = useState(null) + const [rotatingPassword, setRotatingPassword] = useState(false) + + const fetchUsers = useCallback(async () => { + try { + const res = await api.get<{ success: boolean; data: SubUserItem[] }>('/sub-users') + setUsers(res.data.data || []) + } catch (err) { + console.error(err) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchUsers() }, [fetchUsers]) + + const managementUrl = (user: SubUserItem) => `${window.location.origin}/login?code=${user.access_code}` + + const copyText = async (text: string) => { + await copyToClipboard(text) + } + + const rotatePassword = async (user: SubUserItem) => { + setRotatingPassword(true) + try { + const res = await api.post(`/sub-users/${user.id}/rotate-password`) + const data = res.data.data + const updatedUser = { + ...user, + username: data?.username || user.username, + access_code: data?.access_code || user.access_code, + password: data?.password || '', + } + setUsers((prev) => prev.map((item) => (item.id === user.id ? updatedUser : item))) + setPasswordUser(updatedUser) + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } } + dialog.alert('轮换失败', error.response?.data?.message || '请稍后重试') + } finally { + setRotatingPassword(false) + } + } + + const showAuditLogs = async (user: SubUserItem) => { + try { + const res = await api.get(`/sub-users/${user.id}/audit-logs`) + setAuditLogs(res.data.data || []) + setLoginLogs(null) + setModalTitle(`${user.username} - 操作日志`) + } catch { + dialog.alert('错误', '获取操作日志失败') + } + } + + const showLoginLogs = async (user: SubUserItem) => { + try { + const res = await api.get(`/sub-users/${user.id}/login-logs`) + setLoginLogs(res.data.data || []) + setAuditLogs(null) + setModalTitle(`${user.username} - 登录日志`) + } catch { + dialog.alert('错误', '获取登录日志失败') + } + } + + const closeModal = () => { + setAuditLogs(null) + setLoginLogs(null) + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+

子用户管理

+

容器分配的子用户列表,共 {users.length} 个

+
+ +
+ {users.length === 0 ? ( +
+
+ +
+
暂无子用户
+
+ ) : ( + + + + + + + + + + + + {users.map((user, index) => ( + + + + + + + + ))} + +
#容器名称UUID最后登录操作
{index + 1}{user.container_name || '-'}{user.container_uuid || '-'} + {user.last_login ? ( +
+
{user.last_login}
+
{user.last_login_ip}
+
+ ) : ( + 从未登录 + )} +
+
+ + + +
+
+ )} +
+ + {passwordUser && ( +
+
+
+

查看密码

+
+ + +
+
+
+
+
+ 用户 + {passwordUser.username} +
+
+ 地址 +
+ {managementUrl(passwordUser)} + +
+
+
+ 密码 +
+ + {passwordUser.password || '未保存,请轮换生成新密码'} + + {passwordUser.password && ( + + )} +
+
+
+
+
+
+ )} + + {/* Log Modal */} + {(auditLogs || loginLogs) && ( +
+
+
+

{modalTitle}

+ +
+
+ {auditLogs && ( + + + + + + + + + + + + {auditLogs.length === 0 ? ( + + ) : auditLogs.map((log, i) => ( + + + + + + + + ))} + +
操作时间操作IPUA结果
暂无操作日志
{log.time}{log.action}{log.ip || '-'}{log.user_agent || '-'} + {log.success !== undefined ? ( + log.success ? ( + 成功 + ) : ( + {log.error ? '失败' : '失败'} + ) + ) : ( + - + )} +
+ )} + {loginLogs && ( + + + + + + + + + + + {loginLogs.length === 0 ? ( + + ) : loginLogs.map((log, i) => ( + + + + + + + ))} + +
登录时间登录 IPUA结果
暂无登录日志
{log.time}{log.ip}{log.user_agent} + {log.success ? ( + 成功 + ) : ( + 失败 + )} +
+ )} +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6ec1ca3..59e2a83 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -197,6 +197,14 @@ export interface LoginLog { success: boolean } +export interface AuditLog { + time: string + action: string + target: string + detail: string + user: string +} + export const getLoginLogs = () => api.get>('/login-logs') diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts new file mode 100644 index 0000000..5dcc0a9 --- /dev/null +++ b/frontend/src/utils/clipboard.ts @@ -0,0 +1,44 @@ +export async function copyToClipboard(text: string): Promise { + if (!text) return false + + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // Fall through for non-secure HTTP origins where Clipboard API is blocked. + } + } + + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.top = '0' + textarea.style.left = '0' + textarea.style.width = '1px' + textarea.style.height = '1px' + textarea.style.opacity = '0' + textarea.style.pointerEvents = 'none' + + const selection = document.getSelection() + const selectedRange = selection?.rangeCount ? selection.getRangeAt(0) : null + + document.body.appendChild(textarea) + textarea.focus({ preventScroll: true }) + textarea.select() + textarea.setSelectionRange(0, textarea.value.length) + + let copied = false + try { + copied = document.execCommand('copy') + } finally { + document.body.removeChild(textarea) + if (selection && selectedRange) { + selection.removeAllRanges() + selection.addRange(selectedRange) + } + } + + return copied +}