Compare commits

...

17 Commits

Author SHA1 Message Date
MengMengCode 9a826add87 release: v1.1.11 2026-06-09 22:48:20 +08:00
MengMengCode 63611dc932 Support webSSH Origin Allowlist 2026-06-09 22:48:09 +08:00
MengMengCode e6551bf4ae release: v1.1.10 2026-06-09 22:25:42 +08:00
MengMengCode 5bf2b6534a Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-09 22:22:42 +08:00
MengMengCode 917afc3157 支持公网IPV4分配,单独IPV6分配,以及混合网络分配。 2026-06-09 22:22:40 +08:00
Meng Meng c8081edbac Update README.md 2026-06-09 16:50:19 +08:00
Meng Meng 95eb00a31d Update Buy Me a Coffee username in FUNDING.yml 2026-06-09 15:56:08 +08:00
MengMengCode f4edf94800 release: v1.1.9 2026-06-09 15:34:06 +08:00
MengMengCode 82b42e7961 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-09 15:33:19 +08:00
MengMengCode e971d99070 Refactor code structure for improved readability and maintainability 2026-06-09 15:33:17 +08:00
Meng Meng 8dd09ff009 Revise badges and add support indicators in README
Updated badge descriptions and added new badges for supported features.
2026-06-09 15:33:13 +08:00
Meng Meng 7f4755788a Enhance README with features and tech stack
Added detailed features and technology stack information to README.
2026-06-09 15:26:55 +08:00
Meng Meng c24df1d42f Add Let's Encrypt badge to README 2026-06-09 15:16:40 +08:00
MengMengCode cf00d0d03d 加入了certbot检测 2026-06-09 13:49:09 +08:00
MengMengCode 79be2d5cbd 修复了一些已知问题 2026-06-09 13:35:08 +08:00
MengMengCode e66327db29 release: v1.1.8 2026-06-09 13:14:08 +08:00
MengMengCode 2b4fe4f5bc 修复webvnc 2026-06-09 13:13:51 +08:00
48 changed files with 7131 additions and 1217 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: mengmengcode
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+36 -8
View File
@@ -10,14 +10,42 @@
<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">
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
<img alt="LXC" src="https://img.shields.io/badge/LXC-container-111111?style=flat-square">
<img alt="KVM" src="https://img.shields.io/badge/KVM-virtualization-EE0000?style=flat-square&logo=linux&logoColor=white">
<img alt="LXC" src="https://img.shields.io/badge/LXC-Supported-111111?style=flat-square">
<img alt="KVM" src="https://img.shields.io/badge/KVM-Supported-EE0000?style=flat-square">
</p>
<p align="center">
<img alt="WebSSH" src="https://img.shields.io/badge/WebSSH-Built--in-009688?style=flat-square">
<img alt="VNC" src="https://img.shields.io/badge/VNC-Supported-7B1FA2?style=flat-square">
<img alt="IPv6" src="https://img.shields.io/badge/IPv6-Native-1976D2?style=flat-square">
<img alt="NAT" src="https://img.shields.io/badge/NAT-Port_Forwarding-FF9800?style=flat-square">
<img alt="REST API" src="https://img.shields.io/badge/API-REST-4CAF50?style=flat-square">
<img alt="Multi User" src="https://img.shields.io/badge/Multi_User-Supported-8E24AA?style=flat-square">
<img alt="Traffic Control" src="https://img.shields.io/badge/Traffic-Control-795548?style=flat-square">
<img alt="Security Alert" src="https://img.shields.io/badge/Security-Alert-orange?style=flat-square">
<img alt="CLI" src="https://img.shields.io/badge/CLI-Mode-424242?style=flat-square">
<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 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
## 功能介绍
## 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 地址段。
@@ -28,22 +56,22 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制
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 / 安装
一键安装:
One-click Install / 一键安装:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
```
一键卸载:
One-click Uninstall / 一键卸载:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
@@ -83,4 +111,4 @@ This open-source software is intended solely for educational purposes, specifica
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
</picture>
</a>
</a>
+28 -16
View File
@@ -228,13 +228,38 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
if cfg.DiskGB < 1 {
cfg.DiskGB = 5
}
if cfg.PortMappingCount < 2 {
if cfg.PortMappingCount < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
return
}
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
} else if !cfg.WantsNAT() {
cfg.PortMappingCount = 0
cfg.ExtraPorts = nil
}
if cfg.PortMappingCount > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
return
}
if cfg.IPv4Count < 0 || cfg.IPv6Count < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot be negative"})
return
}
if cfg.IPv4Count > 64 || cfg.IPv6Count > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot exceed 64"})
return
}
if !cfg.AssignIPv4 && len(cfg.PublicIPv4s) == 0 {
cfg.IPv4Count = 0
}
if !cfg.AssignIPv6 && len(cfg.IPv6Addresses) == 0 {
cfg.IPv6Count = 0
}
if !hasRequestedNetwork(cfg) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: noNetworkSelectedMessage})
return
}
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
@@ -405,24 +430,11 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
// Find a random unused port between 10000-65535
used := map[int]bool{}
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
}
// Also check all containers
for _, oc := range config.AppConfig.Containers {
if oc.ID == id {
continue
}
for _, pm := range oc.PortMappings {
used[pm.HostPort] = true
}
}
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
// Try random ports
for tries := 0; tries < 100; tries++ {
port := 10000 + (int(time.Now().UnixNano()) % 55535)
if !used[port] {
if lxc.HostPortAvailable(c, hostIP, port, "tcp") {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
return
}
+115 -7
View File
@@ -85,6 +85,7 @@ type HostDiskProbe struct {
Serial string `json:"serial"`
SizeBytes uint64 `json:"size_bytes"`
Type string `json:"type"`
Virtual bool `json:"virtual"`
Rotational bool `json:"rotational"`
Mountpoints []string `json:"mountpoints"`
Health string `json:"health"`
@@ -201,6 +202,7 @@ type NetworkInfo struct {
TXBps float64 `json:"tx_bps"`
PublicIPv4 string `json:"public_ipv4"`
PublicIPv4Interface string `json:"public_ipv4_interface"`
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
PublicIPv6 string `json:"public_ipv6"`
PublicIPv6Interface string `json:"public_ipv6_interface"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
@@ -398,7 +400,8 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
publicIPv4 := lxc.DetectPublicIPv4()
network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface
network.IPv6Prefixes = lxc.DetectPublicIPv6Prefixes()
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
if len(network.IPv6Prefixes) > 0 {
network.PublicIPv6 = network.IPv6Prefixes[0].Address
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
@@ -540,7 +543,7 @@ func getHostProbeReport() HostProbeReport {
Disks: detectHostDisks(),
NetworkInterfaces: detectHostNICs(),
PublicIPv4: detectAllPublicIPv4(),
IPv6Prefixes: lxc.DetectPublicIPv6Prefixes(),
IPv6Prefixes: lxc.DetectHostPublicIPv6Prefixes(),
Gateways: detectGateways(),
GPUs: detectGPUs(),
System: detectSystemProbe(),
@@ -676,17 +679,23 @@ func detectHostDisks() []HostDiskProbe {
}
base := filepath.Join("/sys/block", name)
path := "/dev/" + name
model := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name")))
vendor := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/vendor")))
virtual := isVirtualBlockDevice(name, model, vendor)
disk := HostDiskProbe{
Name: name,
Path: path,
Model: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name"))),
Model: model,
Serial: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/serial"), filepath.Join(base, "serial"))),
SizeBytes: readUintFile(filepath.Join(base, "size")) * 512,
Type: detectDiskType(base, name),
Type: detectDiskType(base, name, virtual),
Virtual: virtual,
Rotational: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1",
Mountpoints: mounts[name],
}
disk.SMART = detectDiskSMART(path)
if !virtual {
disk.SMART = detectDiskSMART(path)
}
disk.Health = disk.SMARTHealth()
disk.HealthDetail = disk.SMARTDetail()
disks = append(disks, disk)
@@ -695,7 +704,10 @@ func detectHostDisks() []HostDiskProbe {
return disks
}
func detectDiskType(base, name string) string {
func detectDiskType(base, name string, virtual bool) string {
if virtual {
return "Virtual"
}
if strings.HasPrefix(name, "nvme") {
return "NVMe"
}
@@ -705,7 +717,26 @@ func detectDiskType(base, name string) string {
return "SSD"
}
func isVirtualBlockDevice(name, model, vendor string) bool {
lower := strings.ToLower(strings.TrimSpace(name + " " + model + " " + vendor))
if strings.HasPrefix(name, "vd") || strings.HasPrefix(name, "xvd") {
return true
}
for _, token := range []string{
"qemu", "virtio", "virtual", "vmware", "vbox", "xen",
"amazon elastic block store", "google persistentdisk", "microsoft",
} {
if strings.Contains(lower, token) {
return true
}
}
return false
}
func (disk HostDiskProbe) SMARTHealth() string {
if disk.Virtual {
return "virtual"
}
if disk.SMART.Available && disk.Health != "" {
return disk.Health
}
@@ -713,6 +744,9 @@ func (disk HostDiskProbe) SMARTHealth() string {
}
func (disk HostDiskProbe) SMARTDetail() string {
if disk.Virtual {
return "虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看"
}
return disk.SMART.Detail()
}
@@ -1424,6 +1458,7 @@ func detectHostEnvironment() []HostEnvCheck {
commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"),
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
certbotCheck(),
}
checks = append(checks, HostEnvCheck{Key: "dev-kvm", Label: "/dev/kvm 硬件虚拟化", OK: fileExists("/dev/kvm"), Required: false, Detail: boolDetail(fileExists("/dev/kvm"))})
checks = append(checks, HostEnvCheck{Key: "ipv4-forward", Label: "IPv4 转发", OK: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward")) == "1", Required: true, Detail: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward"))})
@@ -1436,7 +1471,7 @@ func commandCheck(key, label string, required bool, cmd string, fallback string)
ok := commandExists(cmd)
detail := "missing"
if ok {
detail = strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
detail = commandVersionDetail(cmd)
if detail == "" {
detail = "installed"
}
@@ -1446,6 +1481,79 @@ func commandCheck(key, label string, required bool, cmd string, fallback string)
return HostEnvCheck{Key: key, Label: label, OK: ok, Required: required, Detail: detail}
}
func commandVersionDetail(cmd string) string {
switch cmd {
case "ip":
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", "ip -V 2>&1 | head -n 1"))
default:
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
}
}
func certbotCheck() HostEnvCheck {
check := HostEnvCheck{Key: "certbot", Label: "Certbot 证书工具 >= 5.4", Required: false, Detail: "missing"}
if !commandExists("certbot") {
return check
}
detail := strings.TrimSpace(runCommandOutput(3*time.Second, "certbot", "--version"))
if detail == "" {
detail = strings.TrimSpace(runCommandOutput(3*time.Second, "sh", "-c", "certbot --version 2>&1 | head -n 1"))
}
if detail == "" {
detail = "installed, version unknown"
}
check.Detail = detail
version := extractCertbotVersion(detail)
check.OK = certbotVersionAtLeast(version, 5, 4)
if version == "" {
check.Detail = detail + " (version unknown, need >= 5.4)"
} else if !check.OK {
check.Detail = detail + " (need >= 5.4)"
}
return check
}
func extractCertbotVersion(output string) string {
for _, field := range strings.Fields(output) {
field = strings.Trim(field, "vV,;:()[]{}")
if field == "" || field[0] < '0' || field[0] > '9' {
continue
}
return field
}
return ""
}
func certbotVersionAtLeast(version string, minMajor, minMinor int) bool {
parts := strings.Split(version, ".")
if len(parts) < 2 {
return false
}
major, err := strconv.Atoi(numericPrefix(parts[0]))
if err != nil {
return false
}
minor, err := strconv.Atoi(numericPrefix(parts[1]))
if err != nil {
return false
}
if major != minMajor {
return major > minMajor
}
return minor >= minMinor
}
func numericPrefix(value string) string {
var b strings.Builder
for _, r := range value {
if r < '0' || r > '9' {
break
}
b.WriteRune(r)
}
return b.String()
}
func envCheckOK(checks []HostEnvCheck, key string) bool {
for _, check := range checks {
if check.Key == key {
+43
View File
@@ -0,0 +1,43 @@
package api
import "testing"
func TestExtractCertbotVersion(t *testing.T) {
tests := []struct {
output string
want string
}{
{"certbot 5.4.0", "5.4.0"},
{"certbot v5.10.1", "5.10.1"},
{"certbot, version 4.9", "4.9"},
{"installed", ""},
}
for _, tt := range tests {
if got := extractCertbotVersion(tt.output); got != tt.want {
t.Fatalf("extractCertbotVersion(%q) = %q, want %q", tt.output, got, tt.want)
}
}
}
func TestCertbotVersionAtLeast54(t *testing.T) {
tests := []struct {
version string
want bool
}{
{"5.4", true},
{"5.4.0", true},
{"5.10", true},
{"6.0.0", true},
{"5.3.9", false},
{"4.99", false},
{"5", false},
{"", false},
}
for _, tt := range tests {
if got := certbotVersionAtLeast(tt.version, 5, 4); got != tt.want {
t.Fatalf("certbotVersionAtLeast(%q, 5, 4) = %v, want %v", tt.version, got, tt.want)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"clicd/internal/config"
)
type webSSHOriginSettingsRequest struct {
Origins []string `json:"origins"`
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
}
type webSSHOriginSettingsResponse struct {
Origins []string `json:"origins"`
CurrentOrigin string `json:"current_origin,omitempty"`
}
func HandleWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: webSSHOriginSettingsStatus(r)})
case http.MethodPut:
updateWebSSHOriginSettings(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func updateWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
var req webSSHOriginSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
origins := req.Origins
if len(origins) == 0 && len(req.WebSSHAllowedOrigins) > 0 {
origins = req.WebSSHAllowedOrigins
}
normalized, err := config.NormalizeAllowedOrigins(origins)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
config.AppConfig.WebSSHAllowedOrigins = normalized
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save Origin allowlist failed"})
return
}
auditRequest(r, "settings.webssh_origins", "WebSSH Origin", "origins="+strings.Join(normalized, ","), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Origin allowlist saved", Data: webSSHOriginSettingsStatus(r)})
}
func webSSHOriginSettingsStatus(r *http.Request) webSSHOriginSettingsResponse {
origins := config.AppConfig.WebSSHAllowedOrigins
if origins == nil {
origins = []string{}
}
return webSSHOriginSettingsResponse{
Origins: origins,
CurrentOrigin: requestOrigin(r),
}
}
func requestOrigin(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if host == "" {
return ""
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
scheme = strings.ToLower(strings.Split(forwarded, ",")[0])
}
return scheme + "://" + host
}
+236 -19
View File
@@ -1,7 +1,9 @@
package api
import (
"encoding/json"
"net/http"
"net/netip"
"sort"
"strconv"
@@ -21,12 +23,24 @@ type nat4Route struct {
LXCName string `json:"lxc_name"`
Status string `json:"status"`
IP string `json:"ip"`
HostIP string `json:"host_ip"`
HostPort int `json:"host_port"`
ContainerPort int `json:"container_port"`
Protocol string `json:"protocol"`
Description string `json:"description"`
}
type ipv4Route struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
LXCName string `json:"lxc_name"`
Status string `json:"status"`
Address string `json:"address"`
Interface string `json:"interface"`
PrefixLen int `json:"prefix_len,omitempty"`
Gateway string `json:"gateway,omitempty"`
}
type ipv6Route struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
@@ -38,30 +52,78 @@ type ipv6Route struct {
}
type routingResponse struct {
NAT4 routeCapacity `json:"nat4"`
IPv6 routeCapacity `json:"ipv6"`
NAT4Mappings []nat4Route `json:"nat4_mappings"`
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
NAT4 routeCapacity `json:"nat4"`
IPv4 routeCapacity `json:"ipv4"`
IPv6 routeCapacity `json:"ipv6"`
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
NAT4Mappings []nat4Route `json:"nat4_mappings"`
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
}
type routingPoolsRequest struct {
Addresses *[]string `json:"addresses"`
Items *[]config.PublicIPv4Assignment `json:"items"`
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
}
type publicIPv4ScanRequest struct {
CIDR string `json:"cidr"`
Interface string `json:"interface"`
Gateway string `json:"gateway"`
Verify bool `json:"verify"`
Limit int `json:"limit"`
}
func HandleRouting(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
switch r.Method {
case http.MethodGet:
handleRoutingGet(w, r)
case http.MethodPut:
handleRoutingPoolsUpdate(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func HandleRoutingIPv4Scan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "routing:write") {
return
}
var req publicIPv4ScanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
results, err := lxc.ScanPublicIPv4Segment(req.CIDR, req.Interface, req.Gateway, req.Verify, req.Limit)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
}
func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
if !requireScope(w, r, "routing:read") {
return
}
nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{}
ipv4Assignments := make([]ipv4Route, 0)
ipv6Assignments := make([]ipv6Route, 0)
const nat4StartPort = 20000
const nat4EndPort = 65535
for _, c := range config.AppConfig.Containers {
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
for _, pm := range c.PortMappings {
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
usedPorts[pm.HostPort] = true
@@ -72,30 +134,56 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
LXCName: c.LxcName(),
Status: c.Status,
IP: c.IP,
HostIP: pm.HostIP,
HostPort: pm.HostPort,
ContainerPort: pm.ContainerPort,
Protocol: pm.Protocol,
Description: pm.Description,
})
}
if c.IPv6 != "" {
for _, ip := range c.PublicIPv4s {
if ip.Address == "" {
continue
}
ipv4Assignments = append(ipv4Assignments, ipv4Route{
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: c.LxcName(),
Status: c.Status,
Address: ip.Address,
Interface: ip.Interface,
PrefixLen: ip.PrefixLen,
Gateway: ip.Gateway,
})
}
c.NormalizeNetworkAssignments()
for _, ip := range c.IPv6Addresses {
if ip.Address == "" {
continue
}
ipv6Assignments = append(ipv6Assignments, ipv6Route{
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: c.LxcName(),
Status: c.Status,
Address: c.IPv6,
PrefixLen: c.IPv6PrefixLen,
Interface: c.IPv6Interface,
Address: ip.Address,
PrefixLen: ip.PrefixLen,
Interface: ip.Interface,
})
}
}
sort.SliceStable(nat4Mappings, func(i, j int) bool {
if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort {
if nat4Mappings[i].HostIP != nat4Mappings[j].HostIP {
return nat4Mappings[i].HostIP < nat4Mappings[j].HostIP
}
return nat4Mappings[i].ContainerName < nat4Mappings[j].ContainerName
}
return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort
})
sort.SliceStable(ipv4Assignments, func(i, j int) bool {
return ipv4Assignments[i].Address < ipv4Assignments[j].Address
})
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
})
@@ -108,12 +196,16 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
}
prefixes := lxc.DetectPublicIPv6Prefixes()
ipv6Total := "0"
ipv6Remaining := "0"
if len(prefixes) > 0 {
ipv6Total = lxc.IPv6PrefixCapacity(prefixes[0].PrefixLen)
ipv6Remaining = subtractCapacity(ipv6Total, len(ipv6Assignments))
hostPublicIPv4 := lxc.DetectPublicIPv4()
publicIPv4s := lxc.DetectPublicIPv4Candidates()
ipv4Total := len(publicIPv4s)
ipv4Used := len(ipv4Assignments)
ipv4Remaining := ipv4Total - ipv4Used
if ipv4Remaining < 0 {
ipv4Remaining = 0
}
ipv6Total := totalIPv6Capacity(prefixes)
ipv6Remaining := subtractCapacity(ipv6Total, len(ipv6Assignments))
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
@@ -123,18 +215,143 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
Remaining: strconv.Itoa(nat4Remaining),
Total: strconv.Itoa(totalNAT4Ports),
},
IPv4: routeCapacity{
Used: ipv4Used,
Remaining: strconv.Itoa(ipv4Remaining),
Total: strconv.Itoa(ipv4Total),
},
IPv6: routeCapacity{
Used: len(ipv6Assignments),
Remaining: ipv6Remaining,
Total: ipv6Total,
},
NAT4Mappings: nat4Mappings,
IPv6Assignments: ipv6Assignments,
IPv6Prefixes: prefixes,
HostPublicIPv4: hostPublicIPv4,
PublicIPv4Addresses: publicIPv4s,
IPv4Assignments: ipv4Assignments,
NAT4Mappings: nat4Mappings,
IPv6Assignments: ipv6Assignments,
IPv6Prefixes: prefixes,
},
})
}
func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) {
if !requireScope(w, r, "routing:write") {
return
}
var req routingPoolsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.Items != nil || req.Addresses != nil {
items := []config.PublicIPv4Assignment{}
if req.Items != nil {
items = *req.Items
} else if req.Addresses != nil {
items = make([]config.PublicIPv4Assignment, 0, len(*req.Addresses))
for _, address := range *req.Addresses {
items = append(items, config.PublicIPv4Assignment{Address: address})
}
}
normalized, err := lxc.NormalizePublicIPv4Pool(items)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
allowed := map[string]bool{}
for _, item := range normalized {
allowed[item.Address] = true
}
for _, c := range config.AppConfig.Containers {
for _, item := range c.PublicIPv4s {
if item.Address != "" && !allowed[item.Address] {
jsonResponse(w, http.StatusBadRequest, APIResponse{
Success: false,
Message: "IPv4 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
})
return
}
}
}
config.AppConfig.PublicIPv4Pool = normalized
}
if req.IPv6Prefixes != nil {
normalized, err := lxc.NormalizePublicIPv6Prefixes(*req.IPv6Prefixes)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
parsedPrefixes := make([]netip.Prefix, 0, len(normalized))
for _, item := range normalized {
prefix, err := netip.ParsePrefix(item.Prefix)
if err == nil {
parsedPrefixes = append(parsedPrefixes, prefix)
}
}
for _, c := range config.AppConfig.Containers {
c.NormalizeNetworkAssignments()
for _, item := range c.IPv6Addresses {
if item.Address == "" {
continue
}
addr, err := netip.ParseAddr(item.Address)
if err != nil {
continue
}
contained := false
for _, prefix := range parsedPrefixes {
if prefix.Contains(addr) {
contained = true
break
}
}
if !contained {
jsonResponse(w, http.StatusBadRequest, APIResponse{
Success: false,
Message: "IPv6 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
})
return
}
}
}
config.AppConfig.PublicIPv6Prefixes = normalized
}
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save configuration"})
return
}
handleRoutingGet(w, r)
}
func totalIPv6Capacity(prefixes []lxc.IPv6PrefixInfo) string {
if len(prefixes) == 0 {
return "0"
}
var total uint64
for _, prefix := range prefixes {
capacity := lxc.IPv6PrefixCapacity(prefix.PrefixLen)
if capacity == "large" {
return "large"
}
parsed, err := strconv.ParseUint(capacity, 10, 64)
if err != nil {
continue
}
if ^uint64(0)-total < parsed {
return "large"
}
total += parsed
}
if total == 0 {
return "0"
}
return strconv.FormatUint(total, 10)
}
func subtractCapacity(total string, used int) string {
if total == "" || total == "0" {
return "0"
+6
View File
@@ -13,10 +13,16 @@ import (
var kvmManager = kvm.NewManager()
const noNetworkSelectedMessage = "请勾选任意一个可用网络"
func runtimeFromRequest(value string) string {
return config.NormalizeVirtualization(value)
}
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool {
return cfg.WantsNAT() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
}
func runtimeFromTemplateID(templateID string) string {
if kvm.FindImage(templateID) != nil {
return config.VirtualizationKVM
+28
View File
@@ -20,6 +20,34 @@ type LoginLog struct {
var loginLogs = make([]LoginLog, 0)
// HandleLanguage returns or updates the global panel language.
func HandleLanguage(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
"language": config.NormalizeLanguage(config.AppConfig.Language),
}})
case http.MethodPost, http.MethodPut:
var req struct {
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
config.AppConfig.Language = config.NormalizeLanguage(req.Language)
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save language"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
"language": config.AppConfig.Language,
}})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
// RecordLoginLog adds a login attempt to the log (persisted to config)
func RecordLoginLog(username, ip, userAgent string, success bool) {
config.AddLoginLog(username, ip, userAgent, success)
+37 -30
View File
@@ -17,7 +17,6 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -92,10 +91,12 @@ func updateSSLSettings(w http.ResponseWriter, r *http.Request) {
if target == "" {
target = detectedRequestHost(r)
}
if target == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "SSL target is required"})
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
target = normalizedTarget
next, err := resolveSSLModeCertificate(mode, target, strings.TrimSpace(req.Email), req.CertPEM, req.KeyPEM)
if err != nil {
@@ -220,12 +221,10 @@ func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
if _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)); err != nil {
return "", "", fmt.Errorf("certificate/private key mismatch: %v", err)
}
dir := sslStorageDir()
if err := os.MkdirAll(dir, 0700); err != nil {
certPath, keyPath, err := config.UploadedSSLPaths()
if err != nil {
return "", "", err
}
certPath := filepath.Join(dir, "uploaded-fullchain.pem")
keyPath := filepath.Join(dir, "uploaded-privkey.pem")
if err := os.WriteFile(certPath, []byte(certPEM+"\n"), 0600); err != nil {
return "", "", err
}
@@ -237,9 +236,11 @@ func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
func generateSelfSignedCertificate(target string) (string, string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", "", fmt.Errorf("self-signed certificate target is required")
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
if err != nil {
return "", "", err
}
target = normalizedTarget
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", err
@@ -273,12 +274,10 @@ func generateSelfSignedCertificate(target string) (string, string, error) {
if err != nil {
return "", "", err
}
dir := sslStorageDir()
if err := os.MkdirAll(dir, 0700); err != nil {
certPath, keyPath, err := config.SelfSignedSSLPaths()
if err != nil {
return "", "", err
}
certPath := filepath.Join(dir, "self-signed-fullchain.pem")
keyPath := filepath.Join(dir, "self-signed-privkey.pem")
certOut := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyOut := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certOut, 0600); err != nil {
@@ -295,9 +294,11 @@ func requestLetsEncryptCertificate(target, email string) (string, string, error)
return "", "", fmt.Errorf("certbot is not installed on this server")
}
target = strings.TrimSpace(target)
if target == "" {
return "", "", fmt.Errorf("Let's Encrypt target is required")
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
if err != nil {
return "", "", err
}
target = normalizedTarget
args := []string{"certonly", "--non-interactive", "--agree-tos", "--standalone"}
if email != "" {
args = append(args, "--email", email)
@@ -317,12 +318,14 @@ func requestLetsEncryptCertificate(target, email string) (string, string, error)
if err != nil {
return "", "", fmt.Errorf("Let's Encrypt request failed: %s", strings.TrimSpace(string(output)))
}
certPath := filepath.Join("/etc/letsencrypt/live", target, "fullchain.pem")
keyPath := filepath.Join("/etc/letsencrypt/live", target, "privkey.pem")
if _, err := os.Stat(certPath); err != nil {
certPath, keyPath, err := config.LetsEncryptSSLPaths(target)
if err != nil {
return "", "", err
}
if _, err := config.ReadableFileStat(certPath); err != nil {
return "", "", fmt.Errorf("Let's Encrypt certificate file not found after issuance: %s", certPath)
}
if _, err := os.Stat(keyPath); err != nil {
if _, err := config.ReadableFileStat(keyPath); err != nil {
return "", "", fmt.Errorf("Let's Encrypt private key file not found after issuance: %s", keyPath)
}
return certPath, keyPath, nil
@@ -347,11 +350,19 @@ func ensureCertbotSupportsIPCertificates() error {
}
func validateCertificatePair(certPath, keyPath string) error {
certPEM, err := os.ReadFile(certPath)
safeCertPath, err := config.ResolveSSLPath(certPath)
if err != nil {
return err
}
keyPEM, err := os.ReadFile(keyPath)
safeKeyPath, err := config.ResolveSSLPath(keyPath)
if err != nil {
return err
}
certPEM, err := os.ReadFile(safeCertPath)
if err != nil {
return err
}
keyPEM, err := os.ReadFile(safeKeyPath)
if err != nil {
return err
}
@@ -443,7 +454,11 @@ func readLeafCertificate(certPath string) (*x509.Certificate, error) {
if certPath == "" {
return nil, errors.New("certificate path is empty")
}
data, err := os.ReadFile(certPath)
safeCertPath, err := config.ResolveSSLPath(certPath)
if err != nil {
return nil, err
}
data, err := os.ReadFile(safeCertPath)
if err != nil {
return nil, err
}
@@ -494,14 +509,6 @@ func firstPublicInterfaceIP() string {
return ""
}
func sslStorageDir() string {
dataDir := config.AppConfig.DataDir
if dataDir == "" {
dataDir = "/root/.clicd"
}
return filepath.Join(dataDir, "ssl")
}
func maskExistingPath(path string) string {
if path == "" {
return ""
+30 -1
View File
@@ -575,8 +575,37 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
return
}
if req.Containers[i].PortMappingCount < 2 {
if req.Containers[i].PortMappingCount < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
return
}
if req.Containers[i].WantsNAT() && req.Containers[i].PortMappingCount < 2 {
req.Containers[i].PortMappingCount = 2
} else if !req.Containers[i].WantsNAT() {
req.Containers[i].PortMappingCount = 0
req.Containers[i].ExtraPorts = nil
}
if req.Containers[i].PortMappingCount > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 64"})
return
}
if req.Containers[i].IPv4Count < 0 || req.Containers[i].IPv6Count < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot be negative"})
return
}
if req.Containers[i].IPv4Count > 64 || req.Containers[i].IPv6Count > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot exceed 64"})
return
}
if !req.Containers[i].AssignIPv4 && len(req.Containers[i].PublicIPv4s) == 0 {
req.Containers[i].IPv4Count = 0
}
if !req.Containers[i].AssignIPv6 && len(req.Containers[i].IPv6Addresses) == 0 {
req.Containers[i].IPv6Count = 0
}
if !hasRequestedNetwork(req.Containers[i]) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + noNetworkSelectedMessage})
return
}
if req.Containers[i].SnapshotLimit <= 0 {
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
+3 -17
View File
@@ -1,10 +1,9 @@
package api
import (
"net"
"net/http"
"net/url"
"strings"
"clicd/internal/config"
"github.com/gorilla/websocket"
)
@@ -17,19 +16,6 @@ var upgrader = websocket.Upgrader{
if origin == "" {
return true
}
originURL, err := url.Parse(origin)
if err != nil {
return false
}
originHost := strings.ToLower(stripPort(originURL.Host))
requestHost := strings.ToLower(stripPort(r.Host))
return originHost != "" && originHost == requestHost
return config.IsOriginAllowed(origin, r.Host)
},
}
func stripPort(host string) string {
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
return parsedHost
}
return strings.Trim(host, "[]")
}
+395 -115
View File
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -26,18 +27,170 @@ const (
libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
)
var cliEnglish = detectCLIEnglish()
var cliTranslations = map[string]string{
"重新加载配置失败": "Failed to reload config",
"请选择操作": "Select an action",
"再见": "Goodbye",
"无效选择": "Invalid choice",
"CLICD - LXC 容器管理器": "CLICD - LXC Container Manager",
"Web 面板": "Web panel",
"端口": "port",
"运行中": "running",
"已停止": "stopped",
"当前版本": "Current version",
"查看容器列表": "List containers",
"创建容器": "Create container",
"开机容器": "Start container",
"关机容器": "Stop container",
"重启容器": "Restart container",
"删除容器": "Delete container",
"重装容器系统": "Reinstall container OS",
"重置 Web 管理员密码": "Reset web admin password",
"启动": "Start",
"停止": "Stop",
"导入现有 LXC 容器": "Import existing LXC containers",
"检查并升级 CLICD": "Check and upgrade CLICD",
"卸载 CLICD": "Uninstall CLICD",
"系统信息": "System info",
"退出": "Exit",
"获取容器列表失败": "Failed to get container list",
"暂无容器": "No containers",
"容器": "Container",
"名称": "Name",
"状态": "Status",
"镜像": "Image",
"内存(MB)": "Memory(MB)",
"磁盘(GB)": "Disk(GB)",
"容器名称": "Container name",
"容器名称不能为空": "Container name cannot be empty",
"可用镜像": "Available images",
"镜像选择无效": "Invalid image selection",
"内存 (MB)": "Memory (MB)",
"磁盘 (GB)": "Disk (GB)",
"网络带宽 (Mbps)": "Network bandwidth (Mbps)",
"月流量 (GB)": "Monthly traffic (GB)",
"IO 速度 (MB/s)": "IO speed (MB/s)",
"额外 NAT 端口,多个用逗号分隔": "Extra NAT ports, comma-separated",
"正在创建容器": "Creating container",
"创建失败": "Create failed",
"创建成功": "created successfully",
"端口未分配": "port not assigned",
"密码已保存,请在 Web 面板中查看或重置": "Password saved. View or reset it in the web panel",
"开机失败": "Start failed",
"已开机": "started",
"关机失败": "Stop failed",
"已关机": "stopped",
"重启失败": "Restart failed",
"已重启": "restarted",
"开机": "start",
"关机": "stop",
"重启": "restart",
"删除": "delete",
"重装": "reinstall",
"确认删除容器": "Delete container",
"输入 yes 继续": "type yes to continue",
"已取消": "Cancelled",
"删除失败": "Delete failed",
"已删除": "deleted",
"确认重装容器": "Reinstall container",
"重装失败": "Reinstall failed",
"已重装": "reinstalled",
"新的管理员密码(至少 6 位)": "New admin password (at least 6 characters)",
"密码至少需要 6 位": "Password must be at least 6 characters",
"确认密码": "Confirm password",
"两次输入的密码不一致": "Passwords do not match",
"管理员密码已重置。": "Admin password has been reset.",
"按 Enter 返回菜单": "Press Enter to return to menu",
"选择要": "Select a container to ",
"的容器": "",
"选择无效": "Invalid selection",
"主机名": "Hostname",
"管理员用户": "Admin user",
"容器总数": "Total containers",
"切换语言": "Switch language",
"当前语言": "Current language",
"请选择语言": "Select language",
"语言已切换为": "Language switched to",
"保存语言失败": "Failed to save language",
"简体中文": "Simplified Chinese",
"重置失败": "Reset failed",
"停止 Web 面板失败": "Failed to stop web panel",
"Web 面板已停止,LXC 容器不会受影响。": "Web panel stopped. LXC containers are not affected.",
"启动 Web 面板失败": "Failed to start web panel",
"Web 面板已启动": "Web panel started",
"升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。": "The upgrade only replaces /usr/local/bin/clicd and keeps configuration, container data, and task records under /root/.clicd.",
"升级需要 root 权限。请使用: sudo clicd cli": "Upgrade requires root privileges. Use: sudo clicd cli",
"检查仓库": "Checking repository",
"检查 GitHub 最新版本失败": "Failed to check the latest GitHub version",
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
"最新版本": "Latest version",
"发布页面": "Release page",
"最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。": "The latest release does not contain clicd-linux-amd64.tar.gz, so automatic upgrade is unavailable.",
"当前已经是最新版本。": "The current version is already the latest.",
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
"已取消。": "Cancelled.",
"升级失败": "Upgrade failed",
"升级完成": "Upgrade completed",
"原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
"GitHub API 返回": "GitHub API returned",
"GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
"GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
"GitHub releases/latest 返回": "GitHub releases/latest returned",
"无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
"正在下载升级包...": "Downloading upgrade package...",
"正在解压升级包...": "Extracting upgrade package...",
"解压失败": "Extraction failed",
"备份旧二进制失败": "Failed to back up old binary",
"旧版本已备份": "Old version backed up",
"正在替换二进制...": "Replacing binary...",
"停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
"二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
"下载失败,HTTP": "Download failed, HTTP",
"升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package",
"将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.",
"导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.",
"导入失败": "Import failed",
"没有发现新的 ct-* 容器。": "No new ct-* containers found.",
"已导入": "Imported",
"个容器": "containers",
"将删除 CLICD 服务和 /usr/local/bin/clicd。": "This will remove the CLICD service and /usr/local/bin/clicd.",
"同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。": "It will also remove /root/.clicd, /var/lib/lxc, /var/lib/clicd, image caches, backups, temporary files, /swapfile, and CLICD network rules.",
"卸载需要 root 权限。": "Uninstall requires root privileges.",
"请运行: sudo clicd cli --no-web": "Run: sudo clicd cli --no-web",
"输入 uninstall 继续卸载": "Type uninstall to continue uninstalling",
"CLICD 已卸载。": "CLICD has been uninstalled.",
"服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。": "Service, binary, configuration, containers/VMs, local images, cache, backups, temporary files, and CLICD network rules have been removed.",
"检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。": "Non-CLICD VMs are still using the libvirt default network, so default/virbr0 has been kept.",
"Web 面板重载跳过": "Web panel reload skipped",
"Web 面板已重载并应用配置变更。": "Web panel reloaded and configuration changes applied.",
"读取容器状态失败": "Failed to read container status",
"CLICD 版本": "CLICD version",
"Web 端口": "Web port",
"LXC 版本": "LXC version",
"暂无可用容器": "No available containers",
"忽略无效端口": "Ignoring invalid port",
"": "? ",
"。": ". ",
"": ", ",
"": ": ",
}
// Run starts the CLI interface.
func Run() {
reader := bufio.NewReader(os.Stdin)
for {
if _, err := config.InitConfig(); err != nil {
fmt.Printf("重新加载配置失败: %v\n", err)
cliPrintf("重新加载配置失败: %v\n", err)
waitEnter(reader)
}
refreshCLILanguage()
clearScreen()
printMenu()
fmt.Print("\n请选择操作 [1-12,0/q]: ")
cliPrint("\n请选择操作 [1-12,l,0/q]: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
@@ -94,11 +247,15 @@ func Run() {
clearScreen()
cliShowInfo()
waitEnter(reader)
case "l", "lang", "language":
clearScreen()
cliSwitchLanguage(reader)
waitEnter(reader)
case "q", "exit", "quit":
fmt.Println("再见")
cliPrintln("再见")
return
default:
fmt.Println("无效选择")
cliPrintln("无效选择")
}
}
}
@@ -108,49 +265,86 @@ func printMenu() {
if isWebPanelRunning() {
webStatus = "停止"
}
fmt.Println()
fmt.Println(" ==========================================")
fmt.Println(" CLICD - LXC 容器管理器")
fmt.Println(" ==========================================")
fmt.Println()
fmt.Printf(" Web 面板: %s (端口 %d)\n", func() string {
cliPrintln("")
cliPrintln(" ==========================================")
cliPrintln(" CLICD - LXC 容器管理器")
cliPrintln(" ==========================================")
cliPrintln("")
cliPrintf(" Web 面板: %s (端口 %d)\n", func() string {
if isWebPanelRunning() {
return "运行中"
}
return "已停止"
}(), config.AppConfig.Port)
fmt.Printf(" 当前版本: %s\n", version.Current())
fmt.Println()
fmt.Println(" 1. 查看容器列表")
fmt.Println(" 2. 创建容器")
fmt.Println(" 3. 开机容器")
fmt.Println(" 4. 关机容器")
fmt.Println(" 5. 重启容器")
fmt.Println(" 6. 删除容器")
fmt.Println(" 7. 重装容器系统")
fmt.Println(" 8. 重置 Web 管理员密码")
fmt.Printf(" 9. %s Web 面板\n", webStatus)
fmt.Println(" 10. 导入现有 LXC 容器")
fmt.Println(" 11. 检查并升级 CLICD")
fmt.Println(" 12. 卸载 CLICD")
fmt.Println(" 0. 系统信息")
fmt.Println(" q. 退出")
cliPrintf(" 当前版本: %s\n", version.Current())
cliPrintln("")
cliPrintln(" 1. 查看容器列表")
cliPrintln(" 2. 创建容器")
cliPrintln(" 3. 开机容器")
cliPrintln(" 4. 关机容器")
cliPrintln(" 5. 重启容器")
cliPrintln(" 6. 删除容器")
cliPrintln(" 7. 重装容器系统")
cliPrintln(" 8. 重置 Web 管理员密码")
cliPrintf(" 9. %s Web 面板\n", webStatus)
cliPrintln(" 10. 导入现有 LXC 容器")
cliPrintln(" 11. 检查并升级 CLICD")
cliPrintln(" 12. 卸载 CLICD")
cliPrintln(" 0. 系统信息")
cliPrintln(" l. 切换语言")
cliPrintln(" q. 退出")
}
func cliSwitchLanguage(reader *bufio.Reader) {
cliPrintf("\n--- %s ---\n", cliT("切换语言"))
cliPrintf("%s: %s\n", cliT("当前语言"), cliLanguageLabel(config.NormalizeLanguage(config.AppConfig.Language)))
cliPrintln(" 1. 简体中文")
cliPrintln(" 2. English")
choice := promptString(reader, "请选择语言 [1/2]", func() string {
if config.NormalizeLanguage(config.AppConfig.Language) == "en" {
return "2"
}
return "1"
}())
next := "zh"
switch strings.ToLower(strings.TrimSpace(choice)) {
case "2", "en", "english":
next = "en"
case "1", "zh", "cn", "chinese":
next = "zh"
default:
cliPrintln("无效选择")
return
}
config.AppConfig.Language = next
if err := config.SaveConfig(); err != nil {
cliPrintf("保存语言失败: %v\n", err)
return
}
_ = os.Setenv("CLICD_LANG", next)
refreshCLILanguage()
cliPrintf("%s: %s\n", cliT("语言已切换为"), cliLanguageLabel(next))
if isWebPanelRunning() {
restartWebPanelForConfigChange()
}
}
func cliListContainers() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("获取容器列表失败: %v\n", err)
cliPrintf("获取容器列表失败: %v\n", err)
return
}
if len(containers) == 0 {
fmt.Println("\n暂无容器")
cliPrintln("\n暂无容器")
return
}
fmt.Println()
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", "名称", "状态", "镜像", "vCPU", "内存(MB)", "磁盘(GB)", "SSH")
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", cliT("名称"), cliT("状态"), cliT("镜像"), "vCPU", cliT("内存(MB)"), cliT("磁盘(GB)"), "SSH")
fmt.Println(strings.Repeat("-", 94))
for _, c := range containers {
ssh := "-"
@@ -163,23 +357,23 @@ func cliListContainers() {
}
func cliCreateContainer(reader *bufio.Reader) {
fmt.Println("\n--- 创建容器 ---")
cliPrintln("\n--- 创建容器 ---")
name := promptString(reader, "容器名称", "")
if name == "" {
fmt.Println("容器名称不能为空")
cliPrintln("容器名称不能为空")
return
}
templates := lxc.GetTemplates()
fmt.Println("\n可用镜像:")
cliPrintln("\n可用镜像:")
for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name)
}
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("镜像选择无效")
cliPrintln("镜像选择无效")
return
}
@@ -195,16 +389,16 @@ func cliCreateContainer(reader *bufio.Reader) {
ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"),
}
fmt.Printf("\n正在创建容器 %s ...\n", name)
cliPrintf("\n正在创建容器 %s ...\n", name)
if err := manager.CreateContainer(cfg); err != nil {
fmt.Printf("创建失败: %v\n", err)
cliPrintf("创建失败: %v\n", err)
return
}
container := config.FindContainerByName(name)
fmt.Printf("容器 %s 创建成功\n", name)
cliPrintf("容器 %s 创建成功\n", name)
if container != nil {
fmt.Print(formatSSHAccess(container.SSHPort))
cliPrint(formatSSHAccess(container.SSHPort))
}
restartWebPanelForConfigChange()
}
@@ -222,10 +416,10 @@ func cliStartContainer(reader *bufio.Reader) {
return
}
if err := manager.StartContainer(id); err != nil {
fmt.Printf("开机失败: %v\n", err)
cliPrintf("开机失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已开机\n", name)
cliPrintf("容器 %s 已开机\n", name)
}
func cliStopContainer(reader *bufio.Reader) {
@@ -234,10 +428,10 @@ func cliStopContainer(reader *bufio.Reader) {
return
}
if err := manager.StopContainer(id); err != nil {
fmt.Printf("关机失败: %v\n", err)
cliPrintf("关机失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已关机\n", name)
cliPrintf("容器 %s 已关机\n", name)
}
func cliRestartContainer(reader *bufio.Reader) {
@@ -246,10 +440,10 @@ func cliRestartContainer(reader *bufio.Reader) {
return
}
if err := manager.RestartContainer(id); err != nil {
fmt.Printf("重启失败: %v\n", err)
cliPrintf("重启失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已重启\n", name)
cliPrintf("容器 %s 已重启\n", name)
}
func cliDeleteContainer(reader *bufio.Reader) {
@@ -259,14 +453,14 @@ func cliDeleteContainer(reader *bufio.Reader) {
}
confirm := promptString(reader, fmt.Sprintf("确认删除容器 %s?输入 yes 继续", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("已取消")
cliPrintln("已取消")
return
}
if err := manager.DestroyContainer(id); err != nil {
fmt.Printf("删除失败: %v\n", err)
cliPrintf("删除失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已删除\n", name)
cliPrintf("容器 %s 已删除\n", name)
restartWebPanelForConfigChange()
}
@@ -277,66 +471,66 @@ func cliReinstallContainer(reader *bufio.Reader) {
}
templates := lxc.GetTemplates()
fmt.Println("\n可用镜像:")
cliPrintln("\n可用镜像:")
for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name)
}
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("镜像选择无效")
cliPrintln("镜像选择无效")
return
}
confirm := promptString(reader, fmt.Sprintf("确认重装容器 %s?输入 yes 继续", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("已取消")
cliPrintln("已取消")
return
}
if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil {
fmt.Printf("重装失败: %v\n", err)
cliPrintf("重装失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已重装\n", name)
cliPrintf("容器 %s 已重装\n", name)
restartWebPanelForConfigChange()
}
func cliResetPassword(reader *bufio.Reader) {
newPass := promptString(reader, "新的管理员密码(至少 6 位)", "")
if len(newPass) < 6 {
fmt.Println("密码至少需要 6 位")
cliPrintln("密码至少需要 6 位")
return
}
confirm := promptString(reader, "确认密码", "")
if newPass != confirm {
fmt.Println("两次输入的密码不一致")
cliPrintln("两次输入的密码不一致")
return
}
if err := config.ResetAdminPassword(newPass); err != nil {
fmt.Printf("重置失败: %v\n", err)
cliPrintf("重置失败: %v\n", err)
return
}
fmt.Println("管理员密码已重置。")
cliPrintln("管理员密码已重置。")
restartWebPanelForConfigChange()
}
func cliToggleWebPanel() {
if isWebPanelRunning() {
if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 面板失败: %v\n", err)
cliPrintf("停止 Web 面板失败: %v\n", err)
return
}
fmt.Println("Web 面板已停止,LXC 容器不会受影响。")
cliPrintln("Web 面板已停止,LXC 容器不会受影响。")
return
}
if err := startService("clicd"); err != nil {
fmt.Printf("启动 Web 面板失败: %v\n", err)
cliPrintf("启动 Web 面板失败: %v\n", err)
return
}
fmt.Println("Web 面板已启动")
cliPrintln("Web 面板已启动")
}
type githubRelease struct {
@@ -350,11 +544,11 @@ type githubRelease struct {
}
func cliUpgradeSystem(reader *bufio.Reader) {
fmt.Println("\n--- 检查并升级 CLICD ---")
fmt.Println("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。")
cliPrintln("\n--- 检查并升级 CLICD ---")
cliPrintln("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。")
if os.Geteuid() != 0 {
fmt.Println("升级需要 root 权限。请使用: sudo clicd cli")
cliPrintln("升级需要 root 权限。请使用: sudo clicd cli")
return
}
@@ -363,51 +557,51 @@ func cliUpgradeSystem(reader *bufio.Reader) {
repo = version.Repo
}
current := version.Current()
fmt.Printf("当前版本: %s\n", current)
fmt.Printf("检查仓库: https://github.com/%s\n", repo)
cliPrintf("当前版本: %s\n", current)
cliPrintf("检查仓库: https://github.com/%s\n", repo)
release, err := fetchLatestRelease(repo)
if err != nil {
fmt.Printf("检查 GitHub 最新版本失败: %v\n", err)
cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
return
}
latest := strings.TrimSpace(release.TagName)
if latest == "" {
fmt.Println("GitHub Release 没有 tag_name,无法判断最新版本。")
cliPrintln("GitHub Release 没有 tag_name,无法判断最新版本。")
return
}
fmt.Printf("最新版本: %s\n", latest)
cliPrintf("最新版本: %s\n", latest)
if release.HTMLURL != "" {
fmt.Printf("发布页面: %s\n", release.HTMLURL)
cliPrintf("发布页面: %s\n", release.HTMLURL)
}
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz")
if assetURL == "" {
fmt.Println("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
return
}
if sameVersion(current, latest) {
fmt.Println("当前已经是最新版本。")
cliPrintln("当前已经是最新版本。")
confirm := promptString(reader, "是否仍然重新安装最新版本?输入 reinstall 继续", "no")
if strings.ToLower(confirm) != "reinstall" {
fmt.Println("已取消。")
cliPrintln("已取消。")
return
}
} else {
confirm := promptString(reader, "输入 upgrade 开始升级", "no")
if strings.ToLower(confirm) != "upgrade" {
fmt.Println("已取消。")
cliPrintln("已取消。")
return
}
}
if err := upgradeFromReleaseAsset(assetURL, latest); err != nil {
fmt.Printf("升级失败: %v\n", err)
cliPrintf("升级失败: %v\n", err)
return
}
fmt.Printf("升级完成: %s -> %s\n", current, latest)
fmt.Println("原有数据已保留,Web 服务已重启。")
cliPrintf("升级完成: %s -> %s\n", current, latest)
cliPrintln("原有数据已保留,Web 服务已重启。")
}
func fetchLatestRelease(repo string) (*githubRelease, error) {
@@ -434,9 +628,9 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
fmt.Println("GitHub API 被限流,已切换到备用检查方式。")
cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
} else {
fmt.Println("GitHub API 不可用,已切换到备用检查方式。")
cliPrintln("GitHub API 不可用,已切换到备用检查方式。")
}
return fallback, nil
}
@@ -530,12 +724,12 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz")
fmt.Println("正在下载升级包...")
cliPrintln("正在下载升级包...")
if err := downloadFile(assetURL, archivePath); err != nil {
return err
}
fmt.Println("正在解压升级包...")
cliPrintln("正在解压升级包...")
if out, err := exec.Command("tar", "-xzf", archivePath, "-C", tmpDir).CombinedOutput(); err != nil {
return fmt.Errorf("解压失败: %v, output: %s", err, string(out))
}
@@ -555,12 +749,12 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
if err != nil {
return fmt.Errorf("备份旧二进制失败: %w", err)
}
fmt.Printf("旧版本已备份: %s\n", backupPath)
cliPrintf("旧版本已备份: %s\n", backupPath)
}
fmt.Println("正在替换二进制...")
cliPrintln("正在替换二进制...")
if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
cliPrintf("停止 Web 服务失败,继续尝试替换: %v\n", err)
}
tmpBin := clicdNewBinaryPath
if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
@@ -715,21 +909,21 @@ func isWebPanelRunning() bool {
}
func cliImportExistingContainers() {
fmt.Println("\n--- 导入现有 LXC 容器 ---")
fmt.Println("将 /var/lib/lxc 里的容器导入 CLICD 配置。")
fmt.Println("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。")
cliPrintln("\n--- 导入现有 LXC 容器 ---")
cliPrintln("将 /var/lib/lxc 里的容器导入 CLICD 配置。")
cliPrintln("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。")
imported, err := manager.ImportExistingClicdContainers()
if err != nil {
fmt.Printf("导入失败: %v\n", err)
cliPrintf("导入失败: %v\n", err)
return
}
if len(imported) == 0 {
fmt.Println("没有发现新的 ct-* 容器。")
cliPrintln("没有发现新的 ct-* 容器。")
return
}
fmt.Printf("已导入 %d 个容器:\n", len(imported))
cliPrintf("已导入 %d 个容器:\n", len(imported))
for _, c := range imported {
fmt.Printf(" [%d] %s [%s]\n", c.ID, c.Name, c.Status)
}
@@ -737,19 +931,19 @@ func cliImportExistingContainers() {
}
func cliUninstall(reader *bufio.Reader) {
fmt.Println("\n--- 卸载 CLICD ---")
fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。")
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
cliPrintln("\n--- 卸载 CLICD ---")
cliPrintln("将删除 CLICD 服务和 /usr/local/bin/clicd。")
cliPrintln("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
if os.Geteuid() != 0 {
fmt.Println("卸载需要 root 权限。")
fmt.Println("请运行: sudo clicd cli --no-web")
cliPrintln("卸载需要 root 权限。")
cliPrintln("请运行: sudo clicd cli --no-web")
return
}
confirm := promptString(reader, "输入 uninstall 继续卸载", "no")
if strings.ToLower(confirm) != "uninstall" {
fmt.Println("已取消")
cliPrintln("已取消")
return
}
@@ -776,8 +970,8 @@ func cliUninstall(reader *bufio.Reader) {
reloadSysctl()
fmt.Println()
fmt.Println("CLICD 已卸载。")
fmt.Println("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
cliPrintln("CLICD 已卸载。")
cliPrintln("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
}
func destroyAllLXCContainers() {
@@ -847,7 +1041,7 @@ func removeCLICDLibvirtDefaultNetwork() {
return
}
if libvirtDefaultUsedByNonCLICDDomain() {
fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
cliPrintln("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
return
}
fmt.Println("Removing CLICD-created libvirt default network...")
@@ -1242,10 +1436,10 @@ func shellQuote(value string) string {
func restartWebPanelForConfigChange() {
if err := restartService("clicd"); err != nil {
fmt.Printf("Web 面板重载跳过: %v\n", err)
cliPrintf("Web 面板重载跳过: %v\n", err)
return
}
fmt.Println("Web 面板已重载并应用配置变更。")
cliPrintln("Web 面板已重载并应用配置变更。")
}
func stopService(name string) error {
@@ -1281,7 +1475,7 @@ func restartService(name string) error {
func cliShowInfo() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("读取容器状态失败: %v\n", err)
cliPrintf("读取容器状态失败: %v\n", err)
}
total := len(containers)
@@ -1292,44 +1486,44 @@ func cliShowInfo() {
}
}
fmt.Println("\n--- 系统信息 ---")
fmt.Printf("CLICD 版本: %s\n", version.Current())
fmt.Printf("Web 端口: %d\n", config.AppConfig.Port)
fmt.Printf("管理员用户: %s\n", config.AppConfig.AdminUser)
fmt.Printf("容器总数: %d\n", total)
fmt.Printf("运行中: %d\n", running)
fmt.Printf("已停止: %d\n", total-running)
cliPrintln("\n--- 系统信息 ---")
cliPrintf("CLICD 版本: %s\n", version.Current())
cliPrintf("Web 端口: %d\n", config.AppConfig.Port)
cliPrintf("管理员用户: %s\n", config.AppConfig.AdminUser)
cliPrintf("容器总数: %d\n", total)
cliPrintf("运行中: %d\n", running)
cliPrintf("已停止: %d\n", total-running)
if hostname, err := os.Hostname(); err == nil {
fmt.Printf("主机名: %s\n", hostname)
cliPrintf("主机名: %s\n", hostname)
}
cmd := exec.Command("lxc-info", "--version")
output, err := cmd.Output()
if err == nil {
fmt.Printf("LXC 版本: %s", string(output))
cliPrintf("LXC 版本: %s", string(output))
}
}
func selectContainer(reader *bufio.Reader, action string) (int, string) {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("获取容器列表失败: %v\n", err)
cliPrintf("获取容器列表失败: %v\n", err)
return 0, ""
}
if len(containers) == 0 {
fmt.Println("暂无可用容器")
cliPrintln("暂无可用容器")
return 0, ""
}
fmt.Printf("\n--- 选择要%s的容器 ---\n", action)
cliPrintf("\n--- 选择要%s的容器 ---\n", cliT(action))
for i, container := range containers {
fmt.Printf(" %d. [%d] %s [%s]\n", i+1, container.ID, container.Name, container.Status)
}
idx := promptInt(reader, "容器", 0)
if idx < 1 || idx > len(containers) {
fmt.Println("选择无效")
cliPrintln("选择无效")
return 0, ""
}
@@ -1338,6 +1532,7 @@ func selectContainer(reader *bufio.Reader, action string) (int, string) {
}
func promptString(reader *bufio.Reader, label string, fallback string) string {
label = cliT(label)
if fallback == "" {
fmt.Printf("%s: ", label)
} else {
@@ -1375,7 +1570,7 @@ func clearScreen() {
}
func waitEnter(reader *bufio.Reader) {
fmt.Print("\n按 Enter 返回菜单...")
cliPrint("\n按 Enter 返回菜单...")
reader.ReadString('\n')
}
@@ -1389,10 +1584,95 @@ func promptPortList(reader *bufio.Reader, label string) []int {
for _, part := range strings.Split(input, ",") {
value, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || value <= 0 || value > 65535 {
fmt.Printf("忽略无效端口: %s\n", strings.TrimSpace(part))
cliPrintf("忽略无效端口: %s\n", strings.TrimSpace(part))
continue
}
ports = append(ports, value)
}
return ports
}
func detectCLIEnglish() bool {
lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG")))
if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") {
return true
}
if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") {
return false
}
if config.AppConfig != nil {
return config.NormalizeLanguage(config.AppConfig.Language) == "en"
}
env := strings.ToLower(os.Getenv("LC_ALL") + " " + os.Getenv("LC_MESSAGES") + " " + os.Getenv("LANG"))
return strings.Contains(env, "en_") || strings.Contains(env, "en-") || strings.Contains(env, "english")
}
func refreshCLILanguage() {
cliEnglish = detectCLIEnglish()
}
func cliLanguageLabel(language string) string {
if config.NormalizeLanguage(language) == "en" {
return "English"
}
return cliT("简体中文")
}
func cliT(text string) string {
if !cliEnglish {
return text
}
translated := text
keys := make([]string, 0, len(cliTranslations))
for zh := range cliTranslations {
keys = append(keys, zh)
}
sort.Slice(keys, func(i, j int) bool {
if len(keys[i]) == len(keys[j]) {
return keys[i] < keys[j]
}
return len(keys[i]) > len(keys[j])
})
for _, zh := range keys {
en := cliTranslations[zh]
translated = strings.ReplaceAll(translated, zh, en)
}
return translated
}
func cliPrint(args ...interface{}) {
if cliEnglish {
for i, arg := range args {
if s, ok := arg.(string); ok {
args[i] = cliT(s)
}
}
}
fmt.Print(args...)
}
func cliPrintln(args ...interface{}) {
if cliEnglish {
for i, arg := range args {
if s, ok := arg.(string); ok {
args[i] = cliT(s)
}
}
}
fmt.Println(args...)
}
func cliPrintf(format string, args ...interface{}) {
if cliEnglish {
for i, arg := range args {
if s, ok := arg.(string); ok {
args[i] = cliT(s)
continue
}
if err, ok := arg.(error); ok {
args[i] = cliT(err.Error())
}
}
}
fmt.Printf(cliT(format), args...)
}
+254 -80
View File
@@ -17,10 +17,32 @@ import (
type PortMapping struct {
ContainerPort int `json:"container_port"`
HostPort int `json:"host_port"`
HostIP string `json:"host_ip,omitempty"`
Protocol string `json:"protocol"`
Description string `json:"description"`
}
type PublicIPv4Assignment struct {
Address string `json:"address"`
Interface string `json:"interface,omitempty"`
PrefixLen int `json:"prefix_len,omitempty"`
Gateway string `json:"gateway,omitempty"`
}
type IPv6Assignment struct {
Address string `json:"address"`
PrefixLen int `json:"prefix_len"`
Interface string `json:"interface,omitempty"`
}
type PublicIPv6Prefix struct {
Address string `json:"address"`
Prefix string `json:"prefix,omitempty"`
PrefixLen int `json:"prefix_len"`
Interface string `json:"interface,omitempty"`
Gateway string `json:"gateway,omitempty"`
}
// SavedTask for persisting task queue across restarts
type SavedTask struct {
ID string `json:"id"`
@@ -68,50 +90,52 @@ type VMReadinessCheck struct {
// Container represents an LXC container configuration
type Container struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
LXCName string `json:"lxc_name,omitempty"`
KVMName string `json:"kvm_name,omitempty"`
DiskImage string `json:"disk_image,omitempty"`
MACAddress string `json:"mac_address,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
TrafficUsedRX int64 `json:"traffic_used_rx"`
TrafficUsedTX int64 `json:"traffic_used_tx"`
TrafficResetDate string `json:"traffic_reset_date"`
IOSpeedMBps int `json:"io_speed_mbps"`
Status string `json:"status"`
IP string `json:"ip"`
IPv6 string `json:"ipv6"`
IPv6PrefixLen int `json:"ipv6_prefix_len"`
IPv6Interface string `json:"ipv6_interface"`
VNCPort int `json:"vnc_port"`
SSHPort int `json:"ssh_port"`
SSHPassword string `json:"ssh_password"`
SSHHostKey string `json:"ssh_host_key,omitempty"`
PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"`
SnapshotLimit int `json:"snapshot_limit"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"`
SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"`
SnapshotScheduleTime string `json:"snapshot_schedule_time"`
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
PolicyBlocked bool `json:"policy_blocked"`
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
LXCName string `json:"lxc_name,omitempty"`
KVMName string `json:"kvm_name,omitempty"`
DiskImage string `json:"disk_image,omitempty"`
MACAddress string `json:"mac_address,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
TrafficUsedRX int64 `json:"traffic_used_rx"`
TrafficUsedTX int64 `json:"traffic_used_tx"`
TrafficResetDate string `json:"traffic_reset_date"`
IOSpeedMBps int `json:"io_speed_mbps"`
Status string `json:"status"`
IP string `json:"ip"`
PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
IPv6 string `json:"ipv6"`
IPv6PrefixLen int `json:"ipv6_prefix_len"`
IPv6Interface string `json:"ipv6_interface"`
IPv6Addresses []IPv6Assignment `json:"ipv6_addresses,omitempty"`
VNCPort int `json:"vnc_port"`
SSHPort int `json:"ssh_port"`
SSHPassword string `json:"ssh_password"`
SSHHostKey string `json:"ssh_host_key,omitempty"`
PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"`
SnapshotLimit int `json:"snapshot_limit"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"`
SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"`
SnapshotScheduleTime string `json:"snapshot_schedule_time"`
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
PolicyBlocked bool `json:"policy_blocked"`
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
}
const (
@@ -136,6 +160,101 @@ func (c *Container) IsKVM() bool {
return c.Runtime() == VirtualizationKVM
}
func (c *Container) NormalizeNetworkAssignments() bool {
changed := false
seenIPv4 := map[string]bool{}
filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s))
for _, item := range c.PublicIPv4s {
item.Address = strings.TrimSpace(item.Address)
item.Interface = strings.TrimSpace(item.Interface)
item.Gateway = strings.TrimSpace(item.Gateway)
if item.Address == "" || seenIPv4[item.Address] {
if item.Address != "" {
changed = true
}
continue
}
seenIPv4[item.Address] = true
filteredIPv4 = append(filteredIPv4, item)
}
if len(filteredIPv4) != len(c.PublicIPv4s) {
changed = true
}
c.PublicIPv4s = filteredIPv4
seenIPv6 := map[string]bool{}
filteredIPv6 := make([]IPv6Assignment, 0, len(c.IPv6Addresses)+1)
for _, item := range c.IPv6Addresses {
item.Address = strings.TrimSpace(item.Address)
item.Interface = strings.TrimSpace(item.Interface)
if item.Address == "" || seenIPv6[item.Address] {
if item.Address != "" {
changed = true
}
continue
}
seenIPv6[item.Address] = true
filteredIPv6 = append(filteredIPv6, item)
}
if strings.TrimSpace(c.IPv6) != "" && !seenIPv6[c.IPv6] {
filteredIPv6 = append([]IPv6Assignment{{
Address: c.IPv6,
PrefixLen: c.IPv6PrefixLen,
Interface: c.IPv6Interface,
}}, filteredIPv6...)
changed = true
}
if len(filteredIPv6) != len(c.IPv6Addresses) {
changed = true
}
c.IPv6Addresses = filteredIPv6
if len(c.IPv6Addresses) > 0 {
first := c.IPv6Addresses[0]
if c.IPv6 != first.Address || c.IPv6PrefixLen != first.PrefixLen || c.IPv6Interface != first.Interface {
c.IPv6 = first.Address
c.IPv6PrefixLen = first.PrefixLen
c.IPv6Interface = first.Interface
changed = true
}
} else if c.IPv6 != "" || c.IPv6PrefixLen != 0 || c.IPv6Interface != "" {
c.IPv6 = ""
c.IPv6PrefixLen = 0
c.IPv6Interface = ""
changed = true
}
return changed
}
func (c *Container) PublicIPv4Addresses() []string {
values := make([]string, 0, len(c.PublicIPv4s))
for _, item := range c.PublicIPv4s {
if item.Address != "" {
values = append(values, item.Address)
}
}
return values
}
func (c *Container) PrimaryPublicIPv4() string {
if len(c.PublicIPv4s) == 0 {
return ""
}
return c.PublicIPv4s[0].Address
}
func (c *Container) IPv6AddressStrings() []string {
values := make([]string, 0, len(c.IPv6Addresses))
for _, item := range c.IPv6Addresses {
if item.Address != "" {
values = append(values, item.Address)
}
}
if len(values) == 0 && c.IPv6 != "" {
values = append(values, c.IPv6)
}
return values
}
// LxcName returns the internal LXC container name (ct-{id})
func (c *Container) LxcName() string {
if c.LXCName != "" {
@@ -225,26 +344,30 @@ type SSLConfig struct {
// ClicdConfig is the main configuration structure
type ClicdConfig struct {
AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"`
Port int `json:"port"`
DataDir string `json:"data_dir"`
Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"`
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
SSL SSLConfig `json:"ssl"`
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"`
Port int `json:"port"`
DataDir string `json:"data_dir"`
Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"`
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"`
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
Language string `json:"language"`
SSL SSLConfig `json:"ssl"`
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
}
var configPath string
@@ -358,21 +481,24 @@ func InitConfig() (*ClicdConfig, error) {
}
AppConfig = &ClicdConfig{
AdminUser: adminUser,
AdminPassHash: string(hash),
JWTSecret: jwtSecret,
Port: 8999,
DataDir: dataDir,
Containers: []Container{},
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Snapshots: []Snapshot{},
AdminUser: adminUser,
AdminPassHash: string(hash),
JWTSecret: jwtSecret,
Port: 8999,
DataDir: dataDir,
Containers: []Container{},
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Snapshots: []Snapshot{},
PublicIPv4Pool: []PublicIPv4Assignment{},
PublicIPv6Prefixes: []PublicIPv6Prefix{},
WebSSHAllowedOrigins: []string{},
}
if err := SaveConfig(); err != nil {
@@ -423,6 +549,21 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.Snapshots = make([]Snapshot, 0)
changed = true
}
if AppConfig.PublicIPv4Pool == nil {
AppConfig.PublicIPv4Pool = make([]PublicIPv4Assignment, 0)
changed = true
}
if AppConfig.PublicIPv6Prefixes == nil {
AppConfig.PublicIPv6Prefixes = make([]PublicIPv6Prefix, 0)
changed = true
}
if AppConfig.WebSSHAllowedOrigins == nil {
AppConfig.WebSSHAllowedOrigins = make([]string, 0)
changed = true
} else if normalized, err := NormalizeAllowedOrigins(AppConfig.WebSSHAllowedOrigins); err == nil && strings.Join(normalized, "\n") != strings.Join(AppConfig.WebSSHAllowedOrigins, "\n") {
AppConfig.WebSSHAllowedOrigins = normalized
changed = true
}
if AppConfig.SubUsers == nil {
AppConfig.SubUsers = make([]SubUser, 0)
changed = true
@@ -454,12 +595,29 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.EnabledImages = make([]string, 0)
changed = true
}
if AppConfig.Language == "" {
AppConfig.Language = "zh"
changed = true
}
if AppConfig.Language != "zh" && AppConfig.Language != "en" {
AppConfig.Language = "zh"
changed = true
}
if normalizeSSLDefaults() {
changed = true
}
return changed
}
func NormalizeLanguage(language string) string {
switch strings.ToLower(strings.TrimSpace(language)) {
case "en", "en-us", "en_us", "english":
return "en"
default:
return "zh"
}
}
func normalizeSSLDefaults() bool {
changed := false
previousMode := AppConfig.SSL.Mode
@@ -528,6 +686,9 @@ func migrateLoadedConfig() bool {
if ensureContainerSnapshotLimits() {
changed = true
}
if ensureContainerNetworkAssignments() {
changed = true
}
if ensureContainerSnapshotScheduleDefaults() {
changed = true
}
@@ -590,13 +751,16 @@ func ensureContainerUUIDs() bool {
func ensureContainerPortMappingLimits() bool {
changed := false
for i := range AppConfig.Containers {
if AppConfig.Containers[i].PortMappingLimit <= 0 {
if AppConfig.Containers[i].PortMappingLimit < 0 {
limit := len(AppConfig.Containers[i].PortMappings)
if limit < 2 {
limit = 2
}
AppConfig.Containers[i].PortMappingLimit = limit
changed = true
} else if AppConfig.Containers[i].PortMappingLimit == 0 && len(AppConfig.Containers[i].PortMappings) > 0 {
AppConfig.Containers[i].PortMappingLimit = len(AppConfig.Containers[i].PortMappings)
changed = true
}
}
return changed
@@ -613,6 +777,16 @@ func ensureContainerSnapshotLimits() bool {
return changed
}
func ensureContainerNetworkAssignments() bool {
changed := false
for i := range AppConfig.Containers {
if AppConfig.Containers[i].NormalizeNetworkAssignments() {
changed = true
}
}
return changed
}
func migrateSubUsers() bool {
changed := false
for i := range AppConfig.SubUsers {
+136
View File
@@ -0,0 +1,136 @@
package config
import (
"fmt"
"net"
"net/url"
"strings"
)
// NormalizeAllowedOrigin accepts a browser Origin value such as
// https://www.example.com and returns a canonical form for exact matching.
func NormalizeAllowedOrigin(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
u, err := url.Parse(value)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("Origin must include scheme and host: %s", value)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return "", fmt.Errorf("Origin scheme must be http or https: %s", value)
}
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("Origin must not include path, query, or fragment: %s", value)
}
host := normalizeOriginHostPort(u.Host, scheme)
if host == "" {
return "", fmt.Errorf("Origin host is required: %s", value)
}
return scheme + "://" + host, nil
}
func NormalizeAllowedOrigins(values []string) ([]string, error) {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
origin, err := NormalizeAllowedOrigin(value)
if err != nil {
return nil, err
}
if origin == "" || seen[origin] {
continue
}
seen[origin] = true
result = append(result, origin)
}
return result, nil
}
func IsOriginAllowed(origin string, requestHost string) bool {
origin = strings.TrimSpace(origin)
if origin == "" {
return true
}
if isSameRequestOrigin(origin, requestHost) {
return true
}
normalized, err := NormalizeAllowedOrigin(origin)
if err != nil {
return false
}
if AppConfig == nil {
return false
}
for _, allowed := range AppConfig.WebSSHAllowedOrigins {
allowed, err := NormalizeAllowedOrigin(allowed)
if err == nil && normalized == allowed {
return true
}
}
return false
}
func isSameRequestOrigin(origin string, requestHost string) bool {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
originHost := normalizeHostOnly(u.Hostname())
host := normalizeHostOnly(requestHost)
if originHost == "" || host == "" {
return false
}
if originHost == host {
return true
}
return isLoopbackHost(originHost) && isLoopbackHost(host)
}
func normalizeOriginHostPort(raw string, scheme string) string {
host := raw
port := ""
if h, p, err := net.SplitHostPort(raw); err == nil {
host = h
port = p
}
host = normalizeHostOnly(host)
if host == "" {
return ""
}
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
port = ""
}
if port != "" {
return net.JoinHostPort(host, port)
}
if strings.Contains(host, ":") && net.ParseIP(host) != nil {
return "[" + host + "]"
}
return host
}
func normalizeHostOnly(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if h, _, err := net.SplitHostPort(raw); err == nil {
raw = h
}
raw = strings.Trim(raw, "[]")
if ip := net.ParseIP(raw); ip != nil {
return strings.ToLower(ip.String())
}
return strings.TrimSuffix(strings.ToLower(raw), ".")
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
+175
View File
@@ -0,0 +1,175 @@
package config
import (
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strings"
)
const letsEncryptLiveDir = "/etc/letsencrypt/live"
var dnsNamePattern = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
func SSLStorageDir() string {
dataDir := ""
if AppConfig != nil {
dataDir = AppConfig.DataDir
}
if dataDir == "" {
dataDir = getDataDir()
}
return filepath.Join(dataDir, "ssl")
}
func UploadedSSLPaths() (string, string, error) {
dir, err := safeSSLStorageDir()
if err != nil {
return "", "", err
}
return filepath.Join(dir, "uploaded-fullchain.pem"), filepath.Join(dir, "uploaded-privkey.pem"), nil
}
func SelfSignedSSLPaths() (string, string, error) {
dir, err := safeSSLStorageDir()
if err != nil {
return "", "", err
}
return filepath.Join(dir, "self-signed-fullchain.pem"), filepath.Join(dir, "self-signed-privkey.pem"), nil
}
func LetsEncryptSSLPaths(target string) (string, string, error) {
name, err := NormalizeSSLCertificateTarget(target)
if err != nil {
return "", "", err
}
base := filepath.Join(letsEncryptLiveDir, name)
return filepath.Join(base, "fullchain.pem"), filepath.Join(base, "privkey.pem"), nil
}
func ResolveSSLConfigPaths(ssl SSLConfig) (string, string, error) {
mode := NormalizeSSLMode(ssl.Mode)
switch mode {
case SSLModeUploaded:
if ssl.CertPath != "" && ssl.KeyPath != "" {
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
}
return UploadedSSLPaths()
case SSLModeSelfSigned:
if ssl.CertPath != "" && ssl.KeyPath != "" {
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
}
return SelfSignedSSLPaths()
case SSLModeLetsEncrypt:
if strings.TrimSpace(ssl.Target) == "" && ssl.CertPath != "" && ssl.KeyPath != "" {
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
}
return LetsEncryptSSLPaths(ssl.Target)
default:
return "", "", fmt.Errorf("SSL is disabled")
}
}
func ResolveSSLPathPair(certPath, keyPath string) (string, string, error) {
safeCertPath, err := ResolveSSLPath(certPath)
if err != nil {
return "", "", err
}
safeKeyPath, err := ResolveSSLPath(keyPath)
if err != nil {
return "", "", err
}
return safeCertPath, safeKeyPath, nil
}
func ResolveSSLPath(path string) (string, error) {
cleaned, err := cleanAbsolutePath(path)
if err != nil {
return "", err
}
if isPathUnder(cleaned, SSLStorageDir()) || isPathUnder(cleaned, letsEncryptLiveDir) || isPathUnder(cleaned, "/etc/letsencrypt/archive") {
return cleaned, nil
}
return "", fmt.Errorf("SSL path is outside allowed certificate directories")
}
func ReadableFileStat(path string) (os.FileInfo, error) {
safePath, err := ResolveSSLPath(path)
if err != nil {
return nil, err
}
return os.Stat(safePath)
}
func NormalizeSSLCertificateTarget(target string) (string, error) {
target = strings.TrimSpace(strings.Trim(target, "[]"))
if target == "" {
return "", fmt.Errorf("SSL target is required")
}
if strings.Contains(target, "/") || strings.Contains(target, "\\") || strings.Contains(target, "..") {
return "", fmt.Errorf("SSL target contains invalid path characters")
}
if ip := net.ParseIP(target); ip != nil {
return ip.String(), nil
}
if len(target) > 253 || !dnsNamePattern.MatchString(target) {
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
}
labels := strings.Split(target, ".")
for _, label := range labels {
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
}
}
return strings.ToLower(target), nil
}
func safeSSLStorageDir() (string, error) {
dir, err := cleanAbsolutePath(SSLStorageDir())
if err != nil {
return "", err
}
dataDir := ""
if AppConfig != nil {
dataDir = AppConfig.DataDir
}
if dataDir == "" {
dataDir = getDataDir()
}
if !isPathUnder(dir, dataDir) {
return "", fmt.Errorf("SSL storage directory is outside the data directory")
}
if err := os.MkdirAll(dir, 0700); err != nil {
return "", err
}
return dir, nil
}
func cleanAbsolutePath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("path is empty")
}
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
return filepath.Clean(abs), nil
}
func isPathUnder(path, root string) bool {
cleanPath, err := cleanAbsolutePath(path)
if err != nil {
return false
}
cleanRoot, err := cleanAbsolutePath(root)
if err != nil {
return false
}
rel, err := filepath.Rel(cleanRoot, cleanPath)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
+186 -33
View File
@@ -20,24 +20,30 @@ var (
)
type savedTaskConfig struct {
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"`
TrafficInGB int `json:"traffic_in_gb"`
TrafficOutGB int `json:"traffic_out_gb"`
IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv6 bool `json:"assign_ipv6"`
ExpiresAt string `json:"expires_at"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"`
TrafficInGB int `json:"traffic_in_gb"`
TrafficOutGB int `json:"traffic_out_gb"`
IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv4 bool `json:"assign_ipv4"`
IPv4Count int `json:"ipv4_count,omitempty"`
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
AssignIPv6 bool `json:"assign_ipv6"`
IPv6Count int `json:"ipv6_count,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
ExpiresAt string `json:"expires_at"`
}
func parseSavedTaskConfig(raw string) savedTaskConfig {
@@ -175,10 +181,28 @@ func ensureSchema() error {
position INTEGER NOT NULL,
container_port INTEGER NOT NULL,
host_port INTEGER NOT NULL,
host_ip TEXT,
protocol TEXT,
description TEXT,
PRIMARY KEY (container_id, position)
)`,
`CREATE TABLE IF NOT EXISTS container_public_ipv4s (
container_id INTEGER NOT NULL,
position INTEGER NOT NULL,
address TEXT NOT NULL,
interface TEXT,
prefix_len INTEGER,
gateway TEXT,
PRIMARY KEY (container_id, position)
)`,
`CREATE TABLE IF NOT EXISTS container_ipv6_addresses (
container_id INTEGER NOT NULL,
position INTEGER NOT NULL,
address TEXT NOT NULL,
prefix_len INTEGER,
interface TEXT,
PRIMARY KEY (container_id, position)
)`,
`CREATE TABLE IF NOT EXISTS sub_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
@@ -253,8 +277,14 @@ func ensureSchema() error {
cfg_traffic_out_gb INTEGER,
cfg_io_speed_mbps INTEGER,
cfg_port_mapping_count INTEGER,
cfg_assign_nat INTEGER,
cfg_snapshot_limit INTEGER,
cfg_assign_ipv4 INTEGER,
cfg_ipv4_count INTEGER,
cfg_public_ipv4s TEXT,
cfg_assign_ipv6 INTEGER,
cfg_ipv6_count INTEGER,
cfg_ipv6_addresses TEXT,
cfg_expires_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS task_extra_ports (
@@ -308,6 +338,15 @@ func ensureSchemaMigrations() error {
{"api_keys", "last_used_ip", "TEXT"},
{"tasks", "ip", "TEXT"},
{"tasks", "user_agent", "TEXT"},
{"tasks", "cfg_assign_ipv4", "INTEGER"},
{"tasks", "cfg_ipv4_count", "INTEGER"},
{"tasks", "cfg_public_ipv4s", "TEXT"},
{"tasks", "cfg_assign_nat", "INTEGER"},
{"tasks", "cfg_ipv6_count", "INTEGER"},
{"tasks", "cfg_ipv6_addresses", "TEXT"},
{"port_mappings", "host_ip", "TEXT"},
{"container_public_ipv4s", "prefix_len", "INTEGER"},
{"container_public_ipv4s", "gateway", "TEXT"},
} {
if err := ensureColumn(column.table, column.name, column.def); err != nil {
return err
@@ -373,6 +412,7 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
NextSSHPort: atoi(meta["next_ssh_port"]),
SetupComplete: atob(meta["setup_complete"]),
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
Language: meta["language"],
}
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.SSL)
@@ -380,6 +420,15 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates)
}
if raw := strings.TrimSpace(meta["public_ipv4_pool"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv4Pool)
}
if raw := strings.TrimSpace(meta["public_ipv6_prefixes"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv6Prefixes)
}
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
}
if cfg.Containers, err = loadContainers(); err != nil {
return nil, false, err
@@ -423,6 +472,8 @@ func saveConfigToDB() error {
for _, table := range []string{
"port_mappings",
"container_public_ipv4s",
"container_ipv6_addresses",
"sub_user_container_names",
"sub_user_container_uuids",
"containers",
@@ -474,6 +525,9 @@ func saveConfigToDB() error {
func saveMeta(tx *sql.Tx) error {
sslJSON, _ := json.Marshal(AppConfig.SSL)
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
values := map[string]string{
"admin_user": AppConfig.AdminUser,
"admin_pass_hash": AppConfig.AdminPassHash,
@@ -485,8 +539,12 @@ func saveMeta(tx *sql.Tx) error {
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
"setup_complete": btoa(AppConfig.SetupComplete),
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
"language": NormalizeLanguage(AppConfig.Language),
"ssl": string(sslJSON),
"ssl_certificates": string(sslCertificatesJSON),
"public_ipv4_pool": string(publicIPv4PoolJSON),
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
"schema_version": "1",
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
}
@@ -522,8 +580,20 @@ func saveContainers(tx *sql.Tx) error {
return err
}
for i, pm := range c.PortMappings {
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, protocol, description)
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.Protocol, pm.Description); err != nil {
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, host_ip, protocol, description)
VALUES (?, ?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.HostIP, pm.Protocol, pm.Description); err != nil {
return err
}
}
for i, ip := range c.PublicIPv4s {
if _, err := tx.Exec(`INSERT INTO container_public_ipv4s(container_id, position, address, interface, prefix_len, gateway)
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.Interface, ip.PrefixLen, ip.Gateway); err != nil {
return err
}
}
for i, ip := range c.IPv6Addresses {
if _, err := tx.Exec(`INSERT INTO container_ipv6_addresses(container_id, position, address, prefix_len, interface)
VALUES (?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.PrefixLen, ip.Interface); err != nil {
return err
}
}
@@ -588,14 +658,16 @@ func saveTasksDB(tx *sql.Tx) error {
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
cfg_assign_ipv6, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
boolInt(cfg.AssignIPv6), cfg.ExpiresAt,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses), cfg.ExpiresAt,
); err != nil {
return err
}
@@ -684,12 +756,21 @@ func loadContainers() ([]Container, error) {
if err != nil {
return nil, err
}
result[i].PublicIPv4s, err = loadContainerPublicIPv4s(result[i].ID)
if err != nil {
return nil, err
}
result[i].IPv6Addresses, err = loadContainerIPv6Addresses(result[i].ID)
if err != nil {
return nil, err
}
result[i].NormalizeNetworkAssignments()
}
return result, nil
}
func loadPortMappings(containerID int) ([]PortMapping, error) {
rows, err := db.Query(`SELECT container_port, host_port, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
rows, err := db.Query(`SELECT container_port, host_port, host_ip, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
if err != nil {
return nil, err
}
@@ -697,14 +778,64 @@ func loadPortMappings(containerID int) ([]PortMapping, error) {
result := []PortMapping{}
for rows.Next() {
var pm PortMapping
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &pm.Protocol, &pm.Description); err != nil {
var hostIP sql.NullString
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &hostIP, &pm.Protocol, &pm.Description); err != nil {
return nil, err
}
pm.HostIP = hostIP.String
result = append(result, pm)
}
return result, rows.Err()
}
func loadContainerPublicIPv4s(containerID int) ([]PublicIPv4Assignment, error) {
rows, err := db.Query(`SELECT address, interface, prefix_len, gateway FROM container_public_ipv4s WHERE container_id = ? ORDER BY position`, containerID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []PublicIPv4Assignment{}
for rows.Next() {
var item PublicIPv4Assignment
var iface sql.NullString
var prefixLen sql.NullInt64
var gateway sql.NullString
if err := rows.Scan(&item.Address, &iface, &prefixLen, &gateway); err != nil {
return nil, err
}
item.Interface = iface.String
if prefixLen.Valid {
item.PrefixLen = int(prefixLen.Int64)
}
item.Gateway = gateway.String
result = append(result, item)
}
return result, rows.Err()
}
func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) {
rows, err := db.Query(`SELECT address, prefix_len, interface FROM container_ipv6_addresses WHERE container_id = ? ORDER BY position`, containerID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []IPv6Assignment{}
for rows.Next() {
var item IPv6Assignment
var prefixLen sql.NullInt64
var iface sql.NullString
if err := rows.Scan(&item.Address, &prefixLen, &iface); err != nil {
return nil, err
}
if prefixLen.Valid {
item.PrefixLen = int(prefixLen.Int64)
}
item.Interface = iface.String
result = append(result, item)
}
return result, rows.Err()
}
func loadSubUsers() ([]SubUser, error) {
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`)
if err != nil {
@@ -806,8 +937,9 @@ func loadTasks() ([]SavedTask, error) {
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
cfg_assign_ipv6, cfg_expires_at
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
cfg_expires_at
FROM tasks ORDER BY created_at, id`)
if err != nil {
return nil, err
@@ -818,20 +950,34 @@ func loadTasks() ([]SavedTask, error) {
for rows.Next() {
var t SavedTask
var cfg savedTaskConfig
var assignIPv6 int
var ip, userAgent sql.NullString
var assignIPv4, assignIPv6 int
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
if err := rows.Scan(
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
&assignIPv6, &cfg.ExpiresAt,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses, &cfg.ExpiresAt,
); err != nil {
return nil, err
}
t.IP = ip.String
t.UserAgent = userAgent.String
if assignNAT.Valid {
value := assignNAT.Int64 != 0
cfg.AssignNAT = &value
}
cfg.AssignIPv4 = assignIPv4 != 0
if ipv4Count.Valid {
cfg.IPv4Count = int(ipv4Count.Int64)
}
cfg.PublicIPv4s = decodeStringSlice(publicIPv4s.String)
cfg.AssignIPv6 = assignIPv6 != 0
if ipv6Count.Valid {
cfg.IPv6Count = int(ipv6Count.Int64)
}
cfg.IPv6Addresses = decodeStringSlice(ipv6Addresses.String)
result = append(result, t)
configs = append(configs, cfg)
}
@@ -945,6 +1091,13 @@ func boolInt(value bool) int {
return 0
}
func boolPtrInt(value *bool) interface{} {
if value == nil {
return nil
}
return boolInt(*value)
}
func btoa(value bool) string {
if value {
return "1"
+262 -86
View File
@@ -364,8 +364,11 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) {
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
}
if cfg.PortMappingCount < 2 {
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
} else if !cfg.WantsNAT() {
cfg.PortMappingCount = 0
cfg.ExtraPorts = nil
}
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
@@ -403,18 +406,21 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso")
mac := randomMAC()
sshPassword := generateRandomString(16)
ipv6 := ""
ipv6PrefixLen := 0
ipv6Interface := ""
if cfg.AssignIPv6 {
assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil {
return nil, err
}
ipv6Assignments := []config.IPv6Assignment{}
if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 {
assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true)
if err != nil {
return nil, err
}
ipv6 = assigned
ipv6PrefixLen = prefixLen
ipv6Interface = iface
ipv6Assignments = assigned
}
ipv6List := configIPv6AssignmentAddresses(ipv6Assignments)
defaultHostIP := lxc.DefaultPortMappingHostIP(publicIPv4s)
var xml string
winAdminPassword := ""
@@ -433,7 +439,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, ipv6); err != nil {
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List); err != nil {
return nil, err
}
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
@@ -449,7 +455,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, mac, ipv6, *image); err != nil {
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6List, *image); err != nil {
return nil, err
}
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
@@ -465,20 +471,26 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
sshPort := 0
portMappings := []config.PortMapping{}
if allocatePorts {
if allocatePorts && cfg.WantsNAT() {
sshPort = config.AllocateSSHPort()
if IsWindowsImage(image.ID) {
// Windows: RDP (3389) instead of SSH (22)
portMappings = []config.PortMapping{{
ContainerPort: 3389,
HostPort: sshPort,
HostIP: defaultHostIP,
Protocol: "tcp",
Description: "RDP",
}}
} else {
portMappings = lxc.SetupDefaultPortMappings(sshPort)
if defaultHostIP != "" {
for i := range portMappings {
portMappings[i].HostIP = defaultHostIP
}
}
}
tempC := &config.Container{PortMappings: portMappings}
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
extraPorts := cfg.ExtraPorts
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
@@ -490,6 +502,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
tempC.PortMappings = append(tempC.PortMappings, config.PortMapping{
ContainerPort: port,
HostPort: port,
HostIP: defaultHostIP,
Protocol: "tcp",
Description: fmt.Sprintf("Port-%d", port),
})
@@ -502,7 +515,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if trafficMode == "" {
trafficMode = "total"
}
return &config.Container{
container := &config.Container{
ID: id,
UUID: config.NewContainerUUID(),
Name: cfg.Name,
@@ -521,9 +534,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
TrafficOutGB: cfg.TrafficOutGB,
TrafficResetDate: now[:7],
IOSpeedMBps: cfg.IOSpeedMBps,
IPv6: ipv6,
IPv6PrefixLen: ipv6PrefixLen,
IPv6Interface: ipv6Interface,
PublicIPv4s: publicIPv4s,
IPv6Addresses: ipv6Assignments,
Status: "stopped",
SSHPort: sshPort,
SSHPassword: func() string {
@@ -537,7 +549,9 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
CreatedAt: now,
ExpiresAt: cfg.ExpiresAt,
}, nil
}
container.NormalizeNetworkAssignments()
return container, nil
}
func (m *Manager) StartContainer(id int) error {
@@ -548,6 +562,7 @@ func (m *Manager) StartContainer(id int) error {
if err := m.validateHost(IsWindowsImage(c.Template)); err != nil {
return err
}
lxc.EnsureAssignedPublicIPv4s(c.PublicIPv4s)
name := c.VirshName()
if err := m.ensureDomainDefinition(c); err != nil {
fmt.Printf("Warning: failed to refresh KVM domain definition for %s: %v\n", name, err)
@@ -598,7 +613,7 @@ func (m *Manager) StartContainer(id int) error {
return err
}
}
if c.IPv6 != "" {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.applyIPv6Runtime(c); err != nil {
return err
}
@@ -1559,7 +1574,7 @@ func createEmptyDisk(target string, diskGB int) error {
return nil
}
func createWindowsUnattendISO(target, hostname, adminPassword, ipv6 string) error {
func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string) error {
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
if tool == "" {
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
@@ -1585,13 +1600,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword, ipv6 string) erro
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, ipv6)), 0600); err != nil {
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 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, ipv6)), 0600); err != nil {
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil {
return err
}
_ = os.Remove(target)
@@ -1701,7 +1716,7 @@ exit /b 0
`
}
func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string {
commands := []string{
"$ErrorActionPreference='Continue'",
"$ProgressPreference='SilentlyContinue'",
@@ -1731,9 +1746,10 @@ func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
"Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic",
"Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue",
}
if strings.TrimSpace(ipv6) != "" {
ipv6s = normalizeKVMIPv6List(ipv6s)
if len(ipv6s) > 0 {
commands = append(commands,
windowsIPv6PowerShell(strings.TrimSpace(ipv6)),
windowsIPv6PowerShell(ipv6s),
)
}
commands = append(commands,
@@ -1743,17 +1759,24 @@ func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
return strings.Join(commands, "\r\n") + "\r\n"
}
func windowsIPv6PowerShell(ipv6 string) string {
ipv6 = strings.TrimSpace(ipv6)
if ipv6 == "" {
func windowsIPv6PowerShell(ipv6s []string) string {
ipv6s = normalizeKVMIPv6List(ipv6s)
if len(ipv6s) == 0 {
return ""
}
quoted := make([]string, 0, len(ipv6s))
for _, ipv6 := range ipv6s {
quoted = append(quoted, "'"+strings.ReplaceAll(ipv6, "'", "''")+"'")
}
return strings.Join([]string{
"$clicdIPv6=@(" + 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) {",
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq '" + ipv6 + "' } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
" New-NetIPAddress -IPAddress '" + ipv6 + "' -PrefixLength 128 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null",
" foreach ($ip in $clicdIPv6) {",
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
" New-NetIPAddress -IPAddress $ip -PrefixLength 128 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null",
" }",
" Get-NetRoute -InterfaceIndex $iface.ifIndex -DestinationPrefix '::/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue",
" New-NetRoute -DestinationPrefix '::/0' -InterfaceIndex $iface.ifIndex -NextHop '" + ipv6GatewayLinkLocal + "' -RouteMetric 100 -ErrorAction SilentlyContinue | Out-Null",
" Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ServerAddresses @('2001:4860:4860::8888','2606:4700:4700::1111') -ErrorAction SilentlyContinue",
@@ -1765,13 +1788,14 @@ func shellQuoteWindows(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
}
func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string, image Image) error {
func createSeedISO(seedPath, instanceID, hostname, password, mac string, ipv6s []string, image Image) error {
guestSetup := kvmSSHSetupScript(password)
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
guestSetup += "\n" + desktopSetup
}
if strings.TrimSpace(ipv6) != "" {
guestSetup += "\n" + kvmIPv6SetupScript(ipv6)
ipv6s = normalizeKVMIPv6List(ipv6s)
if len(ipv6s) > 0 {
guestSetup += "\n" + kvmIPv6SetupScript(ipv6s)
}
setupScript := indentScript(guestSetup, 4)
userData := fmt.Sprintf(`#cloud-config
@@ -1795,15 +1819,19 @@ runcmd:
`, hostname, password, setupScript)
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
ipv6Block := ""
if strings.TrimSpace(ipv6) != "" {
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/128
%s
routes:
- to: default
via: %s
on-link: true
metric: 100`, ipv6, ipv6GatewayLinkLocal)
metric: 100`, strings.Join(addressLines, "\n"), ipv6GatewayLinkLocal)
}
networkConfig := fmt.Sprintf(`version: 2
ethernets:
@@ -1834,6 +1862,42 @@ ethernets:
return nil
}
func configIPv6AssignmentAddresses(assignments []config.IPv6Assignment) []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))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func shellQuotedKVMIPv6List(values []string) string {
values = normalizeKVMIPv6List(values)
if len(values) == 0 {
return "''"
}
quoted := make([]string, 0, len(values))
for _, value := range values {
quoted = append(quoted, shellQuote(value))
}
return strings.Join(quoted, " ")
}
func indentScript(script string, spaces int) string {
prefix := strings.Repeat(" ", spaces)
lines := strings.Split(strings.TrimRight(script, "\n"), "\n")
@@ -2575,7 +2639,7 @@ func (m *Manager) syncRunningNetworks() {
} else if err != nil {
fmt.Printf("Warning: failed to sync KVM network for %s: %v\n", c.Name, err)
}
if c.IPv6 != "" {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.applyIPv6Runtime(c); err != nil {
fmt.Printf("Warning: failed to sync KVM IPv6 for %s: %v\n", c.Name, err)
}
@@ -2589,7 +2653,7 @@ func (m *Manager) applyIPv6Guards() {
if !c.IsKVM() || c.MACAddress == "" {
continue
}
if c.IPv6 == "" {
if c.IPv6 == "" && len(c.IPv6Addresses) == 0 {
ensureKVMIPv6DenyRule("virbr0", c.MACAddress)
continue
}
@@ -2935,13 +2999,12 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
}
if c.IPv6 == "" {
addr, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true)
if err != nil {
return nil, err
}
c.IPv6 = addr
c.IPv6PrefixLen = prefixLen
c.IPv6Interface = iface
c.IPv6Addresses = append(c.IPv6Addresses, assignments...)
c.NormalizeNetworkAssignments()
config.SaveConfig()
}
if err := m.applyIPv6Runtime(c); err != nil {
@@ -2951,9 +3014,10 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
}
func (m *Manager) applyIPv6Runtime(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
c.NormalizeNetworkAssignments()
if err := m.applyIPv6HostRuntime(c); err != nil {
return err
}
@@ -2964,7 +3028,13 @@ func (m *Manager) applyIPv6Runtime(c *config.Container) error {
}
}
}
ensureKVMIPv6NAT66(c.IPv6, c.IPv6Interface)
for _, assignment := range c.IPv6Addresses {
uplink := assignment.Interface
if uplink == "" {
uplink = c.IPv6Interface
}
ensureKVMIPv6NAT66(assignment.Address, uplink)
}
return nil
}
@@ -2980,9 +3050,10 @@ func shouldLogIPv6GuestWarning(id int) bool {
}
func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
c.NormalizeNetworkAssignments()
if c.IPv6Interface == "" {
prefixes := lxc.DetectPublicIPv6Prefixes()
if len(prefixes) == 0 {
@@ -2990,6 +3061,14 @@ func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
}
c.IPv6Interface = prefixes[0].Interface
c.IPv6PrefixLen = prefixes[0].PrefixLen
for i := range c.IPv6Addresses {
if c.IPv6Addresses[i].Interface == "" {
c.IPv6Addresses[i].Interface = c.IPv6Interface
}
if c.IPv6Addresses[i].PrefixLen == 0 {
c.IPv6Addresses[i].PrefixLen = c.IPv6PrefixLen
}
}
config.SaveConfig()
}
runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1")
@@ -3001,14 +3080,20 @@ func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
runQuiet("sysctl", "-w", "net.ipv6.conf."+bridge+".proxy_ndp=1")
runQuiet("ip", "link", "set", bridge, "up")
runQuiet("ip", "-6", "addr", "replace", ipv6GatewayLinkLocal+"/64", "dev", bridge)
if out, err := exec.Command("ip", "-6", "route", "replace", c.IPv6+"/128", "dev", bridge).CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out))
for _, assignment := range c.IPv6Addresses {
uplink := assignment.Interface
if uplink == "" {
uplink = c.IPv6Interface
}
if out, err := exec.Command("ip", "-6", "route", "replace", assignment.Address+"/128", "dev", bridge).CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out))
}
if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", assignment.Address, "dev", uplink).CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out))
}
ensureKVMIPv6ForwardRules(assignment.Address, bridge)
ensureKVMIPv6AntiSpoofRules(assignment.Address, bridge, c.MACAddress)
}
if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", c.IPv6, "dev", c.IPv6Interface).CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out))
}
ensureKVMIPv6ForwardRules(c.IPv6, bridge)
ensureKVMIPv6AntiSpoofRules(c.IPv6, bridge, c.MACAddress)
return nil
}
@@ -3074,16 +3159,23 @@ func removeKVMIPv6Runtime(c *config.Container) {
}
bridge := "virbr0"
removeKVMIPv6DenyRule(bridge, c.MACAddress)
if c.IPv6 == "" {
if c.IPv6 == "" && len(c.IPv6Addresses) == 0 {
return
}
removeKVMIPv6NAT66(c.IPv6, c.IPv6Interface)
removeKVMIPv6ForwardRules(c.IPv6, bridge)
removeKVMIPv6AntiSpoofRules(c.IPv6, bridge, c.MACAddress)
if c.IPv6Interface != "" {
_ = exec.Command("ip", "-6", "neigh", "del", "proxy", c.IPv6, "dev", c.IPv6Interface).Run()
c.NormalizeNetworkAssignments()
for _, assignment := range c.IPv6Addresses {
uplink := assignment.Interface
if uplink == "" {
uplink = c.IPv6Interface
}
removeKVMIPv6NAT66(assignment.Address, uplink)
removeKVMIPv6ForwardRules(assignment.Address, bridge)
removeKVMIPv6AntiSpoofRules(assignment.Address, bridge, c.MACAddress)
if uplink != "" {
_ = exec.Command("ip", "-6", "neigh", "del", "proxy", assignment.Address, "dev", uplink).Run()
}
_ = exec.Command("ip", "-6", "route", "del", assignment.Address+"/128", "dev", bridge).Run()
}
_ = exec.Command("ip", "-6", "route", "del", c.IPv6+"/128", "dev", bridge).Run()
}
func removeKVMIPv6ForwardRules(ipv6 string, bridge string) {
@@ -3147,13 +3239,14 @@ func deleteIP6Rule(rule []string) {
}
func (m *Manager) applyGuestIPv6(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
c.NormalizeNetworkAssignments()
if IsWindowsImage(c.Template) {
return m.applyWindowsGuestIPv6(c)
}
script := kvmIPv6SetupScript(c.IPv6)
script := kvmIPv6SetupScript(c.IPv6AddressStrings())
if err := qemuGuestPing(c.VirshName()); err != nil {
return err
}
@@ -3167,14 +3260,15 @@ func (m *Manager) applyWindowsGuestIPv6(c *config.Container) error {
if err := qemuGuestPing(c.VirshName()); err != nil {
return err
}
script := windowsIPv6PowerShell(c.IPv6)
script := windowsIPv6PowerShell(c.IPv6AddressStrings())
return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second)
}
func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
c.NormalizeNetworkAssignments()
qgaErr := m.applyGuestIPv6(c)
if qgaErr == nil {
return nil
@@ -3187,7 +3281,7 @@ func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error {
}
func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
if IsWindowsImage(c.Template) {
@@ -3206,12 +3300,13 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
return err
}
defer client.Close()
return runKVMSSHScript(client, kvmIPv6SetupScript(c.IPv6), "KVM IPv6", 60*time.Second)
return runKVMSSHScript(client, kvmIPv6SetupScript(c.IPv6AddressStrings()), "KVM IPv6", 60*time.Second)
}
func kvmIPv6SetupScript(ipv6 string) string {
func kvmIPv6SetupScript(ipv6s []string) string {
ipv6s = normalizeKVMIPv6List(ipv6s)
return `set -eu
IPV6_ADDR=` + shellQuote(ipv6) + `
IPV6_ADDRS="` + strings.Join(ipv6s, " ") + `"
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
if [ -z "$IFACE" ]; then
@@ -3224,13 +3319,15 @@ fi
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
for IPV6_ADDR in $IPV6_ADDRS; do
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
done
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
mkdir -p /usr/local/sbin /etc/systemd/system /etc/network/if-up.d /etc/local.d
cat > /usr/local/sbin/clicd-kvm-ipv6-init <<'EOF'
#!/bin/sh
set -eu
IPV6_ADDR=` + shellQuote(ipv6) + `
IPV6_ADDRS="` + strings.Join(ipv6s, " ") + `"
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
if [ -z "$IFACE" ]; then
@@ -3240,7 +3337,9 @@ fi
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
for IPV6_ADDR in $IPV6_ADDRS; do
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
done
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
EOF
chmod +x /usr/local/sbin/clicd-kvm-ipv6-init
@@ -3279,15 +3378,44 @@ chmod +x /etc/network/if-up.d/clicd-kvm-ipv6
}
func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) {
prefixes := lxc.DetectPublicIPv6Prefixes()
if len(prefixes) == 0 {
return "", 0, "", fmt.Errorf("public IPv6 allocation is unavailable: no usable public IPv6 prefix found")
}
prefixInfo := prefixes[0]
prefix, err := netip.ParsePrefix(prefixInfo.Prefix)
assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true)
if err != nil {
return "", 0, "", err
}
if len(assignments) == 0 {
return "", 0, "", fmt.Errorf("no free IPv6 address")
}
return assignments[0].Address, assignments[0].PrefixLen, assignments[0].Interface, nil
}
func (m *Manager) allocateIPv6AssignmentsForContainer(id int, requested []string, count int, auto bool) ([]config.IPv6Assignment, error) {
if count <= 0 {
count = 1
}
if len(requested) > count {
count = len(requested)
}
prefixes := lxc.DetectPublicIPv6Prefixes()
if len(prefixes) == 0 {
return nil, fmt.Errorf("public IPv6 allocation is unavailable: no usable public IPv6 prefix found")
}
parsedPrefixes := make([]struct {
info lxc.IPv6PrefixInfo
prefix netip.Prefix
}, 0, len(prefixes))
for _, prefixInfo := range prefixes {
prefix, err := netip.ParsePrefix(prefixInfo.Prefix)
if err != nil {
continue
}
parsedPrefixes = append(parsedPrefixes, struct {
info lxc.IPv6PrefixInfo
prefix netip.Prefix
}{info: prefixInfo, prefix: prefix})
}
if len(parsedPrefixes) == 0 {
return nil, fmt.Errorf("public IPv6 allocation is unavailable: no valid IPv6 prefix found")
}
used := map[string]bool{}
hostAddrs := map[string]bool{}
@@ -3295,21 +3423,69 @@ func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error)
hostAddrs[p.Address] = true
}
for _, c := range config.AppConfig.Containers {
if c.ID == id {
continue
}
if c.IPv6 != "" {
used[c.IPv6] = true
}
}
for offset := uint64(0x2000 + id); offset < 0x100000; offset++ {
addr, err := ipv6Add(prefix.Masked().Addr(), offset)
if err != nil || !prefix.Contains(addr) {
break
}
candidate := addr.String()
if !used[candidate] && !hostAddrs[candidate] {
return candidate, prefix.Bits(), prefixInfo.Interface, nil
for _, ip := range c.IPv6Addresses {
if ip.Address != "" {
used[ip.Address] = true
}
}
}
return "", 0, "", fmt.Errorf("no free IPv6 address in %s", prefix.String())
result := make([]config.IPv6Assignment, 0, count)
selected := map[string]bool{}
for _, raw := range requested {
raw = strings.TrimSpace(raw)
if raw == "" || selected[raw] {
continue
}
addr, err := netip.ParseAddr(raw)
if err != nil || !addr.Is6() {
return nil, fmt.Errorf("requested IPv6 %s is not valid", raw)
}
matchIndex := -1
for i, item := range parsedPrefixes {
if item.prefix.Contains(addr) {
matchIndex = i
break
}
}
if matchIndex < 0 {
return nil, fmt.Errorf("requested IPv6 %s is not in the configured IPv6 prefixes", raw)
}
if hostAddrs[raw] {
return nil, fmt.Errorf("requested IPv6 %s is used by host", raw)
}
if used[raw] {
return nil, fmt.Errorf("requested IPv6 %s is already assigned", raw)
}
selected[raw] = true
used[raw] = true
result = append(result, config.IPv6Assignment{Address: raw, PrefixLen: parsedPrefixes[matchIndex].prefix.Bits(), Interface: parsedPrefixes[matchIndex].info.Interface})
}
if len(result) >= count || !auto {
return result, nil
}
for _, item := range parsedPrefixes {
for offset := uint64(0x2000 + id); offset < 0x100000; offset++ {
addr, err := ipv6Add(item.prefix.Masked().Addr(), offset)
if err != nil || !item.prefix.Contains(addr) {
break
}
candidate := addr.String()
if !used[candidate] && !hostAddrs[candidate] {
used[candidate] = true
result = append(result, config.IPv6Assignment{Address: candidate, PrefixLen: item.prefix.Bits(), Interface: item.info.Interface})
if len(result) >= count {
return result, nil
}
}
}
}
return nil, fmt.Errorf("no free IPv6 address in configured prefixes")
}
func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) {
File diff suppressed because it is too large Load Diff
+198 -78
View File
@@ -218,24 +218,34 @@ func NewManager() *Manager {
// ContainerConfig defines container creation parameters
type ContainerConfig struct {
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv6 bool `json:"assign_ipv6"`
ExpiresAt string `json:"expires_at"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv4 bool `json:"assign_ipv4"`
IPv4Count int `json:"ipv4_count,omitempty"`
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
AssignIPv6 bool `json:"assign_ipv6"`
IPv6Count int `json:"ipv6_count,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
ExpiresAt string `json:"expires_at"`
}
func (cfg ContainerConfig) WantsNAT() bool {
return cfg.AssignNAT == nil || *cfg.AssignNAT
}
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
@@ -244,8 +254,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if tmpl == nil {
return fmt.Errorf("template not found: %s", cfg.TemplateID)
}
if cfg.PortMappingCount < 2 {
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
} else if !cfg.WantsNAT() {
cfg.PortMappingCount = 0
cfg.ExtraPorts = nil
}
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
@@ -296,50 +309,64 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
return err
}
ipv6 := ""
ipv6PrefixLen := 0
ipv6Interface := ""
if cfg.AssignIPv6 {
assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
ipv6Assignments := []config.IPv6Assignment{}
if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 {
assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true)
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
ipv6 = assigned
ipv6PrefixLen = prefixLen
ipv6Interface = iface
if err := m.applyIPv6Config(lxcName, ipv6); err != nil {
ipv6Assignments = assigned
if err := m.applyIPv6Config(lxcName, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
}
sshPort := config.AllocateSSHPort()
sshPassword := generateRandomString(16)
// Setup default port mappings (SSH only)
portMappings := SetupDefaultPortMappings(sshPort)
tempC := &config.Container{PortMappings: portMappings}
sshPort := 0
portMappings := []config.PortMapping{}
if cfg.WantsNAT() {
sshPort = config.AllocateSSHPort()
extraPorts := cfg.ExtraPorts
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
}
for _, containerPort := range extraPorts {
if containerPort <= 0 {
continue
// Setup default port mappings (SSH only)
portMappings = SetupDefaultPortMappings(sshPort)
defaultHostIP := defaultPortMappingHostIP(publicIPv4s)
if defaultHostIP != "" {
for i := range portMappings {
portMappings[i].HostIP = defaultHostIP
}
}
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
ContainerPort: containerPort,
HostPort: containerPort,
Protocol: "tcp",
Description: fmt.Sprintf("Port-%d", containerPort),
})
if err != nil {
continue
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
extraPorts := cfg.ExtraPorts
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
}
for _, containerPort := range extraPorts {
if containerPort <= 0 {
continue
}
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
ContainerPort: containerPort,
HostPort: containerPort,
HostIP: defaultHostIP,
Protocol: "tcp",
Description: fmt.Sprintf("Port-%d", containerPort),
})
if err != nil {
continue
}
tempC.PortMappings = append(tempC.PortMappings, pm)
portMappings = tempC.PortMappings
}
tempC.PortMappings = append(tempC.PortMappings, pm)
portMappings = tempC.PortMappings
}
now := time.Now().Format("2006-01-02 15:04:05")
@@ -368,9 +395,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
IOSpeedMBps: cfg.IOSpeedMBps,
Status: "stopped",
IP: "",
IPv6: ipv6,
IPv6PrefixLen: ipv6PrefixLen,
IPv6Interface: ipv6Interface,
PublicIPv4s: publicIPv4s,
IPv6Addresses: ipv6Assignments,
VNCPort: 0,
SSHPort: sshPort,
SSHPassword: sshPassword,
@@ -380,13 +406,14 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
CreatedAt: now,
ExpiresAt: cfg.ExpiresAt,
}
container.NormalizeNetworkAssignments()
config.AddContainer(container)
// Pre-configure network and SSH in the rootfs before first boot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, cfg.TemplateID)
if ipv6 != "" {
if err := installContainerIPv6Init(rootfsPath, ipv6); err != nil {
if len(ipv6Assignments) > 0 {
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
}
}
@@ -525,7 +552,7 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
if err != nil {
return err
}
apparmorProfile, err := findAppArmorProfile()
apparmorProfile, err := appArmorProfileForTemplate(cfg.TemplateID)
if err != nil {
return err
}
@@ -940,6 +967,26 @@ func findAppArmorProfile() (string, error) {
return "", errors.New("required LXC AppArmor profile not loaded")
}
func appArmorProfileForTemplate(templateID string) (string, error) {
if systemdTemplateNeedsUnconfinedAppArmor(templateID) {
return "unconfined", nil
}
return findAppArmorProfile()
}
func systemdTemplateNeedsUnconfinedAppArmor(templateID string) bool {
id := strings.ToLower(strings.TrimSpace(templateID))
if id == "" || strings.Contains(id, "alpine") {
return false
}
for _, token := range []string{"ubuntu", "debian", "centos", "fedora", "rocky", "rockylinux", "archlinux"} {
if strings.Contains(id, token) {
return true
}
}
return false
}
func unprivilegedIDMap() (int, int, error) {
if err := ensureSubIDRange("/etc/subuid", "root", 100000, 65536); err != nil {
return 0, 0, err
@@ -1197,27 +1244,31 @@ func (m *Manager) StartContainer(id int) error {
NetworkBWMbps: c.NetworkBWMbps,
MonthlyTrafficGB: c.MonthlyTrafficGB,
IOSpeedMBps: c.IOSpeedMBps,
AssignIPv6: c.IPv6 != "",
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
ExpiresAt: c.ExpiresAt,
}); err != nil {
return err
}
}
if c.IPv6 != "" {
if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
c.NormalizeNetworkAssignments()
if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil {
return err
}
if err := m.ApplyIPv6(id); err != nil {
return err
}
}
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
os.Remove(logFile)
cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG")
output, err := cmd.CombinedOutput()
logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName)
if err != nil {
return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80))
config.UpdateContainerStatus(id, "stopped")
return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80))
}
if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil {
config.UpdateContainerStatus(id, "stopped")
return err
}
config.UpdateContainerStatus(id, "running")
@@ -1262,7 +1313,7 @@ func (m *Manager) StartContainer(id int) error {
if err := m.ApplyPortMappings(id); err != nil {
fmt.Printf("Warning: failed to apply port mappings: %v\n", err)
}
if c.IPv6 != "" {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.ApplyIPv6(id); err != nil {
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
}
@@ -1272,6 +1323,41 @@ func (m *Manager) StartContainer(id int) error {
return nil
}
func (m *Manager) startLXCContainerDaemon(lxcName string) (string, string, []byte, error) {
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
consoleLog := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-console.log")
os.Remove(logFile)
os.Remove(consoleLog)
cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG", "--console-log", consoleLog)
output, err := cmd.CombinedOutput()
return logFile, consoleLog, output, err
}
func (m *Manager) waitForLXCStartup(lxcName, logFile, consoleLog string) error {
runningChecks := 0
lastStatus := "unknown"
for retry := 0; retry < 10; retry++ {
time.Sleep(1 * time.Second)
status, err := m.GetContainerStatus(lxcName)
if err != nil {
lastStatus = "unknown"
continue
}
lastStatus = status
if status == "running" {
runningChecks++
if runningChecks >= 3 {
return nil
}
continue
}
if runningChecks > 0 || retry >= 1 {
break
}
}
return fmt.Errorf("container exited immediately after start (status: %s), lxc log: %s, console: %s", lastStatus, tailFile(logFile, 80), tailFile(consoleLog, 80))
}
// applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface
// ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container.
func (m *Manager) ApplyContainerLimits(c *config.Container) error {
@@ -1553,8 +1639,17 @@ func (m *Manager) DestroyContainer(id int) error {
return fmt.Errorf("container not found: %d", id)
}
lxcName := c.LxcName()
if c.IPv6 != "" && c.IPv6Interface != "" {
removeHostIPv6Routing(c.IPv6, c.IPv6Interface)
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
c.NormalizeNetworkAssignments()
for _, assignment := range c.IPv6Addresses {
uplink := assignment.Interface
if uplink == "" {
uplink = c.IPv6Interface
}
if uplink != "" {
removeHostIPv6Routing(assignment.Address, uplink)
}
}
}
if err := m.StopContainer(id); err != nil {
@@ -1799,6 +1894,11 @@ install_sshd() {
return 1
}
ensure_sshd_runtime_dir() {
mkdir -p /run/sshd /var/run/sshd
chmod 0755 /run/sshd /var/run/sshd 2>/dev/null || true
}
set_sshd_option() {
key="$1"
value="$2"
@@ -1825,7 +1925,8 @@ set_sshd_option() {
install_sshd || exit 30
mkdir -p /run/sshd /var/run/sshd /etc/ssh /etc/ssh/sshd_config.d
mkdir -p /etc/ssh /etc/ssh/sshd_config.d
ensure_sshd_runtime_dir
ssh-keygen -A >/dev/null 2>&1 || true
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
@@ -1858,6 +1959,7 @@ if command -v chkconfig >/dev/null 2>&1; then
fi
SSHD_BIN="$(sshd_path)" || exit 32
ensure_sshd_runtime_dir
"$SSHD_BIN" -t -f /etc/ssh/sshd_config >/tmp/clicd-sshd-test.log 2>&1 || {
cat /tmp/clicd-sshd-test.log
exit 32
@@ -1872,6 +1974,7 @@ if command -v systemctl >/dev/null 2>&1; then
systemctl stop ssh.socket 2>/dev/null || true
systemctl disable ssh.socket 2>/dev/null || true
fi
ensure_sshd_runtime_dir
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true
fi
@@ -1882,9 +1985,22 @@ service ssh restart >/dev/null 2>&1 ||
/etc/init.d/sshd restart >/dev/null 2>&1 ||
true
ensure_sshd_runtime_dir
for i in 1 2 3 4 5; do
if (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
exit 0
fi
if pgrep -x sshd >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
if ! (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
pkill -x sshd >/dev/null 2>&1 || killall sshd >/dev/null 2>&1 || true
rm -f /run/sshd.pid /var/run/sshd.pid
ensure_sshd_runtime_dir
"$SSHD_BIN" -f /etc/ssh/sshd_config >/dev/null 2>&1 || exit 32
fi
@@ -2425,14 +2541,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
NetworkBWMbps: c.NetworkBWMbps,
MonthlyTrafficGB: c.MonthlyTrafficGB,
IOSpeedMBps: c.IOSpeedMBps,
AssignIPv6: c.IPv6 != "",
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
ExpiresAt: c.ExpiresAt,
}
if err := m.applyResourceLimits(lxcName, cfg); err != nil {
return err
}
if c.IPv6 != "" {
if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
c.NormalizeNetworkAssignments()
if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil {
return err
}
}
@@ -2440,8 +2557,8 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
// Set root password and pre-configure network/SSH via chroot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, templateID)
if c.IPv6 != "" {
if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
}
}
@@ -2470,14 +2587,17 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
config.SaveConfig()
return err
}
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
os.Remove(logFile)
startCmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG")
if output, err := startCmd.CombinedOutput(); err != nil {
logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName)
if err != nil {
fmt.Printf("Warning: failed to start container after reinstall: %v\n", err)
c.Status = "stopped"
config.SaveConfig()
return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80))
return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80))
}
if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil {
c.Status = "stopped"
config.SaveConfig()
return fmt.Errorf("reinstalled but container did not stay running: %v", err)
}
// Wait for network and install SSH
@@ -2503,7 +2623,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
if c.NetworkBWMbps > 0 {
m.applyBandwidthLimit(c.LxcName(), c.NetworkBWMbps)
}
if c.IPv6 != "" {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.ApplyIPv6(id); err != nil {
fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err)
}
+326 -26
View File
@@ -2,8 +2,10 @@ package lxc
import (
"fmt"
"net/netip"
"os/exec"
"strconv"
"strings"
"clicd/internal/config"
)
@@ -17,6 +19,7 @@ func (m *Manager) ApplyPortMappings(id int) error {
if c.IP == "" {
return fmt.Errorf("container has no IP")
}
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
tag := clicdTag(id)
bridge := "lxcbr0"
subnet := "10.0.3.0/24"
@@ -27,35 +30,180 @@ func (m *Manager) ApplyPortMappings(id int) error {
EnsureForwardRules(bridge)
m.CleanPortMappings(id)
deleteBridgeMasquerade(subnet)
for _, pm := range c.PortMappings {
cmd := exec.Command("iptables",
"-t", "nat",
"-I", "PREROUTING", "1",
"-p", pm.Protocol,
"--dport", fmt.Sprintf("%d", pm.HostPort),
"-j", "DNAT",
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%d", tag, pm.HostPort),
)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Warning: failed to apply port mapping %d->%s:%d: %v, output: %s\n",
pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
continue
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
args := []string{
"-t", "nat",
"-I", "PREROUTING", "1",
"-p", pm.Protocol,
}
if hostIP != "" {
args = append(args, "-d", hostIP)
}
args = append(args,
"--dport", fmt.Sprintf("%d", pm.HostPort),
"-j", "DNAT",
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-%d", tag, natRuleIPTag(hostIP), pm.HostPort),
)
cmd := exec.Command("iptables", args...)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Warning: failed to apply port mapping %s:%d->%s:%d: %v, output: %s\n",
displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
continue
}
fmt.Printf("Port mapping: %s:%d -> %s:%d\n", displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort)
}
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
}
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
}
applyIPv4EgressPolicy(c, bridge, subnet, tag)
return nil
}
func applyIPv4EgressPolicy(c *config.Container, bridge, subnet, tag string) {
if c == nil || strings.TrimSpace(c.IP) == "" {
return
}
if containerAllowsPublicIPv4Egress(c) {
if _, ok := primaryPublicIPv4Assignment(c); ok {
applyPublicIPv4SNAT(c, tag)
return
}
ensureContainerMasquerade(c, tag)
return
}
ensureIPv4EgressBlocked(c, bridge, subnet, tag)
}
func containerAllowsPublicIPv4Egress(c *config.Container) bool {
if c == nil {
return false
}
if len(c.PublicIPv4s) > 0 {
return true
}
return c.PortMappingLimit > 0 || len(c.PortMappings) > 0
}
func ensureContainerMasquerade(c *config.Container, tag string) {
args := []string{
"-s", c.IP + "/32",
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-masq", tag),
"-j", "MASQUERADE",
}
if host := DetectPublicIPv4(); strings.TrimSpace(host.Interface) != "" {
args = append([]string{"-o", strings.TrimSpace(host.Interface)}, args...)
} else {
args = append([]string{"-o", "eth+"}, args...)
}
ensureNATRule("POSTROUTING", args)
}
func ensureIPv4EgressBlocked(c *config.Container, bridge, subnet, tag string) {
args := []string{
"-i", bridge,
"-s", c.IP + "/32",
"!", "-d", subnet,
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-v4-egress-block", tag),
"-j", "REJECT",
}
ensureFilterRule("FORWARD", args)
}
func ensureNATRule(chain string, args []string) {
check := append([]string{"-t", "nat", "-C", chain}, args...)
if exec.Command("iptables", check...).Run() == nil {
return
}
add := append([]string{"-t", "nat", "-I", chain, "1"}, args...)
exec.Command("iptables", add...).Run()
}
func ensureFilterRule(chain string, args []string) {
check := append([]string{"-C", chain}, args...)
if exec.Command("iptables", check...).Run() == nil {
return
}
add := append([]string{"-I", chain, "1"}, args...)
exec.Command("iptables", add...).Run()
}
func deleteBridgeMasquerade(subnet string) {
for exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() == nil {
}
}
func applyPublicIPv4SNAT(c *config.Container, tag string) {
if c == nil || strings.TrimSpace(c.IP) == "" {
return
}
assignment, ok := primaryPublicIPv4Assignment(c)
if !ok {
return
}
hostIP := strings.TrimSpace(assignment.Address)
if hostIP == "" {
return
}
iface := strings.TrimSpace(assignment.Interface)
if iface == "" {
if info, ok := publicIPv4InfoByAddress(hostIP); ok {
iface = strings.TrimSpace(info.Interface)
}
}
if iface == "" {
if host := DetectPublicIPv4(); host.Interface != "" {
iface = host.Interface
}
}
args := []string{
"-t", "nat",
"-I", "POSTROUTING", "1",
"-s", c.IP + "/32",
}
if iface != "" {
args = append(args, "-o", iface)
}
args = append(args,
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-snat-%s", tag, natRuleIPTag(hostIP)),
"-j", "SNAT", "--to-source", hostIP,
)
if output, err := exec.Command("iptables", args...).CombinedOutput(); err != nil {
fmt.Printf("Warning: failed to apply public IPv4 SNAT %s -> %s: %v, output: %s\n", c.IP, hostIP, err, string(output))
}
}
func primaryPublicIPv4Assignment(c *config.Container) (config.PublicIPv4Assignment, bool) {
if c == nil {
return config.PublicIPv4Assignment{}, false
}
for _, item := range c.PublicIPv4s {
if strings.TrimSpace(item.Address) != "" {
return item, true
}
}
return config.PublicIPv4Assignment{}, false
}
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
func EnsureAllRunningPortMappings() {
m := NewManager()
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.Status != "running" || strings.TrimSpace(c.IP) == "" {
continue
}
if err := m.ApplyPortMappings(c.ID); err != nil {
fmt.Printf("Warning: failed to restore port mappings for %s: %v\n", c.Name, err)
}
}
}
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
func EnsureForwardRules(bridge string) {
if bridge == "" {
@@ -81,8 +229,13 @@ func EnsureForwardRules(bridge string) {
// CleanPortMappings removes all iptables rules for a container
func (m *Manager) CleanPortMappings(id int) error {
tag := clicdTag(id)
for _, chain := range []string{"PREROUTING", "POSTROUTING"} {
cmd := exec.Command("sh", "-c",
fmt.Sprintf("iptables -t nat -L %s -n --line-numbers 2>/dev/null | grep 'clicd-%s-' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D %s $num; done", chain, tag, chain))
cmd.Run()
}
cmd := exec.Command("sh", "-c",
fmt.Sprintf("iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-%s' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D PREROUTING $num; done", tag))
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
cmd.Run()
return nil
}
@@ -94,12 +247,26 @@ func SetupDefaultPortMappings(sshPort int) []config.PortMapping {
}
}
func DefaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
if len(assignments) == 1 {
return strings.TrimSpace(assignments[0].Address)
}
return ""
}
func defaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
return DefaultPortMappingHostIP(assignments)
}
// AddPortMapping adds a NAT rule to a container
func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if c.PortMappingLimit <= 0 {
return nil, fmt.Errorf("container has no IPv4 NAT port quota")
}
if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit {
return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit)
}
@@ -168,6 +335,17 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
if pm.Protocol == "" {
pm.Protocol = "tcp"
}
pm.Protocol = strings.ToLower(strings.TrimSpace(pm.Protocol))
pm.HostIP = strings.TrimSpace(pm.HostIP)
if pm.HostIP != "" {
addr, err := netip.ParseAddr(pm.HostIP)
if err != nil || !addr.Is4() {
return pm, fmt.Errorf("host_ip must be a valid IPv4 address")
}
if !containerHasPublicIPv4(c, pm.HostIP) {
return pm, fmt.Errorf("host_ip %s is not assigned to this container", pm.HostIP)
}
}
if pm.Description == "" {
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
}
@@ -179,8 +357,8 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
if i == skipIndex {
continue
}
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
return pm, fmt.Errorf("host port %d/%s already mapped in this container", pm.HostPort, pm.Protocol)
if portMappingsConflict(c, pm, c, existing) {
return pm, fmt.Errorf("host port %d/%s already mapped on the same IPv4 in this container", pm.HostPort, pm.Protocol)
}
}
// Check all other containers (LXC + KVM) for port conflicts
@@ -189,8 +367,9 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
continue
}
for _, existing := range oc.PortMappings {
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
return pm, fmt.Errorf("host port %d/%s already used by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
oc := oc
if portMappingsConflict(c, pm, &oc, existing) {
return pm, fmt.Errorf("host port %d/%s already used on the same IPv4 by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
}
}
}
@@ -204,7 +383,9 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
used := map[int]bool{}
// Mark current container's ports
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
used[hostPortKey(hostIP, pm.HostPort)] = true
}
used[pm.ContainerPort] = true
}
// Also mark all other containers' host ports (LXC + KVM)
@@ -213,13 +394,17 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
continue
}
for _, pm := range oc.PortMappings {
used[pm.HostPort] = true
oc := oc
for _, hostIP := range expandPortMappingHostIPs(&oc, pm) {
used[hostPortKey(hostIP, pm.HostPort)] = true
}
}
}
ports := make([]int, 0, count)
next := 20000
for len(ports) < count {
if !used[next] {
hostIP := c.PrimaryPublicIPv4()
if !used[hostPortKey(hostIP, next)] && !used[next] {
ports = append(ports, next)
}
next++
@@ -229,3 +414,118 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
}
return ports
}
func HostPortAvailable(c *config.Container, hostIP string, hostPort int, protocol string) bool {
if c == nil || hostPort <= 0 {
return false
}
pm := config.PortMapping{HostIP: strings.TrimSpace(hostIP), HostPort: hostPort, Protocol: protocol}
for _, existing := range c.PortMappings {
if portMappingsConflict(c, pm, c, existing) {
return false
}
}
for _, oc := range config.AppConfig.Containers {
if oc.ID == c.ID {
continue
}
oc := oc
for _, existing := range oc.PortMappings {
if portMappingsConflict(c, pm, &oc, existing) {
return false
}
}
}
return true
}
func expandPortMappingHostIPs(c *config.Container, pm config.PortMapping) []string {
if strings.TrimSpace(pm.HostIP) != "" {
return []string{strings.TrimSpace(pm.HostIP)}
}
if c != nil && len(c.PublicIPv4s) > 0 {
values := make([]string, 0, len(c.PublicIPv4s))
for _, item := range c.PublicIPv4s {
if strings.TrimSpace(item.Address) != "" {
values = append(values, strings.TrimSpace(item.Address))
}
}
if len(values) > 0 {
return values
}
}
return []string{""}
}
func containerHasPublicIPv4(c *config.Container, hostIP string) bool {
if c == nil {
return false
}
for _, item := range c.PublicIPv4s {
if item.Address == hostIP {
return true
}
}
return false
}
func portMappingsConflict(aContainer *config.Container, a config.PortMapping, bContainer *config.Container, b config.PortMapping) bool {
if a.HostPort != b.HostPort || !protocolsOverlap(a.Protocol, b.Protocol) {
return false
}
aIPs := expandPortMappingHostIPs(aContainer, a)
bIPs := expandPortMappingHostIPs(bContainer, b)
for _, aIP := range aIPs {
for _, bIP := range bIPs {
if aIP == "" || bIP == "" || aIP == bIP {
return true
}
}
}
return false
}
func protocolsOverlap(a, b string) bool {
a = strings.ToLower(strings.TrimSpace(a))
b = strings.ToLower(strings.TrimSpace(b))
if a == "" {
a = "tcp"
}
if b == "" {
b = "tcp"
}
if a == b || a == "all" || b == "all" {
return true
}
return (a == "tcp+udp" && (b == "tcp" || b == "udp")) ||
(b == "tcp+udp" && (a == "tcp" || a == "udp"))
}
func natRuleIPTag(ip string) string {
ip = strings.TrimSpace(ip)
if ip == "" {
return "any"
}
return strings.ReplaceAll(ip, ".", "_")
}
func displayHostIP(ip string) string {
if strings.TrimSpace(ip) == "" {
return "host"
}
return ip
}
func hostPortKey(hostIP string, port int) int {
if hostIP == "" {
return port
}
sum := 0
for _, r := range hostIP {
sum = sum*31 + int(r)
}
if sum < 0 {
sum = -sum
}
return port + (sum % 1000000 * 100000)
}
+3 -3
View File
@@ -46,17 +46,17 @@ func GetTemplates() []Template {
},
{
ID: "archlinux-current", Name: "Arch Linux",
Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud",
Distro: "archlinux", Release: "current", Arch: "amd64",
Description: "Arch Linux (Rolling)",
},
{
ID: "fedora-44", Name: "Fedora 44",
Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud",
Distro: "fedora", Release: "44", Arch: "amd64",
Description: "Fedora 44",
},
{
ID: "rockylinux-10", Name: "Rocky Linux 10",
Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud",
Distro: "rockylinux", Release: "10", Arch: "amd64",
Description: "Rocky Linux 10",
},
}
+43 -38
View File
@@ -4,10 +4,7 @@ import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"clicd/internal/api"
@@ -20,7 +17,7 @@ var webFS http.FileSystem
// corsMiddleware adds CORS headers
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" && isAllowedOrigin(origin, r.Host) {
if origin := r.Header.Get("Origin"); origin != "" && config.IsOriginAllowed(origin, r.Host) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
@@ -29,7 +26,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
if r.Method == http.MethodOptions {
if origin := r.Header.Get("Origin"); origin != "" && !isAllowedOrigin(origin, r.Host) {
if origin := r.Header.Get("Origin"); origin != "" && !config.IsOriginAllowed(origin, r.Host) {
w.WriteHeader(http.StatusForbidden)
return
}
@@ -41,43 +38,17 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
}
func isAllowedOrigin(origin string, requestHost string) bool {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
originHost := normalizeHost(u.Host)
host := normalizeHost(requestHost)
if originHost == host {
return true
}
return isLoopbackHost(originHost) && isLoopbackHost(host)
}
func normalizeHost(host string) string {
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.ToLower(h)
}
return strings.ToLower(host)
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// setupRoutes configures API and static routes
func setupRoutes(mux *http.ServeMux) {
// API routes
mux.HandleFunc("/api/login", corsMiddleware(api.HandleLogin))
mux.HandleFunc("/api/language", corsMiddleware(api.HandleLanguage))
mux.HandleFunc("/api/check-auth", corsMiddleware(api.AuthMiddleware(api.HandleCheckAuth)))
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
@@ -92,6 +63,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
@@ -120,6 +92,7 @@ func setupRoutes(mux *http.ServeMux) {
// Versioned external API routes
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/v1/language", corsMiddleware(api.HandleLanguage))
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/v1/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
@@ -133,6 +106,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
@@ -145,6 +119,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
mux.HandleFunc("/api/v1/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
@@ -213,11 +188,26 @@ func Run() error {
}
if sslEnabled() {
certPath, keyPath, err := config.ResolveSSLConfigPaths(config.AppConfig.SSL)
if err != nil {
return err
}
server.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(config.AppConfig.SSL.CertPath, config.AppConfig.SSL.KeyPath)
return &cert, err
safeCertPath, err := config.ResolveSSLPath(certPath)
if err != nil {
return nil, err
}
safeKeyPath, err := config.ResolveSSLPath(keyPath)
if err != nil {
return nil, err
}
cert, err := tls.LoadX509KeyPair(safeCertPath, safeKeyPath)
if err != nil {
return nil, err
}
return &cert, nil
},
}
log.Printf("CLICD Web Server SSL enabled on https://0.0.0.0:%d", config.AppConfig.Port)
@@ -229,14 +219,29 @@ func Run() error {
func sslEnabled() bool {
ssl := config.AppConfig.SSL
if !ssl.Enabled || ssl.CertPath == "" || ssl.KeyPath == "" {
if !ssl.Enabled {
return false
}
if _, err := os.Stat(ssl.CertPath); err != nil {
certPath, keyPath, err := config.ResolveSSLConfigPaths(ssl)
if err != nil {
log.Printf("SSL paths are invalid, falling back to HTTP: %v", err)
return false
}
safeCertPath, err := config.ResolveSSLPath(certPath)
if err != nil {
log.Printf("SSL certificate path is not allowed, falling back to HTTP: %v", err)
return false
}
safeKeyPath, err := config.ResolveSSLPath(keyPath)
if err != nil {
log.Printf("SSL private key path is not allowed, falling back to HTTP: %v", err)
return false
}
if _, err := config.ReadableFileStat(safeCertPath); err != nil {
log.Printf("SSL certificate is not readable, falling back to HTTP: %v", err)
return false
}
if _, err := os.Stat(ssl.KeyPath); err != nil {
if _, err := config.ReadableFileStat(safeKeyPath); err != nil {
log.Printf("SSL private key is not readable, falling back to HTTP: %v", err)
return false
}
-1
View File
@@ -1 +0,0 @@

+1 -1
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.1.7"
Version = "1.1.11"
Repo = "MengMengCode/CLICD"
)
+29 -2
View File
@@ -55,6 +55,7 @@ func main() {
// Ensure iptables FORWARD rules allow managed bridge traffic.
lxc.EnsureForwardRules("lxcbr0")
lxc.EnsureForwardRules("virbr0")
lxc.EnsureAllAssignedPublicIPv4s()
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
manager := lxc.NewManager()
@@ -74,6 +75,7 @@ func main() {
// Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers()
lxc.EnsureAllRunningPortMappings()
// Pre-warm SSH for containers already running after host boot or service restart.
manager.StartSSHWarmupScanner()
@@ -107,8 +109,33 @@ func isWebPanelSystemdRunning() bool {
func startWebPanelSystemd() {
cmd := exec.Command("systemctl", "start", "clicd")
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "警告: 自动启动 Web 面板失败: %v\n", err)
fmt.Fprintf(os.Stderr, "%s: %v\n", mainT("警告: 自动启动 Web 面板失败"), err)
} else {
fmt.Println("Web 面板已自动启动")
fmt.Println(mainT("Web 面板已自动启动"))
}
}
func mainT(text string) string {
if !mainEnglish() {
return text
}
switch text {
case "警告: 自动启动 Web 面板失败":
return "Warning: failed to auto-start web panel"
case "Web 面板已自动启动":
return "Web panel auto-started"
default:
return text
}
}
func mainEnglish() bool {
lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG")))
if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") {
return true
}
if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") {
return false
}
return config.AppConfig != nil && config.NormalizeLanguage(config.AppConfig.Language) == "en"
}
+245 -128
View File
@@ -369,9 +369,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
@@ -382,13 +382,13 @@
"aix"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
@@ -399,13 +399,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
@@ -416,13 +416,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
@@ -433,13 +433,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
@@ -450,13 +450,13 @@
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
@@ -467,13 +467,13 @@
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
@@ -484,13 +484,13 @@
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
@@ -501,13 +501,13 @@
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
@@ -518,13 +518,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
@@ -535,13 +535,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
@@ -552,13 +552,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
@@ -569,13 +569,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
@@ -586,13 +586,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
@@ -603,13 +603,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
@@ -620,13 +620,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
@@ -637,13 +637,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
@@ -654,13 +654,30 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
@@ -671,13 +688,30 @@
"netbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
@@ -688,13 +722,30 @@
"openbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
@@ -705,13 +756,13 @@
"sunos"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
@@ -722,13 +773,13 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
@@ -739,13 +790,13 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
@@ -756,7 +807,7 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@iconify-json/simple-icons": {
@@ -1706,9 +1757,9 @@
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1716,32 +1767,35 @@
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/estree-walker": {
@@ -1751,6 +1805,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/focus-trap": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
@@ -2037,6 +2109,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -2258,6 +2343,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -2373,21 +2475,24 @@
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -2396,19 +2501,25 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
@@ -2429,6 +2540,12 @@
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
+3
View File
@@ -9,5 +9,8 @@
},
"devDependencies": {
"vitepress": "^1.6.4"
},
"overrides": {
"vite": "6.4.2"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "clicd-frontend",
"private": true,
"version": "1.1.7",
"version": "1.1.11",
"type": "module",
"scripts": {
"dev": "vite",
+182
View File
@@ -0,0 +1,182 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useLanguage } from '../contexts/LanguageContext'
import { shouldTranslateText, translateText } from '../utils/i18n'
const translatedTitleAttr = 'data-i18n-title-original'
const translatedPlaceholderAttr = 'data-i18n-placeholder-original'
const translatedAriaLabelAttr = 'data-i18n-aria-label-original'
const attributeNames = ['title', 'placeholder', 'aria-label'] as const
const translatedTextNodes = new Set<Text>()
const textOriginals = new WeakMap<Text, string>()
const wholeTextSelector = 'button,a,span,label,option,th,td,p,h1,h2,h3,h4,small'
export default function AutoTranslate() {
const { language } = useLanguage()
const location = useLocation()
useEffect(() => {
if (language === 'zh') {
restoreTranslatedNodes(document.body)
return
}
translateNode(document.body)
const pending = new Set<Node>()
let scheduled = false
const flush = () => {
scheduled = false
const nodes = Array.from(pending)
pending.clear()
for (const node of nodes) {
if (node.isConnected) translateNode(node)
}
}
const schedule = (node: Node) => {
pending.add(node)
if (scheduled) return
scheduled = true
window.requestAnimationFrame(flush)
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(schedule)
} else {
schedule(mutation.target)
}
}
})
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: [...attributeNames],
})
return () => observer.disconnect()
}, [language, location.pathname, location.search])
return null
}
function translateNode(root: Node) {
if (root.nodeType === Node.TEXT_NODE) {
translateTextNode(root as Text)
return
}
if (!(root instanceof Element)) return
if (shouldSkipElement(root)) return
translateWholeTextElement(root)
root.querySelectorAll<HTMLElement>(wholeTextSelector).forEach(translateWholeTextElement)
translateElementAttributes(root)
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!node.textContent || !shouldTranslateText(node.textContent)) return NodeFilter.FILTER_REJECT
const parent = node.parentElement
if (!parent || shouldSkipElement(parent)) {
return NodeFilter.FILTER_REJECT
}
return NodeFilter.FILTER_ACCEPT
},
})
const nodes: Text[] = []
while (walker.nextNode()) nodes.push(walker.currentNode as Text)
for (const node of nodes) translateTextNode(node)
root.querySelectorAll<HTMLElement>('[title], [placeholder], [aria-label]').forEach(translateElementAttributes)
}
function translateTextNode(node: Text) {
const original = node.textContent || ''
if (!shouldTranslateText(original)) return
const parent = node.parentElement
if (!parent || shouldSkipElement(parent)) return
const translated = translateText(original)
if (translated === original) return
textOriginals.set(node, original)
translatedTextNodes.add(node)
node.textContent = translated
}
function translateWholeTextElement(el: Element) {
if (!(el instanceof HTMLElement) || shouldSkipElement(el) || !isSimpleTextElement(el)) return
const original = el.textContent || ''
if (!shouldTranslateText(original)) return
const translated = translateText(original)
if (translated === original) return
const textNodes = directTextNodes(el)
if (textNodes.length === 0) return
textNodes.forEach((node, index) => {
textOriginals.set(node, node.textContent || '')
translatedTextNodes.add(node)
node.textContent = index === 0 ? translated : ''
})
}
function directTextNodes(el: HTMLElement) {
return Array.from(el.childNodes).filter((node): node is Text => node.nodeType === Node.TEXT_NODE)
}
function translateElementAttributes(el: Element) {
if (!(el instanceof HTMLElement)) return
translateAttribute(el, 'title', translatedTitleAttr)
translateAttribute(el, 'placeholder', translatedPlaceholderAttr)
translateAttribute(el, 'aria-label', translatedAriaLabelAttr)
}
function restoreTranslatedNodes(root: ParentNode) {
for (const node of Array.from(translatedTextNodes)) {
if (!node.isConnected) {
translatedTextNodes.delete(node)
continue
}
if (root instanceof Document || root.contains(node)) {
node.textContent = textOriginals.get(node) || node.textContent
translatedTextNodes.delete(node)
}
}
root.querySelectorAll<HTMLElement>(`[${translatedTitleAttr}]`).forEach((el) => {
el.setAttribute('title', el.getAttribute(translatedTitleAttr) || '')
el.removeAttribute(translatedTitleAttr)
})
root.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(`[${translatedPlaceholderAttr}]`).forEach((el) => {
el.setAttribute('placeholder', el.getAttribute(translatedPlaceholderAttr) || '')
el.removeAttribute(translatedPlaceholderAttr)
})
root.querySelectorAll<HTMLElement>(`[${translatedAriaLabelAttr}]`).forEach((el) => {
el.setAttribute('aria-label', el.getAttribute(translatedAriaLabelAttr) || '')
el.removeAttribute(translatedAriaLabelAttr)
})
}
function translateAttribute(el: HTMLElement, attr: 'title' | 'placeholder' | 'aria-label', originalAttr: string) {
const storedOriginal = el.getAttribute(originalAttr)
const original = storedOriginal || el.getAttribute(attr) || ''
if (!shouldTranslateText(original)) return
const translated = translateText(original)
if (translated === original) return
if (!storedOriginal) {
el.setAttribute(originalAttr, original)
}
if (el.getAttribute(attr) !== translated) {
el.setAttribute(attr, translated)
}
}
function shouldSkipElement(el: Element) {
return !!el.closest('script, style, code, pre, textarea, [data-no-translate]')
}
function isSimpleTextElement(el: HTMLElement) {
if (!el.matches(wholeTextSelector)) return false
if (el.querySelector('input, textarea, select, button, table, pre, code, canvas, iframe')) return false
const textNodes = directTextNodes(el)
if (textNodes.length === 0) return false
return Array.from(el.children).every((child) => child.tagName.toLowerCase() === 'svg')
}
@@ -0,0 +1,19 @@
import { useEffect } from 'react'
import { useLanguage } from '../contexts/LanguageContext'
export default function BrowserDialogTranslator() {
const { t } = useLanguage()
useEffect(() => {
const originalAlert = window.alert
const originalConfirm = window.confirm
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
return () => {
window.alert = originalAlert
window.confirm = originalConfirm
}
}, [t])
return null
}
+223 -37
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, X } from 'lucide-react'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
import { useDialog } from './Dialog'
import { useLanguage, type Language } from '../contexts/LanguageContext'
interface CreateContainerModalProps {
isOpen: boolean
@@ -26,13 +27,21 @@ const defaultForm: CreateContainerRequest = {
io_speed_mbps: 0,
extra_ports: [],
port_mapping_count: 2,
assign_nat: true,
snapshot_limit: 1,
assign_ipv4: false,
ipv4_count: 1,
public_ipv4s: [],
assign_ipv6: false,
ipv6_count: 1,
ipv6_addresses: [],
expires_at: '',
}
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
const dialog = useDialog()
const { language } = useLanguage()
const networkText = createNetworkText[language]
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(false)
const [batchCount, setBatchCount] = useState(1)
@@ -74,16 +83,23 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
}, [isOpen, form.virtualization])
const ipv6Available = !!ipv6Status?.available
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
const ipv6Prefixes = ipv6Status?.prefixes || []
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
const publicIPv4s = hostInfo?.network.public_ipv4_addresses || []
const ipv4Available = publicIPv4s.length > 0
const manualIPv4s = form.public_ipv4s || []
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const natEnabled = form.assign_nat !== false
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
const autoPorts = useMemo(() => {
const count = Math.max(2, form.port_mapping_count)
if (!natEnabled) return []
const count = natPortCount
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
}, [form.port_mapping_count])
}, [natEnabled, natPortCount])
// SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000
@@ -127,7 +143,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return
}
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false) {
dialog.alert('提示', '请勾选任意一个可用网络')
return
}
const boundedForm = normalizeCreateForm(form)
const wantsNAT = boundedForm.assign_nat !== false
// Build batch of containers
const containers: CreateContainerRequest[] = []
@@ -137,8 +159,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
containers.push({
...boundedForm,
name,
port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2),
assign_nat: wantsNAT,
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 0,
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0,
ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0,
extra_ports: [],
})
}
@@ -229,21 +254,157 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field>
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<input
type="checkbox"
checked={!!form.assign_ipv6}
disabled={!ipv6Available}
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">Public IPv6</span>
<span className="block text-xs text-gray-500 truncate">
{ipv6Available ? `Use ${ipv6Prefix}` : (ipv6Status?.reason || 'Checking IPv6 prefix...')}
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={!!form.assign_ipv4}
disabled={!ipv4Available}
onChange={(event) => setForm({ ...form, assign_ipv4: event.target.checked, public_ipv4s: event.target.checked ? form.public_ipv4s : [] })}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicIPv4}</span>
<span className="block text-xs text-gray-500">
{ipv4Available ? formatAllocatableIPv4Count(publicIPv4s.length, language) : networkText.noAllocatableIPv4}
</span>
</span>
</span>
</label>
</label>
{form.assign_ipv4 && (
<div className="mt-3 space-y-3 pl-6">
<div className="grid grid-cols-2 gap-3">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={manualIPv4s.length === 0}
onChange={() => setForm({ ...form, public_ipv4s: [] })}
/>
Auto assign
</label>
<Field label="IPv4 count">
<NumberInput
value={form.ipv4_count || 1}
min={1}
max={Math.max(1, publicIPv4s.length)}
onChange={(value) => setForm({ ...form, ipv4_count: Math.max(1, Math.round(value || 1)) })}
/>
</Field>
</div>
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={manualIPv4s.length > 0}
onChange={() => setForm({ ...form, public_ipv4s: publicIPv4s[0]?.address ? [publicIPv4s[0].address] : [], ipv4_count: 1 })}
/>
Manual select
</label>
{manualIPv4s.length > 0 && (
<div className="grid gap-1.5 sm:grid-cols-2">
{publicIPv4s.map((ip) => (
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded border border-gray-200 px-2 py-1.5 text-xs text-gray-700">
<input
type="checkbox"
checked={manualIPv4s.includes(ip.address)}
onChange={(event) => {
const next = event.target.checked
? [...manualIPv4s, ip.address]
: manualIPv4s.filter((value) => value !== ip.address)
setForm({ ...form, public_ipv4s: next, ipv4_count: Math.max(1, next.length || 1) })
}}
/>
<span className="truncate font-mono">{ip.address}</span>
<span className="shrink-0 text-gray-400">{ip.interface}</span>
{ip.gateway && <span className="shrink-0 text-gray-400">gw {ip.gateway}</span>}
</label>
))}
</div>
)}
</div>
</div>
)}
</div>
<div className={`rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<div className="flex items-start justify-between gap-3">
<label className="flex min-w-0 flex-1 items-start gap-3">
<input
type="checkbox"
checked={!!form.assign_ipv6}
disabled={!ipv6Available}
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
<span className="block text-xs text-gray-500 truncate">
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)}
</span>
</span>
</label>
{form.assign_ipv6 && (
<span className="block w-24 shrink-0">
<NumberInput
value={form.ipv6_count || 1}
min={1}
max={64}
onChange={(value) => setForm({ ...form, ipv6_count: Math.max(1, Math.round(value || 1)) })}
/>
</span>
)}
</div>
</div>
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
<div className="flex items-start justify-between gap-3">
<label className="flex min-w-0 flex-1 items-start gap-3">
<input
type="checkbox"
checked={natEnabled}
onChange={(event) => {
const checked = event.target.checked
setForm({
...form,
assign_nat: checked,
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
extra_ports: [],
})
}}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicNAT}</span>
<span className="block text-xs text-gray-500">
{natEnabled ? formatNATPortCount(natPortCount, language) : networkText.noNATPorts}
</span>
</span>
</label>
{natEnabled && (
<span className="block w-24 shrink-0">
<NumberInput
value={natPortCount}
min={2}
max={64}
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true })}
/>
</span>
)}
</div>
{natEnabled && (
<div className="mt-2 pl-6">
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}
</span>
{autoPorts.map((port) => (
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
{port} -&gt; {port}
</span>
))}
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="vCPU">
@@ -319,25 +480,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
)}
</div>
<Field label="NAT 端口映射数量">
<NumberInput
value={form.port_mapping_count}
min={2}
max={64}
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2) })}
/>
<div className="mt-2 flex flex-wrap gap-1.5">
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}
</span>
{autoPorts.map((port) => (
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
{port} -&gt; {port}
</span>
))}
</div>
</Field>
<Field label="子用户快照上限">
<NumberInput
value={form.snapshot_limit}
@@ -475,11 +617,22 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
const normalized = applyTemplateDefaults(form)
const wantsNAT = normalized.assign_nat !== false
const wantsIPv4 = !!normalized.assign_ipv4
const wantsIPv6 = !!normalized.assign_ipv6
return {
...normalized,
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
ram_mb: Math.round(normalized.ram_mb),
disk_gb: Math.round(normalized.disk_gb),
assign_nat: wantsNAT,
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
assign_ipv4: wantsIPv4,
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
assign_ipv6: wantsIPv6,
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
}
}
@@ -509,5 +662,38 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
return Math.min(Math.max(next, min), max ?? next)
}
const createNetworkText = {
zh: {
publicIPv4: '公网 IPv4',
noAllocatableIPv4: '未检测到可分配公网 IPv4',
publicIPv6: '公网 IPv6',
use: '使用',
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
publicNAT: '公网 NAT',
noNATPorts: '不分配 NAT 端口',
},
en: {
publicIPv4: 'Public IPv4',
noAllocatableIPv4: 'No allocatable public IPv4 detected',
publicIPv6: 'Public IPv6',
use: 'Use',
checkingIPv6Prefix: 'Checking IPv6 prefix...',
publicNAT: 'Public NAT',
noNATPorts: 'No NAT ports will be assigned',
},
} as const
function formatAllocatableIPv4Count(count: number, language: Language) {
return language === 'en'
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
: `检测到 ${count} 个可分配地址`
}
function formatNATPortCount(count: number, language: Language) {
return language === 'en'
? `${count} NAT ports will be assigned`
: `将分配 ${count} 个 NAT 端口`
}
const inputClass =
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
+6 -4
View File
@@ -1,5 +1,6 @@
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext'
type DialogType = 'confirm' | 'alert'
@@ -20,6 +21,7 @@ const DialogContext = createContext<DialogContextType | undefined>(undefined)
export function DialogProvider({ children }: { children: ReactNode }) {
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
const { t } = useLanguage()
const confirm = useCallback((title: string, message: string) => {
return new Promise<boolean>((resolve) => {
@@ -50,7 +52,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
}`}>
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
</div>
<h3 className="text-sm font-semibold text-black flex-1">{dialog.title}</h3>
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
{dialog.type === 'alert' && (
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
<X className="w-4 h-4" />
@@ -58,7 +60,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
)}
</div>
<div className="px-5 py-4">
<p className="text-sm text-gray-600">{dialog.message}</p>
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
</div>
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
{dialog.type === 'confirm' && (
@@ -66,7 +68,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
onClick={() => close(false)}
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
>
{t('取消')}
</button>
)}
<button
@@ -77,7 +79,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
: 'bg-black text-white hover:bg-gray-800'
}`}
>
{dialog.type === 'confirm' ? '确认' : '确定'}
{dialog.type === 'confirm' ? t('确认') : t('确定')}
</button>
</div>
</div>
+4
View File
@@ -1,12 +1,16 @@
import { Outlet } from 'react-router-dom'
import Sidebar from './Sidebar'
import { useState } from 'react'
import AutoTranslate from './AutoTranslate'
import BrowserDialogTranslator from './BrowserDialogTranslator'
export default function Layout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
return (
<div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
<AutoTranslate />
<BrowserDialogTranslator />
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
<div className="p-6">
+36 -13
View File
@@ -19,6 +19,7 @@ import {
UserCog,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { useTheme } from '../contexts/ThemeContext'
import { getVersion } from '../services/api'
import AppIcon from './AppIcon'
@@ -45,11 +46,23 @@ function GitHubIcon({ className = '' }: { className?: string }) {
)
}
function LanguageIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path
d="M213.333333 640v85.333333a85.333333 85.333333 0 0 0 78.933334 85.12L298.666667 810.666667h128v85.333333H298.666667a170.666667 170.666667 0 0 1-170.666667-170.666667v-85.333333h85.333333z m554.666667-213.333333l187.733333 469.333333h-91.946666l-51.242667-128h-174.506667l-51.157333 128h-91.904L682.666667 426.666667h85.333333z m-42.666667 123.093333L672.128 682.666667h106.325333L725.333333 549.76zM341.333333 85.333333v85.333334h170.666667v298.666666H341.333333v128H256v-128H85.333333V170.666667h170.666667V85.333333h85.333333z m384 42.666667a170.666667 170.666667 0 0 1 170.666667 170.666667v85.333333h-85.333333V298.666667a85.333333 85.333333 0 0 0-85.333334-85.333334h-128V128h128zM256 256H170.666667v128h85.333333V256z m170.666667 0H341.333333v128h85.333334V256z"
fill="currentColor"
/>
</svg>
)
}
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const navigate = useNavigate()
const location = useLocation()
const { logout, isSubUser } = useAuth()
const { theme, toggleTheme } = useTheme()
const { language, toggleLanguage, t } = useLanguage()
const [version, setVersion] = useState('')
useEffect(() => {
@@ -99,7 +112,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
<button
onClick={onToggle}
className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
title="切换侧边栏"
title={t('切换侧边栏')}
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
@@ -253,18 +266,28 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
<div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
title={theme === 'dark' ? '切换亮色模式' : '切换暗黑模式'}
>
{theme === 'dark' ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
</button>
<div className={collapsed ? 'space-y-1' : 'flex items-center gap-1'}>
<button
onClick={toggleTheme}
className={`${collapsed ? 'w-full justify-center' : 'flex-1'} flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
title={t(theme === 'dark' ? '切换亮色模式' : '切换暗黑模式')}
>
{theme === 'dark' ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
</button>
<button
onClick={() => { void toggleLanguage() }}
className={`${collapsed ? 'w-full' : 'w-10'} flex items-center justify-center rounded-md px-2 py-2.5 text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
title={language === 'en' ? '切换中文' : 'Switch to English'}
>
<LanguageIcon className="h-4 w-4" />
</button>
</div>
{/* Version */}
{version && (
+49 -23
View File
@@ -1,8 +1,39 @@
import { useEffect, useRef, useState } from 'react'
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
import RFB from '@novnc/novnc'
import RFBModule from '@novnc/novnc/lib/rfb'
import { createVNCTicket, getWebVNCUrl } from '../services/api'
type RFBConstructor = new (
target: HTMLElement,
url: string,
options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] }
) => RFBInstance
interface RFBInstance extends EventTarget {
scaleViewport: boolean
resizeSession: boolean
focusOnClick: boolean
viewOnly: boolean
qualityLevel: number
compressionLevel: number
background: string
disconnect(): void
sendCtrlAltDel(): void
}
const RFB = resolveRFBConstructor(RFBModule)
function resolveRFBConstructor(moduleValue: unknown): RFBConstructor {
if (typeof moduleValue === 'function') {
return moduleValue as RFBConstructor
}
const maybeDefault = (moduleValue as { default?: unknown })?.default
if (typeof maybeDefault === 'function') {
return maybeDefault as RFBConstructor
}
throw new Error('noVNC RFB constructor is unavailable')
}
interface WebVNCViewerProps {
containerName: string
onClose: () => void
@@ -10,7 +41,7 @@ interface WebVNCViewerProps {
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
const screenRef = useRef<HTMLDivElement>(null)
const rfbRef = useRef<RFB | null>(null)
const rfbRef = useRef<RFBInstance | null>(null)
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
const [errorMsg, setErrorMsg] = useState('')
@@ -80,6 +111,7 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
return
}
if (!ticket) {
setStatus('error')
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
@@ -97,9 +129,7 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
rfb.qualityLevel = 6
rfb.compressionLevel = 2
rfb.background = '#050505'
rfb.addEventListener('connect', () => {
setStatus('connected')
})
rfb.addEventListener('connect', () => setStatus('connected'))
rfb.addEventListener('disconnect', (event) => {
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
setStatus((current) => current === 'error' ? current : 'disconnected')
@@ -133,35 +163,31 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
}, [containerName])
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4 text-gray-600" />
<Monitor className="h-4 w-4 text-gray-600" />
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
{status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700"></span>}
{status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">...</span>}
{status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"></span>}
{status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700"></span>}
{status === 'connected' && <span className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700"></span>}
{status === 'connecting' && <span className="rounded bg-yellow-100 px-1.5 py-0.5 text-xs text-yellow-700">...</span>}
{status === 'disconnected' && <span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600"></span>}
{status === 'error' && <span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700"></span>}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => rfbRef.current?.sendCtrlAltDel()}
className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
title="发送 Ctrl+Alt+Del"
>
<Send className="w-3.5 h-3.5" />
<button onClick={() => rfbRef.current?.sendCtrlAltDel()} className="inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs text-gray-500 hover:bg-gray-200" title="发送 Ctrl+Alt+Del">
<Send className="h-3.5 w-3.5" />
Ctrl+Alt+Del
</button>
<button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接">
<RefreshCw className="w-3.5 h-3.5" />
<button onClick={connect} className="rounded p-1.5 text-xs text-gray-500 hover:bg-gray-200" title="重新连接">
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭">
<X className="w-4 h-4" />
<button onClick={onClose} className="rounded p-1.5 text-gray-500 hover:bg-gray-200" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
+72
View File
@@ -0,0 +1,72 @@
import { ReactNode, createContext, useContext, useEffect, useMemo, useState } from 'react'
import { translateText } from '../utils/i18n'
import { getLanguage, updateLanguage } from '../services/api'
export type Language = 'zh' | 'en'
interface LanguageContextValue {
language: Language
setLanguage: (language: Language) => void
toggleLanguage: () => Promise<void>
t: (value: string) => string
}
const LanguageContext = createContext<LanguageContextValue | undefined>(undefined)
function initialLanguage(): Language {
return 'zh'
}
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(initialLanguage)
const setLanguageLocal = (next: Language) => {
setLanguageState(next)
}
const setLanguage = (next: Language) => {
setLanguageLocal(next)
updateLanguage(next).catch(() => {})
}
const value = useMemo<LanguageContextValue>(() => ({
language,
setLanguage,
toggleLanguage: async () => {
const next = language === 'zh' ? 'en' : 'zh'
setLanguageLocal(next)
try {
const res = await updateLanguage(next)
setLanguageLocal(res.data.data?.language || next)
} catch {
setLanguageLocal(language)
}
},
t: (text: string) => language === 'en' ? translateText(text) : text,
}), [language])
useEffect(() => {
getLanguage()
.then((res) => {
const serverLanguage = res.data.data?.language
if (serverLanguage === 'zh' || serverLanguage === 'en') {
setLanguageLocal(serverLanguage)
}
})
.catch(() => {})
}, [])
useEffect(() => {
document.documentElement.lang = language === 'en' ? 'en' : 'zh-CN'
document.documentElement.dataset.language = language
}, [language])
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
}
export function useLanguage() {
const context = useContext(LanguageContext)
if (!context) {
throw new Error('useLanguage must be used within LanguageProvider')
}
return context
}
+8 -5
View File
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import { LanguageProvider } from './contexts/LanguageContext'
import { DialogProvider } from './components/Dialog'
import './index.css'
@@ -11,11 +12,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<DialogProvider>
<App />
</DialogProvider>
</AuthProvider>
<LanguageProvider>
<AuthProvider>
<DialogProvider>
<App />
</DialogProvider>
</AuthProvider>
</LanguageProvider>
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>,
+1 -1
View File
@@ -1,4 +1,4 @@
declare module '@novnc/novnc' {
declare module '@novnc/novnc/lib/rfb' {
export default class RFB extends EventTarget {
constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] })
scaleViewport: boolean
+60 -18
View File
@@ -93,6 +93,7 @@ type MappingDraft = {
index: number | null
description: string
host_port: string
host_ip: string
container_port: string
protocol: string
}
@@ -101,6 +102,7 @@ const emptyDraft: MappingDraft = {
index: null,
description: '',
host_port: '',
host_ip: '',
container_port: '',
protocol: 'all',
}
@@ -526,6 +528,7 @@ export default function ContainerDetail() {
index,
description: pm.description,
host_port: String(pm.host_port),
host_ip: pm.host_ip || '',
container_port: String(pm.container_port),
protocol: pm.protocol || 'all',
})
@@ -538,7 +541,11 @@ export default function ContainerDetail() {
if (!(await ensureSubUserCanOperate())) return false
if (draft.index === null && container) {
const currentCount = container.port_mappings?.length || 0
const limit = container.port_mapping_limit || Math.max(currentCount, 2)
const limit = Math.max(container.port_mapping_limit || 0, currentCount)
if (limit <= 0) {
dialog.alert('未分配 IPv4 NAT', '该容器未分配 IPv4 NAT 端口配额。')
return false
}
if (currentCount >= limit) {
dialog.alert('端口配额已满', '已达到管理员分配的 NAT 端口配额。')
return false
@@ -563,6 +570,7 @@ export default function ContainerDetail() {
const payload: PortMapping = {
container_port: containerPort,
host_port: hostPortVal,
host_ip: isSubUser ? undefined : (draft.host_ip || undefined),
protocol: protocolVal,
description: draft.description.trim() || `Port-${containerPort}`,
}
@@ -737,10 +745,17 @@ export default function ContainerDetail() {
const isPolicyBlocked = !!container.policy_blocked
const isSubUserPolicyBlocked = isSubUser && isPolicyBlocked
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
const publicIPv4s = container.public_ipv4s || []
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
const ipv6List = (container.ipv6_addresses || [])
.map((item) => item.address)
.filter(Boolean)
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 sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
const publicEndpoint = container.ssh_port > 0 ? `${publicHost}:${container.ssh_port}` : '-'
const sshCommand = container.ssh_port > 0 ? `ssh -p ${container.ssh_port} root@${publicHost}` : ''
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
@@ -758,8 +773,9 @@ export default function ContainerDetail() {
const netPct = Math.min(((usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)) / (container.network_bw_mbps > 0 ? container.network_bw_mbps * 125000 : 125000000) * 100, 100)
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
const mappingCount = container.port_mappings?.length || 0
const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2)
const canAddMapping = isSubUser ? mappingCount < mappingLimit && !isSubUserPolicyBlocked : true
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
const hasNATQuota = mappingLimit > 0
const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked
const managementUrl = subUser?.access_code
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
: ''
@@ -828,8 +844,8 @@ export default function ContainerDetail() {
<InfoTag color="blue"> {container.template}</InfoTag>
<InfoTag color="slate"> {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
<InfoTag color="emerald"> {container.ip || '-'}</InfoTag>
<InfoTag color="amber">NAT {mappingCount} </InfoTag>
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
<InfoTag color="amber">IPv4 NAT {hasNATQuota ? `${mappingCount}` : '未分配'}</InfoTag>
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicEndpoint}</InfoTag>
{isPolicyBlocked && <InfoTag color="red"></InfoTag>}
</div>
</div>
@@ -874,7 +890,7 @@ export default function ContainerDetail() {
<>
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
<Settings className="w-3.5 h-3.5" />
NAT
IPv4 NAT
</ActionButton>
</>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
@@ -926,7 +942,7 @@ export default function ContainerDetail() {
</div>
) : isWindows ? (
<>
<PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
<PlainRow label="RDP 地址" value={publicEndpoint} mono />
<PlainRow label="用户名" value="Administrator" mono />
<div className="flex items-center justify-between gap-3">
<span className="text-gray-500"></span>
@@ -951,7 +967,7 @@ export default function ContainerDetail() {
</>
) : (
<>
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} />
<PlainRow label="SSH 地址" value={publicEndpoint} mono copyValue={sshCommand} onCopy={copyText} />
<PlainRow label="用户名" value="root" mono />
<div className="flex items-center justify-between gap-3">
<span className="text-gray-500">SSH </span>
@@ -993,8 +1009,9 @@ export default function ContainerDetail() {
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
<PlainRow label="内网 IP" value={container.ip || '-'} mono />
<PlainRow label="IPv6" value={container.ipv6 || '-'} mono copyValue={container.ipv6} onCopy={copyText}>
{!isSubUser && !container.ipv6 && (
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText} />
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
{!isSubUser && ipv6List.length === 0 && (
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
Assign
</button>
@@ -1386,7 +1403,7 @@ export default function ContainerDetail() {
)}
{showNat && (
<Modal title="NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
!isSubUser && canAddMapping && (
<button onClick={openAddMapping} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
<Plus className="w-3.5 h-3.5" />
@@ -1396,10 +1413,14 @@ export default function ContainerDetail() {
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div className="text-xs text-gray-500">
<span className="font-mono text-gray-800">{mappingCount}/{mappingLimit}</span>
{hasNATQuota ? (
<><span className="font-mono text-gray-800">{mappingCount}/{mappingLimit}</span></>
) : (
<span> IPv4 NAT </span>
)}
</div>
{!isSubUser && !canAddMapping && (
<div className="text-xs text-amber-600"> NAT </div>
{!isSubUser && hasNATQuota && !canAddMapping && (
<div className="text-xs text-amber-600"> IPv4 NAT </div>
)}
</div>
<MappingTable mappings={container.port_mappings || []} publicHost={publicHost} onEdit={openEditMapping} onDelete={isSubUser ? () => {} : removeMapping} isSubUser={isSubUser} />
@@ -1419,6 +1440,7 @@ export default function ContainerDetail() {
canAddMapping={canAddMapping}
saving={savingMapping}
containerIdentifier={containerIdentifier}
publicIPv4s={publicIPv4s}
onCancel={() => { setShowMappingEditor(false); setDraft(emptyDraft) }}
onSubmit={async () => {
if (await submitMapping()) {
@@ -1739,6 +1761,7 @@ function MappingEditor({
canAddMapping,
saving,
containerIdentifier,
publicIPv4s,
onCancel,
onSubmit,
}: {
@@ -1748,6 +1771,7 @@ function MappingEditor({
canAddMapping: boolean
saving: boolean
containerIdentifier: string
publicIPv4s: { address: string; interface?: string }[]
onCancel: () => void
onSubmit: () => void
}) {
@@ -1757,7 +1781,8 @@ function MappingEditor({
const fillRandomPort = async () => {
try {
const res = await api.get<APIResponse<{ port: number }>>(`/containers/${containerIdentifier}/random-port`)
const params = draft.host_ip ? { host_ip: draft.host_ip } : undefined
const res = await api.get<APIResponse<{ port: number }>>(`/containers/${containerIdentifier}/random-port`, { params })
const port = res.data.data?.port || 0
if (port > 0) updateDraft({ host_port: String(port) })
} catch {
@@ -1815,6 +1840,21 @@ function MappingEditor({
)}
</Field>
<Field label="Host IPv4">
{isSubUser ? (
<input value={draft.host_ip || 'All IPv4'} disabled className={disabledInputClass} />
) : (
<select value={draft.host_ip} onChange={(e) => updateDraft({ host_ip: e.target.value })} className={inputClass}>
<option value="">All assigned IPv4</option>
{publicIPv4s.map((ip) => (
<option key={`${ip.interface}-${ip.address}`} value={ip.address}>
{ip.address}{ip.interface ? ` (${ip.interface})` : ''}
</option>
))}
</select>
)}
</Field>
<Field label="内部端口">
<input
value={draft.container_port}
@@ -1854,6 +1894,7 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
<tr>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Host IPv4</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
{!compact && <th className="text-right px-3 py-2 text-xs font-medium text-gray-500"></th>}
@@ -1869,7 +1910,8 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
{isSSH && <span className="ml-2 px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 text-xs"></span>}
</td>
<td className="px-3 py-2 text-xs text-gray-500">{pm.protocol.toUpperCase()}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-800">{publicHost}:{pm.host_port}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.host_ip || publicHost || 'All IPv4'}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.host_port}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.container_port}</td>
{!compact && (
<td className="px-3 py-2">
+3 -1
View File
@@ -704,14 +704,16 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
io_speed_mbps: cfg.io_speed_mbps,
status: 'creating',
ip: '',
public_ipv4s: [],
ipv6: '',
ipv6_prefix_len: 0,
ipv6_interface: '',
ipv6_addresses: [],
vnc_port: 0,
ssh_port: 0,
ssh_password: '',
port_mappings: [],
port_mapping_limit: 2,
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
snapshot_limit: cfg.snapshot_limit || 3,
created_at: '',
expires_at: cfg.expires_at,
+273 -74
View File
@@ -9,8 +9,12 @@ import {
XCircle,
} from 'lucide-react'
import { getHostReport, HostProbeReport } from '../services/api'
import { useLanguage, type Language } from '../contexts/LanguageContext'
import { translateText } from '../utils/i18n'
export default function HostReport() {
const { language } = useLanguage()
const text = hostReportText[language]
const [report, setReport] = useState<HostProbeReport | null>(null)
const [loading, setLoading] = useState(true)
@@ -31,61 +35,61 @@ export default function HostReport() {
}, [fetchReport])
return (
<div className="space-y-6">
<div className="space-y-6" data-no-translate>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-black">宿</h1>
<p className="mt-1 text-sm text-gray-500"></p>
<h1 className="text-2xl font-bold text-black">{text.title}</h1>
<p className="mt-1 text-sm text-gray-500">{text.subtitle}</p>
</div>
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
{text.refresh}
</button>
</div>
{loading && !report ? (
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">宿...</div>
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">{text.loading}</div>
) : !report ? (
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">宿</div>
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">{text.emptyReport}</div>
) : (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} />
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} />
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} />
<ProbeMetric icon={<Activity className="h-4 w-4" />} label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} />
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={formatCPUThreads(report.cpu.cores, report.cpu.threads, language)} />
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={formatUsedMemory(report.memory.used_mb, language)} />
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={formatDiskCount(report.disks.length, language)} sub={report.disks.map(d => diskTypeLabel(d, language)).filter(Boolean).join(' / ') || 'Unknown'} />
<ProbeMetric icon={<Activity className="h-4 w-4" />} label={text.runtimeStatus} value={translateDynamic(report.system.uptime_text, language)} sub={formatProcessCount(report.system.process_count, language)} />
</div>
<ProbeSection title="系统概览">
<ProbeSection title={text.systemOverview}>
<ProbeRows rows={[
['主机名', report.hostname],
['操作系统', report.os],
['内核', report.kernel],
['生成时间', report.generated_at],
['CPU 架构', report.cpu.architecture],
['CPU 虚拟化指令', report.cpu.virtualization ? `支持 (${report.cpu.virtualization_key})` : '未检测到'],
['CPU 核显', report.cpu.has_integrated_gpu ? '检测到' : '未检测到'],
['显卡', report.gpus.length ? `${report.gpus.length}` : '未检测到'],
['运行能力', runtimeModeLabel(report.runtime.support_mode)],
['KVM 嵌套虚拟化', `${report.runtime.nested_virtualization ? '支持' : '未检测到'} (${report.runtime.nested_detail || '-'})`],
[text.hostname, report.hostname],
[text.os, report.os],
[text.kernel, report.kernel],
[text.generatedAt, report.generated_at],
[text.cpuArch, report.cpu.architecture],
[text.cpuVirtualization, report.cpu.virtualization ? `${text.supported} (${report.cpu.virtualization_key})` : text.notDetected],
[text.cpuIntegratedGPU, report.cpu.has_integrated_gpu ? text.detected : text.notDetected],
[text.gpu, report.gpus.length ? formatItemCount(report.gpus.length, language) : text.notDetected],
[text.runtimeCapability, runtimeModeLabel(report.runtime.support_mode, language)],
[text.kvmNested, `${report.runtime.nested_virtualization ? text.supported : text.notDetected} (${translateDynamic(report.runtime.nested_detail || '-', language)})`],
]} />
</ProbeSection>
<ProbeSection title="公网与路由">
<ProbeSection title={text.publicNetwork}>
<ProbeRows rows={[
['公网 IPv4', report.public_ipv4.length ? report.public_ipv4.join('\n') : '未检测到'],
['IPv4 地址', report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : '未检测到'],
['IPv4 段', report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : '未检测到'],
['IPv6 地址', report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'],
['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'],
['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'],
[text.publicIPv4, report.public_ipv4.length ? report.public_ipv4.join('\n') : text.notDetected],
[text.ipv4Address, report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : text.notDetected],
[text.ipv4Prefix, report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : text.notDetected],
[text.ipv6Address, report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : text.notDetected],
[text.ipv6Prefix, report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : text.notDetected],
[text.gateway, report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : text.notDetected],
]} />
</ProbeSection>
<ProbeTable
title="内存条"
empty="未检测到内存条明细,可能缺少 dmidecode 或权限受限"
headers={['插槽', '容量', '类型', '频率', '厂商', '型号/序列号']}
title={text.memoryModules}
empty={text.noMemoryModules}
headers={[text.slot, text.capacity, text.type, text.frequency, text.vendor, text.modelSerial]}
rows={(report.memory.modules || []).map(m => [
m.locator || '-',
m.size || '-',
@@ -97,29 +101,29 @@ export default function HostReport() {
/>
<ProbeTable
title="硬盘与健康"
empty="未检测到硬盘"
headers={['设备', '型号', '容量', '类型', '挂载点', '健康', '寿命', '通电', '读取', '写入', '命令数', '擦写']}
title={text.disksHealth}
empty={text.noDisks}
headers={[text.device, text.model, text.capacity, text.type, text.mountPoint, text.health, text.lifetime, text.powerOn, text.reads, text.writes, text.commands, text.eraseCount]}
rows={report.disks.map(d => [
`${d.path || d.name}\n${d.serial || ''}`,
d.model || '-',
formatBytes(d.size_bytes),
d.type || (d.rotational ? 'HDD' : 'SSD'),
diskTypeLabel(d, language),
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
`${diskHealthLabel(d.health)}\n${d.health_detail || ''}`,
formatLifeUsed(d.smart?.life_used_percent),
d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-',
formatBytes(d.smart?.read_data_bytes || 0),
formatBytes(d.smart?.written_data_bytes || 0),
formatCommands(d.smart?.read_commands, d.smart?.write_commands),
formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count),
`${diskHealthLabel(d.health, language)}\n${diskHealthDetail(d, language)}`,
d.virtual ? text.unsupported : formatLifeUsed(d.smart?.life_used_percent, language),
d.virtual ? text.unsupported : (d.smart?.power_on_hours ? `${d.smart.power_on_hours} ${text.hours}\n${formatPowerOnDays(d.smart.power_on_hours, language)}` : '-'),
d.virtual ? text.unsupported : formatBytes(d.smart?.read_data_bytes || 0),
d.virtual ? text.unsupported : formatBytes(d.smart?.written_data_bytes || 0),
d.virtual ? text.unsupported : formatCommands(d.smart?.read_commands, d.smart?.write_commands, language),
d.virtual ? text.unsupported : formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count, language),
])}
/>
<ProbeTable
title="网卡"
empty="未检测到网卡"
headers={['网卡', '状态', '驱动/速率', 'MAC', 'IPv4', 'IPv6']}
title={text.networkInterfaces}
empty={text.noNetworkInterfaces}
headers={[text.nic, text.status, text.driverSpeed, 'MAC', 'IPv4', 'IPv6']}
rows={report.network_interfaces.map(n => [
`${n.name}\n${n.model || ''}`,
n.state || '-',
@@ -131,25 +135,25 @@ export default function HostReport() {
/>
<ProbeTable
title="显卡"
empty="未检测到显卡"
headers={['名称', '厂商', '类型', '驱动']}
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])}
title={text.gpus}
empty={text.noGPUs}
headers={[text.name, text.vendor, text.type, text.driver]}
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type, language), g.driver || '-'])}
/>
<ProbeSection title="环境支持">
<ProbeSection title={text.environmentSupport}>
<div className="grid gap-2 md:grid-cols-2">
{report.environment.map(item => (
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
<span>{item.label}</span>
<span>{translateDynamic(item.label, language)}</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
{item.required ? '必要' : '可选'}
{item.required ? text.required : text.optional}
</span>
</div>
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{item.detail || '-'}</div>
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{translateDynamic(item.detail || '-', language)}</div>
</div>
</div>
))}
@@ -174,6 +178,153 @@ function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: stri
)
}
const hostReportText = {
zh: {
title: '宿主机信息',
subtitle: '硬件、网络、磁盘健康与运行环境探测报告',
refresh: '刷新',
loading: '正在探测宿主机环境...',
emptyReport: '暂未获取到宿主机信息',
runtimeStatus: '运行状态',
systemOverview: '系统概览',
hostname: '主机名',
os: '操作系统',
kernel: '内核',
generatedAt: '生成时间',
cpuArch: 'CPU 架构',
cpuVirtualization: 'CPU 虚拟化指令',
cpuIntegratedGPU: 'CPU 核显',
gpu: '显卡',
runtimeCapability: '运行能力',
kvmNested: 'KVM 嵌套虚拟化',
supported: '支持',
detected: '检测到',
notDetected: '未检测到',
publicNetwork: '公网与路由',
publicIPv4: '公网 IPv4',
ipv4Address: 'IPv4 地址',
ipv4Prefix: 'IPv4 段',
ipv6Address: 'IPv6 地址',
ipv6Prefix: 'IPv6 段',
gateway: '网关',
memoryModules: '内存条',
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
slot: '插槽',
capacity: '容量',
type: '类型',
frequency: '频率',
vendor: '厂商',
modelSerial: '型号/序列号',
disksHealth: '硬盘与健康',
noDisks: '未检测到硬盘',
device: '设备',
model: '型号',
mountPoint: '挂载点',
health: '健康',
lifetime: '寿命',
powerOn: '通电',
reads: '读取',
writes: '写入',
commands: '命令数',
eraseCount: '擦写',
virtualDisk: '虚拟磁盘',
virtualDiskDetail: '虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看',
unsupported: '不支持',
hours: '小时',
used: '已用',
remaining: '剩余',
read: '读',
write: '写',
wear: '磨损',
erase: '擦写',
powerCycles: '启停',
networkInterfaces: '网卡',
noNetworkInterfaces: '未检测到网卡',
nic: '网卡',
status: '状态',
driverSpeed: '驱动/速率',
gpus: '显卡',
noGPUs: '未检测到显卡',
name: '名称',
driver: '驱动',
environmentSupport: '环境支持',
required: '必要',
optional: '可选',
},
en: {
title: 'Host Info',
subtitle: 'Hardware, network, disk health, and runtime environment report',
refresh: 'Refresh',
loading: 'Probing host environment...',
emptyReport: 'No host information available',
runtimeStatus: 'Runtime Status',
systemOverview: 'System Overview',
hostname: 'Hostname',
os: 'Operating System',
kernel: 'Kernel',
generatedAt: 'Generated At',
cpuArch: 'CPU Architecture',
cpuVirtualization: 'CPU Virtualization',
cpuIntegratedGPU: 'CPU Integrated GPU',
gpu: 'GPU',
runtimeCapability: 'Runtime Capability',
kvmNested: 'KVM Nested Virtualization',
supported: 'Supported',
detected: 'Detected',
notDetected: 'Not detected',
publicNetwork: 'Public Network & Routing',
publicIPv4: 'Public IPv4',
ipv4Address: 'IPv4 Addresses',
ipv4Prefix: 'IPv4 Prefixes',
ipv6Address: 'IPv6 Addresses',
ipv6Prefix: 'IPv6 Prefixes',
gateway: 'Gateway',
memoryModules: 'Memory Modules',
noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
slot: 'Slot',
capacity: 'Capacity',
type: 'Type',
frequency: 'Frequency',
vendor: 'Vendor',
modelSerial: 'Model / Serial',
disksHealth: 'Disks & Health',
noDisks: 'No disks detected',
device: 'Device',
model: 'Model',
mountPoint: 'Mount Point',
health: 'Health',
lifetime: 'Lifetime',
powerOn: 'Power-on',
reads: 'Reads',
writes: 'Writes',
commands: 'Commands',
eraseCount: 'Erase Count',
virtualDisk: 'Virtual Disk',
virtualDiskDetail: 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
unsupported: 'Unsupported',
hours: 'hours',
used: 'used',
remaining: 'remaining',
read: 'Read',
write: 'Write',
wear: 'Wear',
erase: 'Erase',
powerCycles: 'Power cycles',
networkInterfaces: 'Network Interfaces',
noNetworkInterfaces: 'No network interfaces detected',
nic: 'NIC',
status: 'Status',
driverSpeed: 'Driver / Speed',
gpus: 'GPUs',
noGPUs: 'No GPUs detected',
name: 'Name',
driver: 'Driver',
environmentSupport: 'Environment Support',
required: 'Required',
optional: 'Optional',
},
} as const
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
@@ -255,6 +406,26 @@ function formatMB(value: number) {
return `${value} MB`
}
function formatCPUThreads(cores: number, threads: number, language: Language) {
return language === 'en' ? `${cores} cores / ${threads} threads` : `${cores} 核 / ${threads} 线程`
}
function formatUsedMemory(usedMB: number, language: Language) {
return language === 'en' ? `${formatMB(usedMB)} used` : `${formatMB(usedMB)} 已用`
}
function formatDiskCount(count: number, language: Language) {
return language === 'en' ? `${count} disk${count === 1 ? '' : 's'}` : `${count} 块硬盘`
}
function formatProcessCount(count: number, language: Language) {
return language === 'en' ? `${count} process${count === 1 ? '' : 'es'}` : `${count} 个进程`
}
function formatItemCount(count: number, language: Language) {
return language === 'en' ? `${count} item${count === 1 ? '' : 's'}` : `${count}`
}
function formatBytes(value: number) {
if (!value) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
@@ -267,20 +438,24 @@ function formatBytes(value: number) {
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
function formatLifeUsed(value?: number) {
function formatLifeUsed(value: number | undefined, language: Language) {
if (value === undefined || value === null) return '-'
return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余`
const text = hostReportText[language]
return `${value}% ${text.used}\n${Math.max(0, 100 - value)}% ${text.remaining}`
}
function formatPowerOnDays(hours: number) {
function formatPowerOnDays(hours: number, language: Language) {
const days = Math.floor(hours / 24)
const rest = hours % 24
return days > 0 ? `${days}${rest} 小时` : `${hours} 小时`
return language === 'en'
? (days > 0 ? `${days} days ${rest} hours` : `${hours} hours`)
: (days > 0 ? `${days}${rest} 小时` : `${hours} 小时`)
}
function formatCommands(read?: number, write?: number) {
function formatCommands(read: number | undefined, write: number | undefined, language: Language) {
if (!read && !write) return '-'
return `${formatCount(read || 0)}\n写 ${formatCount(write || 0)}`
const text = hostReportText[language]
return `${text.read} ${formatCount(read || 0)}\n${text.write} ${formatCount(write || 0)}`
}
function formatCount(value: number) {
@@ -291,38 +466,62 @@ function formatCount(value: number) {
return `${value}`
}
function formatWear(wear?: string, erase?: string, powerCycles?: number) {
function formatWear(wear: string | undefined, erase: string | undefined, powerCycles: number | undefined, language: Language) {
const text = hostReportText[language]
const rows: string[] = []
if (wear) rows.push(`磨损 ${wear}`)
if (erase) rows.push(`擦写 ${erase}`)
if (powerCycles) rows.push(`启停 ${powerCycles}`)
if (wear) rows.push(`${text.wear} ${wear}`)
if (erase) rows.push(`${text.erase} ${erase}`)
if (powerCycles) rows.push(`${text.powerCycles} ${powerCycles}`)
return rows.length ? rows.join('\n') : '-'
}
function runtimeModeLabel(value: string) {
function runtimeModeLabel(value: string, language: Language) {
switch (value) {
case 'kvm_lxc':
return '支持 KVM + LXC'
return language === 'en' ? 'KVM + LXC supported' : '支持 KVM + LXC'
case 'lxc_only':
return '仅支持 LXC'
return language === 'en' ? 'LXC only' : '仅支持 LXC'
default:
return '未满足运行环境'
return language === 'en' ? 'Runtime requirements not met' : '未满足运行环境'
}
}
function diskHealthLabel(value: string) {
function diskHealthLabel(value: string, language: Language) {
const text = hostReportText[language]
switch (value) {
case 'ok':
return '健康'
return language === 'en' ? 'Healthy' : '健康'
case 'failed':
return '异常'
return language === 'en' ? 'Failed' : '异常'
case 'virtual':
return text.virtualDisk
default:
return '未知'
return language === 'en' ? 'Unknown' : '未知'
}
}
function gpuTypeLabel(value: string) {
if (value === 'integrated') return '核显'
if (value === 'discrete') return '独显'
function diskHealthDetail(d: { virtual?: boolean; health_detail?: string }, language: Language) {
if (d.virtual) return hostReportText[language].virtualDiskDetail
return translateDynamic(d.health_detail || '', language)
}
function diskTypeLabel(d: { type?: string; rotational?: boolean; virtual?: boolean }, language: Language) {
if (d.virtual || d.type === 'Virtual') return hostReportText[language].virtualDisk
return d.type || (d.rotational ? 'HDD' : 'SSD')
}
function gpuTypeLabel(value: string, language: Language) {
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
return value || '-'
}
function translateDynamic(value: string, language: Language) {
if (language !== 'en' || !value) return value
return translateText(value)
.replace(/寿命已用\s*(\d+)%/g, 'Lifetime used $1%')
.replace(/通电\s*(\d+)h/g, 'Power-on $1h')
.replace(/写入\s*([^|]+)/g, 'Written $1')
.replace(/读取\s*([^|]+)/g, 'Read $1')
.replace(/介质错误\s*(\d+)/g, 'Media errors $1')
}
+24 -2
View File
@@ -2,9 +2,21 @@ import { FormEvent, useState } from 'react'
import { Lock, User } from 'lucide-react'
import AppIcon from '../components/AppIcon'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import AutoTranslate from '../components/AutoTranslate'
import BrowserDialogTranslator from '../components/BrowserDialogTranslator'
function LanguageIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M213.333333 640v85.333333a85.333333 85.333333 0 0 0 78.933334 85.12L298.666667 810.666667h128v85.333333H298.666667a170.666667 170.666667 0 0 1-170.666667-170.666667v-85.333333h85.333333z m554.666667-213.333333l187.733333 469.333333h-91.946666l-51.242667-128h-174.506667l-51.157333 128h-91.904L682.666667 426.666667h85.333333z m-42.666667 123.093333L672.128 682.666667h106.325333L725.333333 549.76zM341.333333 85.333333v85.333334h170.666667v298.666666H341.333333v128H256v-128H85.333333V170.666667h170.666667V85.333333h85.333333z m384 42.666667a170.666667 170.666667 0 0 1 170.666667 170.666667v85.333333h-85.333333V298.666667a85.333333 85.333333 0 0 0-85.333334-85.333334h-128V128h128zM256 256H170.666667v128h85.333333V256z m170.666667 0H341.333333v128h85.333334V256z" fill="currentColor" />
</svg>
)
}
export default function Login() {
const { login, accessCodeLogin } = useAuth()
const { language, toggleLanguage, t } = useLanguage()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
@@ -29,7 +41,7 @@ export default function Login() {
}
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
setError(error.response?.data?.message || '登录失败,请检查用户名和密码')
setError(error.response?.data?.message || t('登录失败,请检查用户名和密码'))
} finally {
setLoading(false)
}
@@ -37,6 +49,16 @@ export default function Login() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<AutoTranslate />
<BrowserDialogTranslator />
<button
type="button"
onClick={() => { void toggleLanguage() }}
className="absolute right-4 top-4 inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm hover:bg-gray-50"
>
<LanguageIcon className="h-3.5 w-3.5" />
{language === 'en' ? '中文' : 'English'}
</button>
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="flex flex-col items-center mb-8">
@@ -106,7 +128,7 @@ export default function Login() {
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.7</p>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.11</p>
</div>
</div>
)
File diff suppressed because it is too large Load Diff
+114 -22
View File
@@ -1,13 +1,16 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Upload, UserCog } from 'lucide-react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
getSSLSettings,
getWebSSHOriginSettings,
LoginLog,
SSLSettings,
updateSSLSettings,
updateWebSSHOriginSettings,
WebSSHOriginSettings,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
@@ -33,6 +36,9 @@ export default function Settings() {
const [keyPEM, setKeyPEM] = useState('')
const [applyNow, setApplyNow] = useState(true)
const [savingSSL, setSavingSSL] = useState(false)
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
const [savingWebSSHOrigins, setSavingWebSSHOrigins] = useState(false)
const fetchLogs = useCallback(async () => {
try {
@@ -60,12 +66,25 @@ export default function Settings() {
}
}, [])
const fetchWebSSHOrigins = useCallback(async () => {
try {
const res = await getWebSSHOriginSettings()
const data = res.data.data
if (!data) return
setWebSSHOrigins(data)
setWebSSHOriginsText((data.origins || []).join('\n'))
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
fetchLogs()
fetchSSL()
fetchWebSSHOrigins()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs, fetchSSL])
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
setSSLMode(mode)
@@ -101,6 +120,25 @@ export default function Settings() {
}
}
const handleSaveWebSSHOrigins = async () => {
setSavingWebSSHOrigins(true)
try {
const origins = webSSHOriginsText.split(/\r?\n/).map(item => item.trim()).filter(Boolean)
const res = await updateWebSSHOriginSettings(origins)
const data = res.data.data
if (data) {
setWebSSHOrigins(data)
setWebSSHOriginsText((data.origins || []).join('\n'))
}
dialog.alert('完成', 'Origin 白名单已保存')
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || 'Origin 白名单保存失败')
} finally {
setSavingWebSSHOrigins(false)
}
}
const handleSaveAccount = async () => {
if (!oldPwd) {
dialog.alert('提示', '请输入当前密码以确认修改')
@@ -159,26 +197,37 @@ export default function Settings() {
</div>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<div className="space-y-6">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<WebSSHOriginCard
settings={webSSHOrigins}
originsText={webSSHOriginsText}
saving={savingWebSSHOrigins}
onOriginsTextChange={setWebSSHOriginsText}
onRefresh={fetchWebSSHOrigins}
onSave={handleSaveWebSSHOrigins}
/>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
@@ -232,6 +281,49 @@ interface SSLCardProps {
onSave: () => void
}
interface WebSSHOriginCardProps {
settings: WebSSHOriginSettings | null
originsText: string
saving: boolean
onOriginsTextChange: (value: string) => void
onRefresh: () => void
onSave: () => void
}
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<Terminal className="h-4 w-4" />WebSSH Origin
</h2>
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs text-gray-500"> Origin</label>
<textarea
value={props.originsText}
onChange={(e) => props.onOriginsTextChange(e.target.value)}
rows={5}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black"
/>
</div>
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>{props.settings?.current_origin || '-'}</div>
<div className="mt-1"> Origin</div>
</div>
<button onClick={props.onSave} disabled={props.saving} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.saving ? '保存中...' : '保存 Origin 白名单'}
</button>
</div>
</div>
)
}
function SSLCard(props: SSLCardProps) {
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
+89
View File
@@ -40,10 +40,24 @@ export type ContainerIdentifier = number | string
export interface PortMapping {
container_port: number
host_port: number
host_ip?: string
protocol: string
description: string
}
export interface PublicIPv4Assignment {
address: string
interface?: string
prefix_len?: number
gateway?: string
}
export interface IPv6Assignment {
address: string
prefix_len: number
interface?: string
}
export interface Container {
id: number
uuid: string
@@ -64,9 +78,11 @@ export interface Container {
io_speed_mbps: number
status: string
ip: string
public_ipv4s?: PublicIPv4Assignment[]
ipv6: string
ipv6_prefix_len: number
ipv6_interface: string
ipv6_addresses?: IPv6Assignment[]
vnc_port: number
ssh_port: number
ssh_password: string
@@ -114,8 +130,14 @@ export interface CreateContainerRequest {
io_speed_mbps: number
extra_ports: number[]
port_mapping_count: number
assign_nat?: boolean
snapshot_limit: number
assign_ipv4?: boolean
ipv4_count?: number
public_ipv4s?: string[]
assign_ipv6: boolean
ipv6_count?: number
ipv6_addresses?: string[]
expires_at: string
}
@@ -136,6 +158,17 @@ export interface IPv6Status {
prefixes: IPv6PrefixInfo[]
}
export interface PublicIPv4Info {
interface: string
address: string
prefix: string
prefix_len?: number
subnet_mask?: string
gateway?: string
is_tunnel?: boolean
source?: string
}
export interface IPv4PrefixInfo {
interface: string
address: string
@@ -163,6 +196,7 @@ export interface HostInfo {
tx_bps: number
public_ipv4?: string
public_ipv4_interface?: string
public_ipv4_addresses?: PublicIPv4Info[]
public_ipv6?: string
public_ipv6_interface?: string
ipv6_prefixes?: IPv6PrefixInfo[]
@@ -207,6 +241,7 @@ export interface HostProbeReport {
serial: string
size_bytes: number
type: string
virtual?: boolean
rotational: boolean
mountpoints: string[]
health: string
@@ -358,6 +393,17 @@ export const getSSLSettings = () =>
export const updateSSLSettings = (data: UpdateSSLSettingsRequest) =>
api.put<APIResponse<SSLSettings>>('/ssl', data)
export interface WebSSHOriginSettings {
origins: string[]
current_origin?: string
}
export const getWebSSHOriginSettings = () =>
api.get<APIResponse<WebSSHOriginSettings>>('/webssh-origins')
export const updateWebSSHOriginSettings = (origins: string[]) =>
api.put<APIResponse<WebSSHOriginSettings>>('/webssh-origins', { origins })
// Containers
export const getContainers = () =>
api.get<APIResponse<Container[]>>('/containers')
@@ -453,12 +499,24 @@ export interface NAT4Route {
lxc_name: string
status: string
ip: string
host_ip: string
host_port: number
container_port: number
protocol: string
description: string
}
export interface IPv4Route {
container_id: number
container_name: string
lxc_name: string
status: string
address: string
interface: string
prefix_len?: number
gateway?: string
}
export interface IPv6Route {
container_id: number
container_name: string
@@ -471,15 +529,37 @@ export interface IPv6Route {
export interface RoutingInfo {
nat4: RouteCapacity
ipv4: RouteCapacity
ipv6: RouteCapacity
host_public_ipv4?: PublicIPv4Info
public_ipv4_addresses: PublicIPv4Info[]
ipv4_assignments: IPv4Route[]
nat4_mappings: NAT4Route[]
ipv6_assignments: IPv6Route[]
ipv6_prefixes: IPv6PrefixInfo[]
}
export interface PublicIPv4ScanResult extends PublicIPv4Info {
status: string
usable: boolean
reason: string
}
export const getRoutingInfo = () =>
api.get<APIResponse<RoutingInfo>>('/routing')
export const updateRoutingPools = (payload: { items?: PublicIPv4Info[]; ipv6_prefixes?: IPv6PrefixInfo[] }) =>
api.put<APIResponse<RoutingInfo>>('/routing', payload)
export const updateRoutingIPv4Pool = (items: PublicIPv4Info[]) =>
updateRoutingPools({ items })
export const updateRoutingIPv6Prefixes = (ipv6_prefixes: IPv6PrefixInfo[]) =>
updateRoutingPools({ ipv6_prefixes })
export const scanRoutingIPv4Segment = (payload: { cidr: string; interface: string; gateway: string; verify: boolean; limit?: number }) =>
api.post<APIResponse<PublicIPv4ScanResult[]>>('/routing/ipv4-scan', payload)
// Templates
export const getTemplates = () =>
api.get<APIResponse<Template[]>>('/templates')
@@ -714,6 +794,15 @@ export const createWebSSHTicket = (containerName: string) =>
export const createVNCTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/vnc-ticket', { container_name: containerName })
// Language
export type PanelLanguage = 'zh' | 'en'
export const getLanguage = () =>
api.get<APIResponse<{ language: PanelLanguage }>>('/language')
export const updateLanguage = (language: PanelLanguage) =>
api.post<APIResponse<{ language: PanelLanguage }>>('/language', { language })
// Version
export const getVersion = () =>
api.get<APIResponse<{ version: string }>>('/version')
+941
View File
@@ -0,0 +1,941 @@
const exact: Record<string, string> = {
'控制面板': 'Dashboard',
'共': 'Total',
'第': 'Page',
'页': 'page',
'个': 'items',
'条': 'records',
'核': 'cores',
'个容器': 'containers',
'条操作记录': 'audit records',
'个地址': 'addresses',
'列表': 'List',
'主机资源': 'Host Resources',
'主机资源状态': 'Host Resource Status',
'容器管理': 'Containers',
'镜像管理': 'Images',
'安全告警': 'Security Alerts',
'快照管理': 'Snapshots',
'路由管理': 'Routing',
'操作日志': 'Audit Logs',
'子用户管理': 'Sub Users',
'API 集成': 'API Integration',
'宿主机信息': 'Host Info',
'面板设置': 'Panel Settings',
'退出登录': 'Log out',
'亮色模式': 'Light Mode',
'暗黑模式': 'Dark Mode',
'切换亮色模式': 'Switch to light mode',
'切换暗黑模式': 'Switch to dark mode',
'切换侧边栏': 'Toggle sidebar',
'刷新': 'Refresh',
'搜索': 'Search',
'复制': 'Copy',
'编辑': 'Edit',
'删除': 'Delete',
'保存': 'Save',
'提交': 'Submit',
'应用': 'Apply',
'查看': 'View',
'详情': 'Details',
'返回': 'Back',
'返回列表': 'Back to list',
'取消': 'Cancel',
'确认': 'Confirm',
'确定': 'OK',
'完成': 'Done',
'失败': 'Failed',
'成功': 'Success',
'提示': 'Notice',
'警告': 'Warning',
'开机': 'Start',
'关机': 'Stop',
'重启': 'Restart',
'重装': 'Reinstall',
'创建': 'Create',
'在线': 'Online',
'离线': 'Offline',
'永久': 'Permanent',
'长期有效': 'No expiration',
'长期': 'No expiration',
'不限制': 'Unlimited',
'未设置流量限制': 'No traffic limit set',
'未设置': 'Not set',
'已选': 'Selected',
',已选': ', selected',
'筛选后': 'Filtered',
',筛选后': ', filtered',
'每页数量': 'Items per page',
'任务中': 'In task',
'1周': '1 week',
'1 周': '1 week',
'资源配置': 'Resource Configuration',
'实时状态': 'Live Status',
'连接信息': 'Connection Info',
'管理链接': 'Management Link',
'NAT 管理': 'NAT Management',
'快照': 'Snapshots',
'系统': 'System',
'全部类型': 'All types',
'全部系统': 'All systems',
'全部状态': 'All statuses',
'类型筛选': 'Type filter',
'系统筛选': 'System filter',
'状态筛选': 'Status filter',
'内网': 'Private IP',
'内网 IP': 'Private IP',
'策略封禁': 'Policy Blocked',
'已封禁': 'Blocked',
'已到期': 'Expired',
'识别码': 'Identifier',
'CPU 累计时间': 'CPU Total Time',
'创建时间': 'Created At',
'网络速率': 'Network Speed',
'IO 速度': 'IO Speed',
'月流量': 'Monthly Traffic',
'统计信息': 'Statistics',
'CPU 使用率': 'CPU Usage',
'内存使用': 'Memory Usage',
'网络流量': 'Network Traffic',
'磁盘IO': 'Disk IO',
'磁盘 IO': 'Disk IO',
'负载': 'Load',
'平均': 'Average',
'峰值': 'Peak',
'容量': 'Capacity',
'累计': 'Total',
'读': 'Read',
'写': 'Write',
'入': 'In',
'出': 'Out',
'运行中': 'Running',
'已停止': 'Stopped',
'已完成': 'Completed',
'等待中': 'Pending',
'执行中': 'Running',
'未知': 'Unknown',
'必要': 'Required',
'可选': 'Optional',
'用户名': 'Username',
'密码': 'Password',
'输入用户名': 'Enter username',
'输入密码': 'Enter password',
'登录': 'Log in',
'登录中...': 'Logging in...',
'登录失败,请检查用户名和密码': 'Login failed. Check your username and password.',
'Authentication required': 'Authentication required',
'Administrator permission required': 'Administrator permission required',
'Method not allowed': 'Method not allowed',
'Invalid request body': 'Invalid request body',
'Invalid credentials': 'Invalid credentials',
'Access denied': 'Access denied',
'Access denied to this container': 'Access denied to this container',
'Container not found': 'Container not found',
'Template not found': 'Template not found',
'Template is required': 'Template is required',
'Template is not enabled or downloaded': 'Template is not enabled or downloaded',
'Container name is required': 'Container name is required',
'Container created successfully': 'Container created successfully',
'Password changed successfully': 'Password changed successfully',
'SSL settings saved': 'SSL settings saved',
'Save SSL settings failed': 'Save SSL settings failed',
'Task deleted': 'Task deleted',
'Snapshot deleted': 'Snapshot deleted',
'Snapshot restored': 'Snapshot restored',
'Security check completed': 'Security check completed',
'当前密码不正确': 'Current password is incorrect',
'密码不正确': 'Password is incorrect',
'新密码至少 6 位': 'New password must be at least 6 characters',
'用户名至少 3 位': 'Username must be at least 3 characters',
'密码加密失败': 'Failed to hash password',
'保存配置失败': 'Failed to save configuration',
'密码修改成功': 'Password changed successfully',
'用户名修改成功': 'Username changed successfully',
'容器已到期,不允许此操作': 'Container has expired. This action is not allowed.',
'容器管理登录': 'Container Access Login',
'操作失败': 'Action failed',
'错误': 'Error',
'保存失败': 'Save failed',
'重装失败': 'Reinstall failed',
'密码重置失败': 'Password reset failed',
'端口配额已满': 'Port quota reached',
'输入错误': 'Input error',
'请输入有效的内部端口': 'Enter a valid internal port',
'密码长度必须为 8-64 位': 'Password length must be 8-64 characters',
'密码不能包含空白字符': 'Password cannot contain whitespace',
'密码至少需要包含字母': 'Password must contain at least one letter',
'密码至少需要包含数字': 'Password must contain at least one number',
'密码格式不正确': 'Invalid password format',
'策略临时封禁': 'Temporarily blocked by policy',
'虚拟机被策略临时封禁,暂不能执行操作。': 'This VM is temporarily blocked by policy and cannot perform actions.',
'确定要删除容器': 'Delete container',
'吗?此操作不可撤销。': '? This action cannot be undone.',
'容器名称不能包含空格': 'Container name cannot contain spaces',
'该容器名称已存在': 'Container name already exists',
'请填写容器名称并选择系统模板': 'Enter a container name and select a system template',
'资源配置有误': 'Invalid resource configuration',
'请按红色提示修改 vCPU、内存或磁盘配置': 'Fix the vCPU, memory, or disk fields marked in red',
'创建失败': 'Create failed',
'创建新容器': 'Create New Container',
'批量创建数量': 'Batch Count',
'虚拟化架构': 'Virtualization',
'LXC 容器': 'LXC Container',
'KVM 虚拟机': 'KVM VM',
'系统模板': 'System Template',
'搜索名称、ID、UUID、IP': 'Search name, ID, UUID, IP',
'带宽 (Mbps)': 'Bandwidth (Mbps)',
'双向统计': 'Total In+Out',
'入/出分离': 'Separate In/Out',
'GB (0=不限制)': 'GB (0=unlimited)',
'入站 (GB)': 'Inbound (GB)',
'出站 (GB)': 'Outbound (GB)',
'NAT 端口映射数量': 'NAT Port Mapping Count',
'子用户快照上限': 'Sub-user Snapshot Limit',
'到期时间': 'Expiration Time',
'不选择则长期有效;选择日期后,到期会自动关机。': 'Leave blank for no expiration. If a date is selected, the container will shut down automatically when it expires.',
'创建中...': 'Creating...',
'请输入 vCPU': 'Enter vCPU',
'内存 (MB)': 'Memory (MB)',
'磁盘 (GB)': 'Disk (GB)',
'IO 速度 (MB/s)': 'IO Speed (MB/s)',
'将创建': 'Will create',
'暂无可用的': 'No available',
'不能小于': 'Cannot be less than',
'不能大于': 'Cannot be greater than',
'KVM vCPU 必须是整数': 'KVM vCPU must be an integer',
'请输入内存': 'Enter memory',
'请输入磁盘': 'Enter disk',
'轮换失败': 'Rotation failed',
'获取操作日志失败': 'Failed to load audit logs',
'获取登录日志失败': 'Failed to load login logs',
'暂无子用户': 'No sub-users',
'容器名称': 'Container Name',
'最后登录': 'Last Login',
'从未登录': 'Never logged in',
'查看密码': 'View Password',
'查看操作日志': 'View Audit Logs',
'查看登录日志': 'View Login Logs',
'轮换密码': 'Rotate Password',
'轮换中...': 'Rotating...',
'用户': 'User',
'未保存,请轮换生成新密码': 'Not saved. Rotate to generate a new password.',
'操作时间': 'Action Time',
'登录时间': 'Login Time',
'登录 IP': 'Login IP',
'请输入当前密码以确认修改': 'Enter current password to confirm changes',
'至少填写新密码或新用户名中的一项': 'Enter at least a new password or a new username',
'用户名已修改': 'Username changed',
'用户名修改失败': 'Username change failed',
'密码已修改': 'Password changed',
'密码修改失败': 'Password change failed',
'下次登录生效': 'Takes effect at next login',
'修改失败': 'Change failed',
'账号、安全证书与登录日志': 'Account, certificates, and login logs',
'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。': 'SSL settings saved. The service is restarting. Reopen the panel with the new protocol shortly.',
'SSL 设置已保存,重启 clicd 服务后生效。': 'SSL settings saved. Restart the clicd service to apply them.',
'SSL 设置保存失败': 'Failed to save SSL settings',
'纯 IP 证书需要服务器安装 Certbot 5.4+,且验证时 80 端口必须能被 Let’s Encrypt 访问。IP 证书是短有效期证书,certbot 需要保持自动续签。': 'Pure IP certificates require Certbot 5.4+ on the server, and port 80 must be reachable by Lets Encrypt during validation. IP certificates are short-lived, so certbot auto-renewal must remain enabled.',
'自签证书可以加密面板和 VNC,但浏览器会提示证书不受信任;证书快到期时系统会自动重新签发。': 'Self-signed certificates can encrypt the panel and VNC, but browsers will show an untrusted certificate warning. The system will renew them automatically before expiration.',
'上传来源还没有保存证书,请粘贴证书和私钥后保存。': 'No certificate has been saved for the uploaded source. Paste the certificate and private key, then save.',
'当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。': 'No certificate has been saved for the current source. It will be generated or requested when SSL settings are saved.',
'暂无容器': 'No containers',
'暂无快照': 'No snapshots',
'暂无操作日志': 'No audit logs',
'暂无登录日志': 'No login logs',
'暂无登录记录': 'No login records',
'暂无 NAT4 端口映射': 'No NAT4 port mappings',
'暂无 IPv6 地址分配': 'No IPv6 assignments',
'暂无可分配 IPv6 前缀': 'No allocatable IPv6 prefixes',
'暂无镜像': 'No images',
'暂无数据': 'No data',
'容器': 'Container',
'名称': 'Name',
'状态': 'Status',
'剩余时间': 'Time Left',
'配置': 'Config',
'镜像': 'Image',
'内存': 'Memory',
'磁盘': 'Disk',
'流量': 'Traffic',
'操作': 'Actions',
'类型': 'Type',
'创建者': 'Creator',
'管理员密码': 'Admin Password',
'SSH 密码': 'SSH Password',
'SSH 地址': 'SSH Address',
'RDP 地址': 'RDP Address',
'VNC 端口': 'VNC Port',
'点击隐藏': 'Click to hide',
'点击显示': 'Click to show',
'编辑资源限制': 'Edit resource limits',
'编辑流量限制': 'Edit traffic limit',
'修改到期时间': 'Change expiration time',
'新 SSH 密码': 'New SSH Password',
'生成随机密码': 'Generate random password',
'密码已修改成功': 'Password changed successfully',
'修改中...': 'Changing...',
'确认修改': 'Confirm Change',
'容器不存在': 'Container not found',
'容器未运行,请先开机': 'Container is not running. Start it first.',
'VNC 控制台暂不可用,请确认 KVM 虚拟机已开机并刷新页面': 'VNC console is unavailable. Make sure the KVM VM is running and refresh the page.',
'虚拟机被策略临时封禁': 'VM temporarily blocked by policy',
'虚拟机被策略临时封禁,连接信息暂不可用。': 'This VM is temporarily blocked by policy. Connection info is unavailable.',
'已达到管理员分配的 NAT 端口配额。': 'The NAT port quota assigned by the administrator has been reached.',
'保存端口映射失败': 'Failed to save port mapping',
'删除端口映射失败': 'Failed to delete port mapping',
'删除映射': 'Delete Mapping',
'确定要删除这条映射规则吗?': 'Delete this mapping rule?',
'快照配额已满': 'Snapshot quota reached',
'已达到管理员设置的快照配额,请先删除旧快照。': 'The snapshot quota set by the administrator has been reached. Delete old snapshots first.',
'拍摄快照': 'Take Snapshot',
'拍摄快照需要先关机,完成后会自动重启容器': 'Taking a snapshot requires shutdown first. The container will restart automatically afterward',
'是否继续?': 'Continue?',
'创建快照失败': 'Failed to create snapshot',
'参数错误': 'Invalid parameters',
'自动快照周期最低是 1 天一次。': 'The minimum automatic snapshot interval is once per day.',
'定时快照失败': 'Scheduled snapshot failed',
'保存快照配额失败': 'Failed to save snapshot quota',
'删除快照失败': 'Failed to delete snapshot',
'恢复快照失败': 'Failed to restore snapshot',
'确定恢复到': 'Restore to',
'当前容器数据会被覆盖。': 'Current container data will be overwritten.',
'确定删除': 'Delete',
'的快照吗?': 'snapshot?',
'新建快照': 'New Snapshot',
'定时设置': 'Schedule Settings',
'定时快照': 'Scheduled Snapshot',
'处理中...': 'Processing...',
'快照数量:': 'Snapshot count:',
'子用户配额:': 'Sub-user quota:',
'定时状态:': 'Schedule status:',
'下次执行:': 'Next run:',
'未开启': 'Off',
'已开启': 'On',
'每': 'Every',
'执行': 'run',
'子用户每台容器快照上限': 'Sub-user snapshot limit per container',
'自动快照周期': 'Automatic snapshot interval',
'天': 'days',
'小时': 'hours',
'分钟': 'minutes',
'秒': 'seconds',
'大小': 'Size',
'手动': 'Manual',
'定时': 'Scheduled',
'时间': 'Time',
'设备': 'Device',
'结果': 'Result',
'地址': 'Address',
'前缀': 'Prefix',
'出口网卡': 'Uplink',
'宿主地址': 'Host Address',
'协议': 'Protocol',
'说明': 'Description',
'端口': 'Port',
'容器端口': 'Container Port',
'宿主机端口': 'Host Port',
'容器 IPv4': 'Container IPv4',
'IPv6 地址': 'IPv6 Address',
'IPv6 前缀': 'IPv6 Prefix',
'可分配 IPv6 前缀': 'Allocatable IPv6 Prefixes',
'编辑前缀': 'Edit Prefixes',
'添加 IPv6 前缀': 'Add IPv6 Prefix',
'保存前缀': 'Save Prefixes',
'服务商面板里的额外 IPv6 段不会自动出现在网卡里,请把可分配的前缀手动填入这里,例如 2401:b60:26:5e::2/64。': 'Extra IPv6 prefixes from the provider panel will not automatically appear on the NIC. Enter allocatable prefixes here manually, for example 2401:b60:26:5e::2/64.',
'LXC 名称': 'LXC Name',
'快照时间': 'Snapshot Time',
'删除快照': 'Delete Snapshot',
'全局快照列表': 'Global snapshot list',
'主机名': 'Hostname',
'操作系统': 'Operating System',
'内核': 'Kernel',
'生成时间': 'Generated At',
'系统概览': 'System Overview',
'公网与路由': 'Public Network & Routing',
'内存条': 'Memory Modules',
'硬盘与健康': 'Disks & Health',
'网卡': 'Network Interfaces',
'显卡': 'GPUs',
'环境支持': 'Environment Support',
'服务管理器 systemd/OpenRC': 'Service Manager systemd/OpenRC',
'LXC 创建工具': 'LXC Create Tool',
'LXC 启动工具': 'LXC Start Tool',
'iptables 网络规则': 'iptables Network Rules',
'iproute2 网络工具': 'iproute2 Network Tool',
'conntrack 安全扫描': 'conntrack Security Scan',
'QEMU/KVM 虚拟机': 'QEMU/KVM Virtualization',
'KVM cloud-init ISO 工具': 'KVM cloud-init ISO Tool',
'ISO 备用工具': 'ISO Fallback Tool',
'硬盘健康检测': 'Disk Health Check',
'Certbot 证书工具 >= 5.4': 'Certbot Certificate Tool >= 5.4',
'/dev/kvm 硬件虚拟化': '/dev/kvm Hardware Virtualization',
'IPv4 转发': 'IPv4 Forwarding',
'lxcfs 服务': 'lxcfs Service',
'libvirt 服务': 'libvirt Service',
'正在探测宿主机环境...': 'Probing host environment...',
'暂未获取到宿主机信息': 'No host information available',
'面板资源状态与容器概览': 'Panel resource status and container overview',
'宿主机资源状态与容器概览': 'Host resource status and container overview',
'账号设置': 'Account Settings',
'当前用户名': 'Current Username',
'新用户名,留空则不修改': 'New Username, leave blank to keep unchanged',
'新密码,留空则不修改': 'New Password, leave blank to keep unchanged',
'当前密码,验证身份': 'Current Password, for verification',
'至少 3 位': 'At least 3 characters',
'至少 6 位': 'At least 6 characters',
'输入当前密码以确认修改': 'Enter current password to confirm changes',
'保存修改': 'Save Changes',
'SSL 证书': 'SSL Certificate',
'启用 HTTPS / WSS': 'Enable HTTPS / WSS',
'IP / 域名': 'IP / Domain',
'服务器公网 IP 或域名': 'Server public IP or domain',
'邮箱,可选': 'Email, optional',
'自签证书': 'Self-signed Certificate',
'上传证书': 'Uploaded Certificate',
'证书 PEM / fullchain.pem': 'Certificate PEM / fullchain.pem',
'私钥 PEM / privkey.pem': 'Private Key PEM / privkey.pem',
'保存后自动重启服务并立即生效': 'Restart service automatically after saving',
'保存中...': 'Saving...',
'保存 SSL 设置': 'Save SSL Settings',
'WebSSH Origin 白名单': 'WebSSH Origin Allowlist',
'允许的 Origin': 'Allowed Origins',
'当前面板来源:': 'Current panel origin:',
'默认允许当前面板来源和本机回环来源;额外域名每行填写一个完整 Origin。': 'The current panel origin and local loopback origins are allowed by default. Add one full Origin per line.',
'保存 Origin 白名单': 'Save Origin Allowlist',
'Origin 白名单已保存': 'Origin allowlist saved',
'Origin 白名单保存失败': 'Failed to save Origin allowlist',
'登录日志': 'Login Logs',
'首页': 'First',
'上一页': 'Previous',
'下一页': 'Next',
'末页': 'Last',
'搜索端口/容器...': 'Search port/container...',
'搜索地址/容器...': 'Search address/container...',
'NAT4 端口': 'NAT4 Ports',
'NAT4 端口分配': 'NAT4 Port Allocation',
'IPv6 地址分配': 'IPv6 Address Allocation',
'剩余端口 / 端口总数': 'Available Ports / Total Ports',
'剩余地址 / 地址总数': 'Available Addresses / Total Addresses',
'已分配': 'Allocated',
'充足': 'Enough',
'容器列表': 'Container List',
'刷新列表': 'Refresh list',
'创建容器': 'Create Container',
'点击"创建容器"开始': 'Click "Create Container" to start',
'创建中': 'Creating',
'批量创建': 'Batch Create',
'导入容器': 'Import Container',
'重置密码': 'Reset Password',
'WebSSH': 'WebSSH',
'WebVNC': 'WebVNC',
'发送 Ctrl+Alt+Del': 'Send Ctrl+Alt+Del',
'重新连接': 'Reconnect',
'关闭': 'Close',
'已连接': 'Connected',
'连接中...': 'Connecting...',
'已断开': 'Disconnected',
'连接失败': 'Connection Failed',
'正在连接 KVM VNC 控制台...': 'Connecting to KVM VNC console...',
'WebVNC 已断开': 'WebVNC disconnected',
'下载': 'Download',
'下载中': 'Downloading',
'启用': 'Enable',
'停用': 'Disable',
'已启用': 'Enabled',
'未启用': 'Disabled',
'系统镜像': 'System Images',
'安全检查': 'Security Check',
'告警列表': 'Alert List',
'自动关机已开': 'Auto-stop on',
'自动关机已关': 'Auto-stop off',
'暂无安全告警': 'No security alerts',
'严重': 'Critical',
'高': 'High',
'中': 'Medium',
'低': 'Low',
'管理员': 'Admin',
'子用户': 'Sub User',
'公网 IPv4': 'Public IPv4',
'IPv4 地址': 'IPv4 Addresses',
'IPv4 段': 'IPv4 Prefixes',
'IPv6 段': 'IPv6 Prefixes',
'网关': 'Gateways',
'CPU 架构': 'CPU Architecture',
'CPU 虚拟化指令': 'CPU Virtualization Flags',
'CPU 核显': 'Integrated GPU',
'运行能力': 'Runtime Capability',
'KVM 嵌套虚拟化': 'KVM Nested Virtualization',
'支持': 'Supported',
'未检测到': 'Not detected',
'检测到': 'Detected',
'有效': 'Valid',
'已过期或未生效': 'Expired or not active',
'是': 'Yes',
'否': 'No',
'开启': 'On',
'已关闭': 'Off',
'自动': 'Auto',
'默认': 'Default',
'全部': 'All',
'无': 'None',
'根目录': 'Root',
'版本': 'Version',
'当前': 'Current',
'最近': 'Recent',
'来源': 'Source',
'目标': 'Target',
'描述': 'Description',
'备注': 'Notes',
'搜索容器...': 'Search containers...',
'搜索镜像...': 'Search images...',
'搜索日志...': 'Search logs...',
'复制成功': 'Copied',
'复制失败': 'Copy failed',
'请稍后重试': 'Please try again later',
'请稍后重试。': 'Please try again later.',
'开机中...': 'Starting...',
'关机中...': 'Stopping...',
'重启中...': 'Restarting...',
'删除中...': 'Deleting...',
'重装中...': 'Reinstalling...',
'开机中': 'Starting',
'关机中': 'Stopping',
'重启中': 'Restarting',
'删除中': 'Deleting',
'重装中': 'Reinstalling',
'正在初始化': 'Initializing',
'容器总数': 'Total Containers',
'驱动/速率': 'Driver / Speed',
'支持 KVM + LXC': 'KVM + LXC supported',
'仅支持 LXC': 'LXC only',
'未满足运行环境': 'Runtime requirements not met',
'健康': 'Healthy',
'异常': 'Abnormal',
'核显': 'Integrated',
'独显': 'Discrete',
'获取镜像列表失败': 'Failed to load image list',
'下载失败': 'Download failed',
'删除失败': 'Delete failed',
'取消失败': 'Cancel failed',
'删除镜像': 'Delete Image',
'确定要删除该镜像缓存吗?删除后需要重新下载才能使用。': 'Delete this image cache? You must download it again before using it.',
'取消下载并清理临时文件': 'Cancel download and clean temporary files',
'删除镜像缓存': 'Delete image cache',
'取消中': 'Cancelling',
'取消中...': 'Cancelling...',
'下载中...': 'Downloading...',
'阶段:': 'Stage:',
'转换中': 'Converting',
'端口扫描': 'Port scan',
'横向扫描': 'Lateral scan',
'暴力破解': 'Brute force',
'DDoS/大规模扫描': 'DDoS / large-scale scan',
'垃圾邮件': 'Spam',
'恶意软件': 'Malware',
'挖矿连接': 'Mining connection',
'代理/VPN/Tor': 'Proxy / VPN / Tor',
'UDP反射放大': 'UDP reflection amplification',
'高危': 'High risk',
'中危': 'Medium risk',
'低危': 'Low risk',
'告警自动关机': 'Auto shutdown on alerts',
'相关连接记录': 'Related Connection Records',
'查看相关记录': 'View related records',
'告警原始记录': 'Raw Alert Record',
'正在加载连接记录...': 'Loading connection records...',
'暂无可用连接记录。历史告警对应的 conntrack 记录可能已经过期。': 'No connection records available. Conntrack records for historical alerts may have expired.',
'源地址': 'Source Address',
'目标地址': 'Target Address',
'源IP': 'Source IP',
'次数': 'Count',
'等级': 'Severity',
'总览与只读': 'Overview & Read-only',
'路由信息': 'Routing Info',
'IPv6 状态': 'IPv6 Status',
'镜像列表': 'Image List',
'查看容器': 'View Container',
'开关机/重启': 'Power / Restart',
'重装系统': 'Reinstall OS',
'资源/到期': 'Resources / Expiration',
'流量管理': 'Traffic Management',
'端口映射': 'Port Mappings',
'分配 IPv6': 'Assign IPv6',
'快照与终端': 'Snapshots & Terminal',
'查看快照': 'View Snapshots',
'创建快照': 'Create Snapshot',
'恢复快照': 'Restore Snapshot',
'计划/配额': 'Schedule / Quota',
'平台管理': 'Platform Management',
'下载镜像': 'Download Image',
'启停镜像': 'Enable / Disable Image',
'安全数据': 'Security Data',
'安全扫描': 'Security Scan',
'安全设置': 'Security Settings',
'Swap 信息': 'Swap Info',
'Swap 管理': 'Swap Management',
'Key 列表': 'Key List',
'创建 Key': 'Create Key',
'更新 Key': 'Update Key',
'删除 Key': 'Delete Key',
'总览': 'Overview',
'NAT/IPv6 路由': 'NAT / IPv6 Routing',
'任务队列': 'Task Queue',
'任务列表': 'Task List',
'操作记录': 'audit records',
'子用户列表': 'Sub-user List',
'创建子用户': 'Create Sub-user',
'更新子用户': 'Update Sub-user',
'管理员接口': 'Admin API',
'控制面板统计': 'Dashboard Stats',
'立即安全检查': 'Run Security Check',
'返回响应样例': 'Response Example',
'请求参数': 'Request Parameters',
'响应字段': 'Response Fields',
'接口地址': 'Endpoint',
'请求方法': 'Method',
'权限范围': 'Scopes',
'绑定容器': 'Bound Containers',
'全部容器': 'All Containers',
'全权限': 'Full Access',
'取消全权限': 'Remove Full Access',
'禁用这个 Key': 'Disable this key',
'过期时间': 'Expiration Time',
'永不过期': 'Never expires',
'IP 白名单': 'IP Whitelist',
'密钥名称': 'Key Name',
'删除任务': 'Delete Task',
'容器详情': 'Container Details',
'资源用量': 'Resource Usage',
'流量统计': 'Traffic Stats',
'重置流量': 'Reset Traffic',
'调整流量限制': 'Adjust Traffic Limit',
'调整资源限制': 'Adjust Resource Limit',
'重置 SSH 密码': 'Reset SSH Password',
'端口与快照': 'Ports & Snapshots',
'随机可用端口': 'Random Available Port',
'添加端口映射': 'Add Port Mapping',
'更新端口映射': 'Update Port Mapping',
'删除端口映射': 'Delete Port Mapping',
'快照总览': 'Snapshot Overview',
'容器快照': 'Container Snapshots',
'计划快照': 'Scheduled Snapshots',
'快照配额': 'Snapshot Quota',
'模板列表': 'Template List',
'取消镜像下载': 'Cancel Image Download',
'启用/禁用镜像': 'Enable / Disable Image',
'安全连接日志': 'Security Connection Logs',
'安全汇总': 'Security Summary',
'更新安全设置': 'Update Security Settings',
'调整 Swap': 'Adjust Swap',
'批量开关机/删除/重装': 'Batch power/delete/reinstall',
'账号与日志': 'Account & Logs',
'API Key 列表': 'API Key List',
'创建 API Key': 'Create API Key',
'更新 API Key': 'Update API Key',
'删除 API Key': 'Delete API Key',
'30分钟': '30 minutes',
'1小时': '1 hour',
'1天': '1 day',
'切换中文': 'Switch to Chinese',
'WebSSH ticket 创建失败,请重新登录后再试': 'Failed to create WebSSH ticket. Log in again and retry.',
'WebSSH ticket 为空,请重新登录后再试': 'WebSSH ticket is empty. Log in again and retry.',
'WebSSH 连接失败,请确认容器已运行且 SSH 服务可用': 'WebSSH connection failed. Make sure the container is running and SSH is available.',
'WebVNC ticket 创建失败,请重新登录后再试': 'Failed to create WebVNC ticket. Log in again and retry.',
'WebVNC ticket 为空,请重新登录后再试': 'WebVNC ticket is empty. Log in again and retry.',
'WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用': 'WebVNC disconnected. Make sure the VM is running and the VNC console is available.',
'VNC 安全协商失败': 'VNC security negotiation failed',
'当前 VNC 控制台要求密码,暂不支持自动输入': 'This VNC console requires a password. Automatic input is not supported yet.',
'删除容器': 'Delete Container',
'WebSSH 票据': 'WebSSH Ticket',
'WebVNC 票据': 'WebVNC Ticket',
'容器列表(兼容旧接口)': 'Container List (legacy-compatible API)',
'调整到期时间': 'Adjust Expiration Time',
'镜像管理列表': 'Image Management List',
'批量创建容器': 'Batch Create Containers',
'创建 WebSSH 票据': 'Create WebSSH Ticket',
'创建 WebVNC 票据': 'Create WebVNC Ticket',
'创建子用户链接': 'Create Sub-user Link',
'轮换子用户密码': 'Rotate Sub-user Password',
'子用户操作日志': 'Sub-user Audit Logs',
'子用户登录日志': 'Sub-user Login Logs',
'确定删除这个 API Key 吗?': 'Delete this API Key?',
'管理外部调用凭据、权限范围与平台 API 文档': 'Manage external credentials, permission scopes, and platform API docs',
'新的 API Key 已生成': 'New API Key generated',
'已复制': 'Copied',
'加载中...': 'Loading...',
'暂无 API Key': 'No API Keys',
'权限': 'Permissions',
'限制': 'Limits',
'最后使用': 'Last Used',
'已禁用': 'Disabled',
'不限 IP': 'Any IP',
'从未使用': 'Never used',
'API 文档': 'API Docs',
'查看使用范例': 'View examples',
'Python 使用范例': 'Python example',
'编辑 API Key': 'Edit API Key',
'CI/CD、计费系统、自动化脚本': 'CI/CD, billing systems, automation scripts',
'SWAP 已调整为 16384 MB': 'SWAP adjusted to 16384 MB',
'***60秒有效票据***': '***60-second valid ticket***',
'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。': 'WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".',
'该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。': 'This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.',
'样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。': 'Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.',
'编辑月流量限制': 'Edit Monthly Traffic Limit',
'流量统计模式': 'Traffic Accounting Mode',
'双向合并统计': 'Combined In+Out',
'入站/出站分开统计': 'Separate Inbound/Outbound',
'月流量上限 (GB0=不限制)': 'Monthly traffic limit (GB, 0=unlimited)',
'入站上限 (GB0=不限制)': 'Inbound limit (GB, 0=unlimited)',
'出站上限 (GB0=不限制)': 'Outbound limit (GB, 0=unlimited)',
'请输入 8-64 位,至少包含字母和数字': 'Enter 8-64 characters, including at least letters and numbers',
'Linux LXC/KVM 修改 root SSH 密码通常无需重启;KVM 需要虚拟机运行且 guest agent 或 SSH 可用。': 'Changing the root SSH password for Linux LXC/KVM usually does not require a restart. KVM requires the VM to be running and guest agent or SSH to be available.',
'退出全屏': 'Exit Fullscreen',
'全屏显示': 'Fullscreen',
'全屏': 'Fullscreen',
'修改': 'Change',
'关闭定时': 'Disable Schedule',
'执行时间': 'Run Time',
'NAT 端口管理': 'NAT Port Management',
'添加映射': 'Add Mapping',
'端口配额:': 'Port quota:',
'已达到管理员分配的 NAT 端口配额': 'The NAT port quota assigned by the administrator has been reached',
'修改端口映射': 'Edit Port Mapping',
'重装系统会删除容器内所有数据,请谨慎操作。': 'Reinstalling the OS will delete all data in the container. Proceed carefully.',
'选择新系统模板': 'Select New System Template',
'确认重装': 'Confirm Reinstall',
'当前:': 'Current:',
'新到期日期(留空为长期有效)': 'New expiration date (leave blank for no expiration)',
'vCPU 核数': 'vCPU Cores',
'网络速率 (Mbps0=不限制)': 'Network speed (Mbps, 0=unlimited)',
'IO 速度 (MB/s0=不限制)': 'IO speed (MB/s, 0=unlimited)',
'磁盘容量不支持动态修改。修改后运行中的容器会立即应用新的 cgroup 限制。': 'Disk capacity cannot be changed dynamically. Running containers apply the new cgroup limits immediately.',
'恢复': 'Restore',
'全部 (ALL)': 'All (ALL)',
'外部端口': 'External Port',
'默认同内部': 'Same as internal by default',
'随机空闲端口': 'Random Free Port',
'随机': 'Random',
'内部端口': 'Internal Port',
'例如 80': 'e.g. 80',
'暂无端口映射': 'No port mappings',
'默认 SSH 映射不能删除': 'Default SSH mapping cannot be deleted',
'入站 (RX)': 'Inbound (RX)',
'(不限制)': '(unlimited)',
'出站 (TX)': 'Outbound (TX)',
'已用': 'Used',
'重置': 'Reset',
'执行中...': 'Running...',
'点击': 'Click',
'开始': 'Start',
'没有匹配的容器': 'No matching containers',
'显示': 'Showing',
'初始化失败': 'Initialization failed',
'初始化完成': 'Initialization complete',
'排队等待': 'Queued',
'处理中': 'Processing',
'未知系统': 'Unknown system',
'处理失败': 'Failed',
'暂无任务': 'No tasks',
'取消任务': 'Cancel Task',
'硬件、网络、磁盘健康与运行环境探测报告': 'Hardware, network, disk health, and runtime environment report',
'运行状态': 'Runtime Status',
'未检测到内存条明细,可能缺少 dmidecode 或权限受限': 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
'插槽': 'Slot',
'频率': 'Frequency',
'厂商': 'Vendor',
'型号/序列号': 'Model / Serial',
'未检测到硬盘': 'No disks detected',
'虚拟磁盘': 'Virtual Disk',
'不支持': 'Unsupported',
'虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看': 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
'虚拟Disk,真实 SMART/Lifetime/Power-on数据需在物理宿主机View': 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
'型号': 'Model',
'挂载点': 'Mount Point',
'寿命': 'Lifetime',
'通电': 'Power-on',
'读取': 'Reads',
'写入': 'Writes',
'命令数': 'Commands',
'擦写': 'Erase Count',
'未检测到网卡': 'No network interfaces detected',
'未检测到显卡': 'No GPUs detected',
'驱动': 'Driver',
'管理 LXC / KVM 系统镜像,下载后的镜像才能用于创建容器/虚拟机。': 'Manage LXC / KVM system images. Downloaded images can be used to create containers/VMs.',
'已下载': 'Downloaded',
'LXC 容器镜像': 'LXC Container Images',
'KVM 虚拟机镜像': 'KVM VM Images',
'发行版': 'Distribution',
'架构': 'Architecture',
'禁用': 'Disable',
'可用': 'Available',
'未下载': 'Not downloaded',
'中文': 'Chinese',
'宿主机分配给 LXC 的 NAT4 端口和 IPv6 地址': 'NAT4 ports and IPv6 addresses assigned to LXC by the host',
'确认删除容器': 'Delete container',
'剩余地址 / 地址总数 ·': 'Available Addresses / Total Addresses ·',
'结果 ': 'Result ',
'告警列表 (': 'Alert List (',
'当前证书:': 'Current certificate:',
'到期时间:': 'Expires:',
'证书路径:': 'Certificate path:',
'最近错误:': 'Last error:',
'1 天': '1 day',
'3 天': '3 days',
'7 天': '7 days',
'14 天': '14 days',
'10 / 页': '10 / page',
'20 / 页': '20 / page',
'50 / 页': '50 / page',
'全局快照列表,共': 'Global snapshot list, total',
'容器分配的子用户列表,共': 'Sub-user list assigned to containers, total',
}
const artifactPatterns: RegExp[] = [
/Back\s*列表/,
/SearchName、ID、UUID、IP/,
/All(Type|Status|系统)/,
/AutoStop\s*已[开关]/,
/暂\s*(None|无)\s*Security Alerts/,
/Memory\s*使用/,
/网络\s*Traffic/,
/实时\s*Status/,
/Create\s*Time/,
/长期\s*Valid/,
/虚拟Disk/,
/宿主机View/,
/SMART\/Lifetime\/Power-on数据/,
]
const replacements: Array<[RegExp, string]> = [
[/Back\s*列表/g, 'Back to list'],
[/虚拟Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.'],
[/虚拟\s*Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机\s*View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.'],
[/真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Real SMART, lifetime, and power-on data must be checked on the physical host'],
[/Search\s*名称、ID、UUID、IP/g, 'Search name, ID, UUID, IP'],
[/All\s*类型/g, 'All types'],
[/All\s*系统/g, 'All systems'],
[/All\s*状态/g, 'All statuses'],
[/AllType/g, 'All types'],
[/All系统/g, 'All systems'],
[/AllStatus/g, 'All statuses'],
[/SearchName、ID、UUID、IP/g, 'Search name, ID, UUID, IP'],
[/AutoStop\s*已关/g, 'Auto-stop off'],
[/AutoStop\s*已开/g, 'Auto-stop on'],
[/暂\s*None\s*Security Alerts/g, 'No security alerts'],
[/暂\s*无\s*Security Alerts/g, 'No security alerts'],
[/WebVNC\s*初始化失败(.+)$/g, 'WebVNC initialization failed$1'],
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
[/拍摄快照需要先关机,完成后会自动重启容器\s*(.+?)。是否继续?/g, 'Taking a snapshot requires shutdown first. Container $1 will restart automatically afterward. Continue?'],
[/确定删除\s*(.+?)\s*的快照吗?/g, 'Delete snapshot $1?'],
[/确定恢复到\s*(.+?)\s*的快照吗?当前容器数据会被覆盖。/g, 'Restore to snapshot $1? Current container data will be overwritten.'],
[/旧版\s*\/api\/containers\/list\s*已兼容,但新接入请使用\s*GET\s*\/api\/v1\/containers/g, 'Legacy /api/containers/list remains compatible, but new integrations should use GET /api/v1/containers'],
[/到期\s*(.+)$/g, 'Expires $1'],
[/支持\s*\((.+?)\)/g, 'Supported ($1)'],
[/下载中\s*(.+)$/g, 'Downloading $1'],
[/结果\s*(.+)$/g, 'Result $1'],
[/磨损\s*(.+)$/g, 'Wear $1'],
[/擦写\s*(.+)$/g, 'Erase $1'],
[/启停\s*(.+)$/g, 'Power cycles $1'],
[/每\s*(.+)$/g, 'Every $1'],
[/已开启,每\s*(.+)$/g, 'Enabled, every $1'],
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
[/(\d+)\s*个前缀,(\d+)\s*个地址已分配/g, '$1 prefixes, $2 addresses assigned'],
[/共\s*(\d+)\s*条/g, 'Total $1'],
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
[/,筛选后\s*(\d+)\s*个/g, ', filtered $1 items'],
[/,已选\s*(\d+)\s*个/g, ', selected $1 items'],
[/第\s*(\d+)\/(\d+)\s*页/g, 'Page $1/$2'],
[/显示\s*(\d+)-(\d+)\s*\/\s*(\d+)/g, 'Showing $1-$2 / $3'],
[/显示\s*(\d+)-(\d+),共\s*(\d+)\s*条/g, 'Showing $1-$2 of $3'],
[/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*条,/g, 'Search "$1" returned $2 results, '],
[/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*个地址/g, 'Search "$1" returned $2 addresses, '],
[/(\d+)\s*个/g, '$1 items'],
[/(\d+)\s*条/g, '$1 records'],
[/(\d+)\s*核/g, '$1 cores'],
[/(\d+)\s*线程/g, '$1 threads'],
[/已用/g, 'used'],
[/未设置\s*Traffic\s*限制/g, 'No traffic limit set'],
[/Memory\s*使用/g, 'Memory Usage'],
[/网络\s*Traffic/g, 'Network Traffic'],
[/实时\s*Status/g, 'Live Status'],
[/Expiration Time\s*长期\s*Valid/g, 'Expiration Time No expiration'],
[/长期\s*Valid/g, 'No expiration'],
[/Create\s*Time/g, 'Created At'],
[/CPU\s*累计\s*Time/g, 'CPU Total Time'],
[/(\d+(?:\.\d+)?)\s*cores\s*\/\s*(\d+)\s*核/g, '$1 cores / $2 cores'],
[/(\d+)\s*核\/(.+?)\/(\d+)\s*GB/g, '$1 cores / $2 / $3 GB'],
[/(\d+)\s*\/\s*页/g, '$1 / page'],
[/到期时间:/g, 'Expires: '],
[/证书路径:/g, 'Certificate path: '],
[/最近错误:/g, 'Last error: '],
[/当前证书:/g, 'Current certificate: '],
[/第\s*(\d+)\s*页/g, 'Page $1'],
[/入\s*([^/]+)\s*\/\s*出\s*([^]+),累计\s*(.+)$/g, 'In $1 / Out $2, total $3'],
[/读\s*([^/]+)\s*\/\s*写\s*([^]+),累计\s*([^]+),容量\s*(.+)$/g, 'Read $1 / Write $2, total $3, capacity $4'],
[/(.+?),筛选后\s*(\d+)\s*items/g, '$1, filtered $2 items'],
[/(.+?),已选\s*(\d+)\s*items/g, '$1, selected $2 items'],
[/将创建\s*(\d+)\s*个容器:(.+?)\s*至\s*(.+)$/g, 'Will create $1 containers: $2 to $3'],
[/暂无可用的\s*(KVM|LXC)\s*系统镜像,请先在「镜像管理」中下载镜像模板。/g, 'No available $1 system images. Download image templates in Images first.'],
[/不能小于\s*(.+)$/g, 'Cannot be less than $1'],
[/不能大于\s*(.+)$/g, 'Cannot be greater than $1'],
[/^(.+?)\s*-\s*操作日志$/g, '$1 - Audit Logs'],
[/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'],
[/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'],
[/阶段:(.+)$/g, 'Stage: $1'],
[/\$\{days\}天/g, '${days} days'],
[/\$\{hours\}小时/g, '${hours} hours'],
[/\$\{hours\}\s*小时/g, '${hours} hours'],
[/\$\{Math\.floor\(diff \/ 60000\)\}分钟/g, '${Math.floor(diff / 60000)} minutes'],
[/(\d+)分钟/g, '$1 minutes'],
[/(\d+)小时/g, '$1 hours'],
[/(\d+)\s*周/g, '$1 weeks'],
[/(\d+)天/g, '$1 days'],
[/确认删除容器\s*(.+?)\s*的快照吗?此操作不可恢复。/g, 'Delete the snapshot for container $1? This cannot be undone.'],
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
[/容器\s*(.+?)\s*已开机/g, 'Container $1 started'],
[/容器\s*(.+?)\s*已关机/g, 'Container $1 stopped'],
[/容器\s*(.+?)\s*已重启/g, 'Container $1 restarted'],
]
export function translateText(value: string): string {
if (!shouldTranslateText(value)) return value
const leading = value.match(/^\s*/)?.[0] || ''
const trailing = value.match(/\s*$/)?.[0] || ''
const body = value.trim()
if (!body) return value
if (exact[body]) return leading + exact[body] + trailing
let translated = body
for (const [pattern, replacement] of replacements) {
translated = translated.replace(pattern, replacement)
}
for (const [source, target] of Object.entries(exact).sort((a, b) => b[0].length - a[0].length)) {
translated = translated.split(source).join(target)
}
translated = cleanupTranslatedText(translated)
return leading + translated + trailing
}
export function shouldTranslateText(value: string): boolean {
return /[\u3400-\u9fff]/.test(value) || artifactPatterns.some((pattern) => pattern.test(value))
}
function cleanupTranslatedText(value: string): string {
return value
.replace(/虚拟Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
.replace(/Virtual Disk,真实 SMART\/Lifetime\/Power-on数据需在物理Host View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
.replace(/Virtual Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
.replace(/虚拟\s*Disk/g, 'Virtual disk')
.replace(/宿主机\s*View/g, 'physical host')
.replace(/Back\s*List/g, 'Back to list')
.replace(/Container\s*List/g, 'Container List')
.replace(/Snapshot\s*List/g, 'Snapshot List')
.replace(/All\s*Type/g, 'All types')
.replace(/All\s*Status/g, 'All statuses')
.replace(/All\s*System/g, 'All systems')
.replace(/AutoStop\s*Off/g, 'Auto-stop off')
.replace(/AutoStop\s*On/g, 'Auto-stop on')
.replace(/\s{2,}/g, ' ')
}
+332 -41
View File
@@ -11,8 +11,218 @@ LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
normalize_lang() {
lang="$1"
case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in
en|en_*|en-*) echo en ;;
zh|zh_*|zh-*) echo zh ;;
*) echo "" ;;
esac
}
detect_lang() {
normalized="$(normalize_lang "${CLICD_LANG:-}")"
if [ -n "$normalized" ]; then
echo "$normalized"
return
fi
normalized="$(normalize_lang "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}")"
if [ -n "$normalized" ]; then
echo "$normalized"
return
fi
echo zh
}
choose_language() {
normalized="$(normalize_lang "${CLICD_LANG:-}")"
if [ -n "$normalized" ]; then
echo "$normalized"
return
fi
case "$ACTION" in
-h|--help|help)
detect_lang
return
;;
esac
if [ -t 0 ]; then
{
echo "====================================="
echo " 请选择语言 / Select language"
echo "====================================="
echo " 1) 简体中文"
echo " 2) English"
printf " 请输入 1/2 [1]: "
} >&2
IFS= read -r answer || answer=""
case "$answer" in
2) echo en ;;
*) echo zh ;;
esac
return
fi
if [ -r /dev/tty ] && [ -w /dev/tty ] && { printf '' > /dev/tty; } 2>/dev/null; then
{
echo "====================================="
echo " 请选择语言 / Select language"
echo "====================================="
echo " 1) 简体中文"
echo " 2) English"
printf " 请输入 1/2 [1]: "
} > /dev/tty
IFS= read -r answer < /dev/tty || answer=""
case "$answer" in
2) echo en ;;
*) echo zh ;;
esac
return
fi
detect_lang
}
CLICD_LANG_DETECTED="$(choose_language)"
export CLICD_LANG="$CLICD_LANG_DETECTED"
tr_msg() {
msg="$*"
[ "$CLICD_LANG_DETECTED" = "en" ] || { printf '%s' "$msg"; return; }
msg="$(printf '%s' "$msg" | sed \
-e 's/中文安装\/卸载脚本/Installer\/Uninstaller/g' \
-e 's/警告/Warning/g' \
-e 's/错误/Error/g' \
-e 's/安装\/卸载未完成。请查看日志:/Install\/uninstall did not complete. Check log: /g' \
-e 's/如果你确认这是程序问题,请提交 issue/If this looks like a CLICD bug, please open an issue: /g' \
-e 's/开始:/Starting: /g' \
-e 's/完成:/Completed: /g' \
-e 's/步骤失败:/Step failed: /g' \
-e 's/退出码:/exit code: /g' \
-e 's/最近 80 行日志:/Last 80 log lines: /g' \
-e 's/请将上述日志和系统信息提交到:/Please submit the log above and system info to: /g' \
-e 's/系统检测:/System check: /g' \
-e 's/当前安装包仅支持/This installer only supports/g' \
-e 's/当前架构:/current architecture: /g' \
-e 's/未检测到 systemd 或 OpenRC,无法安装服务。/systemd or OpenRC was not detected; cannot install service./g' \
-e 's/暂不支持当前 Linux 发行版:/Unsupported Linux distribution: /g' \
-e 's/请提交 issue 并附上/Please open an issue with/g' \
-e 's/发行版/Distribution/g' \
-e 's/不在主要支持列表,将按检测到的软件包管理器尝试安装。/is not in the primary support list; trying the detected package manager./g' \
-e 's/存储检测:/Storage check: /g' \
-e 's/根文件系统=/root filesystem=/g' \
-e 's/可用空间=/available space=/g' \
-e 's/根分区可用空间低于 5GB,下载镜像或创建 KVM\/LXC 时可能失败。/Root partition has less than 5GB available; image downloads or KVM\/LXC creation may fail./g' \
-e 's/请使用 root 权限运行:/Run as root: /g' \
-e 's/或执行:/Or run: /g' \
-e 's/卸载:/Uninstall: /g' \
-e 's/问题反馈:/Issues: /g' \
-e 's/日志文件:/Log file: /g' \
-e 's/仓库地址:/Repository: /g' \
-e 's/未知操作:/Unknown action: /g' \
-e 's/卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC\/KVM 实例和缓存数据。/Uninstall will stop and remove the CLICD service, configuration database, CLICD-created LXC\/KVM instances, and cached data./g' \
-e 's/为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-\* 下载临时容器和 vm-数字 的 KVM 域。/To avoid deleting production data, the script only removes LXC containers named ct-NUMBER, temporary clicd-img-dl-* download containers, and KVM domains named vm-NUMBER./g' \
-e 's/如需确认卸载,请输入:YES/Type YES to confirm uninstall:/g' \
-e 's/已取消卸载。如需非交互卸载,请设置 CLICD_UNINSTALL_CONFIRM=1。/Uninstall cancelled. For non-interactive uninstall, set CLICD_UNINSTALL_CONFIRM=1./g' \
-e 's/正在卸载 CLICD.../Uninstalling CLICD.../g' \
-e 's/正在删除 CLICD 创建的 LXC 容器(\/var\/lib\/lxc\/ct-数字).../Removing CLICD-created LXC containers (\/var\/lib\/lxc\/ct-NUMBER).../g' \
-e 's/保留 \/root\/clicd-backups,避免误删部署\/回滚备份。确认不需要后可手动删除。/Keeping \/root\/clicd-backups to avoid deleting deployment\/rollback backups. Remove it manually if no longer needed./g' \
-e 's/CLICD 卸载完成/CLICD uninstall complete/g' \
-e 's/已删除服务、二进制、SQLite\/配置数据、CLICD LXC\/KVM 实例、/Removed service, binary, SQLite\/config data, CLICD LXC\/KVM instances,/g' \
-e 's/CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。/CLICD image cache, firewall rules, host hooks, quota records, and temporary files./g' \
-e 's/已保留 \/root\/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份\/共享镜像。/Kept \/root\/clicd-backups and non-CLICD global LXC cache to avoid deleting production backups\/shared images./g' \
-e 's/日志:/Log: /g' \
-e 's/兼容性检查/Compatibility check/g' \
-e 's/存储环境检查/Storage environment check/g' \
-e 's/安装系统依赖/Install system dependencies/g' \
-e 's/配置内核网络参数/Configure kernel networking/g' \
-e 's/配置运行时服务/Configure runtime services/g' \
-e 's/配置 libvirt default NAT 网络/Configure libvirt default NAT network/g' \
-e 's/配置 UID\/GID 映射/Configure UID\/GID mapping/g' \
-e 's/配置 LXC 存储权限/Configure LXC storage permissions/g' \
-e 's/检查 project quota/Check project quota/g' \
-e 's/下载发行版包/Download release package/g' \
-e 's/安装 CLICD 二进制/Install CLICD binary/g' \
-e 's/安装并启动 CLICD 服务/Install and start CLICD service/g' \
-e 's/已写入面板语言:/Panel language saved: /g' \
-e 's/写入面板语言/Save panel language/g' \
-e 's/面板语言写入失败,请安装后在面板右下角手动切换。/Failed to save panel language. Please switch it manually from the lower-left panel control after installation./g' \
-e 's/正在使用 apk 安装依赖.../Installing dependencies with apk.../g' \
-e 's/正在使用 apt 安装依赖.../Installing dependencies with apt.../g' \
-e 's/正在使用 dnf 安装依赖.../Installing dependencies with dnf.../g' \
-e 's/正在使用 yum 安装依赖.../Installing dependencies with yum.../g' \
-e 's/依赖安装后仍未找到/Still missing after dependency installation: /g' \
-e 's/,请检查 LXC 软件源\/安装日志。/. Check the LXC repository\/install log./g' \
-e 's/,请检查系统网络工具包。/. Check the system network tools package./g' \
-e 's/,请检查 iproute2 安装。/. Check the iproute2 installation./g' \
-e 's/,请检查 libvirt-client\/libvirt-clients 安装。/. Check the libvirt-client\/libvirt-clients installation./g' \
-e 's/,请检查 qemu-utils\/qemu-img 安装。/. Check the qemu-utils\/qemu-img installation./g' \
-e 's/,请检查 cloud-image-utils\/cloud-utils 安装。/. Check the cloud-image-utils\/cloud-utils installation./g' \
-e 's/可选依赖未安装:/Optional dependency was not installed: /g' \
-e 's/当前系统 /Current system /g' \
-e 's/ 未找到 dnf\/yum,无法安装依赖。/ does not have dnf\/yum; cannot install dependencies./g' \
-e 's/Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。/Windows KVM initialization requires one of genisoimage, mkisofs, or xorriso./g' \
-e 's/未检测到 \/dev\/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。/\/dev\/kvm was not detected. LXC is available, but KVM VMs require hardware virtualization or nested virtualization./g' \
-e 's/正在启用内核转发配置.../Enabling kernel forwarding settings.../g' \
-e 's/正在配置 LXC 和 KVM 服务.../Configuring LXC and KVM services.../g' \
-e 's/服务 /Service /g' \
-e 's/ 启动失败,将继续安装并在运行时降级处理。/ failed to start; installation will continue and runtime fallback will be used./g' \
-e 's/未检测到 systemd 单元 /systemd unit was not detected: /g' \
-e 's/,跳过。/; skipped./g' \
-e 's/检测到 libvirt 传统 libvirtd 服务,已使用 libvirtd 模式。/Detected the legacy libvirt libvirtd service; using libvirtd mode./g' \
-e 's/未检测到支持的服务管理器。CLICD 当前支持 systemd 或 OpenRC。/No supported service manager was detected. CLICD currently supports systemd or OpenRC./g' \
-e 's/正在检查 libvirt default NAT 网络.../Checking libvirt default NAT network.../g' \
-e 's/未找到 virsh,跳过 libvirt default NAT 网络检查。/virsh was not found; skipping the libvirt default NAT network check./g' \
-e 's/libvirt default 网络仍未启动。请执行 virsh net-info default 查看详情。/libvirt default network is still not active. Run virsh net-info default for details./g' \
-e 's/libvirt default NAT 网络已启用。/libvirt default NAT network is enabled./g' \
-e 's/正在配置 subordinate UID\/GID 范围.../Configuring subordinate UID\/GID ranges.../g' \
-e 's/根文件系统 /Root filesystem /g' \
-e 's/ 不需要\/不适合自动启用 ext4 project quotaCLICD 将使用兼容磁盘限制模式。/ does not need or is not suitable for automatic ext4 project quota; CLICD will use compatible disk limit mode./g' \
-e 's/ 不在自动 project quota 支持范围,CLICD 将使用兼容磁盘限制模式。/ is not supported for automatic project quota; CLICD will use compatible disk limit mode./g' \
-e 's/根分区来源 /Root partition source /g' \
-e 's/ 不是块设备,跳过 project quota 自动检查,CLICD 将使用兼容磁盘限制模式。/ is not a block device; skipping automatic project quota check and using compatible disk limit mode./g' \
-e 's/未找到 tune2fs,跳过 project quota 检查,CLICD 将使用兼容磁盘限制模式。/tune2fs was not found; skipping project quota check and using compatible disk limit mode./g' \
-e 's/检测到 ext4 project quota 已可用。/ext4 project quota is already available./g' \
-e 's/ext4 project quota 未启用,CLICD 将自动回退到 loopback 镜像磁盘限制模式。/ext4 project quota is not enabled; CLICD will automatically fall back to loopback image disk limit mode./g' \
-e 's/当前目录未找到 clicd 二进制,将下载发行版包。/No local clicd binary found; downloading release package./g' \
-e 's/正在下载发行版包:/Downloading release package: /g' \
-e 's/下载发行版包需要 curl 或 wget。/Downloading the release package requires curl or wget./g' \
-e 's/下载的发行版包中未找到 clicd 二进制。/The downloaded release package does not contain the clicd binary./g' \
-e 's/未找到 clicd 二进制,安装无法继续。/clicd binary was not found; installation cannot continue./g' \
-e 's/已安装二进制:/Installed binary: /g' \
-e 's/正在安装 CLICD 服务.../Installing CLICD service.../g' \
-e 's/正在清理 CLICD 防火墙和网桥规则.../Cleaning CLICD firewall and bridge rules.../g' \
-e 's/已清理 /Cleaned /g' \
-e 's/ 中的 CLICD 配额记录/ CLICD quota records/g' \
-e 's/跳过当前安装目录 /Skipping current installation directory /g' \
-e 's/,避免中断后续安装步骤。/ to avoid interrupting later installation steps./g' \
-e 's/ 被占用,终止占用进程后重试删除.../ is busy; killing occupying processes and retrying removal.../g' \
-e 's/正在删除 CLICD 使用的 LXC 镜像缓存.../Removing LXC image cache used by CLICD.../g' \
-e 's/正在删除 KVM 虚拟机域 /Removing KVM VM domain /g' \
-e 's/正在销毁 CLICD 创建的 KVM 虚拟机.../Destroying CLICD-created KVM VMs.../g' \
-e 's/检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default\/virbr0。/Non-CLICD VMs are still using the libvirt default network, so default\/virbr0 has been kept./g' \
-e 's/正在删除 CLICD 创建的 libvirt default NAT 网络.../Removing CLICD-created libvirt default NAT network.../g' \
-e 's/已删除 /Removed /g' \
-e 's/检测到 /Detected /g' \
-e 's/安装完成/Installation complete/g' \
-e 's/Web 面板/Web panel/g' \
-e 's/二进制/Binary/g' \
-e 's/安装日志/Install log/g' \
-e 's/服务/Service/g' \
-e 's/运行日志/Runtime log/g' \
-e 's/首次安装时的初始账号信息:/Initial account information for first installation:/g' \
-e 's/如果没有显示密码,说明服务器已有/If no password is shown, the server already has/g' \
-e 's/已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。/Existing admin passwords are stored with bcrypt and cannot be recovered. Change it in the panel or reset configuration./g' \
-e 's//: /g' \
-e 's//, /g' \
-e 's/。/./g' \
-e 's//(/g' \
-e 's//)/g' \
-e 's/、/, /g' \
)"
printf '%s' "$msg"
}
echo "====================================="
echo " CLICD 中文安装/卸载脚本"
echo " $(tr_msg "CLICD 中文安装/卸载脚本")"
echo "====================================="
write_log_file() {
@@ -22,21 +232,26 @@ write_log_file() {
}
log() {
echo "[clicd] $*"
write_log_file "[clicd] $*"
msg="$(tr_msg "$*")"
echo "[clicd] $msg"
write_log_file "[clicd] $msg"
}
warn() {
echo "[clicd][警告] $*" >&2
write_log_file "[警告] $*"
label="$(tr_msg "警告")"
msg="$(tr_msg "$*")"
echo "[clicd][$label] $msg" >&2
write_log_file "[$label] $msg"
}
die() {
echo "[clicd][错误] $*" >&2
write_log_file "[错误] $*"
label="$(tr_msg "错误")"
msg="$(tr_msg "$*")"
echo "[clicd][$label] $msg" >&2
write_log_file "[$label] $msg"
echo "" >&2
echo "安装/卸载未完成。请查看日志:$LOG_FILE" >&2
echo "如果你确认这是程序问题,请提交 issue:$ISSUE_URL" >&2
echo "$(tr_msg "安装/卸载未完成。请查看日志:")$LOG_FILE" >&2
echo "$(tr_msg "如果你确认这是程序问题,请提交 issue:")$ISSUE_URL" >&2
exit 1
}
@@ -62,11 +277,11 @@ run_step() {
fi
rc="$?"
echo "" >&2
echo "[clicd][错误] 步骤失败:$step_name,退出码:$rc" >&2
echo "[clicd][错误] 最近 80 行日志:$LOG_FILE" >&2
echo "[clicd][$(tr_msg "错误")] $(tr_msg "步骤失败:")$(tr_msg "$step_name")$(tr_msg "")$(tr_msg "退出码:")$rc" >&2
echo "[clicd][$(tr_msg "错误")] $(tr_msg "最近 80 行日志:")$LOG_FILE" >&2
tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true
echo "" >&2
echo "请将上述日志和系统信息提交到:$ISSUE_URL" >&2
echo "$(tr_msg "请将上述日志和系统信息提交到:")$ISSUE_URL" >&2
exit "$rc"
}
@@ -104,10 +319,10 @@ check_storage_compatibility() {
}
if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 权限运行:sudo ./install.sh"
echo "或执行:curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh"
echo "卸载:curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall"
echo "问题反馈:$ISSUE_URL"
echo "$(tr_msg "请使用 root 权限运行:")sudo ./install.sh"
echo "$(tr_msg "或执行:")curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh"
echo "$(tr_msg "卸载:")curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall"
echo "$(tr_msg "问题反馈:")$ISSUE_URL"
exit 1
fi
@@ -125,6 +340,28 @@ if [ -r /etc/os-release ]; then
fi
usage() {
if [ "$CLICD_LANG_DETECTED" = "en" ]; then
cat << EOF
Usage:
./install.sh Install or upgrade CLICD
./install.sh uninstall Uninstall CLICD (removes containers, VMs, image cache, and config data)
Environment variables:
CLICD_REPO=owner/repo Default: ${REPO}
CLICD_VERSION=latest|v1.0.0 Default: latest
CLICD_LANG=en|zh Default: auto
CLICD_LOG_FILE=/path/file.log Default: ${LOG_FILE}
Examples:
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall --yes
Log: ${LOG_FILE}
Issues: ${ISSUE_URL}
EOF
return
fi
cat << EOF
用法:
./install.sh 安装或升级 CLICD
@@ -133,6 +370,7 @@ usage() {
环境变量:
CLICD_REPO=owner/repo 默认:${REPO}
CLICD_VERSION=latest|v1.0.0 默认:latest
CLICD_LANG=en|zh 默认:自动检测
CLICD_LOG_FILE=/path/file.log 默认:${LOG_FILE}
示例:
@@ -598,9 +836,9 @@ confirm_uninstall() {
return
fi
echo ""
echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。" >&2
echo "如需确认卸载,请输入:YES" >&2
echo "[clicd][$(tr_msg "警告")] $(tr_msg "卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。")" >&2
echo "[clicd][$(tr_msg "警告")] $(tr_msg "为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。")" >&2
echo "$(tr_msg "如需确认卸载,请输入:YES")" >&2
if [ -r /dev/tty ]; then
IFS= read -r answer < /dev/tty
elif [ -t 0 ]; then
@@ -667,13 +905,13 @@ uninstall_clicd() {
echo ""
echo "====================================="
echo " CLICD 卸载完成"
echo " $(tr_msg "CLICD 卸载完成")"
echo "====================================="
echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、"
echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。"
echo " 已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。"
echo " 日志:$LOG_FILE"
echo " 问题反馈:$ISSUE_URL"
echo " $(tr_msg "已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、")"
echo " $(tr_msg "CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。")"
echo " $(tr_msg "已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。")"
echo " $(tr_msg "日志:")$LOG_FILE"
echo " $(tr_msg "问题反馈:")$ISSUE_URL"
echo "====================================="
}
@@ -720,7 +958,7 @@ install_apk() {
libvirt-client \
libvirt-qemu
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso; do
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools; do
apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done
}
@@ -749,12 +987,14 @@ install_apt() {
xfsprogs \
dnsmasq-base \
qemu-kvm \
qemu-system-x86 \
qemu-utils \
libvirt-daemon-system \
libvirt-clients \
cloud-image-utils \
genisoimage \
xorriso \
smartmontools \
virtinst \
ovmf
}
@@ -802,7 +1042,7 @@ install_dnf() {
cloud-utils \
genisoimage
for pkg in lxcfs xorriso edk2-ovmf; do
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do
dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done
}
@@ -837,7 +1077,7 @@ install_yum() {
cloud-utils \
genisoimage
for pkg in lxcfs xorriso edk2-ovmf; do
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do
yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done
}
@@ -1279,33 +1519,83 @@ install_service() {
fi
}
set_panel_language() {
lang="$CLICD_LANG_DETECTED"
db="/root/.clicd/config.db"
if [ "$lang" != "zh" ] && [ "$lang" != "en" ]; then
lang="zh"
fi
saved=0
i=0
while [ ! -f "$db" ] && [ "$i" -lt 20 ]; do
i=$((i + 1))
sleep 1
done
if [ -f "$db" ] && has_cmd python3; then
CLICD_PANEL_LANG="$lang" CLICD_DB="$db" python3 - <<'PY' >/dev/null 2>&1 && saved=1 || saved=0
import os
import sqlite3
db = os.environ["CLICD_DB"]
lang = os.environ["CLICD_PANEL_LANG"]
conn = sqlite3.connect(db)
conn.execute("CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
conn.execute("INSERT OR REPLACE INTO app_meta(key, value) VALUES('language', ?)", (lang,))
conn.commit()
conn.close()
PY
fi
if [ "$saved" != "1" ] && [ -f "$db" ] && has_cmd sqlite3; then
sqlite3 "$db" "CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); INSERT OR REPLACE INTO app_meta(key, value) VALUES('language', '$lang');" >/dev/null 2>&1 && saved=1 || saved=0
fi
if [ "$saved" != "1" ] && has_cmd curl; then
curl -k -fsS -X POST -H 'Content-Type: application/json' -d "{\"language\":\"$lang\"}" "https://127.0.0.1:8999/api/language" >/dev/null 2>&1 && saved=1 || \
curl -fsS -X POST -H 'Content-Type: application/json' -d "{\"language\":\"$lang\"}" "http://127.0.0.1:8999/api/language" >/dev/null 2>&1 && saved=1 || saved=0
fi
if [ "$saved" = "1" ]; then
log "已写入面板语言:$lang"
if has_cmd systemctl && systemctl is-active clicd >/dev/null 2>&1; then
systemctl restart clicd >/dev/null 2>&1 || true
elif has_cmd rc-service; then
rc-service clicd restart >/dev/null 2>&1 || true
fi
else
warn "面板语言写入失败,请安装后在面板右下角手动切换。"
fi
}
print_summary() {
echo ""
echo "====================================="
echo " 安装完成"
echo " $(tr_msg "安装完成")"
echo "====================================="
echo " Web 面板:http://YOUR_SERVER_IP:8999"
echo " 二进制:/usr/local/bin/clicd"
echo " 安装日志:$LOG_FILE"
echo " 问题反馈:$ISSUE_URL"
echo " $(tr_msg "Web 面板:")http://YOUR_SERVER_IP:8999"
echo " $(tr_msg "二进制:")/usr/local/bin/clicd"
echo " $(tr_msg "安装日志:")$LOG_FILE"
echo " $(tr_msg "问题反馈:")$ISSUE_URL"
if is_systemd; then
echo " 服务:systemctl {start|stop|restart|status} clicd"
echo " 运行日志:journalctl -u clicd -f"
echo " $(tr_msg "服务:")systemctl {start|stop|restart|status} clicd"
echo " $(tr_msg "运行日志:")journalctl -u clicd -f"
elif is_openrc; then
echo " 服务:rc-service clicd {start|stop|restart|status}"
echo " 运行日志:tail -f /var/log/clicd.log /var/log/clicd.err"
echo " $(tr_msg "服务:")rc-service clicd {start|stop|restart|status}"
echo " $(tr_msg "运行日志:")tail -f /var/log/clicd.log /var/log/clicd.err"
fi
echo "====================================="
echo ""
echo "首次安装时的初始账号信息:"
echo "$(tr_msg "首次安装时的初始账号信息:")"
if is_systemd; then
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true
else
grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true
fi
echo ""
echo "如果没有显示密码,说明服务器已有 /root/.clicd/config.db"
echo "已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。"
echo "$(tr_msg "如果没有显示密码,说明服务器已有") /root/.clicd/config.db."
echo "$(tr_msg "已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。")"
}
run_step "兼容性检查" check_os_compatibility
@@ -1315,10 +1605,11 @@ run_step "配置内核网络参数" configure_kernel_networking
run_step "配置运行时服务" setup_runtime_services
run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
run_step "配置 UID/GID 映射" setup_subids
run_step "Configure LXC storage permissions" configure_lxc_storage_access
run_step "配置 LXC 存储权限" configure_lxc_storage_access
run_step "检查 project quota" try_enable_project_quota
run_step "下载发行版包" download_release_if_needed
run_step "安装 CLICD 二进制" install_binary
run_step "安装并启动 CLICD 服务" install_service
run_step "写入面板语言" set_panel_language
sleep 2
print_summary