mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c303fe6d17 | |||
| eedb2d7fb0 | |||
| bfc98d043b |
@@ -180,6 +180,12 @@ func (ss *SecurityScanner) monitorLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) alertCount() int {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
return len(ss.alerts)
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) checkAllContainers() {
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
if c.Status != "running" || c.IP == "" {
|
||||
@@ -208,6 +214,7 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
|
||||
return
|
||||
}
|
||||
|
||||
alertBefore := ss.alertCount()
|
||||
ss.detectPortScans(name, ip, stats)
|
||||
ss.detectBruteForce(name, ip, stats)
|
||||
ss.detectSpam(name, ip, stats)
|
||||
@@ -216,6 +223,11 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
|
||||
ss.detectMining(name, ip, stats)
|
||||
ss.detectProxyAndTor(name, ip, stats)
|
||||
ss.detectMalware(name, ip, stats)
|
||||
|
||||
// If new alerts were generated, snapshot the conntrack data for later retrieval.
|
||||
if ss.alertCount() > alertBefore {
|
||||
config.SaveConntrackSnapshot(ip, lines)
|
||||
}
|
||||
}
|
||||
|
||||
func newTrafficStats() *trafficStats {
|
||||
@@ -759,28 +771,49 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func getConnectionLogs(ip string) []map[string]interface{} {
|
||||
logs := make([]map[string]interface{}, 0)
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, line := range readConntrackLines(ip) {
|
||||
parseLine := func(line string) map[string]interface{} {
|
||||
srcIP := extractField(line, "src=")
|
||||
dstIP := extractField(line, "dst=")
|
||||
srcPort := extractField(line, "sport=")
|
||||
dstPort := extractField(line, "dport=")
|
||||
|
||||
sPort, _ := strconv.Atoi(srcPort)
|
||||
dPort, _ := strconv.Atoi(dstPort)
|
||||
|
||||
logs = append(logs, map[string]interface{}{
|
||||
return map[string]interface{}{
|
||||
"src_ip": srcIP,
|
||||
"dst_ip": dstIP,
|
||||
"src_port": sPort,
|
||||
"dst_port": dPort,
|
||||
"protocol": extractProtocol(line),
|
||||
"state": extractConnState(line),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// First, load stored snapshots from database (persisted at alert time).
|
||||
for _, line := range config.GetConntrackSnapshotLines(ip) {
|
||||
if len(logs) >= 100 {
|
||||
break
|
||||
}
|
||||
key := strings.TrimSpace(line)
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
logs = append(logs, parseLine(line))
|
||||
}
|
||||
|
||||
// Then, merge live conntrack data (deduplicated).
|
||||
for _, line := range readConntrackLines(ip) {
|
||||
if len(logs) >= 100 {
|
||||
break
|
||||
}
|
||||
key := strings.TrimSpace(line)
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
logs = append(logs, parseLine(line))
|
||||
}
|
||||
|
||||
return logs
|
||||
|
||||
@@ -34,7 +34,7 @@ var cliTranslations = map[string]string{
|
||||
"请选择操作": "Select an action",
|
||||
"再见": "Goodbye",
|
||||
"无效选择": "Invalid choice",
|
||||
"CLICD - LXC 容器管理器": "CLICD - LXC Container Manager",
|
||||
"CLICD - LXC 容器管理器": "CLICD - Container Manager",
|
||||
"Web 面板": "Web panel",
|
||||
"端口": "port",
|
||||
"运行中": "running",
|
||||
|
||||
@@ -254,6 +254,14 @@ func ensureSchema() error {
|
||||
success INTEGER,
|
||||
error TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS security_conntrack_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
container_ip TEXT NOT NULL,
|
||||
line TEXT NOT NULL,
|
||||
captured_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_conntrack_snapshots_ip_time
|
||||
ON security_conntrack_snapshots(container_ip, captured_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT,
|
||||
@@ -642,6 +650,59 @@ func saveAPIKeys(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveConntrackSnapshot stores raw conntrack lines for a container IP.
|
||||
func SaveConntrackSnapshot(containerIP string, lines []string) {
|
||||
if db == nil || len(lines) == 0 || strings.TrimSpace(containerIP) == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stmt, err := tx.Prepare(`INSERT INTO security_conntrack_snapshots (container_ip, line, captured_at) VALUES (?, ?, ?)`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
stmt.Exec(containerIP, line, now)
|
||||
}
|
||||
tx.Commit()
|
||||
|
||||
// Cleanup old snapshots (>1 hour)
|
||||
db.Exec(`DELETE FROM security_conntrack_snapshots WHERE captured_at < ?`,
|
||||
time.Now().Add(-1*time.Hour).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// GetConntrackSnapshotLines returns stored conntrack lines for a container IP.
|
||||
func GetConntrackSnapshotLines(containerIP string) []string {
|
||||
if db == nil || strings.TrimSpace(containerIP) == "" {
|
||||
return nil
|
||||
}
|
||||
rows, err := db.Query(
|
||||
`SELECT line FROM security_conntrack_snapshots WHERE container_ip = ? ORDER BY captured_at DESC LIMIT 200`,
|
||||
containerIP,
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var lines []string
|
||||
for rows.Next() {
|
||||
var line string
|
||||
if rows.Scan(&line) == nil {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func saveAuditLogs(tx *sql.Tx) error {
|
||||
for _, log := range AppConfig.AuditLogs {
|
||||
successSet := 0
|
||||
|
||||
@@ -588,7 +588,7 @@ func (m *Manager) StartContainer(id int) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
config.UpdateContainerStatus(id, "running")
|
||||
config.UpdateContainerStatus(id, "initializing")
|
||||
// Detect VNC port
|
||||
if _, err := m.RefreshVNCPort(id); err != nil {
|
||||
fmt.Printf("Warning: failed to refresh VNC port for %s: %v\n", name, err)
|
||||
@@ -625,6 +625,11 @@ func (m *Manager) StartContainer(id int) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Wait for cloud-init to finish and SSH to be reachable (password-only mode)
|
||||
if !isWindows && c.IP != "" {
|
||||
m.waitForCloudInitReady(name, c.IP, c.SSHPassword)
|
||||
}
|
||||
config.UpdateContainerStatus(id, "running")
|
||||
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||
if err := m.applyIPv6Runtime(c); err != nil {
|
||||
return err
|
||||
@@ -635,6 +640,50 @@ func (m *Manager) StartContainer(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForCloudInitReady waits for cloud-init to finish and SSH to be reachable.
|
||||
func (m *Manager) waitForCloudInitReady(vmName, ip, password string) {
|
||||
if ip == "" || password == "" {
|
||||
return
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Minute)
|
||||
target := net.JoinHostPort(ip, "22")
|
||||
sshWasUp := false
|
||||
qgaAttempted := false
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
client, err := ssh.Dial("tcp", target, &ssh.ClientConfig{
|
||||
User: "root",
|
||||
Auth: []ssh.AuthMethod{ssh.Password(password)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
if err == nil {
|
||||
client.Close()
|
||||
if !sshWasUp {
|
||||
sshWasUp = true
|
||||
fmt.Printf("KVM %s SSH up, waiting for cloud-init to settle...\n", vmName)
|
||||
time.Sleep(10 * time.Second)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("KVM %s ready\n", vmName)
|
||||
return
|
||||
}
|
||||
|
||||
// Try guest agent ONCE to speed things up, with timeout to avoid blocking
|
||||
if !qgaAttempted && qemuGuestPing(vmName) == nil {
|
||||
qgaAttempted = true
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
cmd := exec.CommandContext(ctx, "virsh", "qemu-agent-command", vmName,
|
||||
`{"execute":"guest-exec","arguments":{"path":"/bin/sh","arg":["-c","cloud-init status --wait 2>/dev/null; systemctl restart sshd 2>/dev/null || systemctl restart ssh 2>/dev/null; true"],"capture-output":false}}`)
|
||||
cmd.Run()
|
||||
cancel()
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
fmt.Printf("Warning: KVM %s not ready after 3 minutes\n", vmName)
|
||||
}
|
||||
|
||||
func (m *Manager) StopContainer(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
@@ -2530,7 +2579,10 @@ if [ -n "$SSH_PUBLIC_KEY" ]; then
|
||||
chown -R root:root /root/.ssh 2>/dev/null || true
|
||||
fi
|
||||
if command -v chpasswd >/dev/null 2>&1; then
|
||||
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true
|
||||
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd 2>/tmp/clicd-chpasswd.log && echo "root password set via chpasswd" || echo "WARNING: chpasswd failed: $(cat /tmp/clicd-chpasswd.log 2>/dev/null)"
|
||||
elif command -v openssl >/dev/null 2>&1 && command -v usermod >/dev/null 2>&1; then
|
||||
HASH=$(echo "$ROOT_PASSWORD" | openssl passwd -6 -stdin 2>/dev/null)
|
||||
[ -n "$HASH" ] && usermod -p "$HASH" root 2>/dev/null && echo "root password set via openssl/usermod" || echo "WARNING: openssl/usermod failed"
|
||||
fi
|
||||
ssh-keygen -A >/dev/null 2>&1 || true
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.14"
|
||||
Version = "1.1.15"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CLICD - LXC Container Manager</title>
|
||||
<title>CLICD - Container Manager</title>
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('clicd_theme');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.14",
|
||||
"version": "1.1.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -753,6 +753,7 @@ export default function ContainerDetail() {
|
||||
}
|
||||
|
||||
const isRunning = container.status === 'running'
|
||||
const isInitializing = container.status === 'initializing'
|
||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||
const isWindows = container.template?.includes('windows')
|
||||
const reinstallLinuxTemplate = !isWindowsTemplate(selectedTemplate)
|
||||
@@ -872,7 +873,7 @@ export default function ContainerDetail() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-black">{container.name}</h1>
|
||||
<StatusBadge running={isRunning} />
|
||||
<StatusBadge running={isRunning} initializing={isInitializing} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2">
|
||||
<InfoTag color="blue">系统 {container.template}</InfoTag>
|
||||
@@ -1670,7 +1671,15 @@ function RangeSwitch({ value, onChange }: { value: StatsRangeKey; onChange: (val
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ running }: { running: boolean }) {
|
||||
function StatusBadge({ running, initializing }: { running: boolean; initializing?: boolean }) {
|
||||
if (initializing) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap bg-amber-50 text-amber-700">
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-amber-500 animate-pulse"></span>
|
||||
正在初始化
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap ${running ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-emerald-500' : 'bg-rose-500'}`}></span>
|
||||
|
||||
@@ -390,6 +390,7 @@ export default function Containers() {
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{pageContainers.map((container) => {
|
||||
const isRunning = container.status === 'running'
|
||||
const isInitializing = container.status === 'initializing'
|
||||
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
||||
const isPlaceholder = !!container.isPlaceholder
|
||||
const isPolicyBlocked = !!container.policy_blocked
|
||||
@@ -437,7 +438,7 @@ export default function Containers() {
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2.5 py-2 align-top">
|
||||
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
|
||||
<StatusBadge running={isRunning} initializing={isInitializing} 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">
|
||||
@@ -483,7 +484,7 @@ export default function Containers() {
|
||||
try {
|
||||
const { default: api } = await import('../services/api')
|
||||
await api.delete(`/tasks/${task.id}`)
|
||||
fetchData()
|
||||
await Promise.all([fetchData(), fetchTasks()])
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-red-200 text-[11px] text-red-600 hover:bg-red-50 transition-colors whitespace-nowrap"
|
||||
@@ -581,7 +582,7 @@ type DisplayContainer = Container & {
|
||||
createTask?: Task
|
||||
}
|
||||
|
||||
function StatusBadge({ running, task, placeholder, policyBlocked }: { running: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
|
||||
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: 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 (
|
||||
@@ -640,6 +641,15 @@ function StatusBadge({ running, task, placeholder, policyBlocked }: { running: b
|
||||
)
|
||||
}
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
正在初始化
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function Login() {
|
||||
<AppIcon className="w-10 h-10" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">{isAccessCodeLogin ? '容器管理登录' : 'LXC Container Manager'}</p>
|
||||
<p className="text-gray-500 mt-1 text-sm">{isAccessCodeLogin ? '容器管理登录' : 'Container Manager'}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
@@ -128,7 +128,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.14</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.15</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user