mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2fa2449e9 | |||
| 53d56be8f9 | |||
| ec38ab9136 | |||
| fdcd7df9e9 | |||
| d6d46296fe | |||
| 3f44c7565f | |||
| 61d842d94c | |||
| ca303d33f6 | |||
| 6bdeafccf2 |
@@ -119,10 +119,4 @@ This open-source software is intended solely for educational purposes, specifica
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&legend=top-left" />
|
||||
<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>
|
||||
[](https://meteor-history.com)
|
||||
|
||||
@@ -1724,12 +1724,12 @@ func ensureDefaultNetwork() error {
|
||||
// Ensure libvirtd is running
|
||||
if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil {
|
||||
// Non-systemd systems may use a different init, try virsh connect
|
||||
if exec.Command("virsh", "connect").Run() != nil {
|
||||
if virshCLocaleCommand("connect").Run() != nil {
|
||||
return fmt.Errorf("libvirtd is not running and could not be started")
|
||||
}
|
||||
}
|
||||
// Ensure default network is defined
|
||||
if exec.Command("virsh", "net-info", "default").Run() != nil {
|
||||
if virshCLocaleCommand("net-info", "default").Run() != nil {
|
||||
// Default network may not be defined; try to define it
|
||||
netXML := `<network>
|
||||
<name>default</name>
|
||||
@@ -1746,7 +1746,7 @@ func ensureDefaultNetwork() error {
|
||||
return fmt.Errorf("failed to write default network XML: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
|
||||
if out, err := virshCLocaleCommand("net-define", tmpFile).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(libvirtDefaultNetworkMarker), 0755); err == nil {
|
||||
@@ -1754,19 +1754,27 @@ func ensureDefaultNetwork() error {
|
||||
}
|
||||
}
|
||||
// Start and autostart the default network
|
||||
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
|
||||
if out, err := virshCLocaleCommand("net-info", "default").Output(); err == nil {
|
||||
if !libvirtNetworkActive(string(out)) {
|
||||
if startOut, startErr := exec.Command("virsh", "net-start", "default").CombinedOutput(); startErr != nil {
|
||||
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut))
|
||||
if startOut, startErr := virshCLocaleCommand("net-start", "default").CombinedOutput(); startErr != nil {
|
||||
if verifyOut, verifyErr := virshCLocaleCommand("net-info", "default").Output(); verifyErr != nil || !libvirtNetworkActive(string(verifyOut)) {
|
||||
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if out, err := exec.Command("virsh", "net-autostart", "default").CombinedOutput(); err != nil {
|
||||
if out, err := virshCLocaleCommand("net-autostart", "default").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to set autostart for libvirt default network: %v, output: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func virshCLocaleCommand(args ...string) *exec.Cmd {
|
||||
cmd := exec.Command("virsh", args...)
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C", "LC_MESSAGES=C", "LANG=C", "LANGUAGE=C")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func libvirtNetworkActive(info string) bool {
|
||||
for _, line := range strings.Split(info, "\n") {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
|
||||
@@ -3,6 +3,7 @@ package kvm
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
@@ -11,14 +12,36 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestLocalImageIDRejectsPathExpressions(t *testing.T) {
|
||||
for _, id := range []string{"", ".", "..", "../../etc/passwd", `..\\..\\windows`, "/absolute"} {
|
||||
if got := localImageID(id); got != "__invalid_image_id__" {
|
||||
t.Fatalf("localImageID(%q) = %q", id, got)
|
||||
func TestImagePathUsesAllowlistedImageID(t *testing.T) {
|
||||
for _, id := range []string{"", ".", "..", "../../etc/passwd", `..\\..\\windows`, "/absolute", "unknown-image"} {
|
||||
if got := filepath.Base(ImagePath(id)); got != "__invalid_image_id__.qcow2" {
|
||||
t.Fatalf("ImagePath(%q) basename = %q", id, got)
|
||||
}
|
||||
}
|
||||
if got := localImageID("debian-13-kvm"); got != "debian-13-kvm" {
|
||||
t.Fatalf("localImageID(valid) = %q", got)
|
||||
validID := GetImages()[0].ID
|
||||
if got := filepath.Base(ImagePath(validID)); got != validID+".qcow2" {
|
||||
t.Fatalf("ImagePath(%q) basename = %q", validID, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibvirtNetworkActiveParsesCLocaleOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info string
|
||||
want bool
|
||||
}{
|
||||
{name: "active", info: "Name: default\nActive: yes\n", want: true},
|
||||
{name: "spacing and case", info: " Active : YES \r\n", want: true},
|
||||
{name: "inactive", info: "Name: default\nActive: no\n", want: false},
|
||||
{name: "missing field", info: "Name: default\nAutostart: yes\n", want: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := libvirtNetworkActive(tc.info); got != tc.want {
|
||||
t.Fatalf("libvirtNetworkActive(%q) = %v, want %v", tc.info, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
@@ -193,10 +192,14 @@ func CacheDir() string {
|
||||
func ImagePath(id string) string {
|
||||
img := FindImage(id)
|
||||
ext := ".qcow2"
|
||||
safeID := "__invalid_image_id__"
|
||||
if img != nil {
|
||||
safeID = img.ID
|
||||
}
|
||||
if img != nil && img.Distro == "windows" {
|
||||
ext = ".iso"
|
||||
}
|
||||
fileName := localImageID(id) + ext
|
||||
fileName := safeID + ext
|
||||
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
|
||||
candidate := filepath.Join(pool.Path, "images", "kvm", fileName)
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
@@ -210,15 +213,6 @@ func ImagePath(id string) string {
|
||||
return filepath.Join(CacheDir(), fileName)
|
||||
}
|
||||
|
||||
func localImageID(id string) string {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
local := filepath.Base(trimmed)
|
||||
if trimmed == "" || local == "." || local == ".." || local != trimmed || strings.ContainsAny(trimmed, `/\\`) {
|
||||
return "__invalid_image_id__"
|
||||
}
|
||||
return local
|
||||
}
|
||||
|
||||
// IsWindowsImage returns true if the image distro is "windows".
|
||||
func IsWindowsImage(id string) bool {
|
||||
img := FindImage(id)
|
||||
|
||||
@@ -708,7 +708,7 @@ func (m *Manager) applyLANIPv4Config(lxcName string, cfg ContainerConfig) (strin
|
||||
values["lxc.net.0.ipv4.gateway"] = strings.TrimSpace(cfg.LANIPv4Gateway)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
next := make([]string, 0, len(lines)+len(values))
|
||||
next := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !cfg.WantsLANStaticIPv4() && (strings.HasPrefix(trimmed, "lxc.net.0.ipv4.address") || strings.HasPrefix(trimmed, "lxc.net.0.ipv4.gateway")) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.25"
|
||||
Version = "1.1.26"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.1.19",
|
||||
"version": "1.1.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.1.19",
|
||||
"version": "1.1.25",
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "1.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.18.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -957,9 +957,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
|
||||
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
|
||||
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.26",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,7 +12,7 @@
|
||||
"@novnc/novnc": "1.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.18.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -81,7 +81,7 @@ const scopeGroups = [
|
||||
['container:delete', '删除容器'],
|
||||
['container:resize', '资源/到期'],
|
||||
['container:traffic', '流量管理'],
|
||||
['container:network', '端口映射'],
|
||||
['container:network', '网络与端口映射'],
|
||||
['container:password', '重置密码'],
|
||||
['ipv6:assign', '分配 IPv6'],
|
||||
],
|
||||
@@ -140,6 +140,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
endpoints: [
|
||||
['GET', '/api/v1/dashboard', '控制面板统计'],
|
||||
['GET', '/api/v1/host-info', '主机资源'],
|
||||
['GET', '/api/v1/host-history', '宿主机历史指标(后台每 30 秒采集)'],
|
||||
['GET', '/api/v1/host-report', '宿主机硬件、网络与运行环境探测报告'],
|
||||
['GET', '/api/v1/routing', 'NAT/IPv4/IPv6 路由'],
|
||||
['PUT', '/api/v1/routing', '更新公网 IPv4/IPv6 池'],
|
||||
['POST', '/api/v1/routing/ipv4-scan', '扫描公网 IPv4 段'],
|
||||
@@ -161,6 +163,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
['POST', '/api/v1/containers/{id}/reinstall', '重装'],
|
||||
['DELETE', '/api/v1/containers/{id}/delete', '删除'],
|
||||
['GET', '/api/v1/containers/{id}/usage', '资源用量'],
|
||||
['GET', '/api/v1/containers/{id}/history', '容器历史指标(后台每 30 秒采集)'],
|
||||
['GET', '/api/v1/containers/{id}/traffic', '流量统计'],
|
||||
['POST', '/api/v1/containers/{id}/traffic-reset', '重置流量'],
|
||||
['PUT', '/api/v1/containers/{id}/traffic-limit', '调整流量限制'],
|
||||
@@ -168,6 +171,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
['PUT', '/api/v1/containers/{id}/expiry', '调整到期时间'],
|
||||
['POST', '/api/v1/containers/{id}/reset-password', '重置 SSH 密码'],
|
||||
['POST', '/api/v1/containers/{id}/ipv6', '分配 IPv6'],
|
||||
['PUT', '/api/v1/containers/{id}/public-ipv4', '更新独立公网 IPv4 地址'],
|
||||
['PUT', '/api/v1/containers/{id}/ipv6-addresses', '更新独立 IPv6 地址'],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -193,6 +198,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
endpoints: [
|
||||
['GET', '/api/v1/templates', '模板列表'],
|
||||
['GET', '/api/v1/images', '镜像管理列表'],
|
||||
['GET', '/api/v1/images/enabled?type=lxc&container={id}', '可用于创建或重装的已启用镜像'],
|
||||
['POST', '/api/v1/images/download', '下载镜像'],
|
||||
['POST', '/api/v1/images/cancel', '取消镜像下载'],
|
||||
['DELETE', '/api/v1/images/delete', '删除镜像缓存'],
|
||||
@@ -211,6 +217,21 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
['POST', '/api/v1/vnc-ticket', '创建 WebVNC 票据'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '主机与设置',
|
||||
endpoints: [
|
||||
['GET', '/api/v1/storage', '已挂载磁盘、存储池和空间占用'],
|
||||
['PUT', '/api/v1/storage', '更新各磁盘的存储用途和默认盘'],
|
||||
['GET', '/api/v1/task-queue/settings', '任务队列并发状态'],
|
||||
['PUT', '/api/v1/task-queue/settings', '调整任务并发数量'],
|
||||
['GET', '/api/v1/ssl', 'SSL 配置和证书状态'],
|
||||
['PUT', '/api/v1/ssl', '更新 SSL 配置'],
|
||||
['GET', '/api/v1/webssh-origins', 'WebSSH/VNC Origin 白名单'],
|
||||
['PUT', '/api/v1/webssh-origins', '更新 WebSSH/VNC Origin 白名单'],
|
||||
['GET', '/api/v1/language', '面板语言'],
|
||||
['PUT', '/api/v1/language', '更新面板语言'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '账号与日志',
|
||||
endpoints: [
|
||||
@@ -728,6 +749,7 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
name: 'demo-lxc-01',
|
||||
virtualization: 'lxc',
|
||||
template_id: 'debian-bookworm',
|
||||
storage_pool_id: 'disk-root',
|
||||
vcpu: 1,
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
@@ -744,7 +766,14 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
extra_ports: [8080],
|
||||
port_mapping_count: 2,
|
||||
assign_nat: true,
|
||||
lan_ipv4_mode: '',
|
||||
lan_interface: '',
|
||||
lan_ipv4_address: '',
|
||||
lan_ipv4_prefix_len: 24,
|
||||
lan_ipv4_gateway: '',
|
||||
snapshot_limit: 1,
|
||||
allowed_image_ids: ['debian-bookworm'],
|
||||
image_limit_configured: true,
|
||||
assign_ipv4: false,
|
||||
ipv4_count: 1,
|
||||
public_ipv4s: [],
|
||||
@@ -780,6 +809,14 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
|
||||
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
|
||||
'PUT /api/v1/containers/{id}/public-ipv4': {
|
||||
mode: 'random',
|
||||
count: 1,
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/ipv6-addresses': {
|
||||
mode: 'custom',
|
||||
addresses: ['2001:db8:100::1005'],
|
||||
},
|
||||
'POST /api/v1/containers/{id}/port-mappings': {
|
||||
container_port: 8080,
|
||||
host_port: 61320,
|
||||
@@ -792,6 +829,7 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
protocol: 'tcp',
|
||||
description: 'HTTP',
|
||||
},
|
||||
'POST /api/v1/containers/{id}/snapshots': { storage_pool_id: 'disk-root' },
|
||||
'POST /api/v1/containers/{id}/snapshots/schedule': {
|
||||
enabled: true,
|
||||
interval_hours: 24,
|
||||
@@ -802,6 +840,31 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
|
||||
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
|
||||
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
|
||||
'PUT /api/v1/storage': {
|
||||
pools: [
|
||||
{
|
||||
id: 'disk-root',
|
||||
name: 'system (/)',
|
||||
path: '/var/lib/clicd',
|
||||
mount_point: '/',
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'PUT /api/v1/task-queue/settings': { concurrency: 4 },
|
||||
'PUT /api/v1/ssl': {
|
||||
enabled: true,
|
||||
mode: 'letsencrypt',
|
||||
target: 'panel.example.com',
|
||||
email: 'admin@example.com',
|
||||
apply_now: false,
|
||||
},
|
||||
'PUT /api/v1/webssh-origins': {
|
||||
origins: ['https://panel.example.com'],
|
||||
},
|
||||
'PUT /api/v1/language': { language: 'zh' },
|
||||
'PUT /api/v1/routing': {
|
||||
items: [
|
||||
{
|
||||
@@ -910,6 +973,37 @@ const responseSamples: Record<string, unknown> = {
|
||||
load: { load1: 0.01, load5: 0.03, load15: 0.01 },
|
||||
},
|
||||
},
|
||||
'GET /api/v1/host-history': {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
ts: 1784642400000,
|
||||
cpu: 8.4,
|
||||
memory: 21.3,
|
||||
network: 12288,
|
||||
network_rx: 10240,
|
||||
network_tx: 2048,
|
||||
disk_io: 1052672,
|
||||
disk_read: 4096,
|
||||
disk_write: 1048576,
|
||||
disk_usage_pct: 18.8,
|
||||
},
|
||||
],
|
||||
},
|
||||
'GET /api/v1/host-report': {
|
||||
success: true,
|
||||
data: {
|
||||
generated_at: '2026-07-21 14:00:00',
|
||||
hostname: 'ubuntu',
|
||||
os: 'Ubuntu 22.04.5 LTS',
|
||||
kernel: 'Linux 6.8.0-1054-oracle aarch64 GNU/Linux',
|
||||
cpu: { model: 'Neoverse-N1', cores: 4, threads: 4, architecture: 'arm64', virtualization: true },
|
||||
memory: { total_mb: 11980, used_mb: 2100, free_mb: 9880, modules: [] },
|
||||
runtime: { lxc_available: true, kvm_available: false, support_mode: 'lxc_only' },
|
||||
public_ipv4: [{ address: '203.0.113.10', interface: 'eth0' }],
|
||||
ipv6_prefixes: [],
|
||||
},
|
||||
},
|
||||
'GET /api/v1/routing': {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -1016,6 +1110,12 @@ const responseSamples: Record<string, unknown> = {
|
||||
load15: 0.01,
|
||||
},
|
||||
},
|
||||
'GET /api/v1/containers/{id}/history': {
|
||||
success: true,
|
||||
data: [
|
||||
{ ts: 1784642400000, cpu: 1.2, memory: 5.6, network: 4096, network_rx: 3072, network_tx: 1024, disk_io: 8192, disk_read: 2048, disk_write: 6144 },
|
||||
],
|
||||
},
|
||||
'GET /api/v1/containers/{id}/traffic': {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -1036,6 +1136,16 @@ const responseSamples: Record<string, unknown> = {
|
||||
'PUT /api/v1/containers/{id}/expiry': { success: true, message: 'Expiry updated' },
|
||||
'POST /api/v1/containers/{id}/reset-password': { success: true, message: 'SSH password reset successfully', data: { password: '***' } },
|
||||
'POST /api/v1/containers/{id}/ipv6': { success: true, message: 'IPv6 assigned', data: { id: 5, name: 'example-vm', ipv6: '2001:db8:100::1005' } },
|
||||
'PUT /api/v1/containers/{id}/public-ipv4': {
|
||||
success: true,
|
||||
message: 'Public IPv4 assignments updated',
|
||||
data: { id: 5, name: 'example-vm', public_ipv4s: ['203.0.113.10'] },
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/ipv6-addresses': {
|
||||
success: true,
|
||||
message: 'IPv6 assignments updated',
|
||||
data: { id: 5, name: 'example-vm', ipv6_addresses: ['2001:db8:100::1005'] },
|
||||
},
|
||||
'GET /api/v1/containers/{id}/random-port': { success: true, data: { port: 61320 } },
|
||||
'POST /api/v1/containers/{id}/port-mappings': {
|
||||
success: true,
|
||||
@@ -1100,10 +1210,57 @@ const responseSamples: Record<string, unknown> = {
|
||||
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', type: 'lxc', downloaded: true, enabled: true, downloading: false, progress: 0, size_bytes: 135005452 },
|
||||
],
|
||||
},
|
||||
'GET /api/v1/images/enabled?type=lxc&container={id}': {
|
||||
success: true,
|
||||
data: [
|
||||
{ id: 'debian-bookworm', name: 'Debian 12', distro: 'debian', release: 'bookworm', arch: 'amd64', type: 'lxc', downloaded: true, enabled: true },
|
||||
],
|
||||
},
|
||||
'POST /api/v1/images/download': { success: true, message: 'Already downloaded' },
|
||||
'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' },
|
||||
'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' },
|
||||
'PUT /api/v1/images/toggle': { success: true, message: 'OK' },
|
||||
'GET /api/v1/storage': {
|
||||
success: true,
|
||||
data: {
|
||||
pools: [
|
||||
{
|
||||
id: 'disk-root',
|
||||
name: 'system (/)',
|
||||
path: '/var/lib/clicd',
|
||||
mount_point: '/',
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
enabled: true,
|
||||
available: true,
|
||||
free_bytes: 54653493248,
|
||||
},
|
||||
],
|
||||
disks: [
|
||||
{ name: 'sda2', path: '/dev/sda2', fstype: 'ext4', mount_point: '/', size_bytes: 67331063808, used_bytes: 12677570560, free_bytes: 54653493248 },
|
||||
],
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
},
|
||||
},
|
||||
'PUT /api/v1/storage': {
|
||||
success: true,
|
||||
data: {
|
||||
pools: [{ id: 'disk-root', path: '/var/lib/clicd', mount_point: '/', content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'], enabled: true, available: true }],
|
||||
disks: [],
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
},
|
||||
},
|
||||
'GET /api/v1/task-queue/settings': { success: true, data: { concurrency: 4, active: 1, pending: 2 } },
|
||||
'PUT /api/v1/task-queue/settings': { success: true, message: '任务队列设置已保存', data: { concurrency: 4, active: 1, pending: 2 } },
|
||||
'GET /api/v1/ssl': {
|
||||
success: true,
|
||||
data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', email: 'admin@example.com', detected_host: 'panel.example.com', certificate: { subject: 'panel.example.com', issuer: "Let's Encrypt", dns_names: ['panel.example.com'], ip_names: [], valid: true } },
|
||||
},
|
||||
'PUT /api/v1/ssl': { success: true, message: 'SSL settings saved', data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', needs_restart: true } },
|
||||
'GET /api/v1/webssh-origins': { success: true, data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
|
||||
'PUT /api/v1/webssh-origins': { success: true, message: 'Origin allowlist saved', data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
|
||||
'GET /api/v1/language': { success: true, data: { language: 'zh' } },
|
||||
'PUT /api/v1/language': { success: true, data: { language: 'zh' } },
|
||||
'GET /api/v1/security/alerts': { success: true, data: [] },
|
||||
'POST /api/v1/security/check': { success: true, message: 'Security check completed' },
|
||||
'GET /api/v1/security/logs?container={name}': { success: true, data: [] },
|
||||
@@ -1182,12 +1339,14 @@ function endpointNoteFor(key: string) {
|
||||
if (key === 'POST /api/v1/containers') {
|
||||
notes.push('Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.')
|
||||
notes.push('Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.')
|
||||
notes.push('storage_pool_id selects an enabled disk for the runtime. For an LXC with an independent LAN address, set lan_ipv4_mode=dhcp or static and set assign_nat=false; static mode also requires lan_ipv4_address, lan_ipv4_prefix_len, and lan_ipv4_gateway.')
|
||||
notes.push('allowed_image_ids and image_limit_configured define which downloaded images the container owner may use for reinstall. Include the initial template ID when it should remain reinstallable.')
|
||||
}
|
||||
if (key === 'POST /api/v1/containers/{id}/reinstall') {
|
||||
notes.push('Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.')
|
||||
}
|
||||
if (key === 'POST /api/v1/batch-create') {
|
||||
notes.push('Each containers[] item in batch creation supports the same network and SSH authentication fields as POST /api/v1/containers.')
|
||||
notes.push('Each containers[] item in batch creation supports the same storage, network, image allowlist, and SSH authentication fields as POST /api/v1/containers.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
|
||||
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
|
||||
@@ -1204,6 +1363,30 @@ function endpointNoteFor(key: string) {
|
||||
if (key === 'POST /api/v1/routing/ipv4-scan') {
|
||||
notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.')
|
||||
}
|
||||
if (key === 'GET /api/v1/host-history' || key === 'GET /api/v1/containers/{id}/history') {
|
||||
notes.push('Metrics are collected in the background every 30 seconds, even when the statistics page is closed.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/public-ipv4' || key === 'PUT /api/v1/containers/{id}/ipv6-addresses') {
|
||||
notes.push('mode accepts random, custom, or clear. random uses count, custom uses addresses, and clear removes all assignments of that address family.')
|
||||
}
|
||||
if (key === 'GET /api/v1/images/enabled?type=lxc&container={id}') {
|
||||
notes.push('type accepts lxc or kvm. Supplying container applies that container image allowlist; omit container when listing images for a new container.')
|
||||
}
|
||||
if (key === 'POST /api/v1/containers/{id}/snapshots') {
|
||||
notes.push('storage_pool_id is optional. The selected pool must be enabled for snapshots; otherwise the server chooses an available snapshot pool by free space and default priority.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/storage') {
|
||||
notes.push('Start from GET /api/v1/storage and submit mounted disks returned by the server. Paths and mount points are server-managed and custom paths are rejected. content_types enables a disk for each workload; only one pool may be the default for each type.')
|
||||
}
|
||||
if (key.includes('/api/v1/storage') || key.includes('/task-queue/settings') || key.includes('/api/v1/ssl') || key.includes('/webssh-origins')) {
|
||||
notes.push('This endpoint requires an API key with admin:access.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/task-queue/settings') {
|
||||
notes.push('concurrency must be between 1 and 16. Tasks targeting the same container are still serialized.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/ssl') {
|
||||
notes.push('mode accepts disabled, letsencrypt, self_signed, or uploaded. uploaded mode uses cert_pem and key_pem. apply_now requests a service restart after saving.')
|
||||
}
|
||||
if (key.includes('/vnc-ticket')) notes.push('WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".')
|
||||
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.')
|
||||
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.')
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.25</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.26</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+10
-2
@@ -1326,7 +1326,9 @@ setup_runtime_services() {
|
||||
|
||||
|
||||
libvirt_network_active() {
|
||||
virsh net-info default 2>/dev/null | awk -F: 'tolower($1) ~ /^[[:space:]]*active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' | grep -qx yes
|
||||
LC_ALL=C LANG=C virsh net-info default 2>/dev/null \
|
||||
| awk -F: '$1 ~ /^[[:space:]]*Active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' \
|
||||
| grep -qx yes
|
||||
}
|
||||
|
||||
setup_default_libvirt_network() {
|
||||
@@ -1355,7 +1357,13 @@ EOF
|
||||
touch "$LIBVIRT_DEFAULT_MARKER"
|
||||
fi
|
||||
if ! libvirt_network_active; then
|
||||
virsh net-start default
|
||||
if ! start_output="$(LC_ALL=C LANG=C virsh net-start default 2>&1)"; then
|
||||
# Another process may have activated the network after our check.
|
||||
if ! libvirt_network_active; then
|
||||
printf '%s\n' "$start_output" >&2
|
||||
die "libvirt default 网络仍未启动。请执行 virsh net-info default 查看详情。"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
virsh net-autostart default >/dev/null
|
||||
if ! libvirt_network_active; then
|
||||
|
||||
Reference in New Issue
Block a user