Compare commits

..

12 Commits

Author SHA1 Message Date
MengMengCode c303fe6d17 release: v1.1.15 2026-06-10 14:52:30 +08:00
MengMengCode eedb2d7fb0 修复了一些已知问题 2026-06-10 14:51:22 +08:00
MengMengCode bfc98d043b feat(security): 快照并持久化告警时的连接跟踪数据 2026-06-10 13:28:46 +08:00
MengMengCode 0a4cf5c0bd release: v1.1.14 2026-06-10 11:12:43 +08:00
MengMengCode 54a608c19d Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-10 11:12:32 +08:00
MengMengCode 73bd6934f9 修复预设密码以及KEY问题 2026-06-10 11:12:27 +08:00
Meng Meng a35595edcf Update README.md 2026-06-10 11:04:58 +08:00
MengMengCode b605df613e Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-10 10:35:13 +08:00
MengMengCode 3cc8f7df7f 修复了一些已知问题 2026-06-10 10:35:08 +08:00
Meng Meng 744246c0a8 Update thanks section formatting in README.md 2026-06-10 09:33:31 +08:00
MengMengCode aed512cb09 patch 2026-06-10 02:39:58 +08:00
MengMengCode b6f48acc4c Refactor code structure for improved readability and maintainability 2026-06-10 02:33:18 +08:00
20 changed files with 420 additions and 94 deletions
+52 -38
View File
@@ -5,7 +5,7 @@
<h1 align="center">CLICD</h1>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.22-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111111">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white">
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
@@ -27,41 +27,11 @@
<img alt="TLS" src="https://img.shields.io/badge/TLS-Let's_Encrypt-003A70?style=flat-square&logo=letsencrypt&logoColor=white">
</p>
CLICD is a lightweight virtualization management panel for LXC and KVM, featuring a web console, CLI management, batch operations, image management, NAT networking, IPv6 allocation, WebSSH, VNC access, resource controls, bandwidth limiting, and security alerting.
It is designed for managing LXC containers and KVM virtual machines on VPS servers, and is particularly suitable for environments that require bulk provisioning and delegated access management through sub-user management links.
CLICD is a lightweight virtualization management panel for LXC and KVM. It combines a web console, CLI tools, REST API, NAT/IPv6 networking, WebSSH/WebVNC access, resource quotas, traffic limits, snapshots, delegated sub-user access, and security alerts into a single deployable service.
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSHVNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,集成 Web 控制台、CLI、REST API、NAT/IPv6 网络、WebSSH/WebVNC、资源配额、流量限制、快照、子用户授权和安全告警能力,适合 VPS 商家、实验室、开发者自建虚拟化节点以及需要批量开通容器的场景。
## Features / 功能介绍
### English
1. Supports Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other operating system images. Images can be downloaded on demand through the image management interface. For hosts with limited resources, lightweight distributions such as Alpine are recommended.
2. Supports WebSSH management, allowing users to access container terminals directly from the browser without manually copying SSH credentials.
3. Supports NAT4 port quotas, port forwarding, and protocol restrictions, as well as public IPv6 allocation. IPv6 assignment requires the host machine to have a routable IPv6 prefix.
4. Supports both inbound and outbound traffic limits. Containers are automatically powered off when configured limits are reached, preventing bandwidth overuse.
5. Supports container expiration dates. Expired containers are automatically shut down, and delegated users lose access until an administrator extends the expiration period.
6. Includes lightweight conntrack-based security monitoring. The system does not store full logs of normal connections, but generates audit alerts for suspicious activities such as port scanning, lateral scanning, brute-force attempts, SMTP abuse, UDP reflection attacks, cryptocurrency mining ports, and proxy/VPN/Tor usage.
7. Supports delegated management links. Administrators can assign specific containers to sub-users, while ensuring that each user can only manage the containers explicitly authorized to them.
8. Provides a REST API for automating the management of containers, tasks, images, networking, traffic controls, and security alerts.
9. Supports operating entirely through the CLI. When the web console is not required, administrators can stop and disable the systemd service and launch CLI-only mode using `clicd cli --no-web`.
### 中文
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。
5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。
6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
## Technology Stack / 技术栈
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
- Runtime: Linux, systemd, LXC, KVM/QEMU
- Build: GitHub Actions, Node.js 20, Go 1.22
![alt text](/img/image-1.png)
## Installation / 安装
@@ -77,9 +47,52 @@ One-click Uninstall / 一键卸载:
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
```
![alt text](/img/image.png)
![alt text](/img/image-1.png)
## Features / 功能介绍
### English
| Area | What CLICD provides |
| --- | --- |
| Virtualization | Manage LXC containers and KVM virtual machines from one panel, including create, reinstall, start, stop, restart, delete, password reset, expiry control, and batch actions. |
| Images and templates | Built-in template and image management for Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other common distributions. Images can be enabled, disabled, downloaded, cancelled, or removed from cache. |
| Networking | NAT4 port quotas, random available port allocation, TCP/UDP port mappings, public IPv4 pool management, IPv6 prefix detection, IPv6 status checks, and per-container IPv6 assignment. |
| Resource control | CPU, memory, disk, swap, bandwidth usage, traffic reset, traffic limit, and resource limit management, with automatic shutdown behavior for expired or over-quota containers. |
| Console access | Browser-based WebSSH and WebVNC ticket access, so users can open terminals or consoles without manually exchanging credentials. |
| Snapshots | Snapshot overview, per-container snapshots, create/delete/restore operations, scheduled snapshots, and quota controls. |
| Security | Conntrack-based security alerts for port scans, lateral scans, brute-force behavior, SMTP abuse, UDP reflection, mining ports, proxy/VPN/Tor usage, plus security logs, summaries, and configurable settings. |
| Accounts and audit | Delegated sub-user links, sub-user password rotation, per-user container permissions, audit logs, login logs, and API key management. |
| Automation | Versioned REST API under `/api/v1`, task queue endpoints, batch create/action endpoints, and a Mofang finance integration module packaged automatically by GitHub Actions. |
| Operations | Dashboard statistics, host resource overview, routing overview, swap management, CLI-only mode, and release artifacts generated by GitHub Actions. |
### 中文
| 模块 | CLICD 提供的能力 |
| --- | --- |
| 虚拟化管理 | 在同一个面板里管理 LXC 容器和 KVM 虚拟机,支持创建、重装、开机、关机、重启、删除、重置密码、到期时间和批量操作。 |
| 镜像与模板 | 内置模板和镜像管理,支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等常见发行版,镜像可按需下载、取消、启用、禁用和清理缓存。 |
| 网络能力 | 支持 NAT4 端口配额、随机可用端口、TCP/UDP 端口映射、公网 IPv4 池管理、IPv6 前缀检测、IPv6 状态检查和容器级 IPv6 分配。 |
| 资源限制 | 支持 CPU、内存、磁盘、Swap、带宽用量、流量重置、流量限制和资源限制管理;容器到期或超额后可自动关机,避免资源和流量失控。 |
| 远程控制 | 内置 WebSSH 和 WebVNC 票据访问,用户可以直接在浏览器打开终端或控制台,不需要手动复制连接信息。 |
| 快照能力 | 支持快照总览、容器快照、创建快照、删除快照、恢复快照、计划快照和快照配额。 |
| 安全告警 | 基于 conntrack 做轻量安全检测,可识别端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等风险,并提供安全日志、汇总和设置项。 |
| 账号与审计 | 支持子用户管理链接、子用户密码轮换、按容器授权、操作日志、登录日志和 API Key 管理,适合分发给下游用户或拼车用户。 |
| 自动化接入 | 全量接口统一使用 `/api/v1`,覆盖任务队列、容器、镜像、网络、流量、安全、批量创建和批量操作;同时提供魔方财务对接模块,并由 GitHub Actions 自动打包发布。 |
| 运维入口 | 提供总览统计、主机资源、路由概览、Swap 管理、CLI-only 模式和 GitHub Actions 自动发布产物,便于在小型节点上长期维护。 |
## Technology Stack / 技术栈
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
- Runtime: Linux, systemd, LXC, KVM/QEMU
- Build: GitHub Actions, Node.js 20, Go 1.24
## Preview / 预览
![alt text](/img/image-2.png)
![alt text](/img/image-3.png)
![alt text](/img/image-4.png)
![alt text](/img/image-5.png)
## Disclaimer/免责声明
@@ -99,10 +112,11 @@ This open-source software is intended solely for educational purposes, specifica
本开源软件仅供学习和研究 LXC、KVM 等虚拟化技术原理之目的使用,不得用于任何违反适用法律法规、软件许可协议或第三方权益的行为。
本软件中涉及的 Windows 名称、标识、图标及相关知识产权均归 Microsoft Corporation 及其权利人所有。本项目与微软公司不存在任何关联、授权或合作关系。
## Thanks/鸣谢
## Thanks / 鸣谢
- [Nodeseek.com](https://www.nodeseek.com) — 一个专注于服务器的社区
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
## Star History
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
+38 -5
View File
@@ -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
+1 -1
View File
@@ -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",
+61
View File
@@ -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
+154 -29
View File
@@ -407,6 +407,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
mac := randomMAC()
sshPassword := generateRandomString(16)
sshPublicKey := ""
sshAuthMode := ""
if !IsWindowsImage(image.ID) {
sshAccess, err := lxc.ResolveCreateSSHAccess(cfg)
if err != nil {
@@ -414,6 +415,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}
sshPassword = sshAccess.Password
sshPublicKey = sshAccess.PublicKey
sshAuthMode = sshAccess.Mode
}
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil {
@@ -429,6 +431,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
ipv6Assignments = assigned
}
ipv6List := configIPv6AssignmentAddresses(ipv6Assignments)
ipv4List := configIPv4AssignmentAddresses(publicIPv4s)
defaultHostIP := lxc.DefaultPortMappingHostIP(publicIPv4s)
var xml string
@@ -448,7 +451,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}
winAdminPassword = generateWindowsPassword()
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List); err != nil {
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List, ipv4List); err != nil {
return nil, err
}
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
@@ -464,7 +467,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
return nil, err
}
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, *image); err != nil {
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image, sshAuthMode); err != nil {
return nil, err
}
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
@@ -585,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)
@@ -622,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
@@ -632,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 {
@@ -1389,7 +1441,7 @@ func (m *Manager) RefreshNetwork(id int) (string, error) {
if changed {
config.SaveConfig()
}
if c.Status == "running" && len(c.PortMappings) > 0 && shouldApplyPortMappings(id, changed) {
if c.Status == "running" && shouldApplyPortMappings(id, changed) {
if err := lxc.NewManager().ApplyPortMappings(id); err != nil {
return ip, err
}
@@ -1596,7 +1648,7 @@ func createEmptyDisk(target string, diskGB int) error {
return nil
}
func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string) error {
func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string, ipv4s []string) error {
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
if tool == "" {
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
@@ -1622,13 +1674,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []st
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil {
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil {
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil {
return err
}
_ = os.Remove(target)
@@ -1738,7 +1790,7 @@ exit /b 0
`
}
func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string {
func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []string) string {
commands := []string{
"$ErrorActionPreference='Continue'",
"$ProgressPreference='SilentlyContinue'",
@@ -1774,6 +1826,12 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string {
windowsIPv6PowerShell(ipv6s),
)
}
ipv4s = normalizeKVMIPv4List(ipv4s)
if len(ipv4s) > 0 {
commands = append(commands,
windowsIPv4PowerShell(ipv4s),
)
}
commands = append(commands,
"New-Item -ItemType File -Force -Path 'C:\\CLICD\\init.done' | Out-Null",
"} finally { Stop-Transcript | Out-Null }",
@@ -1806,12 +1864,49 @@ func windowsIPv6PowerShell(ipv6s []string) string {
}, "\r\n")
}
func windowsIPv4PowerShell(ipv4s []string) string {
ipv4s = normalizeKVMIPv4List(ipv4s)
if len(ipv4s) == 0 {
return ""
}
quoted := make([]string, 0, len(ipv4s))
for _, ipv4 := range ipv4s {
quoted = append(quoted, "'"+strings.ReplaceAll(ipv4, "'", "''")+"'")
}
return strings.Join([]string{
"$clicdIPv4=@(" + strings.Join(quoted, ",") + ")",
"$iface=$null",
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
"if ($iface) {",
" foreach ($ip in $clicdIPv4) {",
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
" New-NetIPAddress -IPAddress $ip -PrefixLength 32 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null",
" }",
"}",
}, "\r\n")
}
func normalizeKVMIPv4List(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func shellQuoteWindows(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
}
func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, image Image) error {
guestSetup := kvmSSHSetupScript(password, publicKey)
func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, ipv4s []string, image Image, sshAuthMode string) error {
disablePubkey := sshAuthMode == "password" || sshAuthMode == "auto_password"
guestSetup := kvmSSHSetupScript(password, disablePubkey, publicKey)
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
guestSetup += "\n" + desktopSetup
}
@@ -1846,29 +1941,40 @@ runcmd:
%s
`, hostname, password, authorizedKeys, setupScript)
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
ipv6Block := ""
// Build static address block (IPv4 + IPv6)
ipv4s = normalizeKVMIPv4List(ipv4s)
addressBlock := ""
addressLines := make([]string, 0, len(ipv4s)+len(ipv6s))
for _, ipv4 := range ipv4s {
addressLines = append(addressLines, fmt.Sprintf(" - %s/32", ipv4))
}
for _, ipv6 := range ipv6s {
addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6))
}
if len(addressLines) > 0 {
addressBlock = fmt.Sprintf("\n addresses:\n%s", strings.Join(addressLines, "\n"))
}
// IPv6 routes (only needed when IPv6 addresses are configured)
ipv6RouteBlock := ""
if len(ipv6s) > 0 {
addressLines := make([]string, 0, len(ipv6s))
for _, ipv6 := range ipv6s {
addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6))
}
ipv6Block = fmt.Sprintf(`
addresses:
%s
ipv6RouteBlock = fmt.Sprintf(`
routes:
- to: default
via: %s
on-link: true
metric: 100`, strings.Join(addressLines, "\n"), ipv6GatewayLinkLocal)
metric: 100`, ipv6GatewayLinkLocal)
}
networkConfig := fmt.Sprintf(`version: 2
ethernets:
nic0:
match:
macaddress: "%s"
dhcp4: true
dhcp6: false%s
`, strings.ToLower(mac), ipv6Block)
dhcp6: false%s%s
`, strings.ToLower(mac), addressBlock, ipv6RouteBlock)
dir := filepath.Dir(seedPath)
userPath := filepath.Join(dir, "user-data")
metaPath := filepath.Join(dir, "meta-data")
@@ -1900,6 +2006,16 @@ func configIPv6AssignmentAddresses(assignments []config.IPv6Assignment) []string
return values
}
func configIPv4AssignmentAddresses(assignments []config.PublicIPv4Assignment) []string {
values := make([]string, 0, len(assignments))
for _, item := range assignments {
if strings.TrimSpace(item.Address) != "" {
values = append(values, strings.TrimSpace(item.Address))
}
}
return values
}
func normalizeKVMIPv6List(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
@@ -2368,11 +2484,11 @@ func runKVMGuestAgentSSHSetup(name string, password string) error {
if err := qemuGuestPing(name); err != nil {
return err
}
return qemuGuestExec(name, kvmSSHSetupScript(password), 180*time.Second)
return qemuGuestExec(name, kvmSSHSetupScript(password, false), 180*time.Second)
}
func runKVMSSHSetup(client *ssh.Client, password string) error {
return runKVMSSHScript(client, kvmSSHSetupScript(password), "KVM SSH", 150*time.Second)
return runKVMSSHScript(client, kvmSSHSetupScript(password, false), "KVM SSH", 150*time.Second)
}
func runKVMSSHScript(client *ssh.Client, script string, description string, timeout time.Duration) error {
@@ -2401,12 +2517,16 @@ func runKVMSSHScript(client *ssh.Client, script string, description string, time
}
}
func kvmSSHSetupScript(password string, publicKeys ...string) string {
func kvmSSHSetupScript(password string, disablePubkeyAuth bool, publicKeys ...string) string {
publicKey := ""
if len(publicKeys) > 0 {
publicKey = strings.TrimSpace(publicKeys[0])
}
return `set -u
pubkeyValue := "yes"
if disablePubkeyAuth {
pubkeyValue = "no"
}
script := `set -u
ROOT_PASSWORD=` + shellQuote(password) + `
SSH_PUBLIC_KEY=` + shellQuote(publicKey) + `
export DEBIAN_FRONTEND=noninteractive
@@ -2435,7 +2555,7 @@ fi
mkdir -p /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF'
PermitRootLogin yes
PubkeyAuthentication yes
PubkeyAuthentication __CLICD_PUBKEY_AUTH__
PasswordAuthentication yes
KbdInteractiveAuthentication yes
ChallengeResponseAuthentication yes
@@ -2443,8 +2563,8 @@ EOF
if [ -f /etc/ssh/sshd_config ]; then
grep -q '^PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config
grep -q '^#PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true
grep -q '^PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || printf '\nPubkeyAuthentication yes\n' >> /etc/ssh/sshd_config
grep -q '^#PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || true
grep -q '^PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PubkeyAuthentication .*/PubkeyAuthentication __CLICD_PUBKEY_AUTH__/' /etc/ssh/sshd_config || printf '\nPubkeyAuthentication __CLICD_PUBKEY_AUTH__\n' >> /etc/ssh/sshd_config
grep -q '^#PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PubkeyAuthentication .*/PubkeyAuthentication __CLICD_PUBKEY_AUTH__/' /etc/ssh/sshd_config || true
grep -q '^PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || printf '\nPasswordAuthentication yes\n' >> /etc/ssh/sshd_config
grep -q '^#PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true
grep -q '^KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || printf '\nKbdInteractiveAuthentication yes\n' >> /etc/ssh/sshd_config
@@ -2459,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
@@ -2485,6 +2608,8 @@ if [ -w /dev/tty1 ]; then
printf '\nCLICD VNC console is ready. Press Enter for login prompt.\n' >/dev/tty1 || true
fi
`
script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue)
return script
}
func kvmDesktopSetupScript(image Image) string {
+16 -9
View File
@@ -424,7 +424,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
}
}
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
}
if sshAccess.PublicKey != "" {
@@ -512,11 +512,13 @@ IPv6AcceptRA=no
}
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error {
func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode string) error {
_ = templateID
// Disable pubkey auth when user chose password-only mode (password or auto_password)
disablePubkey := sshAuthMode == SSHAuthPassword || sshAuthMode == SSHAuthAutoPassword
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false, disablePubkey))
if err != nil {
return err
}
@@ -1758,7 +1760,7 @@ func (m *Manager) EnsureSSH(id int) error {
config.SaveConfig()
}
script := sshSetupScript(true)
script := sshSetupScript(true, false) // keep pubkey enabled for runtime ensure
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
@@ -1826,7 +1828,11 @@ func (m *Manager) containerPortListening(lxcName string, port int) bool {
return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil
}
func sshSetupScript(startService bool) string {
func sshSetupScript(startService bool, disablePubkeyAuth bool) string {
pubkeyValue := "yes"
if disablePubkeyAuth {
pubkeyValue = "no"
}
script := `set -u
# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04).
@@ -1945,7 +1951,7 @@ ssh-keygen -A >/dev/null 2>&1 || true
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
PermitRootLogin yes
PubkeyAuthentication yes
PubkeyAuthentication __CLICD_PUBKEY_AUTH__
PasswordAuthentication yes
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
@@ -1953,7 +1959,7 @@ UsePAM no
EOF
set_sshd_option PermitRootLogin yes
set_sshd_option PubkeyAuthentication yes
set_sshd_option PubkeyAuthentication __CLICD_PUBKEY_AUTH__
set_sshd_option PasswordAuthentication yes
set_sshd_option KbdInteractiveAuthentication no
set_sshd_option ChallengeResponseAuthentication no
@@ -1981,6 +1987,7 @@ ensure_sshd_runtime_dir
exit 32
}
`
script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue)
if !startService {
return script
}
@@ -2054,7 +2061,7 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
return "", err
}
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
if err := m.preconfigureSSH(rootfsPath, c.Template); err != nil {
if err := m.preconfigureSSH(rootfsPath, c.Template, ""); err != nil {
return "", fmt.Errorf("failed to configure SSH: %v", err)
}
if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
@@ -2626,7 +2633,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
}
}
c.SSHPassword = sshAccess.Password
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
if err := m.preconfigureSSH(rootfsPath, templateID, sshAccess.Mode); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
}
if sshAccess.PublicKey != "" {
+49
View File
@@ -59,11 +59,60 @@ func (m *Manager) ApplyPortMappings(id int) error {
}
}
// When container has public IPv4 but no port mappings (independent IP mode),
// ensure inbound DNAT for standard service ports (SSH / RDP).
if len(c.PortMappings) == 0 && len(c.PublicIPv4s) > 0 {
ensureIndependentIPv4Ingress(c, tag)
}
applyIPv4EgressPolicy(c, bridge, subnet, tag)
return nil
}
func ensureIndependentIPv4Ingress(c *config.Container, tag string) {
if c == nil || c.IP == "" || len(c.PublicIPv4s) == 0 {
return
}
type svcPort struct {
port int
protocol string
desc string
}
servicePorts := []svcPort{{port: 22, protocol: "tcp", desc: "SSH"}}
if strings.Contains(strings.ToLower(c.Template), "windows") {
servicePorts = []svcPort{{port: 3389, protocol: "tcp", desc: "RDP"}}
}
for _, assignment := range c.PublicIPv4s {
hostIP := strings.TrimSpace(assignment.Address)
if hostIP == "" {
continue
}
for _, svc := range servicePorts {
args := []string{
"-t", "nat",
"-I", "PREROUTING", "1",
"-d", hostIP,
"-p", svc.protocol,
"--dport", fmt.Sprintf("%d", svc.port),
"-j", "DNAT",
"--to-destination", fmt.Sprintf("%s:%d", c.IP, svc.port),
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-%d", tag, natRuleIPTag(hostIP), svc.port),
}
cmd := exec.Command("iptables", args...)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Warning: failed to apply %s ingress %s:%d->%s:%d: %v, output: %s\n",
svc.desc, hostIP, svc.port, c.IP, svc.port, err, string(output))
continue
}
fmt.Printf("%s ingress: %s:%d -> %s:%d\n", svc.desc, hostIP, svc.port, c.IP, svc.port)
}
}
}
func applyIPv4EgressPolicy(c *config.Container, bridge, subnet, tag string) {
if c == nil || strings.TrimSpace(c.IP) == "" {
return
+1 -1
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.1.13"
Version = "1.1.15"
Repo = "MengMengCode/CLICD"
)
+1 -1
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "clicd-frontend",
"private": true,
"version": "1.1.13",
"version": "1.1.15",
"type": "module",
"scripts": {
"dev": "vite",
+31 -4
View File
@@ -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)
@@ -770,8 +771,26 @@ export default function ContainerDetail() {
if (ipv6List.length === 0 && container.ipv6) ipv6List.push(container.ipv6)
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const publicEndpoint = container.ssh_port > 0 ? `${publicHost}:${container.ssh_port}` : '-'
const sshCommand = container.ssh_port > 0 ? `ssh -p ${container.ssh_port} root@${publicHost}` : ''
const hasIndependentIPv4 = assignedIPv4List.length > 0
const hasIndependentIPv6 = ipv6List.length > 0
const hasIndependentIP = hasIndependentIPv4 || hasIndependentIPv6
const defaultConnPort = isWindows ? 3389 : 22
let publicEndpoint = '-'
let sshCommand = ''
if (container.ssh_port > 0) {
// NAT port mapping mode
publicEndpoint = `${publicHost}:${container.ssh_port}`
sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
} else if (hasIndependentIP) {
// Direct connection via independent IPv4 or IPv6
const connIP = hasIndependentIPv4 ? assignedIPv4List[0] : `[${ipv6List[0]}]`
publicEndpoint = `${connIP}:${defaultConnPort}`
if (!isWindows) {
sshCommand = `ssh root@${connIP}`
}
}
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389
@@ -854,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>
@@ -1652,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>
+13 -3
View File
@@ -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>
+2 -2
View File
@@ -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.13</p>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.15</p>
</div>
</div>
)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 179 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 91 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB