修复了一些已知问题

This commit is contained in:
MengMengCode
2026-06-07 20:16:00 +08:00
parent 08a1a057e7
commit 2ad17fa520
14 changed files with 549 additions and 79 deletions
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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()
+173 -17
View File
@@ -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
}
+17 -5
View File
@@ -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) {
+24 -1
View File
@@ -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
+33
View File
@@ -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
+10
View File
@@ -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
+38 -18
View File
@@ -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
+6 -5
View File
@@ -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
}
+1
View File
@@ -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)))
+51 -13
View File
@@ -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<boolean> => {
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() {
<InfoTag color="emerald"> {container.ip || '-'}</InfoTag>
<InfoTag color="amber">NAT {mappingCount} </InfoTag>
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
{isPolicyBlocked && <InfoTag color="red"></InfoTag>}
</div>
</div>
</div>
<div className="flex items-center gap-1.5 flex-wrap justify-end">
{!isRunning ? (
<ActionButton dark disabled={!!taskStatus || isExpired} onClick={() => handleAction('start')}>
<ActionButton dark disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('start')}>
<Play className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'}
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'}
</ActionButton>
) : (
<>
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('stop')}>
<ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('stop')}>
<Square className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'}
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'}
</ActionButton>
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('restart')}>
<ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('restart')}>
<RefreshCw className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
</ActionButton>
{!isWindows && (
<ActionButton dark onClick={() => setShowSSH(true)}>
<ActionButton dark disabled={isSubUserPolicyBlocked} onClick={() => setShowSSH(true)}>
<TerminalSquare className="w-3.5 h-3.5" />
WebSSH
</ActionButton>
)}
{isKVM && (
<ActionButton dark disabled={!canOpenVNC} onClick={() => setShowVNC(true)}>
<ActionButton dark disabled={!canOpenVNC || isSubUserPolicyBlocked} onClick={() => setShowVNC(true)}>
<Monitor className="w-3.5 h-3.5" />
WebVNC
</ActionButton>
@@ -815,12 +838,12 @@ export default function ContainerDetail() {
</ActionButton>
)}
<>
<ActionButton onClick={() => setShowNat(true)}>
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
<Settings className="w-3.5 h-3.5" />
NAT
</ActionButton>
</>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
<Camera className="w-3.5 h-3.5" />
</ActionButton>
@@ -840,9 +863,23 @@ export default function ContainerDetail() {
</div>
</div>
{isSubUserPolicyBlocked && (
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium"></div>
<div className="mt-1 text-xs text-red-600">{policyBlockedText}</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<Panel title="连接信息">
{isWindows ? (
{isSubUserPolicyBlocked ? (
<div className="rounded-md border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
</div>
) : isWindows ? (
<>
<PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
<PlainRow label="用户名" value="Administrator" mono />
@@ -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 <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span>
}
+12 -2
View File
@@ -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() {
</button>
</td>
<td className="px-2.5 py-2 align-top">
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} />
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
</td>
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
<span className="inline-flex items-center gap-1">
@@ -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 (
<span className={`${baseClass} bg-red-50 text-red-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
</span>
)
}
if (task?.status === 'failed') {
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
+156 -13
View File
@@ -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<string, string> = {
port_scan: '端口扫描',
@@ -23,12 +23,18 @@ const severityLabels: Record<string, string> = {
export default function Security() {
const [alerts, setAlerts] = useState<SecurityAlert[]>([])
const [autoShutdown, setAutoShutdown] = useState(false)
const [loading, setLoading] = useState(true)
const [savingSettings, setSavingSettings] = useState(false)
const [logAlert, setLogAlert] = useState<SecurityAlert | null>(null)
const [logs, setLogs] = useState<SecurityLog[]>([])
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 (
<div className="flex items-center justify-center py-20">
@@ -52,15 +88,33 @@ export default function Security() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-xl font-semibold text-black"></h1>
<button
onClick={fetchData}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
>
<RefreshCw className="w-4 h-4" />
</button>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
role="switch"
aria-checked={autoShutdown}
onClick={handleAutoShutdownChange}
disabled={savingSettings}
title="告警自动关机"
className={`inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition-colors disabled:opacity-60 ${
autoShutdown
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<Power className="w-4 h-4" />
<span>{autoShutdown ? '自动关机已开' : '自动关机已关'}</span>
</button>
<button
onClick={fetchData}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
@@ -93,12 +147,24 @@ export default function Security() {
</td>
<td className="px-4 py-2.5 text-gray-800 whitespace-nowrap">{typeLabels[alert.type] || alert.type}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 whitespace-nowrap">{alert.container_name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">
{formatTarget(alert)}
</td>
<td className="px-4 py-2.5 text-gray-600 whitespace-nowrap">{alert.count}</td>
<td className="px-4 py-2.5 text-gray-600 min-w-[260px]">{alert.detail}</td>
<td className="px-4 py-2.5 text-gray-600 min-w-[300px]">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1">{alert.detail}</span>
<button
onClick={() => openLogs(alert)}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50"
title="查看相关记录"
>
<FileText className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
@@ -106,6 +172,69 @@ export default function Security() {
</div>
)}
</div>
{logAlert && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-4xl overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-start justify-between gap-3 border-b border-gray-200 px-4 py-3">
<div>
<h3 className="text-sm font-semibold text-black"></h3>
<p className="mt-1 text-xs text-gray-500">
{logAlert.container_name} · {typeLabels[logAlert.type] || logAlert.type} · {formatTarget(logAlert)}
</p>
</div>
<button
onClick={() => setLogAlert(null)}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[70vh] overflow-auto">
{logAlert.log_line && (
<div className="border-b border-gray-100 bg-gray-50 px-4 py-3">
<div className="mb-1 text-xs font-medium text-gray-600"></div>
<pre className="whitespace-pre-wrap break-all rounded border border-gray-200 bg-white p-3 text-xs text-gray-700">{logAlert.log_line}</pre>
</div>
)}
{logsLoading ? (
<div className="p-8 text-center text-sm text-gray-500">...</div>
) : logs.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500">
conntrack
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{logs.map((log, index) => (
<tr key={`${log.src_ip}-${log.src_port}-${log.dst_ip}-${log.dst_port}-${index}`}>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700">{log.protocol || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700">{log.state || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600">
{formatEndpoint(log.src_ip, log.src_port)}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600">
{formatEndpoint(log.dst_ip, log.dst_port)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
</div>
)
}
@@ -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
}
+23 -1
View File
@@ -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<APIResponse<SecurityAlert[]>>('/security/alerts')
@@ -531,11 +547,17 @@ export const checkContainerSecurity = (containerName: string) =>
api.post<APIResponse>('/security/check', { container_name: containerName })
export const getSecurityLogs = (containerName: string) =>
api.get<APIResponse>('/security/logs', { params: { container: containerName } })
api.get<APIResponse<SecurityLog[]>>('/security/logs', { params: { container: containerName } })
export const getSecuritySummary = () =>
api.get<APIResponse<SecuritySummary>>('/security/summary')
export const getSecuritySettings = () =>
api.get<APIResponse<SecuritySettings>>('/security/settings')
export const updateSecuritySettings = (data: SecuritySettings) =>
api.put<APIResponse<SecuritySettings>>('/security/settings', data)
export const createWebSSHTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })