Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c303fe6d17 | |||
| eedb2d7fb0 | |||
| bfc98d043b | |||
| 0a4cf5c0bd | |||
| 54a608c19d | |||
| 73bd6934f9 | |||
| a35595edcf | |||
| b605df613e | |||
| 3cc8f7df7f | |||
| 744246c0a8 | |||
| aed512cb09 | |||
| b6f48acc4c |
@@ -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 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 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
|
||||

|
||||
|
||||
## Installation / 安装
|
||||
|
||||
@@ -77,9 +47,52 @@ One-click Uninstall / 一键卸载:
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
## 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 / 预览
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
## 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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.13"
|
||||
Version = "1.1.15"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
@@ -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.13",
|
||||
"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)
|
||||
@@ -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>
|
||||
|
||||
@@ -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.13</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.15</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 159 KiB |