mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
修复了一些已知问题
This commit is contained in:
+1
-1
@@ -9,4 +9,4 @@ require (
|
|||||||
golang.org/x/term v0.28.0
|
golang.org/x/term v0.28.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.29.0 // indirect
|
require golang.org/x/sys v0.29.0
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"clicd/internal/lxc"
|
"clicd/internal/lxc"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HostInfo struct {
|
type HostInfo struct {
|
||||||
@@ -135,8 +136,8 @@ func getMemoryInfo() MemoryInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getDiskInfo() DiskInfo {
|
func getDiskInfo() DiskInfo {
|
||||||
var stat syscall.Statfs_t
|
var stat unix.Statfs_t
|
||||||
if err := syscall.Statfs("/", &stat); err != nil {
|
if err := unix.Statfs("/", &stat); err != nil {
|
||||||
// Try command-based fallback
|
// Try command-based fallback
|
||||||
cmd := exec.Command("df", "-BG", "/")
|
cmd := exec.Command("df", "-BG", "/")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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) {
|
func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP string, port int, detail, logLine string) {
|
||||||
ss.mu.Lock()
|
ss.mu.Lock()
|
||||||
defer ss.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
cutoff := now.Add(-5 * time.Minute)
|
cutoff := now.Add(-5 * time.Minute)
|
||||||
|
shouldShutdown := false
|
||||||
|
|
||||||
for i := range ss.alerts {
|
for i := range ss.alerts {
|
||||||
a := &ss.alerts[i]
|
a := &ss.alerts[i]
|
||||||
@@ -579,6 +580,11 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
|
|||||||
if severityRank(severity) > severityRank(a.Severity) {
|
if severityRank(severity) > severityRank(a.Severity) {
|
||||||
a.Severity = severity
|
a.Severity = severity
|
||||||
}
|
}
|
||||||
|
shouldShutdown = config.AppConfig.SecurityAutoShutdown
|
||||||
|
ss.mu.Unlock()
|
||||||
|
if shouldShutdown {
|
||||||
|
autoShutdownAlertContainer(name, alertType, severity)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,10 +605,16 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
|
|||||||
|
|
||||||
ss.alerts = append(ss.alerts, alert)
|
ss.alerts = append(ss.alerts, alert)
|
||||||
config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system")
|
config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system")
|
||||||
|
shouldShutdown = config.AppConfig.SecurityAutoShutdown
|
||||||
|
|
||||||
if len(ss.alerts) > 200 {
|
if len(ss.alerts) > 200 {
|
||||||
ss.alerts = ss.alerts[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 {
|
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.
|
// HandleSecurityAlerts returns all security alerts.
|
||||||
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
@@ -627,19 +655,35 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ss := ensureScanner()
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()})
|
||||||
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()
|
|
||||||
|
|
||||||
if reversed == nil {
|
// HandleSecuritySettings returns or updates security automation settings.
|
||||||
reversed = []SecurityAlert{}
|
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.
|
// HandleSecurityCheck triggers immediate security check for a container.
|
||||||
@@ -738,13 +782,12 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ss := ensureScanner()
|
|
||||||
ss.mu.Lock()
|
|
||||||
critical := 0
|
critical := 0
|
||||||
high := 0
|
high := 0
|
||||||
medium := 0
|
medium := 0
|
||||||
low := 0
|
low := 0
|
||||||
for _, a := range ss.alerts {
|
alerts := mergedSecurityAlerts()
|
||||||
|
for _, a := range alerts {
|
||||||
switch a.Severity {
|
switch a.Severity {
|
||||||
case "critical":
|
case "critical":
|
||||||
critical++
|
critical++
|
||||||
@@ -756,8 +799,7 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
low++
|
low++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
total := len(ss.alerts)
|
total := len(alerts)
|
||||||
ss.mu.Unlock()
|
|
||||||
|
|
||||||
summary := map[string]interface{}{
|
summary := map[string]interface{}{
|
||||||
"total_alerts": total,
|
"total_alerts": total,
|
||||||
@@ -769,3 +811,117 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type terminalResizeMessage struct {
|
|||||||
|
|
||||||
type webSSHTicket struct {
|
type webSSHTicket struct {
|
||||||
ContainerName string
|
ContainerName string
|
||||||
|
SubUser bool
|
||||||
ExpiresAt time.Time
|
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"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||||
return
|
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"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if isSubUserRequest(r) && c.PolicyBlocked {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ticket := randomHex(32)
|
ticket := randomHex(32)
|
||||||
webSSHTickets.Lock()
|
webSSHTickets.Lock()
|
||||||
cleanupExpiredWebSSHTicketsLocked(time.Now())
|
cleanupExpiredWebSSHTicketsLocked(time.Now())
|
||||||
webSSHTickets.items[ticket] = webSSHTicket{
|
webSSHTickets.items[ticket] = webSSHTicket{
|
||||||
ContainerName: req.ContainerName,
|
ContainerName: req.ContainerName,
|
||||||
|
SubUser: isSubUserRequest(r),
|
||||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||||
}
|
}
|
||||||
webSSHTickets.Unlock()
|
webSSHTickets.Unlock()
|
||||||
@@ -86,7 +93,8 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !consumeWebSSHTicket(ticket, containerName) {
|
item, ok := consumeWebSSHTicket(ticket, containerName)
|
||||||
|
if !ok {
|
||||||
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -96,6 +104,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "container not found", http.StatusNotFound)
|
http.Error(w, "container not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if item.SubUser && c.PolicyBlocked {
|
||||||
|
http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
if c.Status != "running" {
|
if c.Status != "running" {
|
||||||
http.Error(w, "container is not running", http.StatusBadRequest)
|
http.Error(w, "container is not running", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -359,17 +371,17 @@ func writeWebSocketText(ws *websocket.Conn, writeMu *sync.Mutex, msg string) {
|
|||||||
_ = ws.WriteMessage(websocket.TextMessage, []byte(msg))
|
_ = ws.WriteMessage(websocket.TextMessage, []byte(msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
func consumeWebSSHTicket(ticket, containerName string) bool {
|
func consumeWebSSHTicket(ticket, containerName string) (webSSHTicket, bool) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
webSSHTickets.Lock()
|
webSSHTickets.Lock()
|
||||||
defer webSSHTickets.Unlock()
|
defer webSSHTickets.Unlock()
|
||||||
cleanupExpiredWebSSHTicketsLocked(now)
|
cleanupExpiredWebSSHTicketsLocked(now)
|
||||||
item, ok := webSSHTickets.items[ticket]
|
item, ok := webSSHTickets.items[ticket]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return webSSHTicket{}, false
|
||||||
}
|
}
|
||||||
delete(webSSHTickets.items, ticket)
|
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) {
|
func cleanupExpiredWebSSHTicketsLocked(now time.Time) {
|
||||||
|
|||||||
@@ -352,7 +352,11 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
action := ""
|
action := ""
|
||||||
if len(parts) > 1 {
|
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) {
|
if !isSubUserContainerActionAllowed(action, r.Method) {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"})
|
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]
|
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 {
|
func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||||
if action == "" {
|
if action == "" {
|
||||||
return method == http.MethodGet
|
return method == http.MethodGet
|
||||||
|
|||||||
@@ -196,6 +196,24 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
|
|||||||
return task.ID
|
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.
|
// 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
|
// If a restored task already has a same-name container in config, it resumes
|
||||||
// initialization instead of creating another ct-{id}.
|
// initialization instead of creating another ct-{id}.
|
||||||
@@ -337,10 +355,14 @@ func (q *TaskQueue) opWorker() {
|
|||||||
switch task.Type {
|
switch task.Type {
|
||||||
case TaskStart:
|
case TaskStart:
|
||||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||||
|
clearPolicyBlockAfterAdminRecovery(task)
|
||||||
case TaskStop:
|
case TaskStop:
|
||||||
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
||||||
case TaskRestart:
|
case TaskRestart:
|
||||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||||
|
clearPolicyBlockAfterAdminRecovery(task)
|
||||||
|
case TaskReinstall:
|
||||||
|
clearPolicyBlockAfterAdminRecovery(task)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
q.persistTasks()
|
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 {
|
func resolveTaskContainer(task *Task) error {
|
||||||
if task.Type == TaskCreate {
|
if task.Type == TaskCreate {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
type webVNCTicket struct {
|
type webVNCTicket struct {
|
||||||
ContainerName string
|
ContainerName string
|
||||||
ContainerUUID string
|
ContainerUUID string
|
||||||
|
SubUser bool
|
||||||
ExpiresAt time.Time
|
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"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if isSubUserRequest(r) && c.PolicyBlocked {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
|
||||||
|
return
|
||||||
|
}
|
||||||
if !c.IsKVM() {
|
if !c.IsKVM() {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "VNC console is only available for KVM VMs"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "VNC console is only available for KVM VMs"})
|
||||||
return
|
return
|
||||||
@@ -59,6 +64,7 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
|||||||
webVNCTickets.items[ticket] = webVNCTicket{
|
webVNCTickets.items[ticket] = webVNCTicket{
|
||||||
ContainerName: c.Name,
|
ContainerName: c.Name,
|
||||||
ContainerUUID: c.UUID,
|
ContainerUUID: c.UUID,
|
||||||
|
SubUser: isSubUserRequest(r),
|
||||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||||
}
|
}
|
||||||
webVNCTickets.Unlock()
|
webVNCTickets.Unlock()
|
||||||
@@ -94,6 +100,10 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "container not found", http.StatusNotFound)
|
http.Error(w, "container not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if item.SubUser && c.PolicyBlocked {
|
||||||
|
http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
if !c.IsKVM() {
|
if !c.IsKVM() {
|
||||||
http.Error(w, "VNC console is only available for KVM VMs", http.StatusBadRequest)
|
http.Error(w, "VNC console is only available for KVM VMs", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ type Container struct {
|
|||||||
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
|
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
|
||||||
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
|
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
|
||||||
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
|
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 (
|
const (
|
||||||
@@ -215,6 +218,7 @@ type ClicdConfig struct {
|
|||||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||||
EnabledImages []string `json:"enabled_images"`
|
EnabledImages []string `json:"enabled_images"`
|
||||||
Snapshots []Snapshot `json:"snapshots"`
|
Snapshots []Snapshot `json:"snapshots"`
|
||||||
|
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var configPath string
|
var configPath string
|
||||||
@@ -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
|
// UpdateVNC refreshes all container statuses
|
||||||
func UpdateVNC(containers []Container) {
|
func UpdateVNC(containers []Container) {
|
||||||
AppConfig.Containers = containers
|
AppConfig.Containers = containers
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1004,7 +1005,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rootStat, ok := rootInfo.Sys().(*syscall.Stat_t)
|
rootStat, ok := rootInfo.Sys().(*unix.Stat_t)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
stat, ok := info.Sys().(*unix.Stat_t)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("failed to read uid/gid for %s", path)
|
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 {
|
if gid >= 0 && gid < 65536 {
|
||||||
gid += gidBase
|
gid += gidBase
|
||||||
}
|
}
|
||||||
return syscall.Lchown(path, uid, gid)
|
return unix.Lchown(path, uid, gid)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err)
|
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 {
|
if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := syscall.Lchown(marker, uidBase, gidBase); err != nil {
|
if err := unix.Lchown(marker, uidBase, gidBase); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
|
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
|
||||||
mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs)))
|
mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs)))
|
||||||
mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary)))
|
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-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
|
||||||
mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket
|
mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket
|
||||||
mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
|
mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
AlertTriangle,
|
||||||
Camera,
|
Camera,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -299,8 +300,17 @@ export default function ContainerDetail() {
|
|||||||
start: '开机中...', stop: '关机中...', restart: '重启中...', delete: '删除中...', reinstall: '重装中...',
|
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) => {
|
const handleAction = async (action: string) => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
|
if (!(await ensureSubUserCanOperate())) return
|
||||||
setActionLoading(action)
|
setActionLoading(action)
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -462,11 +472,13 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openAddMapping = () => {
|
const openAddMapping = () => {
|
||||||
|
if (isSubUser && container?.policy_blocked) return
|
||||||
setDraft(emptyDraft)
|
setDraft(emptyDraft)
|
||||||
setShowNat(true)
|
setShowNat(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditMapping = (pm: PortMapping, index: number) => {
|
const openEditMapping = (pm: PortMapping, index: number) => {
|
||||||
|
if (isSubUser && container?.policy_blocked) return
|
||||||
if (isSubUser) {
|
if (isSubUser) {
|
||||||
// Sub-user: only edit container_port in a simple modal
|
// Sub-user: only edit container_port in a simple modal
|
||||||
setDraft({
|
setDraft({
|
||||||
@@ -489,6 +501,7 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const submitMapping = async (): Promise<boolean> => {
|
const submitMapping = async (): Promise<boolean> => {
|
||||||
if (!containerIdentifier) return false
|
if (!containerIdentifier) return false
|
||||||
|
if (!(await ensureSubUserCanOperate())) return false
|
||||||
if (draft.index === null && container) {
|
if (draft.index === null && container) {
|
||||||
const currentCount = container.port_mappings?.length || 0
|
const currentCount = container.port_mappings?.length || 0
|
||||||
const limit = container.port_mapping_limit || Math.max(currentCount, 2)
|
const limit = container.port_mapping_limit || Math.max(currentCount, 2)
|
||||||
@@ -541,6 +554,7 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const removeMapping = async (index: number) => {
|
const removeMapping = async (index: number) => {
|
||||||
if (!containerIdentifier || !(await dialog.confirm('删除映射', '确定要删除这条映射规则吗?'))) return
|
if (!containerIdentifier || !(await dialog.confirm('删除映射', '确定要删除这条映射规则吗?'))) return
|
||||||
|
if (!(await ensureSubUserCanOperate())) return
|
||||||
try {
|
try {
|
||||||
await deletePortMapping(containerIdentifier, index)
|
await deletePortMapping(containerIdentifier, index)
|
||||||
await fetchContainer()
|
await fetchContainer()
|
||||||
@@ -553,6 +567,7 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const handleCreateSnapshot = async () => {
|
const handleCreateSnapshot = async () => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
|
if (!(await ensureSubUserCanOperate())) return
|
||||||
if (isSubUser && snapshots.length >= snapshotQuota) {
|
if (isSubUser && snapshots.length >= snapshotQuota) {
|
||||||
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
||||||
return
|
return
|
||||||
@@ -577,6 +592,7 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openSnapshotSchedule = () => {
|
const openSnapshotSchedule = () => {
|
||||||
|
if (isSubUser && container?.policy_blocked) return
|
||||||
setSnapshotScheduleDraft({
|
setSnapshotScheduleDraft({
|
||||||
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
||||||
time: snapshotSchedule?.time || '03:00',
|
time: snapshotSchedule?.time || '03:00',
|
||||||
@@ -586,6 +602,7 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const saveSnapshotSchedule = async (enabled: boolean) => {
|
const saveSnapshotSchedule = async (enabled: boolean) => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
|
if (!(await ensureSubUserCanOperate())) return
|
||||||
const intervalHours = snapshotScheduleDraft.intervalHours
|
const intervalHours = snapshotScheduleDraft.intervalHours
|
||||||
const scheduleTime = snapshotScheduleDraft.time || '03:00'
|
const scheduleTime = snapshotScheduleDraft.time || '03:00'
|
||||||
if (enabled && intervalHours < 24) {
|
if (enabled && intervalHours < 24) {
|
||||||
@@ -625,6 +642,7 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const handleDeleteSnapshot = async (snapshot: Snapshot) => {
|
const handleDeleteSnapshot = async (snapshot: Snapshot) => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
|
if (!(await ensureSubUserCanOperate())) return
|
||||||
if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return
|
if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return
|
||||||
setSnapshotBusy(snapshot.id)
|
setSnapshotBusy(snapshot.id)
|
||||||
try {
|
try {
|
||||||
@@ -640,6 +658,7 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const handleRestoreSnapshot = async (snapshot: Snapshot) => {
|
const handleRestoreSnapshot = async (snapshot: Snapshot) => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
|
if (!(await ensureSubUserCanOperate())) return
|
||||||
if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return
|
if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return
|
||||||
setSnapshotBusy(snapshot.id)
|
setSnapshotBusy(snapshot.id)
|
||||||
try {
|
try {
|
||||||
@@ -681,6 +700,9 @@ export default function ContainerDetail() {
|
|||||||
const isWindows = container.template?.includes('windows')
|
const isWindows = container.template?.includes('windows')
|
||||||
const canOpenVNC = isKVM && isRunning
|
const canOpenVNC = isKVM && isRunning
|
||||||
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
|
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 publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
||||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
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 diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
|
||||||
const mappingCount = container.port_mappings?.length || 0
|
const mappingCount = container.port_mappings?.length || 0
|
||||||
const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2)
|
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
|
const managementUrl = subUser?.access_code
|
||||||
? `${window.location.origin}/login?code=${encodeURIComponent(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="emerald">内网 {container.ip || '-'}</InfoTag>
|
||||||
<InfoTag color="amber">NAT {mappingCount} 条</InfoTag>
|
<InfoTag color="amber">NAT {mappingCount} 条</InfoTag>
|
||||||
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
|
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
|
||||||
|
{isPolicyBlocked && <InfoTag color="red">策略封禁</InfoTag>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap justify-end">
|
<div className="flex items-center gap-1.5 flex-wrap justify-end">
|
||||||
{!isRunning ? (
|
{!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" />
|
<Play className="w-3.5 h-3.5" />
|
||||||
{isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'}
|
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('stop')}>
|
<ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('stop')}>
|
||||||
<Square className="w-3.5 h-3.5" />
|
<Square className="w-3.5 h-3.5" />
|
||||||
{isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'}
|
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('restart')}>
|
<ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('restart')}>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
{isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
|
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
{!isWindows && (
|
{!isWindows && (
|
||||||
<ActionButton dark onClick={() => setShowSSH(true)}>
|
<ActionButton dark disabled={isSubUserPolicyBlocked} onClick={() => setShowSSH(true)}>
|
||||||
<TerminalSquare className="w-3.5 h-3.5" />
|
<TerminalSquare className="w-3.5 h-3.5" />
|
||||||
WebSSH
|
WebSSH
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
)}
|
)}
|
||||||
{isKVM && (
|
{isKVM && (
|
||||||
<ActionButton dark disabled={!canOpenVNC} onClick={() => setShowVNC(true)}>
|
<ActionButton dark disabled={!canOpenVNC || isSubUserPolicyBlocked} onClick={() => setShowVNC(true)}>
|
||||||
<Monitor className="w-3.5 h-3.5" />
|
<Monitor className="w-3.5 h-3.5" />
|
||||||
WebVNC
|
WebVNC
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
@@ -815,12 +838,12 @@ export default function ContainerDetail() {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
)}
|
)}
|
||||||
<>
|
<>
|
||||||
<ActionButton onClick={() => setShowNat(true)}>
|
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
|
||||||
<Settings className="w-3.5 h-3.5" />
|
<Settings className="w-3.5 h-3.5" />
|
||||||
NAT 管理
|
NAT 管理
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</>
|
</>
|
||||||
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}>
|
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
|
||||||
<Camera className="w-3.5 h-3.5" />
|
<Camera className="w-3.5 h-3.5" />
|
||||||
快照
|
快照
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
@@ -840,9 +863,23 @@ export default function ContainerDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||||
<Panel title="连接信息">
|
<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="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
|
||||||
<PlainRow label="用户名" value="Administrator" 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 = {
|
const classes = {
|
||||||
blue: 'bg-blue-50 text-blue-700 border-blue-100',
|
blue: 'bg-blue-50 text-blue-700 border-blue-100',
|
||||||
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100',
|
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100',
|
||||||
amber: 'bg-amber-50 text-amber-700 border-amber-100',
|
amber: 'bg-amber-50 text-amber-700 border-amber-100',
|
||||||
violet: 'bg-violet-50 text-violet-700 border-violet-100',
|
violet: 'bg-violet-50 text-violet-700 border-violet-100',
|
||||||
slate: 'bg-slate-50 text-slate-700 border-slate-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>
|
return <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ export default function Containers() {
|
|||||||
const isRunning = container.status === 'running'
|
const isRunning = container.status === 'running'
|
||||||
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
||||||
const isPlaceholder = !!container.isPlaceholder
|
const isPlaceholder = !!container.isPlaceholder
|
||||||
|
const isPolicyBlocked = !!container.policy_blocked
|
||||||
const usage = usageByName[container.name]
|
const usage = usageByName[container.name]
|
||||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||||
|
|
||||||
@@ -436,7 +437,7 @@ export default function Containers() {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2.5 py-2 align-top">
|
<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>
|
||||||
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
|
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
@@ -580,8 +581,17 @@ type DisplayContainer = Container & {
|
|||||||
createTask?: Task
|
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"
|
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') {
|
if (task?.status === 'failed') {
|
||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { FileText, Power, RefreshCw, X } from 'lucide-react'
|
||||||
import { getSecurityAlerts, SecurityAlert } from '../services/api'
|
import { getSecurityAlerts, getSecurityLogs, getSecuritySettings, SecurityAlert, SecurityLog, updateSecuritySettings } from '../services/api'
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
port_scan: '端口扫描',
|
port_scan: '端口扫描',
|
||||||
@@ -23,12 +23,18 @@ const severityLabels: Record<string, string> = {
|
|||||||
|
|
||||||
export default function Security() {
|
export default function Security() {
|
||||||
const [alerts, setAlerts] = useState<SecurityAlert[]>([])
|
const [alerts, setAlerts] = useState<SecurityAlert[]>([])
|
||||||
|
const [autoShutdown, setAutoShutdown] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
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 () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const alertRes = await getSecurityAlerts()
|
const [alertRes, settingsRes] = await Promise.all([getSecurityAlerts(), getSecuritySettings()])
|
||||||
if (alertRes.data.data) setAlerts(alertRes.data.data)
|
if (alertRes.data.data) setAlerts(alertRes.data.data)
|
||||||
|
if (settingsRes.data.data) setAutoShutdown(settingsRes.data.data.auto_shutdown)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -42,6 +48,36 @@ export default function Security() {
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [fetchData])
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
@@ -52,8 +88,25 @@ export default function Security() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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>
|
<h1 className="text-xl font-semibold text-black">安全告警</h1>
|
||||||
|
<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
|
<button
|
||||||
onClick={fetchData}
|
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"
|
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"
|
||||||
@@ -62,6 +115,7 @@ export default function Security() {
|
|||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||||
@@ -93,12 +147,24 @@ export default function Security() {
|
|||||||
</td>
|
</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 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-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">
|
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">
|
||||||
{formatTarget(alert)}
|
{formatTarget(alert)}
|
||||||
</td>
|
</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 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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -106,6 +172,69 @@ export default function Security() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -129,3 +258,17 @@ function formatTarget(alert: SecurityAlert): string {
|
|||||||
if (!alert.target_ip) return '-'
|
if (!alert.target_ip) return '-'
|
||||||
return alert.target_port > 0 ? `${alert.target_ip}:${alert.target_port}` : alert.target_ip
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ export interface Container {
|
|||||||
snapshot_schedule_last_run: string
|
snapshot_schedule_last_run: string
|
||||||
snapshot_schedule_next_run: string
|
snapshot_schedule_next_run: string
|
||||||
snapshot_schedule_created_by: string
|
snapshot_schedule_created_by: string
|
||||||
|
policy_blocked?: boolean
|
||||||
|
policy_blocked_reason?: string
|
||||||
|
policy_blocked_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
@@ -524,6 +527,19 @@ export interface SecuritySummary {
|
|||||||
low: number
|
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 = () =>
|
export const getSecurityAlerts = () =>
|
||||||
api.get<APIResponse<SecurityAlert[]>>('/security/alerts')
|
api.get<APIResponse<SecurityAlert[]>>('/security/alerts')
|
||||||
|
|
||||||
@@ -531,11 +547,17 @@ export const checkContainerSecurity = (containerName: string) =>
|
|||||||
api.post<APIResponse>('/security/check', { container_name: containerName })
|
api.post<APIResponse>('/security/check', { container_name: containerName })
|
||||||
|
|
||||||
export const getSecurityLogs = (containerName: string) =>
|
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 = () =>
|
export const getSecuritySummary = () =>
|
||||||
api.get<APIResponse<SecuritySummary>>('/security/summary')
|
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) =>
|
export const createWebSSHTicket = (containerName: string) =>
|
||||||
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
|
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user