diff --git a/backend/go.mod b/backend/go.mod index e9c67ed..0058ba0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,4 +9,4 @@ require ( golang.org/x/term v0.28.0 ) -require golang.org/x/sys v0.29.0 // indirect +require golang.org/x/sys v0.29.0 diff --git a/backend/internal/api/host.go b/backend/internal/api/host.go index 6ebc83e..5f41680 100644 --- a/backend/internal/api/host.go +++ b/backend/internal/api/host.go @@ -8,10 +8,11 @@ import ( "strconv" "strings" "sync" - "syscall" "time" "clicd/internal/lxc" + + "golang.org/x/sys/unix" ) type HostInfo struct { @@ -135,8 +136,8 @@ func getMemoryInfo() MemoryInfo { } func getDiskInfo() DiskInfo { - var stat syscall.Statfs_t - if err := syscall.Statfs("/", &stat); err != nil { + var stat unix.Statfs_t + if err := unix.Statfs("/", &stat); err != nil { // Try command-based fallback cmd := exec.Command("df", "-BG", "/") output, err := cmd.Output() diff --git a/backend/internal/api/security.go b/backend/internal/api/security.go index 9f64058..d7b9962 100644 --- a/backend/internal/api/security.go +++ b/backend/internal/api/security.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "os/exec" + "sort" "strconv" "strings" "sync" @@ -557,10 +558,10 @@ func countPorts(totalCounts map[int]int, destCounts map[int]map[string]int, port func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP string, port int, detail, logLine string) { ss.mu.Lock() - defer ss.mu.Unlock() now := time.Now() cutoff := now.Add(-5 * time.Minute) + shouldShutdown := false for i := range ss.alerts { a := &ss.alerts[i] @@ -579,6 +580,11 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri if severityRank(severity) > severityRank(a.Severity) { a.Severity = severity } + shouldShutdown = config.AppConfig.SecurityAutoShutdown + ss.mu.Unlock() + if shouldShutdown { + autoShutdownAlertContainer(name, alertType, severity) + } return } @@ -599,10 +605,16 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri ss.alerts = append(ss.alerts, alert) config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system") + shouldShutdown = config.AppConfig.SecurityAutoShutdown if len(ss.alerts) > 200 { ss.alerts = ss.alerts[len(ss.alerts)-200:] } + ss.mu.Unlock() + + if shouldShutdown { + autoShutdownAlertContainer(name, alertType, severity) + } } func severityRank(severity string) int { @@ -620,6 +632,22 @@ func severityRank(severity string) int { } } +func autoShutdownAlertContainer(containerName, alertType, severity string) { + c := config.FindContainerByName(containerName) + if c == nil || c.Status != "running" { + return + } + reason := fmt.Sprintf("%s 告警触发策略临时封禁", alertType) + if severity != "" { + reason = fmt.Sprintf("[%s] %s", severity, reason) + } + config.SetContainerPolicyBlock(c.ID, true, reason) + taskID, queued := globalQueue.EnqueueSecurityStop(c.ID, c.Name) + if queued { + config.AddAuditLog("security_auto_shutdown", c.Name, fmt.Sprintf("[%s] %s 告警触发自动关机任务 %s", severity, alertType, taskID), "system") + } +} + // HandleSecurityAlerts returns all security alerts. func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -627,19 +655,35 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) { return } - ss := ensureScanner() - ss.mu.Lock() - reversed := make([]SecurityAlert, len(ss.alerts)) - for i, a := range ss.alerts { - reversed[len(ss.alerts)-1-i] = a - } - ss.mu.Unlock() + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()}) +} - if reversed == nil { - reversed = []SecurityAlert{} +// HandleSecuritySettings returns or updates security automation settings. +func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{ + "auto_shutdown": config.AppConfig.SecurityAutoShutdown, + }}) + case http.MethodPut: + var req struct { + AutoShutdown bool `json:"auto_shutdown"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + config.AppConfig.SecurityAutoShutdown = req.AutoShutdown + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{ + "auto_shutdown": config.AppConfig.SecurityAutoShutdown, + }}) + default: + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) } - - jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed}) } // HandleSecurityCheck triggers immediate security check for a container. @@ -738,13 +782,12 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) { return } - ss := ensureScanner() - ss.mu.Lock() critical := 0 high := 0 medium := 0 low := 0 - for _, a := range ss.alerts { + alerts := mergedSecurityAlerts() + for _, a := range alerts { switch a.Severity { case "critical": critical++ @@ -756,8 +799,7 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) { low++ } } - total := len(ss.alerts) - ss.mu.Unlock() + total := len(alerts) summary := map[string]interface{}{ "total_alerts": total, @@ -769,3 +811,117 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary}) } + +func mergedSecurityAlerts() []SecurityAlert { + ss := ensureScanner() + ss.mu.Lock() + alerts := make([]SecurityAlert, len(ss.alerts)) + copy(alerts, ss.alerts) + ss.mu.Unlock() + + seen := make(map[string]bool) + for _, alert := range alerts { + seen[securityAlertKey(alert)] = true + } + + for i, log := range config.AppConfig.AuditLogs { + alert, ok := alertFromSecurityAuditLog(log, i) + if !ok { + continue + } + key := securityAlertKey(alert) + if seen[key] { + continue + } + seen[key] = true + alerts = append(alerts, alert) + } + + sort.SliceStable(alerts, func(i, j int) bool { + ti, errI := time.Parse("2006-01-02 15:04:05", alerts[i].Timestamp) + tj, errJ := time.Parse("2006-01-02 15:04:05", alerts[j].Timestamp) + if errI == nil && errJ == nil && !ti.Equal(tj) { + return ti.After(tj) + } + return alerts[i].Timestamp > alerts[j].Timestamp + }) + + if len(alerts) > 200 { + alerts = alerts[:200] + } + if alerts == nil { + return []SecurityAlert{} + } + return alerts +} + +func securityAlertKey(alert SecurityAlert) string { + return strings.Join([]string{ + alert.Timestamp, + alert.ContainerName, + alert.Type, + alert.Detail, + strconv.Itoa(alert.TargetPort), + }, "\x1f") +} + +func alertFromSecurityAuditLog(log config.AuditLog, index int) (SecurityAlert, bool) { + if !strings.HasPrefix(log.Action, "security_") || log.Action == "security_auto_shutdown" || log.Action == "security_policy_unblock" { + return SecurityAlert{}, false + } + alertType := strings.TrimPrefix(log.Action, "security_") + severity, detail := parseSecurityAuditDetail(log.Detail) + targetPort := parseDetailPort(detail) + + targetIP := "" + if targetPort > 0 || alertType == "horizontal_scan" || alertType == "brute_force" { + targetIP = "*" + } + + return SecurityAlert{ + ID: fmt.Sprintf("audit-security-%d", index), + ContainerName: log.Target, + Type: alertType, + Severity: severity, + SourceIP: "", + TargetIP: targetIP, + TargetPort: targetPort, + Detail: detail, + LogLine: "", + Timestamp: log.Time, + Count: 1, + }, true +} + +func parseSecurityAuditDetail(detail string) (string, string) { + severity := "medium" + if strings.HasPrefix(detail, "[") { + if end := strings.Index(detail, "]"); end > 1 { + severity = detail[1:end] + detail = strings.TrimSpace(detail[end+1:]) + } + } + return severity, detail +} + +func parseDetailPort(detail string) int { + for _, marker := range []string{"端口 ", "端口"} { + idx := strings.Index(detail, marker) + if idx == -1 { + continue + } + start := idx + len(marker) + for start < len(detail) && (detail[start] == ' ' || detail[start] == ':' || detail[start] == '(') { + start++ + } + end := start + for end < len(detail) && detail[end] >= '0' && detail[end] <= '9' { + end++ + } + if end > start { + port, _ := strconv.Atoi(detail[start:end]) + return port + } + } + return 0 +} diff --git a/backend/internal/api/ssh.go b/backend/internal/api/ssh.go index d439369..8136233 100644 --- a/backend/internal/api/ssh.go +++ b/backend/internal/api/ssh.go @@ -27,6 +27,7 @@ type terminalResizeMessage struct { type webSSHTicket struct { ContainerName string + SubUser bool ExpiresAt time.Time } @@ -52,16 +53,22 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) return } - if config.FindContainerByName(req.ContainerName) == nil { + c := config.FindContainerByName(req.ContainerName) + if c == nil { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) return } + if isSubUserRequest(r) && c.PolicyBlocked { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)}) + return + } ticket := randomHex(32) webSSHTickets.Lock() cleanupExpiredWebSSHTicketsLocked(time.Now()) webSSHTickets.items[ticket] = webSSHTicket{ ContainerName: req.ContainerName, + SubUser: isSubUserRequest(r), ExpiresAt: time.Now().Add(60 * time.Second), } webSSHTickets.Unlock() @@ -86,7 +93,8 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) { return } - if !consumeWebSSHTicket(ticket, containerName) { + item, ok := consumeWebSSHTicket(ticket, containerName) + if !ok { http.Error(w, "invalid or expired ticket", http.StatusUnauthorized) return } @@ -96,6 +104,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) { http.Error(w, "container not found", http.StatusNotFound) return } + if item.SubUser && c.PolicyBlocked { + http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden) + return + } if c.Status != "running" { http.Error(w, "container is not running", http.StatusBadRequest) return @@ -359,17 +371,17 @@ func writeWebSocketText(ws *websocket.Conn, writeMu *sync.Mutex, msg string) { _ = ws.WriteMessage(websocket.TextMessage, []byte(msg)) } -func consumeWebSSHTicket(ticket, containerName string) bool { +func consumeWebSSHTicket(ticket, containerName string) (webSSHTicket, bool) { now := time.Now() webSSHTickets.Lock() defer webSSHTickets.Unlock() cleanupExpiredWebSSHTicketsLocked(now) item, ok := webSSHTickets.items[ticket] if !ok { - return false + return webSSHTicket{}, false } delete(webSSHTickets.items, ticket) - return item.ContainerName == containerName && now.Before(item.ExpiresAt) + return item, item.ContainerName == containerName && now.Before(item.ExpiresAt) } func cleanupExpiredWebSSHTicketsLocked(now time.Time) { diff --git a/backend/internal/api/subuser.go b/backend/internal/api/subuser.go index 29108bb..a6ee4b9 100644 --- a/backend/internal/api/subuser.go +++ b/backend/internal/api/subuser.go @@ -352,7 +352,11 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc { } action := "" if len(parts) > 1 { - action = parts[1] + action = strings.Join(parts[1:], "/") + } + if c.PolicyBlocked && isSubUserBlockedAction(action, r.Method) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)}) + return } if !isSubUserContainerActionAllowed(action, r.Method) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"}) @@ -412,6 +416,25 @@ func isContainerAllowed(allowed subUserAccess, c *config.Container) bool { return c != nil && c.UUID != "" && allowed.uuids[c.UUID] } +func isSubUserBlockedAction(action string, method string) bool { + if action == "" { + return method != http.MethodGet + } + switch action { + case "usage", "traffic": + return method != http.MethodGet + default: + return true + } +} + +func policyBlockedMessage(c *config.Container) string { + if c != nil && c.PolicyBlockedReason != "" { + return "虚拟机被策略临时封禁:" + c.PolicyBlockedReason + } + return "虚拟机被策略临时封禁" +} + func isSubUserContainerActionAllowed(action string, method string) bool { if action == "" { return method == http.MethodGet diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index 87e3751..5694c1a 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -196,6 +196,24 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string return task.ID } +func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) { + q.mu.Lock() + defer q.mu.Unlock() + + for _, task := range q.tasks { + if task.Type != TaskStop || task.ContainerID != containerID { + continue + } + if task.Status == "pending" || task.Status == "running" { + return task.ID, false + } + } + + taskID := q.enqueueSingleWithAudit(containerID, containerName, TaskStop, "", "system:security", "", "") + q.persistTasks() + return taskID, true +} + // createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init. // If a restored task already has a same-name container in config, it resumes // initialization instead of creating another ct-{id}. @@ -337,10 +355,14 @@ func (q *TaskQueue) opWorker() { switch task.Type { case TaskStart: config.UpdateContainerStatus(task.ContainerID, "running") + clearPolicyBlockAfterAdminRecovery(task) case TaskStop: config.UpdateContainerStatus(task.ContainerID, "stopped") case TaskRestart: config.UpdateContainerStatus(task.ContainerID, "running") + clearPolicyBlockAfterAdminRecovery(task) + case TaskReinstall: + clearPolicyBlockAfterAdminRecovery(task) } } q.persistTasks() @@ -348,6 +370,17 @@ func (q *TaskQueue) opWorker() { } } +func clearPolicyBlockAfterAdminRecovery(task *Task) { + if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" { + return + } + c := config.FindContainer(task.ContainerID) + if c != nil && c.PolicyBlocked { + config.SetContainerPolicyBlock(c.ID, false, "") + config.AddAuditLog("security_policy_unblock", c.Name, "管理员操作后解除策略临时封禁", task.User) + } +} + func resolveTaskContainer(task *Task) error { if task.Type == TaskCreate { return nil diff --git a/backend/internal/api/vnc.go b/backend/internal/api/vnc.go index e6e68a0..0409522 100644 --- a/backend/internal/api/vnc.go +++ b/backend/internal/api/vnc.go @@ -18,6 +18,7 @@ import ( type webVNCTicket struct { ContainerName string ContainerUUID string + SubUser bool ExpiresAt time.Time } @@ -48,6 +49,10 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) return } + if isSubUserRequest(r) && c.PolicyBlocked { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)}) + return + } if !c.IsKVM() { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "VNC console is only available for KVM VMs"}) return @@ -59,6 +64,7 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) { webVNCTickets.items[ticket] = webVNCTicket{ ContainerName: c.Name, ContainerUUID: c.UUID, + SubUser: isSubUserRequest(r), ExpiresAt: time.Now().Add(60 * time.Second), } webVNCTickets.Unlock() @@ -94,6 +100,10 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) { http.Error(w, "container not found", http.StatusNotFound) return } + if item.SubUser && c.PolicyBlocked { + http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden) + return + } if !c.IsKVM() { http.Error(w, "VNC console is only available for KVM VMs", http.StatusBadRequest) return diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 57bc082..dcff610 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -108,6 +108,9 @@ type Container struct { SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"` SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"` SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"` + PolicyBlocked bool `json:"policy_blocked"` + PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"` + PolicyBlockedAt string `json:"policy_blocked_at,omitempty"` } const ( @@ -198,23 +201,24 @@ type Snapshot struct { // ClicdConfig is the main configuration structure type ClicdConfig struct { - AdminUser string `json:"admin_user"` - AdminPassHash string `json:"admin_pass_hash"` - JWTSecret string `json:"jwt_secret"` - Port int `json:"port"` - DataDir string `json:"data_dir"` - Containers []Container `json:"containers"` - NextContainerID int `json:"next_container_id"` - NextVNCPort int `json:"next_vnc_port"` - NextSSHPort int `json:"next_ssh_port"` - SetupComplete bool `json:"setup_complete"` - SubUsers []SubUser `json:"sub_users"` - ApiKeys []ApiKeyConfig `json:"api_keys"` - AuditLogs []AuditLog `json:"audit_logs"` - Tasks []SavedTask `json:"tasks"` - LoginLogs []SavedLoginLog `json:"login_logs"` - EnabledImages []string `json:"enabled_images"` - Snapshots []Snapshot `json:"snapshots"` + AdminUser string `json:"admin_user"` + AdminPassHash string `json:"admin_pass_hash"` + JWTSecret string `json:"jwt_secret"` + Port int `json:"port"` + DataDir string `json:"data_dir"` + Containers []Container `json:"containers"` + NextContainerID int `json:"next_container_id"` + NextVNCPort int `json:"next_vnc_port"` + NextSSHPort int `json:"next_ssh_port"` + SetupComplete bool `json:"setup_complete"` + SubUsers []SubUser `json:"sub_users"` + ApiKeys []ApiKeyConfig `json:"api_keys"` + AuditLogs []AuditLog `json:"audit_logs"` + Tasks []SavedTask `json:"tasks"` + LoginLogs []SavedLoginLog `json:"login_logs"` + EnabledImages []string `json:"enabled_images"` + Snapshots []Snapshot `json:"snapshots"` + SecurityAutoShutdown bool `json:"security_auto_shutdown"` } var configPath string @@ -308,7 +312,7 @@ func InitConfig() (*ClicdConfig, error) { AuditLogs: []AuditLog{}, Tasks: []SavedTask{}, LoginLogs: []SavedLoginLog{}, - Snapshots: []Snapshot{}, + Snapshots: []Snapshot{}, } if err := SaveConfig(); err != nil { @@ -717,6 +721,22 @@ func UpdateContainerStatus(id int, status string) { } } +func SetContainerPolicyBlock(id int, blocked bool, reason string) { + c := FindContainer(id) + if c == nil { + return + } + c.PolicyBlocked = blocked + if blocked { + c.PolicyBlockedReason = reason + c.PolicyBlockedAt = time.Now().Format("2006-01-02 15:04:05") + } else { + c.PolicyBlockedReason = "" + c.PolicyBlockedAt = "" + } + SaveConfig() +} + // UpdateVNC refreshes all container statuses func UpdateVNC(containers []Container) { AppConfig.Containers = containers diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 5cf537a..0cf2428 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -15,9 +15,10 @@ import ( "strconv" "strings" "sync" - "syscall" "time" + "golang.org/x/sys/unix" + "clicd/internal/config" ) @@ -1004,7 +1005,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { if err != nil { return err } - rootStat, ok := rootInfo.Sys().(*syscall.Stat_t) + rootStat, ok := rootInfo.Sys().(*unix.Stat_t) if !ok { return fmt.Errorf("failed to read rootfs device for %s", rootfsPath) } @@ -1018,7 +1019,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { if err != nil { return err } - stat, ok := info.Sys().(*syscall.Stat_t) + stat, ok := info.Sys().(*unix.Stat_t) if !ok { return fmt.Errorf("failed to read uid/gid for %s", path) } @@ -1039,7 +1040,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { if gid >= 0 && gid < 65536 { gid += gidBase } - return syscall.Lchown(path, uid, gid) + return unix.Lchown(path, uid, gid) }); err != nil { return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err) } @@ -1047,7 +1048,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil { return err } - if err := syscall.Lchown(marker, uidBase, gidBase); err != nil { + if err := unix.Lchown(marker, uidBase, gidBase); err != nil { return err } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 34c8766..979bff3 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -102,6 +102,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck))) mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs))) mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary))) + mux.HandleFunc("/api/security/settings", corsMiddleware(api.AdminMiddleware(api.HandleSecuritySettings))) mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket))) mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket))) diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index add997a..7e02156 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { ArrowLeft, + AlertTriangle, Camera, Clock, Copy, @@ -299,8 +300,17 @@ export default function ContainerDetail() { start: '开机中...', stop: '关机中...', restart: '重启中...', delete: '删除中...', reinstall: '重装中...', } + const ensureSubUserCanOperate = async () => { + if (isSubUser && container?.policy_blocked) { + await dialog.alert('策略临时封禁', container.policy_blocked_reason || '虚拟机被策略临时封禁,暂不能执行操作。') + return false + } + return true + } + const handleAction = async (action: string) => { if (!containerIdentifier) return + if (!(await ensureSubUserCanOperate())) return setActionLoading(action) try { switch (action) { @@ -462,11 +472,13 @@ export default function ContainerDetail() { } const openAddMapping = () => { + if (isSubUser && container?.policy_blocked) return setDraft(emptyDraft) setShowNat(true) } const openEditMapping = (pm: PortMapping, index: number) => { + if (isSubUser && container?.policy_blocked) return if (isSubUser) { // Sub-user: only edit container_port in a simple modal setDraft({ @@ -489,6 +501,7 @@ export default function ContainerDetail() { const submitMapping = async (): Promise => { if (!containerIdentifier) return false + if (!(await ensureSubUserCanOperate())) return false if (draft.index === null && container) { const currentCount = container.port_mappings?.length || 0 const limit = container.port_mapping_limit || Math.max(currentCount, 2) @@ -541,6 +554,7 @@ export default function ContainerDetail() { const removeMapping = async (index: number) => { if (!containerIdentifier || !(await dialog.confirm('删除映射', '确定要删除这条映射规则吗?'))) return + if (!(await ensureSubUserCanOperate())) return try { await deletePortMapping(containerIdentifier, index) await fetchContainer() @@ -553,6 +567,7 @@ export default function ContainerDetail() { const handleCreateSnapshot = async () => { if (!containerIdentifier) return + if (!(await ensureSubUserCanOperate())) return if (isSubUser && snapshots.length >= snapshotQuota) { await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。') return @@ -577,6 +592,7 @@ export default function ContainerDetail() { } const openSnapshotSchedule = () => { + if (isSubUser && container?.policy_blocked) return setSnapshotScheduleDraft({ intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24), time: snapshotSchedule?.time || '03:00', @@ -586,6 +602,7 @@ export default function ContainerDetail() { const saveSnapshotSchedule = async (enabled: boolean) => { if (!containerIdentifier) return + if (!(await ensureSubUserCanOperate())) return const intervalHours = snapshotScheduleDraft.intervalHours const scheduleTime = snapshotScheduleDraft.time || '03:00' if (enabled && intervalHours < 24) { @@ -625,6 +642,7 @@ export default function ContainerDetail() { const handleDeleteSnapshot = async (snapshot: Snapshot) => { if (!containerIdentifier) return + if (!(await ensureSubUserCanOperate())) return if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return setSnapshotBusy(snapshot.id) try { @@ -640,6 +658,7 @@ export default function ContainerDetail() { const handleRestoreSnapshot = async (snapshot: Snapshot) => { if (!containerIdentifier) return + if (!(await ensureSubUserCanOperate())) return if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return setSnapshotBusy(snapshot.id) try { @@ -681,6 +700,9 @@ export default function ContainerDetail() { const isWindows = container.template?.includes('windows') const canOpenVNC = isKVM && isRunning const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false + const isPolicyBlocked = !!container.policy_blocked + const isSubUserPolicyBlocked = isSubUser && isPolicyBlocked + const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁' const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST const maxVCPU = hostInfo?.cpu.cores || 64 const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined @@ -703,7 +725,7 @@ export default function ContainerDetail() { const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0) const mappingCount = container.port_mappings?.length || 0 const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2) - const canAddMapping = isSubUser ? mappingCount < mappingLimit : true + const canAddMapping = isSubUser ? mappingCount < mappingLimit && !isSubUserPolicyBlocked : true const managementUrl = subUser?.access_code ? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}` : '' @@ -774,34 +796,35 @@ export default function ContainerDetail() { 内网 {container.ip || '-'} NAT {mappingCount} 条 {isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port} + {isPolicyBlocked && 策略封禁}
{!isRunning ? ( - handleAction('start')}> + handleAction('start')}> - {isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'} + {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'} ) : ( <> - handleAction('stop')}> + handleAction('stop')}> - {isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'} + {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'} - handleAction('restart')}> + handleAction('restart')}> - {isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'} + {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'} {!isWindows && ( - setShowSSH(true)}> + setShowSSH(true)}> WebSSH )} {isKVM && ( - setShowVNC(true)}> + setShowVNC(true)}> WebVNC @@ -815,12 +838,12 @@ export default function ContainerDetail() { )} <> - setShowNat(true)}> + setShowNat(true)}> NAT 管理 - setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}> + setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}> 快照 @@ -840,9 +863,23 @@ export default function ContainerDetail() {
+ {isSubUserPolicyBlocked && ( +
+ +
+
虚拟机被策略临时封禁
+
{policyBlockedText}
+
+
+ )} +
- {isWindows ? ( + {isSubUserPolicyBlocked ? ( +
+ 虚拟机被策略临时封禁,连接信息暂不可用。 +
+ ) : isWindows ? ( <> @@ -1507,13 +1544,14 @@ function StatusBadge({ running }: { running: boolean }) { ) } -function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate'; children: ReactNode }) { +function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate' | 'red'; children: ReactNode }) { const classes = { blue: 'bg-blue-50 text-blue-700 border-blue-100', emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100', amber: 'bg-amber-50 text-amber-700 border-amber-100', violet: 'bg-violet-50 text-violet-700 border-violet-100', slate: 'bg-slate-50 text-slate-700 border-slate-100', + red: 'bg-red-50 text-red-700 border-red-100', } return {children} } diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index f39bda4..d7ce1e4 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -392,6 +392,7 @@ export default function Containers() { const isRunning = container.status === 'running' const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask const isPlaceholder = !!container.isPlaceholder + const isPolicyBlocked = !!container.policy_blocked const usage = usageByName[container.name] const isKVM = (container.virtualization || 'lxc') === 'kvm' @@ -436,7 +437,7 @@ export default function Containers() { - + @@ -580,8 +581,17 @@ type DisplayContainer = Container & { createTask?: Task } -function StatusBadge({ running, task, placeholder }: { running: boolean; task?: Task; placeholder?: boolean }) { +function StatusBadge({ running, task, placeholder, policyBlocked }: { running: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) { const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap" + if (policyBlocked) { + return ( + + + 策略封禁 + + ) + } + if (task?.status === 'failed') { return ( diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx index a1d4ded..06456a2 100644 --- a/frontend/src/pages/Security.tsx +++ b/frontend/src/pages/Security.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' -import { RefreshCw } from 'lucide-react' -import { getSecurityAlerts, SecurityAlert } from '../services/api' +import { FileText, Power, RefreshCw, X } from 'lucide-react' +import { getSecurityAlerts, getSecurityLogs, getSecuritySettings, SecurityAlert, SecurityLog, updateSecuritySettings } from '../services/api' const typeLabels: Record = { port_scan: '端口扫描', @@ -23,12 +23,18 @@ const severityLabels: Record = { export default function Security() { const [alerts, setAlerts] = useState([]) + const [autoShutdown, setAutoShutdown] = useState(false) const [loading, setLoading] = useState(true) + const [savingSettings, setSavingSettings] = useState(false) + const [logAlert, setLogAlert] = useState(null) + const [logs, setLogs] = useState([]) + const [logsLoading, setLogsLoading] = useState(false) const fetchData = useCallback(async () => { try { - const alertRes = await getSecurityAlerts() + const [alertRes, settingsRes] = await Promise.all([getSecurityAlerts(), getSecuritySettings()]) if (alertRes.data.data) setAlerts(alertRes.data.data) + if (settingsRes.data.data) setAutoShutdown(settingsRes.data.data.auto_shutdown) } catch (err) { console.error(err) } finally { @@ -42,6 +48,36 @@ export default function Security() { return () => clearInterval(interval) }, [fetchData]) + const handleAutoShutdownChange = async () => { + const next = !autoShutdown + setAutoShutdown(next) + setSavingSettings(true) + try { + const res = await updateSecuritySettings({ auto_shutdown: next }) + if (res.data.data) setAutoShutdown(res.data.data.auto_shutdown) + } catch (err) { + console.error(err) + setAutoShutdown(!next) + } finally { + setSavingSettings(false) + } + } + + const openLogs = async (alert: SecurityAlert) => { + setLogAlert(alert) + setLogs([]) + setLogsLoading(true) + try { + const res = await getSecurityLogs(alert.container_name) + setLogs(filterRelatedLogs(res.data.data || [], alert)) + } catch (err) { + console.error(err) + setLogs([]) + } finally { + setLogsLoading(false) + } + } + if (loading) { return (
@@ -52,15 +88,33 @@ export default function Security() { return (
-
+

安全告警

- +
+ + +
@@ -93,12 +147,24 @@ export default function Security() { {typeLabels[alert.type] || alert.type} {alert.container_name} - {alert.source_ip} + {alert.source_ip || '-'} {formatTarget(alert)} {alert.count} - {alert.detail} + +
+ {alert.detail} + +
+ ))} @@ -106,6 +172,69 @@ export default function Security() {
)}
+ + {logAlert && ( +
+
+
+
+

相关连接记录

+

+ {logAlert.container_name} · {typeLabels[logAlert.type] || logAlert.type} · {formatTarget(logAlert)} +

+
+ +
+ +
+ {logAlert.log_line && ( +
+
告警原始记录
+
{logAlert.log_line}
+
+ )} + {logsLoading ? ( +
正在加载连接记录...
+ ) : logs.length === 0 ? ( +
+ 暂无可用连接记录。历史告警对应的 conntrack 记录可能已经过期。 +
+ ) : ( + + + + + + + + + + + {logs.map((log, index) => ( + + + + + + + ))} + +
协议状态源地址目标地址
{log.protocol || '-'}{log.state || '-'} + {formatEndpoint(log.src_ip, log.src_port)} + + {formatEndpoint(log.dst_ip, log.dst_port)} +
+ )} +
+
+
+ )}
) } @@ -129,3 +258,17 @@ function formatTarget(alert: SecurityAlert): string { if (!alert.target_ip) return '-' return alert.target_port > 0 ? `${alert.target_ip}:${alert.target_port}` : alert.target_ip } + +function filterRelatedLogs(logs: SecurityLog[], alert: SecurityAlert): SecurityLog[] { + return logs.filter((log) => { + if (alert.source_ip && log.src_ip !== alert.source_ip) return false + if (alert.target_ip && alert.target_ip !== '*' && log.dst_ip !== alert.target_ip) return false + if (alert.target_port > 0 && log.dst_port !== alert.target_port) return false + return true + }) +} + +function formatEndpoint(ip: string, port: number): string { + if (!ip) return '-' + return port > 0 ? `${ip}:${port}` : ip +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index c06c954..1e457fe 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -81,6 +81,9 @@ export interface Container { snapshot_schedule_last_run: string snapshot_schedule_next_run: string snapshot_schedule_created_by: string + policy_blocked?: boolean + policy_blocked_reason?: string + policy_blocked_at?: string } export interface Template { @@ -524,6 +527,19 @@ export interface SecuritySummary { low: number } +export interface SecuritySettings { + auto_shutdown: boolean +} + +export interface SecurityLog { + src_ip: string + dst_ip: string + src_port: number + dst_port: number + protocol: string + state: string +} + export const getSecurityAlerts = () => api.get>('/security/alerts') @@ -531,11 +547,17 @@ export const checkContainerSecurity = (containerName: string) => api.post('/security/check', { container_name: containerName }) export const getSecurityLogs = (containerName: string) => - api.get('/security/logs', { params: { container: containerName } }) + api.get>('/security/logs', { params: { container: containerName } }) export const getSecuritySummary = () => api.get>('/security/summary') +export const getSecuritySettings = () => + api.get>('/security/settings') + +export const updateSecuritySettings = (data: SecuritySettings) => + api.put>('/security/settings', data) + export const createWebSSHTicket = (containerName: string) => api.post>('/ssh-ticket', { container_name: containerName })