From 78276d303b356444cd68f754f1cbd91c0b99b94e Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:42:12 +0800 Subject: [PATCH 1/7] =?UTF-8?q?chore(docs):=20=E5=8D=87=E7=BA=A7=20esbuild?= =?UTF-8?q?=20=E8=87=B3=200.28.1=20=E5=B9=B6=E6=9B=B4=E6=96=B0=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + backend/internal/server/web/.gitkeep | 1 - docs/.vitepress/config.ts | 7 + docs/package-lock.json | 214 +++++++++++++-------------- docs/package.json | 3 +- 5 files changed, 117 insertions(+), 109 deletions(-) delete mode 100644 backend/internal/server/web/.gitkeep diff --git a/.gitignore b/.gitignore index 5d40489..5d6d8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ linux.txt push-release.ps1 deploy.ps1 backend/clicd +api.md diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep deleted file mode 100644 index 30259b2..0000000 --- a/backend/internal/server/web/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d17a0dd..83fd7e9 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -110,6 +110,13 @@ export default defineConfig({ head: [ ['link', { rel: 'icon', href: '/favicon.svg' }], ], + vite: { + esbuild: { + supported: { + destructuring: true, + }, + }, + }, locales: { root: { label: '简体中文', diff --git a/docs/package-lock.json b/docs/package-lock.json index 1de4b4c..b338d68 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -369,9 +369,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "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==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -386,9 +386,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -403,9 +403,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "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==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -420,9 +420,9 @@ } }, "node_modules/@esbuild/android-x64": { - "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==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -437,9 +437,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -454,9 +454,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -471,9 +471,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -488,9 +488,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -505,9 +505,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -522,9 +522,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -539,9 +539,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "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==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -556,9 +556,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "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==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -573,9 +573,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -590,9 +590,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "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==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -607,9 +607,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -624,9 +624,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -641,9 +641,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -658,9 +658,9 @@ } }, "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==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -675,9 +675,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -692,9 +692,9 @@ } }, "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==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -709,9 +709,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -726,9 +726,9 @@ } }, "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==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -777,9 +777,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -794,9 +794,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1757,9 +1757,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1770,32 +1770,32 @@ "node": ">=18" }, "optionalDependencies": { - "@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" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/estree-walker": { diff --git a/docs/package.json b/docs/package.json index 538c63a..7545ce5 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,6 +11,7 @@ "vitepress": "^1.6.4" }, "overrides": { - "vite": "6.4.2" + "vite": "6.4.2", + "esbuild": "0.28.1" } } From 49d8093f4557d9e3d6d052e73f233feeedd4d5e9 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:16:28 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E7=BD=91=E7=BB=9C=E6=B5=81=E9=87=8F?= =?UTF-8?q?=E3=80=81=E7=A3=81=E7=9B=98=20IO=20=E5=9B=BE=E8=A1=A8=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ResourceStatsPanel.tsx | 168 ++++++++++++++---- frontend/src/pages/ContainerDetail.tsx | 58 ++++-- frontend/src/pages/Dashboard.tsx | 43 ++++- 3 files changed, 211 insertions(+), 58 deletions(-) diff --git a/frontend/src/components/ResourceStatsPanel.tsx b/frontend/src/components/ResourceStatsPanel.tsx index 0fd9f47..2943160 100644 --- a/frontend/src/components/ResourceStatsPanel.tsx +++ b/frontend/src/components/ResourceStatsPanel.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, useId } from 'react' import { RefreshCw } from 'lucide-react' import { useTheme } from '../contexts/ThemeContext' @@ -9,17 +9,27 @@ export type ChartPoint = { value: number } +export type ResourceChartSeries = { + label: string + points: ChartPoint[] + current?: number + color?: string +} + export type ResourceChartConfig = { title: string icon: ReactNode points: ChartPoint[] current: number + series?: ResourceChartSeries[] detail?: string max?: number unitLabel?: string formatValue: (value: number) => string } +const chartPalette = ['#2563eb', '#16a34a', '#d97706', '#dc2626'] + const rangeLabels: Record = { '30m': '30分钟', '1h': '1小时', @@ -77,36 +87,52 @@ export default function ResourceStatsPanel({
{charts.map((chart, index) => ( - + ))}
) } -function DetailedChart({ chart, className }: { chart: ResourceChartConfig; className: string }) { - const values = chart.points.map((point) => point.value) - const avg = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0 - const peak = values.length > 0 ? Math.max(...values) : 0 +function DetailedChart({ chart, range, className }: { chart: ResourceChartConfig; range: StatsRangeKey; className: string }) { + const series = chart.series?.length + ? chart.series + : [{ label: chart.title, points: chart.points, current: chart.current }] + const primaryStats = getSeriesStats(series[0], chart.current) return (
-
-
+
+
{chart.icon} {chart.title}
{chart.detail &&

{chart.detail}

}
-
- - - -
+ {series.length > 1 ? ( +
+ {series.map((item, index) => ( + + ))} +
+ ) : ( +
+ + + +
+ )}
string +}) { + return ( +
+
+ + {label} +
+
+ {formatValue(stats.current)} +
+
+ 均 {formatValue(stats.avg)} / 峰 {formatValue(stats.peak)} +
+
+ ) +} + function Stat({ label, value }: { label: string; value: string }) { return (
@@ -124,19 +177,33 @@ function Stat({ label, value }: { label: string; value: string }) { ) } +function getSeriesStats(series: ResourceChartSeries, fallbackCurrent = 0) { + const values = series.points + .map((point) => point.value) + .filter((value) => Number.isFinite(value)) + const current = Number.isFinite(series.current) ? Number(series.current) : fallbackCurrent + const samples = values.length > 0 ? values : [current] + const avg = samples.reduce((sum, value) => sum + value, 0) / samples.length + const peak = Math.max(current, ...samples, 0) + return { current, avg, peak } +} + function LineAreaChart({ - points, + series, + range, max, formatValue, unitLabel, }: { - points: ChartPoint[] + series: ResourceChartSeries[] + range: StatsRangeKey max?: number formatValue: (value: number) => string unitLabel?: string }) { const { theme } = useTheme() const isDark = theme === 'dark' + const gradientId = `resource-chart-fill-${useId().replace(/:/g, '')}` const width = 520 const height = 150 @@ -146,21 +213,21 @@ function LineAreaChart({ const bottom = 28 const innerWidth = width - left - right const innerHeight = height - top - bottom - const values = points.length > 0 ? points : [{ ts: Date.now(), value: 0 }] - const maxValue = Math.max(max || 0, ...values.map((point) => point.value), 1) - const minTs = values[0]?.ts || Date.now() - const maxTs = values[values.length - 1]?.ts || minTs + 1 - const span = Math.max(maxTs - minTs, 1) - - const coords = values.map((point, index) => { - const x = left + ((point.ts - minTs) / span) * innerWidth - const y = top + innerHeight - (point.value / maxValue) * innerHeight - return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}` + const now = Date.now() + const chartSeries = series.map((item) => { + const validPoints = item.points.filter((point) => Number.isFinite(point.ts) && Number.isFinite(point.value)) + return { + ...item, + points: validPoints.length > 0 + ? validPoints + : [{ ts: now, value: Number.isFinite(item.current) ? Number(item.current) : 0 }], + } }) - const fallbackX = left - const fallbackY = top + innerHeight - const line = coords.length > 1 ? coords.join(' ') : `${fallbackX},${fallbackY} ${left + innerWidth},${fallbackY}` - const area = `${left},${top + innerHeight} ${line} ${left + innerWidth},${top + innerHeight}` + const allPoints = chartSeries.flatMap((item) => item.points) + const maxValue = Math.max(max || 0, ...allPoints.map((point) => point.value), 1) + const maxTs = now + const minTs = now - statsRanges[range] + const span = Math.max(maxTs - minTs, 1) const yTicks = [1, 0.5, 0] const xTicks = [0, 0.5, 1] @@ -171,11 +238,13 @@ function LineAreaChart({ const lineStroke = isDark ? '#f9fafb' : '#444' const gradientTop = isDark ? '#f9fafb' : '#555' const gradientBottom = isDark ? '#374151' : '#555' + const primaryLine = buildLine(chartSeries[0]?.points || [{ ts: now, value: 0 }], minTs, span, left, top, innerWidth, innerHeight, maxValue) + const area = `${left},${top + innerHeight} ${primaryLine} ${left + innerWidth},${top + innerHeight}` return ( - + @@ -214,12 +283,45 @@ function LineAreaChart({ - - + {chartSeries.length === 1 && } + {chartSeries.map((item, index) => ( + + ))} ) } +function buildLine( + points: ChartPoint[], + minTs: number, + span: number, + left: number, + top: number, + innerWidth: number, + innerHeight: number, + maxValue: number, +) { + const coords = points.map((point) => { + const x = left + ((point.ts - minTs) / span) * innerWidth + const y = top + innerHeight - (point.value / maxValue) * innerHeight + return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}` + }) + if (coords.length > 1) return coords.join(' ') + + const [, yText] = (coords[0] || `${left},${top + innerHeight}`).split(',') + const y = Number(yText) + const safeY = Number.isFinite(y) ? y : top + innerHeight + return `${left},${safeY} ${left + innerWidth},${safeY}` +} + function chartBorderClass(index: number) { const right = index % 2 === 0 ? 'xl:border-r' : '' const top = index > 1 ? 'border-t' : '' diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index fef8614..c4fbfa8 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -89,8 +89,12 @@ type MetricPoint = { ts: number cpu: number memory: number - network: number - diskIO: number + network?: number + networkRx?: number + networkTx?: number + diskIO?: number + diskRead?: number + diskWrite?: number } type MappingDraft = { index: number | null @@ -214,15 +218,21 @@ export default function ContainerDetail() { const memoryPct = memoryTotalBytes > 0 ? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100 : 0 - const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0) - const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0) + const networkRx = nextUsage.network_rx_bps || 0 + const networkTx = nextUsage.network_tx_bps || 0 + const diskRead = nextUsage.disk_read_bps || 0 + const diskWrite = nextUsage.disk_write_bps || 0 const point: MetricPoint = { ts: Date.now(), cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)), memory: clamp(memoryPct), - network: networkBps, - diskIO: diskIOBps, + network: networkRx + networkTx, + networkRx, + networkTx, + diskIO: diskRead + diskWrite, + diskRead, + diskWrite, } setHistory((prev) => { @@ -907,20 +917,23 @@ export default function ContainerDetail() { const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0 const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0 const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0 - const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0) - const rx = usage?.network_rx_bps || 0 + const networkRxBps = usage?.network_rx_bps || 0 + const networkTxBps = usage?.network_tx_bps || 0 + const networkBps = networkRxBps + networkTxBps const networkDownLimit = resourceLimitValue(container.network_down_mbps, container.network_bw_mbps) const networkUpLimit = resourceLimitValue(container.network_up_mbps, container.network_bw_mbps) const netPct = Math.max( - directionUsagePercent(usage?.network_rx_bps || 0, networkDownLimit, 125000, 125000000), - directionUsagePercent(usage?.network_tx_bps || 0, networkUpLimit, 125000, 125000000), + directionUsagePercent(networkRxBps, networkDownLimit, 125000, 125000000), + directionUsagePercent(networkTxBps, networkUpLimit, 125000, 125000000), ) - const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0) + const diskReadBps = usage?.disk_read_bps || 0 + const diskWriteBps = usage?.disk_write_bps || 0 + const diskIOBps = diskReadBps + diskWriteBps const ioReadLimit = resourceLimitValue(container.io_read_mbps, container.io_speed_mbps) const ioWriteLimit = resourceLimitValue(container.io_write_mbps, container.io_speed_mbps) const diskIOPct = Math.max( - directionUsagePercent(usage?.disk_read_bps || 0, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024), - directionUsagePercent(usage?.disk_write_bps || 0, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024), + directionUsagePercent(diskReadBps, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024), + directionUsagePercent(diskWriteBps, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024), ) const mappingCount = container.port_mappings?.length || 0 const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount) @@ -967,16 +980,24 @@ export default function ContainerDetail() { icon: , current: networkBps, points: toChartPoints(filtered, 'network'), + series: [ + { label: '入', points: toChartPoints(filtered, 'networkRx'), current: networkRxBps, color: '#2563eb' }, + { label: '出', points: toChartPoints(filtered, 'networkTx'), current: networkTxBps, color: '#16a34a' }, + ], formatValue: formatRate, - detail: `入 ${formatRate(usage?.network_rx_bps || 0)} / 出 ${formatRate(usage?.network_tx_bps || 0)},限速占用 ${netPct.toFixed(1)}%,累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`, + detail: `入 ${formatRate(networkRxBps)} / 出 ${formatRate(networkTxBps)},限速占用 ${netPct.toFixed(1)}%,累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`, }, { title: '磁盘IO', icon: , current: diskIOBps, points: toChartPoints(filtered, 'diskIO'), + series: [ + { label: '读', points: toChartPoints(filtered, 'diskRead'), current: diskReadBps, color: '#d97706' }, + { label: '写', points: toChartPoints(filtered, 'diskWrite'), current: diskWriteBps, color: '#dc2626' }, + ], formatValue: formatRate, - detail: `读 ${formatRate(usage?.disk_read_bps || 0)} / 写 ${formatRate(usage?.disk_write_bps || 0)},限速占用 ${diskIOPct.toFixed(1)}%,累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`, + detail: `读 ${formatRate(diskReadBps)} / 写 ${formatRate(diskWriteBps)},限速占用 ${diskIOPct.toFixed(1)}%,累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`, }, ] @@ -2514,8 +2535,11 @@ function formatDirectionalLimit(firstLabel: string, firstValue: number, secondLa return `${firstLabel} ${formatLimit(firstValue, unit)} / ${secondLabel} ${formatLimit(secondValue, unit)}` } -function toChartPoints>(history: MetricPoint[], key: T): ChartPoint[] { - return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 })) +function toChartPoints(history: MetricPoint[], key: keyof Omit): ChartPoint[] { + return history.flatMap((point) => { + const value = Number(point[key]) + return Number.isFinite(value) ? [{ ts: point.ts, value }] : [] + }) } function formatPercent(value: number): string { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 2a0c626..c2a29af 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -13,8 +13,12 @@ type HostMetricPoint = { ts: number cpu: number memory: number - network: number - diskIO: number + network?: number + networkRx?: number + networkTx?: number + diskIO?: number + diskRead?: number + diskWrite?: number } const hostHistoryKey = 'clicd_host_metric_history_v2' @@ -58,6 +62,10 @@ export default function Dashboard() { const filtered = filterHistory(history, range) const memoryPct = host && host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0 + const networkRxBps = host?.network.rx_bps || 0 + const networkTxBps = host?.network.tx_bps || 0 + const diskReadBps = host?.disk_io.read_bps || 0 + const diskWriteBps = host?.disk_io.write_bps || 0 const networkBps = (host?.network.rx_bps || 0) + (host?.network.tx_bps || 0) const diskIOBps = (host?.disk_io.read_bps || 0) + (host?.disk_io.write_bps || 0) @@ -85,16 +93,24 @@ export default function Dashboard() { icon: , current: networkBps, points: toChartPoints(filtered, 'network'), + series: [ + { label: '入', points: toChartPoints(filtered, 'networkRx'), current: networkRxBps, color: '#2563eb' }, + { label: '出', points: toChartPoints(filtered, 'networkTx'), current: networkTxBps, color: '#16a34a' }, + ], formatValue: formatRate, - detail: `入 ${formatRate(host?.network.rx_bps || 0)} / 出 ${formatRate(host?.network.tx_bps || 0)}`, + detail: `入 ${formatRate(networkRxBps)} / 出 ${formatRate(networkTxBps)}`, }, { title: '磁盘IO', icon: , current: diskIOBps, points: toChartPoints(filtered, 'diskIO'), + series: [ + { label: '读', points: toChartPoints(filtered, 'diskRead'), current: diskReadBps, color: '#d97706' }, + { label: '写', points: toChartPoints(filtered, 'diskWrite'), current: diskWriteBps, color: '#dc2626' }, + ], formatValue: formatRate, - detail: `读 ${formatRate(host?.disk_io.read_bps || 0)} / 写 ${formatRate(host?.disk_io.write_bps || 0)}`, + detail: `读 ${formatRate(diskReadBps)} / 写 ${formatRate(diskWriteBps)}`, }, ] @@ -157,12 +173,20 @@ function SummaryCard({ } function appendHostPoint(host: HostInfo, setHistory: (updater: (prev: HostMetricPoint[]) => HostMetricPoint[]) => void) { + const networkRx = host.network.rx_bps || 0 + const networkTx = host.network.tx_bps || 0 + const diskRead = host.disk_io.read_bps || 0 + const diskWrite = host.disk_io.write_bps || 0 const point: HostMetricPoint = { ts: Date.now(), cpu: clamp(host.cpu.usage_pct), memory: host.ram.total_mb > 0 ? clamp((host.ram.used_mb / host.ram.total_mb) * 100) : 0, - network: (host.network.rx_bps || 0) + (host.network.tx_bps || 0), - diskIO: (host.disk_io.read_bps || 0) + (host.disk_io.write_bps || 0), + network: networkRx + networkTx, + networkRx, + networkTx, + diskIO: diskRead + diskWrite, + diskRead, + diskWrite, } setHistory((prev) => { @@ -190,8 +214,11 @@ function filterHistory(history: HostMetricPoint[], range: StatsRangeKey) { return history.filter((point) => point.ts >= cutoff) } -function toChartPoints>(history: HostMetricPoint[], key: T): ChartPoint[] { - return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 })) +function toChartPoints(history: HostMetricPoint[], key: keyof Omit): ChartPoint[] { + return history.flatMap((point) => { + const value = Number(point[key]) + return Number.isFinite(value) ? [{ ts: point.ts, value }] : [] + }) } function clamp(value: number) { From a1d9ce8b1c067be2f81df93e8ee4c500125c5a2d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:22:14 +0000 Subject: [PATCH 3/7] build(deps): bump form-data from 4.0.5 to 4.0.6 in /frontend Bumps [form-data](https://github.com/form-data/form-data) from 4.0.5 to 4.0.6. - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) --- updated-dependencies: - dependency-name: form-data dependency-version: 4.0.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3ef907c..fb6103b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "clicd-frontend", - "version": "1.1.1", + "version": "1.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clicd-frontend", - "version": "1.1.1", + "version": "1.1.19", "dependencies": { "@novnc/novnc": "1.5.0", "@xterm/addon-fit": "^0.11.0", @@ -1372,16 +1372,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" From 92e846eecc363f11ed80e5a52f9d59fd7cee954b Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:56:09 +0800 Subject: [PATCH 4/7] IPV6 fix --- backend/internal/kvm/kvm.go | 97 +++++++++++++++++++++------- backend/internal/server/web/.gitkeep | 0 2 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 backend/internal/server/web/.gitkeep diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 9515089..9b10a0d 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -454,7 +454,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig } winAdminPassword = generateWindowsPassword() unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso") - if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List, ipv4List); err != nil { + if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil { return nil, err } xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps) @@ -1680,7 +1680,7 @@ func createEmptyDisk(target string, diskGB int) error { return nil } -func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string, ipv4s []string) error { +func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string) error { tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso") if tool == "" { return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup") @@ -1706,13 +1706,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []st if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil { return err } - if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil { + if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, mac, ipv6s, ipv4s)), 0600); err != nil { return err } if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil { return err } - if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, mac, ipv6s, ipv4s)), 0600); err != nil { return err } _ = os.Remove(target) @@ -1822,7 +1822,7 @@ exit /b 0 ` } -func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []string) string { +func windowsFirstLogonPowerShell(adminPassword, mac string, ipv6s []string, ipv4s []string) string { commands := []string{ "$ErrorActionPreference='Continue'", "$ProgressPreference='SilentlyContinue'", @@ -1832,9 +1832,9 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s "net user Administrator " + shellQuoteWindows(adminPassword) + " /active:yes", "Set-LocalUser -Name 'Administrator' -PasswordNeverExpires $true -ErrorAction SilentlyContinue", "Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope LocalMachine -Force", - "$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 } }", - "$iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1", + windowsAdapterDiscoveryPowerShell(mac), + "$iface=Wait-ClicdNetworkAdapter", + "if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }", "if ($iface) { Set-NetIPInterface -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -Dhcp Enabled -ErrorAction SilentlyContinue }", "if ($iface) { Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ResetServerAddresses -ErrorAction SilentlyContinue }", "Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private -ErrorAction SilentlyContinue", @@ -1852,17 +1852,23 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s "Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic", "Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue", } + networkCommands := []string{} ipv6s = normalizeKVMIPv6List(ipv6s) if len(ipv6s) > 0 { - commands = append(commands, - windowsIPv6PowerShell(ipv6s), - ) + networkCommands = append(networkCommands, windowsIPv6PowerShell(ipv6s, mac)) } ipv4s = normalizeKVMIPv4List(ipv4s) if len(ipv4s) > 0 { - commands = append(commands, - windowsIPv4PowerShell(ipv4s), - ) + networkCommands = append(networkCommands, windowsIPv4PowerShell(ipv4s, mac)) + } + if len(networkCommands) > 0 { + networkScript := strings.Join(append([]string{ + "$ErrorActionPreference='Continue'", + "$ProgressPreference='SilentlyContinue'", + "New-Item -ItemType Directory -Force -Path 'C:\\CLICD' | Out-Null", + }, networkCommands...), "\r\n") + "\r\n" + commands = append(commands, windowsPersistentNetworkTaskPowerShell(networkScript)) + commands = append(commands, networkCommands...) } commands = append(commands, "New-Item -ItemType File -Force -Path 'C:\\CLICD\\init.done' | Out-Null", @@ -1871,18 +1877,58 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s return strings.Join(commands, "\r\n") + "\r\n" } -func windowsIPv6PowerShell(ipv6s []string) string { +func windowsPersistentNetworkTaskPowerShell(script string) string { + return strings.Join([]string{ + "$clicdNetworkScript=@'", + strings.TrimRight(script, "\r\n"), + "'@", + "Set-Content -Path 'C:\\CLICD\\ApplyNetwork.ps1' -Value $clicdNetworkScript -Encoding UTF8", + "$clicdNetworkAction=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File C:\\CLICD\\ApplyNetwork.ps1'", + "$clicdNetworkTrigger=New-ScheduledTaskTrigger -AtStartup", + "Register-ScheduledTask -TaskName 'CLICD Network Init' -Action $clicdNetworkAction -Trigger $clicdNetworkTrigger -RunLevel Highest -Force -ErrorAction SilentlyContinue | Out-Null", + }, "\r\n") +} + +func windowsAdapterDiscoveryPowerShell(mac string) string { + targetMAC := strings.ToUpper(strings.NewReplacer(":", "", "-", "", " ", "").Replace(strings.TrimSpace(mac))) + return strings.Join([]string{ + "$clicdTargetMac=" + powerShellSingleQuote(targetMAC), + "function Get-ClicdNetworkAdapter {", + " $adapters=@(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Disabled' })", + " if ($clicdTargetMac) {", + " $matched=$adapters | Where-Object { (($_.MacAddress -replace '[-:]','').ToUpperInvariant()) -eq $clicdTargetMac } | Sort-Object ifIndex | Select-Object -First 1", + " if ($matched) { return $matched }", + " }", + " $up=$adapters | Where-Object { $_.Status -eq 'Up' } | Sort-Object ifIndex | Select-Object -First 1", + " if ($up) { return $up }", + " return $adapters | Sort-Object ifIndex | Select-Object -First 1", + "}", + "function Wait-ClicdNetworkAdapter {", + " param([int]$Retries=90,[int]$DelaySeconds=4)", + " for ($i=0; $i -lt $Retries; $i++) {", + " $adapter=Get-ClicdNetworkAdapter", + " if ($adapter) { return $adapter }", + " Start-Sleep -Seconds $DelaySeconds", + " }", + " return $null", + "}", + }, "\r\n") +} + +func windowsIPv6PowerShell(ipv6s []string, mac 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, "'", "''")+"'") + quoted = append(quoted, powerShellSingleQuote(ipv6)) } return strings.Join([]string{ + windowsAdapterDiscoveryPowerShell(mac), + "if (-not $iface) { $iface=Wait-ClicdNetworkAdapter }", + "if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }", "$clicdIPv6=@(" + strings.Join(quoted, ",") + ")", - // Reuse $iface already found by the main script "if ($iface) {", " foreach ($ip in $clicdIPv6) {", " Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue", @@ -1895,18 +1941,20 @@ func windowsIPv6PowerShell(ipv6s []string) string { }, "\r\n") } -func windowsIPv4PowerShell(ipv4s []string) string { +func windowsIPv4PowerShell(ipv4s []string, mac string) string { ipv4s = normalizeKVMIPv4List(ipv4s) if len(ipv4s) == 0 { return "" } quoted := make([]string, 0, len(ipv4s)) for _, ipv4 := range ipv4s { - quoted = append(quoted, "'"+strings.ReplaceAll(ipv4, "'", "''")+"'") + quoted = append(quoted, powerShellSingleQuote(ipv4)) } return strings.Join([]string{ + windowsAdapterDiscoveryPowerShell(mac), + "if (-not $iface) { $iface=Wait-ClicdNetworkAdapter }", + "if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }", "$clicdIPv4=@(" + strings.Join(quoted, ",") + ")", - // Reuse $iface already found by the main script "if ($iface) {", " foreach ($ip in $clicdIPv4) {", " Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue", @@ -1930,6 +1978,10 @@ func normalizeKVMIPv4List(values []string) []string { return result } +func powerShellSingleQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + func shellQuoteWindows(value string) string { return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` } @@ -3488,13 +3540,14 @@ func (m *Manager) applyGuestIPv6(c *config.Container) error { } func (m *Manager) applyWindowsGuestIPv6(c *config.Container) error { - if c == nil || c.IPv6 == "" { + if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) { return nil } if err := qemuGuestPing(c.VirshName()); err != nil { return err } - script := windowsIPv6PowerShell(c.IPv6AddressStrings()) + c.NormalizeNetworkAssignments() + script := windowsIPv6PowerShell(c.IPv6AddressStrings(), c.MACAddress) return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second) } diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep new file mode 100644 index 0000000..e69de29 From cd258fd6acb7f66e306786ec76083f28b2a48547 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:41:11 +0800 Subject: [PATCH 5/7] FIX #20 --- backend/internal/api/security.go | 175 +++++++++++++++++++------- backend/internal/api/security_test.go | 148 ++++++++++++++++++++++ backend/internal/api/taskqueue.go | 86 ++++++++++--- 3 files changed, 342 insertions(+), 67 deletions(-) create mode 100644 backend/internal/api/security_test.go diff --git a/backend/internal/api/security.go b/backend/internal/api/security.go index f53a9cd..a03a91a 100644 --- a/backend/internal/api/security.go +++ b/backend/internal/api/security.go @@ -49,15 +49,19 @@ type connEntry struct { } type trafficStats struct { - total int - totalSynSent int - destCounts map[string]int - destPorts map[string]map[int]int - portDestCounts map[int]map[string]int - portTotalCounts map[int]int - udpDestCounts map[int]map[string]int - udpTotalCounts map[int]int - synSentByDst map[string]int + total int + totalSynSent int + destCounts map[string]int + destPorts map[string]map[int]int + portDestCounts map[int]map[string]int + portTotalCounts map[int]int + udpDestCounts map[int]map[string]int + udpTotalCounts map[int]int + udpDestTotalCounts map[string]int + synSentByDst map[string]int + tcpSynDestPorts map[string]map[int]int + tcpSynPortDestCounts map[int]map[string]int + tcpSynPortTotalCounts map[int]int } var scanner *SecurityScanner @@ -232,13 +236,17 @@ func (ss *SecurityScanner) checkContainer(name, ip string) { func newTrafficStats() *trafficStats { return &trafficStats{ - destCounts: make(map[string]int), - destPorts: make(map[string]map[int]int), - portDestCounts: make(map[int]map[string]int), - portTotalCounts: make(map[int]int), - udpDestCounts: make(map[int]map[string]int), - udpTotalCounts: make(map[int]int), - synSentByDst: make(map[string]int), + destCounts: make(map[string]int), + destPorts: make(map[string]map[int]int), + portDestCounts: make(map[int]map[string]int), + portTotalCounts: make(map[int]int), + udpDestCounts: make(map[int]map[string]int), + udpTotalCounts: make(map[int]int), + synSentByDst: make(map[string]int), + udpDestTotalCounts: make(map[string]int), + tcpSynDestPorts: make(map[string]map[int]int), + tcpSynPortDestCounts: make(map[int]map[string]int), + tcpSynPortTotalCounts: make(map[int]int), } } @@ -264,52 +272,64 @@ func (ts *trafficStats) add(conn connEntry) { } ts.udpDestCounts[conn.dstPort][conn.dstIP]++ ts.udpTotalCounts[conn.dstPort]++ + ts.udpDestTotalCounts[conn.dstIP]++ } } - if conn.state == "SYN_SENT" { + if conn.proto == "tcp" && conn.state == "SYN_SENT" { ts.totalSynSent++ ts.synSentByDst[conn.dstIP]++ + if conn.dstPort > 0 { + if ts.tcpSynDestPorts[conn.dstIP] == nil { + ts.tcpSynDestPorts[conn.dstIP] = make(map[int]int) + } + ts.tcpSynDestPorts[conn.dstIP][conn.dstPort]++ + if ts.tcpSynPortDestCounts[conn.dstPort] == nil { + ts.tcpSynPortDestCounts[conn.dstPort] = make(map[string]int) + } + ts.tcpSynPortDestCounts[conn.dstPort][conn.dstIP]++ + ts.tcpSynPortTotalCounts[conn.dstPort]++ + } } } func (ss *SecurityScanner) detectPortScans(name, ip string, stats *trafficStats) { - for dstIP, portCounts := range stats.destPorts { + for dstIP, portCounts := range stats.tcpSynDestPorts { uniquePorts := len(portCounts) switch { - case uniquePorts >= 20: + case uniquePorts >= 25: ss.addAlert(name, "port_scan", "high", ip, dstIP, 0, - fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts), + fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts), "") - case uniquePorts >= 8: + case uniquePorts >= 12: ss.addAlert(name, "port_scan", "medium", ip, dstIP, 0, - fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts), + fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts), "") } } - for port, targets := range stats.portDestCounts { + for port, targets := range stats.tcpSynPortDestCounts { uniqueTargets := len(targets) if service, ok := bruteForcePorts[port]; ok { if uniqueTargets >= 30 { ss.addAlert(name, "brute_force", "critical", ip, "*", port, - fmt.Sprintf("横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets), + fmt.Sprintf("横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets), "") - } else if uniqueTargets >= 10 { + } else if uniqueTargets >= 12 { ss.addAlert(name, "brute_force", "high", ip, "*", port, - fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets), + fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets), "") } continue } - if uniqueTargets >= 40 { + if uniqueTargets >= 50 { ss.addAlert(name, "horizontal_scan", "high", ip, "*", port, - fmt.Sprintf("横向扫描: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets), + fmt.Sprintf("横向扫描: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets), "") - } else if uniqueTargets >= 15 { + } else if uniqueTargets >= 20 { ss.addAlert(name, "horizontal_scan", "medium", ip, "*", port, - fmt.Sprintf("可疑横向探测: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets), + fmt.Sprintf("可疑横向探测: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets), "") } } @@ -323,13 +343,25 @@ func (ss *SecurityScanner) detectBruteForce(name, ip string, stats *trafficStats continue } - if count >= 20 { + synCount := 0 + if ports := stats.tcpSynDestPorts[dstIP]; ports != nil { + synCount = ports[port] + } + if synCount >= 25 { ss.addAlert(name, "brute_force", "critical", ip, dstIP, port, - fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d", service, port, count), + fmt.Sprintf("暴力破解: %s(%d) 当前 TCP 半开连接 %d 条", service, port, synCount), "") - } else if count >= 10 { + } else if synCount >= 12 { ss.addAlert(name, "brute_force", "high", ip, dstIP, port, - fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d", service, port, count), + fmt.Sprintf("疑似暴力破解: %s(%d) 当前 TCP 半开连接 %d 条", service, port, synCount), + "") + } else if count >= 60 { + ss.addAlert(name, "brute_force", "critical", ip, dstIP, port, + fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d 条", service, port, count), + "") + } else if count >= 30 { + ss.addAlert(name, "brute_force", "high", ip, dstIP, port, + fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d 条", service, port, count), "") } } @@ -356,30 +388,41 @@ func (ss *SecurityScanner) detectSpam(name, ip string, stats *trafficStats) { func (ss *SecurityScanner) detectMassAbuse(name, ip string, stats *trafficStats) { targets := len(stats.destCounts) switch { - case targets >= 100: + case targets >= 120 && stats.total >= 600: ss.addAlert(name, "ddos", "critical", ip, "*", 0, - fmt.Sprintf("大规模对外连接: 当前覆盖 %d 个不同目标", targets), + fmt.Sprintf("大规模对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets), "") - case targets >= 35: + case targets >= 60 && stats.total >= 300: ss.addAlert(name, "ddos", "high", ip, "*", 0, - fmt.Sprintf("大量对外连接: 当前覆盖 %d 个不同目标", targets), + fmt.Sprintf("大量对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets), "") } + synTargets := len(stats.synSentByDst) switch { - case stats.total >= 500: + case stats.totalSynSent >= 250 || (synTargets >= 80 && stats.totalSynSent >= 160): ss.addAlert(name, "ddos", "critical", ip, "*", 0, - fmt.Sprintf("异常大量连接: 当前 conntrack 出站记录 %d 条", stats.total), + fmt.Sprintf("大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets), "") - case stats.total >= 200: + case stats.totalSynSent >= 100 || (synTargets >= 35 && stats.totalSynSent >= 70): ss.addAlert(name, "ddos", "high", ip, "*", 0, - fmt.Sprintf("高连接数: 当前 conntrack 出站记录 %d 条", stats.total), + fmt.Sprintf("可疑大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets), "") } - if stats.totalSynSent >= 100 { + udpTargets := len(stats.udpDestTotalCounts) + udpTotal := 0 + for _, count := range stats.udpTotalCounts { + udpTotal += count + } + switch { + case udpTargets >= 120 && udpTotal >= 300: ss.addAlert(name, "ddos", "critical", ip, "*", 0, - fmt.Sprintf("大量半开连接: 当前 SYN_SENT %d 条", stats.totalSynSent), + fmt.Sprintf("UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets), + "") + case udpTargets >= 50 && udpTotal >= 120: + ss.addAlert(name, "ddos", "high", ip, "*", 0, + fmt.Sprintf("可疑 UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets), "") } @@ -404,11 +447,18 @@ func (ss *SecurityScanner) detectReflectionAbuse(name, ip string, stats *traffic continue } - if targets >= 30 || total >= 100 { + criticalTargets, criticalTotal := 40, 120 + highTargets, highTotal := 15, 45 + if port == 53 { + criticalTargets, criticalTotal = 75, 300 + highTargets, highTotal = 25, 100 + } + + if targets >= criticalTargets && total >= criticalTotal { ss.addAlert(name, "reflection", "critical", ip, "*", port, fmt.Sprintf("UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets), "") - } else if targets >= 10 || total >= 30 { + } else if targets >= highTargets && total >= highTotal { ss.addAlert(name, "reflection", "high", ip, "*", port, fmt.Sprintf("疑似 UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets), "") @@ -645,6 +695,9 @@ func severityRank(severity string) int { } func autoShutdownAlertContainer(containerName, alertType, severity string) { + if !config.AppConfig.SecurityAutoShutdown { + return + } c := config.FindContainerByName(containerName) if c == nil || c.Status != "running" { return @@ -660,6 +713,24 @@ func autoShutdownAlertContainer(containerName, alertType, severity string) { } } +func clearSecurityPolicyBlocks() int { + cleared := 0 + for i := range config.AppConfig.Containers { + c := &config.AppConfig.Containers[i] + if !c.PolicyBlocked || !isSecurityPolicyBlockReason(c.PolicyBlockedReason) { + continue + } + config.SetContainerPolicyBlock(c.ID, false, "") + config.AddAuditLog("security_policy_unblock", c.Name, "关闭安全告警自动关机后解除策略临时封禁", "system") + cleared++ + } + return cleared +} + +func isSecurityPolicyBlockReason(reason string) bool { + return strings.Contains(reason, "告警触发策略临时封禁") +} + // HandleSecurityAlerts returns all security alerts. func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -699,9 +770,17 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) return } + cancelledTasks := 0 + clearedBlocks := 0 + if !req.AutoShutdown { + cancelledTasks = globalQueue.CancelPendingSecurityStops() + clearedBlocks = clearSecurityPolicyBlocks() + } auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "") - jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{ - "auto_shutdown": config.AppConfig.SecurityAutoShutdown, + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{ + "auto_shutdown": config.AppConfig.SecurityAutoShutdown, + "cancelled_tasks": cancelledTasks, + "cleared_blocks": clearedBlocks, }}) default: jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) diff --git a/backend/internal/api/security_test.go b/backend/internal/api/security_test.go new file mode 100644 index 0000000..0eaae0e --- /dev/null +++ b/backend/internal/api/security_test.go @@ -0,0 +1,148 @@ +package api + +import ( + "fmt" + "testing" + + "clicd/internal/config" +) + +func TestDetectReflectionAbuseIgnoresSingleDNSResolver(t *testing.T) { + resetSecurityTestConfig() + + stats := newTrafficStats() + for i := 0; i < 180; i++ { + stats.add(connEntry{ + dstIP: "1.1.1.1", + dstPort: 53, + proto: "udp", + state: "UNREPLIED", + }) + } + + ss := newSecurityScanner() + ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats) + + if len(ss.alerts) != 0 { + t.Fatalf("normal DNS queries to one resolver should not trigger reflection alert: %+v", ss.alerts) + } +} + +func TestDetectReflectionAbuseFlagsWideDNSFanout(t *testing.T) { + resetSecurityTestConfig() + + stats := newTrafficStats() + for i := 0; i < 120; i++ { + stats.add(connEntry{ + dstIP: fmt.Sprintf("203.0.113.%d", i), + dstPort: 53, + proto: "udp", + state: "UNREPLIED", + }) + } + + ss := newSecurityScanner() + ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats) + + if len(ss.alerts) != 1 { + t.Fatalf("expected one reflection alert, got %+v", ss.alerts) + } + if got := ss.alerts[0].Type; got != "reflection" { + t.Fatalf("expected reflection alert, got %q", got) + } +} + +func TestDetectPortScansUsesHalfOpenConnections(t *testing.T) { + resetSecurityTestConfig() + + established := newTrafficStats() + for port := 8000; port < 8020; port++ { + established.add(connEntry{ + dstIP: "198.51.100.10", + dstPort: port, + proto: "tcp", + state: "ESTABLISHED", + }) + } + + ss := newSecurityScanner() + ss.detectPortScans("ct-web", "10.0.0.3", established) + if len(ss.alerts) != 0 { + t.Fatalf("established multi-port connections should not trigger port scan alert: %+v", ss.alerts) + } + + halfOpen := newTrafficStats() + for port := 8000; port < 8012; port++ { + halfOpen.add(connEntry{ + dstIP: "198.51.100.10", + dstPort: port, + proto: "tcp", + state: "SYN_SENT", + }) + } + + ss.detectPortScans("ct-web", "10.0.0.3", halfOpen) + if len(ss.alerts) != 1 { + t.Fatalf("expected one port scan alert, got %+v", ss.alerts) + } + if got := ss.alerts[0].Type; got != "port_scan" { + t.Fatalf("expected port_scan alert, got %q", got) + } +} + +func TestCancelPendingSecurityStops(t *testing.T) { + resetSecurityTestConfig() + + q := &TaskQueue{ + tasks: map[string]*Task{}, + } + securityTask := &Task{ + ID: "task-1", + Type: TaskStop, + ContainerID: 1, + Status: "pending", + User: "system:security", + } + userTask := &Task{ + ID: "task-2", + Type: TaskStop, + ContainerID: 2, + Status: "pending", + User: "admin", + } + runningSecurityTask := &Task{ + ID: "task-3", + Type: TaskStop, + ContainerID: 3, + Status: "running", + User: "system:security", + } + q.tasks[securityTask.ID] = securityTask + q.tasks[userTask.ID] = userTask + q.tasks[runningSecurityTask.ID] = runningSecurityTask + q.opQueue = []*Task{securityTask, userTask, runningSecurityTask} + + if got := q.CancelPendingSecurityStops(); got != 1 { + t.Fatalf("expected one pending security stop to be cancelled, got %d", got) + } + if _, ok := q.tasks[securityTask.ID]; ok { + t.Fatal("pending security stop task was not removed") + } + if _, ok := q.tasks[userTask.ID]; !ok { + t.Fatal("user stop task should not be removed") + } + if _, ok := q.tasks[runningSecurityTask.ID]; !ok { + t.Fatal("running security stop task should be left for worker-side skip") + } + if len(q.opQueue) != 2 { + t.Fatalf("expected op queue to keep two tasks, got %d", len(q.opQueue)) + } +} + +func resetSecurityTestConfig() { + config.AppConfig = &config.ClicdConfig{ + Containers: []config.Container{}, + AuditLogs: []config.AuditLog{}, + Tasks: []config.SavedTask{}, + } +} diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index 52dfdba..195b290 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -213,6 +213,10 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string } func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) { + if !config.AppConfig.SecurityAutoShutdown { + return "", false + } + q.mu.Lock() defer q.mu.Unlock() @@ -230,6 +234,34 @@ func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) ( return taskID, true } +func (q *TaskQueue) CancelPendingSecurityStops() int { + q.mu.Lock() + defer q.mu.Unlock() + + cancelled := 0 + newOpQueue := make([]*Task, 0, len(q.opQueue)) + for _, task := range q.opQueue { + if isSecurityStopTask(task) && task.Status == "pending" { + delete(q.tasks, task.ID) + cancelled++ + continue + } + newOpQueue = append(newOpQueue, task) + } + q.opQueue = newOpQueue + + for id, task := range q.tasks { + if isSecurityStopTask(task) && task.Status == "pending" { + delete(q.tasks, id) + cancelled++ + } + } + if cancelled > 0 { + q.persistTasks() + } + return cancelled +} + // createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init. // If a restored task already has a same-name container in config, it resumes // initialization instead of creating another ct-{id}. @@ -324,6 +356,7 @@ func (q *TaskQueue) opWorker() { q.mu.Unlock() var err error + skipped := false err = resolveTaskContainer(task) // Block operations on expired or traffic-exceeded containers (except stop/delete) if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) { @@ -336,27 +369,32 @@ func (q *TaskQueue) opWorker() { } } } + if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown { + skipped = true + } if err == nil { - switch task.Type { - case TaskStart: - err = startByRuntime(task.ContainerID) - case TaskStop: - err = stopByRuntime(task.ContainerID) - case TaskRestart: - err = restartByRuntime(task.ContainerID) - case TaskDelete: - err = destroyByRuntime(task.ContainerID) - if err == nil { - time.Sleep(1 * time.Second) - if config.FindContainer(task.ContainerID) != nil { - err = fmt.Errorf("container still exists after delete: %d", task.ContainerID) + if !skipped { + switch task.Type { + case TaskStart: + err = startByRuntime(task.ContainerID) + case TaskStop: + err = stopByRuntime(task.ContainerID) + case TaskRestart: + err = restartByRuntime(task.ContainerID) + case TaskDelete: + err = destroyByRuntime(task.ContainerID) + if err == nil { + time.Sleep(1 * time.Second) + if config.FindContainer(task.ContainerID) != nil { + err = fmt.Errorf("container still exists after delete: %d", task.ContainerID) + } + } + case TaskReinstall: + if lxc.HasSSHAuthOptions(task.Config) { + err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config) + } else { + err = reinstallByRuntime(task.ContainerID, task.TemplateID) } - } - case TaskReinstall: - if lxc.HasSSHAuthOptions(task.Config) { - err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config) - } else { - err = reinstallByRuntime(task.ContainerID, task.TemplateID) } } } @@ -370,6 +408,9 @@ func (q *TaskQueue) opWorker() { task.Status = "failed" task.Error = err.Error() config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error()) + } else if skipped { + task.Status = "done" + config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "") } else { task.Status = "done" config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "") @@ -391,6 +432,10 @@ func (q *TaskQueue) opWorker() { } } +func isSecurityStopTask(task *Task) bool { + return task != nil && task.Type == TaskStop && task.User == "system:security" +} + func clearPolicyBlockAfterAdminRecovery(task *Task) { if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" { return @@ -817,6 +862,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) { // RestoreTasks restores task queue from config func RestoreTasks() { for _, st := range config.AppConfig.Tasks { + if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown { + continue + } var cfg lxc.ContainerConfig if st.Config != "" { json.Unmarshal([]byte(st.Config), &cfg) From 84d98e40c661c4ca2247cf7ccc95e01303456e9d Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:06:58 +0800 Subject: [PATCH 6/7] FIX #16 --- backend/internal/api/handlers.go | 11 +- backend/internal/api/routing.go | 37 +++- backend/internal/config/config.go | 116 ++++++++++- backend/internal/config/nat_test.go | 46 +++++ backend/internal/config/store_sqlite.go | 4 + backend/internal/kvm/kvm.go | 14 +- backend/internal/lxc/ipv6.go | 3 + backend/internal/lxc/lxc.go | 6 +- backend/internal/lxc/portmap.go | 12 +- frontend/src/pages/ApiIntegration.tsx | 5 +- frontend/src/pages/Routing.tsx | 253 +++++++++++++++++++++++- frontend/src/services/api.ts | 8 +- 12 files changed, 478 insertions(+), 37 deletions(-) create mode 100644 backend/internal/config/nat_test.go diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 8ff60e7..e07e6ba 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -582,9 +582,14 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) { return } 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) + start, end := config.NATPortRange() + capacity := end - start + 1 + offset := 0 + if capacity > 0 { + offset = int(time.Now().UnixNano() % int64(capacity)) + } + for tries := 0; tries < capacity; tries++ { + port := start + ((offset + tries) % capacity) if lxc.HostPortAvailable(c, hostIP, port, "tcp") { jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}}) return diff --git a/backend/internal/api/routing.go b/backend/internal/api/routing.go index c480689..86c2766 100644 --- a/backend/internal/api/routing.go +++ b/backend/internal/api/routing.go @@ -17,6 +17,11 @@ type routeCapacity struct { Total string `json:"total"` } +type nat4PortRange struct { + Start int `json:"start"` + End int `json:"end"` +} + type nat4Route struct { ContainerID int `json:"container_id"` ContainerName string `json:"container_name"` @@ -53,6 +58,7 @@ type ipv6Route struct { type routingResponse struct { NAT4 routeCapacity `json:"nat4"` + NAT4PortRange nat4PortRange `json:"nat4_port_range"` IPv4 routeCapacity `json:"ipv4"` IPv6 routeCapacity `json:"ipv6"` HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"` @@ -64,9 +70,10 @@ type routingResponse struct { } type routingPoolsRequest struct { - Addresses *[]string `json:"addresses"` - Items *[]config.PublicIPv4Assignment `json:"items"` - IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"` + Addresses *[]string `json:"addresses"` + Items *[]config.PublicIPv4Assignment `json:"items"` + IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"` + NAT4PortRange *nat4PortRange `json:"nat4_port_range"` } type publicIPv4ScanRequest struct { @@ -120,13 +127,12 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) { ipv4Assignments := make([]ipv4Route, 0) ipv6Assignments := make([]ipv6Route, 0) - const nat4StartPort = 20000 - const nat4EndPort = 65535 + nat4StartPort, nat4EndPort := config.NATPortRange() for i := range config.AppConfig.Containers { c := &config.AppConfig.Containers[i] for _, pm := range c.PortMappings { - if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort { + if config.NATPortInRange(pm.HostPort) { usedPorts[pm.HostPort] = true } nat4Mappings = append(nat4Mappings, nat4Route{ @@ -189,7 +195,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) { return ipv6Assignments[i].Address < ipv6Assignments[j].Address }) - const totalNAT4Ports = nat4EndPort - nat4StartPort + 1 + totalNAT4Ports := config.NATPortCapacity() nat4Used := len(usedPorts) nat4Remaining := totalNAT4Ports - nat4Used if nat4Remaining < 0 { @@ -216,6 +222,10 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) { Remaining: strconv.Itoa(nat4Remaining), Total: strconv.Itoa(totalNAT4Ports), }, + NAT4PortRange: nat4PortRange{ + Start: nat4StartPort, + End: nat4EndPort, + }, IPv4: routeCapacity{ Used: ipv4Used, Remaining: strconv.Itoa(ipv4Remaining), @@ -246,6 +256,19 @@ func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) { return } + if req.NAT4PortRange != nil { + start, end, err := config.NormalizeNATPortRange(req.NAT4PortRange.Start, req.NAT4PortRange.End) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + config.AppConfig.NATPortStart = start + config.AppConfig.NATPortEnd = end + if config.AppConfig.NextSSHPort < start || config.AppConfig.NextSSHPort > end { + config.AppConfig.NextSSHPort = start + } + } + if req.Items != nil || req.Addresses != nil { items := []config.PublicIPv4Assignment{} if req.Items != nil { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3e3d9ce..fd8a437 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -372,6 +372,8 @@ type ClicdConfig struct { NextContainerID int `json:"next_container_id"` NextVNCPort int `json:"next_vnc_port"` NextSSHPort int `json:"next_ssh_port"` + NATPortStart int `json:"nat_port_start"` + NATPortEnd int `json:"nat_port_end"` SetupComplete bool `json:"setup_complete"` SubUsers []SubUser `json:"sub_users"` ApiKeys []ApiKeyConfig `json:"api_keys"` @@ -394,6 +396,11 @@ var AppConfig *ClicdConfig const DefaultSnapshotLimit = 3 +const ( + DefaultNATPortStart = 20000 + DefaultNATPortEnd = 65535 +) + func getConfigPath() string { if configPath != "" { return configPath @@ -509,6 +516,8 @@ func InitConfig() (*ClicdConfig, error) { NextContainerID: 1, NextVNCPort: 5900, NextSSHPort: 22000, + NATPortStart: DefaultNATPortStart, + NATPortEnd: DefaultNATPortEnd, SetupComplete: false, SubUsers: []SubUser{}, AuditLogs: []AuditLog{}, @@ -552,6 +561,9 @@ func normalizeConfigDefaults(dataDir string) bool { AppConfig.NextSSHPort = 22000 changed = true } + if normalizeNATPortRangeDefaults() { + changed = true + } if AppConfig.NextContainerID == 0 { AppConfig.NextContainerID = 1 changed = true @@ -1168,16 +1180,102 @@ func UpdateVNC(containers []Container) { SaveConfig() } -// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container -func AllocateSSHPort() int { - used := collectAllHostPorts() - port := AppConfig.NextSSHPort - for used[port] { - port++ +func NormalizeNATPortRange(start, end int) (int, int, error) { + if start == 0 && end == 0 { + return DefaultNATPortStart, DefaultNATPortEnd, nil } - AppConfig.NextSSHPort = port + 1 - SaveConfig() - return port + if start == 0 { + start = DefaultNATPortStart + } + if end == 0 { + end = DefaultNATPortEnd + } + if start < 1 || start > 65535 { + return 0, 0, fmt.Errorf("NAT port start must be 1-65535") + } + if end < 1 || end > 65535 { + return 0, 0, fmt.Errorf("NAT port end must be 1-65535") + } + if start > end { + return 0, 0, fmt.Errorf("NAT port start cannot be greater than end") + } + return start, end, nil +} + +func NATPortRange() (int, int) { + if AppConfig == nil { + return DefaultNATPortStart, DefaultNATPortEnd + } + start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd) + if err != nil { + return DefaultNATPortStart, DefaultNATPortEnd + } + return start, end +} + +func NATPortCapacity() int { + start, end := NATPortRange() + return end - start + 1 +} + +func NATPortInRange(port int) bool { + start, end := NATPortRange() + return port >= start && port <= end +} + +func SetNATPortRange(start, end int) error { + start, end, err := NormalizeNATPortRange(start, end) + if err != nil { + return err + } + AppConfig.NATPortStart = start + AppConfig.NATPortEnd = end + if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end { + AppConfig.NextSSHPort = start + } + return SaveConfig() +} + +func normalizeNATPortRangeDefaults() bool { + if AppConfig == nil { + return false + } + start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd) + if err != nil { + start, end = DefaultNATPortStart, DefaultNATPortEnd + } + changed := AppConfig.NATPortStart != start || AppConfig.NATPortEnd != end + AppConfig.NATPortStart = start + AppConfig.NATPortEnd = end + if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end { + AppConfig.NextSSHPort = start + changed = true + } + return changed +} + +// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container +func AllocateSSHPort() (int, error) { + used := collectAllHostPorts() + start, end := NATPortRange() + port := AppConfig.NextSSHPort + if port < start || port > end { + port = start + } + capacity := end - start + 1 + for i := 0; i < capacity; i++ { + candidate := start + ((port - start + i) % capacity) + if used[candidate] { + continue + } + AppConfig.NextSSHPort = candidate + 1 + if AppConfig.NextSSHPort > end { + AppConfig.NextSSHPort = start + } + SaveConfig() + return candidate, nil + } + return 0, fmt.Errorf("no free NAT4 host port in configured range %d-%d", start, end) } // collectAllHostPorts collects all host ports used by any container (LXC + KVM) diff --git a/backend/internal/config/nat_test.go b/backend/internal/config/nat_test.go new file mode 100644 index 0000000..89141a2 --- /dev/null +++ b/backend/internal/config/nat_test.go @@ -0,0 +1,46 @@ +package config + +import "testing" + +func TestAllocateSSHPortUsesConfiguredNATRange(t *testing.T) { + AppConfig = &ClicdConfig{ + NATPortStart: 30000, + NATPortEnd: 30002, + NextSSHPort: 22000, + Containers: []Container{{ + PortMappings: []PortMapping{ + {HostPort: 30000}, + {HostPort: 30001}, + }, + }}, + } + + port, err := AllocateSSHPort() + if err != nil { + t.Fatal(err) + } + if port != 30002 { + t.Fatalf("expected port 30002, got %d", port) + } + if AppConfig.NextSSHPort != 30000 { + t.Fatalf("expected next port to wrap to 30000, got %d", AppConfig.NextSSHPort) + } +} + +func TestAllocateSSHPortErrorsWhenConfiguredRangeIsFull(t *testing.T) { + AppConfig = &ClicdConfig{ + NATPortStart: 31000, + NATPortEnd: 31001, + NextSSHPort: 31000, + Containers: []Container{{ + PortMappings: []PortMapping{ + {HostPort: 31000}, + {HostPort: 31001}, + }, + }}, + } + + if port, err := AllocateSSHPort(); err == nil { + t.Fatalf("expected exhausted NAT range error, got port %d", port) + } +} diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index 658d5a7..02d337a 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -524,6 +524,8 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) { NextContainerID: atoi(meta["next_container_id"]), NextVNCPort: atoi(meta["next_vnc_port"]), NextSSHPort: atoi(meta["next_ssh_port"]), + NATPortStart: atoi(meta["nat_port_start"]), + NATPortEnd: atoi(meta["nat_port_end"]), SetupComplete: atob(meta["setup_complete"]), SecurityAutoShutdown: atob(meta["security_auto_shutdown"]), Language: meta["language"], @@ -651,6 +653,8 @@ func saveMeta(tx *sql.Tx) error { "next_container_id": strconv.Itoa(AppConfig.NextContainerID), "next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort), "next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort), + "nat_port_start": strconv.Itoa(AppConfig.NATPortStart), + "nat_port_end": strconv.Itoa(AppConfig.NATPortEnd), "setup_complete": btoa(AppConfig.SetupComplete), "security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown), "language": NormalizeLanguage(AppConfig.Language), diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 9b10a0d..74c7d06 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -487,7 +487,10 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig sshPort := 0 portMappings := []config.PortMapping{} if allocatePorts && cfg.WantsNAT() { - sshPort = config.AllocateSSHPort() + sshPort, err = config.AllocateSSHPort() + if err != nil { + return nil, err + } if IsWindowsImage(image.ID) { // Windows: RDP (3389) instead of SSH (22) portMappings = []config.PortMapping{{ @@ -2406,7 +2409,11 @@ func normalizeKVMManagementPortMapping(c *config.Container) { } hostPort := c.SSHPort if hostPort <= 0 { - hostPort = config.AllocateSSHPort() + allocated, err := config.AllocateSSHPort() + if err != nil { + return + } + hostPort = allocated c.SSHPort = hostPort } desiredPort := 22 @@ -3908,7 +3915,8 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int { } } ports := make([]int, 0, count) - for next := 20000; next <= 65535 && len(ports) < count; next++ { + start, end := config.NATPortRange() + for next := start; next <= end && len(ports) < count; next++ { if !used[next] { ports = append(ports, next) } diff --git a/backend/internal/lxc/ipv6.go b/backend/internal/lxc/ipv6.go index b82226d..ab074ce 100644 --- a/backend/internal/lxc/ipv6.go +++ b/backend/internal/lxc/ipv6.go @@ -74,6 +74,9 @@ func (m *Manager) DetectIPv6Status() IPv6Status { } func DetectPublicIPv6Prefixes() []IPv6PrefixInfo { + if configured := ConfiguredPublicIPv6Prefixes(); len(configured) > 0 { + return configured + } return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes()) } diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 2b2d136..63bb1df 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -381,7 +381,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { sshPort := 0 portMappings := []config.PortMapping{} if cfg.WantsNAT() { - sshPort = config.AllocateSSHPort() + sshPort, err = config.AllocateSSHPort() + if err != nil { + _ = m.cleanupContainerStorage(lxcName) + return err + } // Setup default port mappings (SSH only) portMappings = SetupDefaultPortMappings(sshPort) diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go index 39f68f3..716d619 100644 --- a/backend/internal/lxc/portmap.go +++ b/backend/internal/lxc/portmap.go @@ -395,6 +395,10 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp if pm.HostPort <= 0 { pm.HostPort = pm.ContainerPort } + if pm.HostIP == "" && !config.NATPortInRange(pm.HostPort) { + start, end := config.NATPortRange() + return pm, fmt.Errorf("host port must be within configured NAT4 range %d-%d", start, end) + } // Check current container's own mappings for i, existing := range c.PortMappings { if i == skipIndex { @@ -444,16 +448,12 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int { } } ports := make([]int, 0, count) - next := 20000 - for len(ports) < count { + start, end := config.NATPortRange() + for next := start; next <= end && len(ports) < count; next++ { hostIP := c.PrimaryPublicIPv4() if !used[hostPortKey(hostIP, next)] && !used[next] { ports = append(ports, next) } - next++ - if next > 65535 || len(ports) >= count { - break - } } return ports } diff --git a/frontend/src/pages/ApiIntegration.tsx b/frontend/src/pages/ApiIntegration.tsx index 73e0f51..8fceb04 100644 --- a/frontend/src/pages/ApiIntegration.tsx +++ b/frontend/src/pages/ApiIntegration.tsx @@ -914,6 +914,7 @@ const responseSamples: Record = { success: true, data: { nat4: { used: 62, remaining: '45474', total: '45536' }, + nat4_port_range: { start: 20000, end: 65535 }, ipv4: { used: 1, remaining: '3', total: '4' }, ipv6: { used: 31, remaining: 'large', total: 'large' }, public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }], @@ -927,6 +928,8 @@ const responseSamples: Record = { 'PUT /api/v1/routing': { success: true, data: { + nat4: { used: 62, remaining: '45474', total: '45536' }, + nat4_port_range: { start: 20000, end: 65535 }, ipv4: { used: 1, remaining: '3', total: '4' }, public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }], ipv6_prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }], @@ -1196,7 +1199,7 @@ function endpointNoteFor(key: string) { notes.push('When action=reinstall, you can include template_id, ssh_auth_mode, ssh_password, and ssh_public_key. Other actions ignore these reinstall fields.') } if (key === 'PUT /api/v1/routing') { - notes.push('Updating public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.') + notes.push('Updating NAT4 port range and public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.') } 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.') diff --git a/frontend/src/pages/Routing.tsx b/frontend/src/pages/Routing.tsx index 1a04efb..5473386 100644 --- a/frontend/src/pages/Routing.tsx +++ b/frontend/src/pages/Routing.tsx @@ -4,9 +4,13 @@ import { useNavigate } from 'react-router-dom' import { useLanguage, type Language } from '../contexts/LanguageContext' import { getRoutingInfo, + updateRoutingIPv6Prefixes, updateRoutingIPv4Pool, + updateRoutingPools, type IPv4Route, type IPv6Route, + type IPv6PrefixInfo, + type NAT4PortRange, type NAT4Route, type PublicIPv4Info, type RoutingInfo, @@ -25,6 +29,12 @@ export default function Routing() { const [savingIPv4, setSavingIPv4] = useState(false) const [ipv4Draft, setIPv4Draft] = useState<(PublicIPv4Info & { _id: number })[]>([]) const nextDraftId = useRef(0) + const [editingNAT4, setEditingNAT4] = useState(false) + const [savingNAT4, setSavingNAT4] = useState(false) + const [nat4Draft, setNAT4Draft] = useState({ start: 20000, end: 65535 }) + const [editingIPv6, setEditingIPv6] = useState(false) + const [savingIPv6, setSavingIPv6] = useState(false) + const [ipv6Draft, setIPv6Draft] = useState<(IPv6PrefixInfo & { _id: number })[]>([]) const [nat4Page, setNat4Page] = useState(1) const [ipv6Page, setIPv6Page] = useState(1) const [nat4Search, setNat4Search] = useState('') @@ -49,9 +59,12 @@ export default function Routing() { const nat4Mappings = routing?.nat4_mappings || [] const ipv6Prefixes = routing?.ipv6_prefixes || [] const ipv6Assignments = routing?.ipv6_assignments || [] + const nat4Range = routing?.nat4_port_range || { start: 20000, end: 65535 } const defaultIPv4Interface = routing?.host_public_ipv4?.interface || publicIPv4s[0]?.interface || 'eth0' const defaultIPv4Gateway = routing?.host_public_ipv4?.gateway || publicIPv4s[0]?.gateway || '' const defaultIPv4PrefixLen = routing?.host_public_ipv4?.prefix_len || publicIPv4s[0]?.prefix_len || 32 + const defaultIPv6Interface = ipv6Prefixes[0]?.interface || defaultIPv4Interface + const defaultIPv6Gateway = ipv6Prefixes[0]?.gateway || '' useEffect(() => { if (!editingIPv4) { @@ -139,6 +152,81 @@ export default function Routing() { } } + const startEditNAT4 = () => { + setNAT4Draft({ start: nat4Range.start || 20000, end: nat4Range.end || 65535 }) + setEditingNAT4(true) + } + + const saveNAT4Range = async () => { + const start = Math.round(Number(nat4Draft.start || 0)) + const end = Math.round(Number(nat4Draft.end || 0)) + if (start < 1 || start > 65535 || end < 1 || end > 65535 || start > end) { + alert(text.nat4RangeInvalid) + return + } + setSavingNAT4(true) + try { + const res = await updateRoutingPools({ nat4_port_range: { start, end } }) + setRouting(res.data.data || null) + setEditingNAT4(false) + } catch (err: any) { + alert(err?.response?.data?.message || text.saveNAT4RangeFailed) + } finally { + setSavingNAT4(false) + } + } + + const startEditIPv6 = () => { + setIPv6Draft(ipv6Prefixes.map((prefix) => ({ ...prefix, _id: nextDraftId.current++ }))) + setEditingIPv6(true) + } + + const addIPv6Row = () => { + setIPv6Draft((items) => [ + ...items, + { + _id: nextDraftId.current++, + prefix: '', + address: '', + prefix_len: 64, + interface: defaultIPv6Interface, + gateway: defaultIPv6Gateway, + source: 'manual', + }, + ]) + } + + const updateIPv6Draft = (index: number, patch: Partial) => { + setIPv6Draft((items) => items.map((item, i) => (i === index ? { ...item, ...patch } : item))) + } + + const saveIPv6Prefixes = async () => { + setSavingIPv6(true) + try { + const items = ipv6Draft + .map(({ _id, ...item }) => ({ + ...item, + prefix: (item.prefix || '').trim(), + address: (item.address || '').trim(), + interface: (item.interface || defaultIPv6Interface).trim(), + gateway: (item.gateway || '').trim(), + prefix_len: Number(item.prefix_len || 0), + })) + .filter((item) => item.prefix || item.address) + if (items.some((item) => !item.interface)) { + alert(text.ipv6InterfaceRequired) + return + } + const res = await updateRoutingIPv6Prefixes(items) + setRouting(res.data.data || null) + setEditingIPv6(false) + } catch (err: any) { + alert(err?.response?.data?.message || text.saveIPv6PrefixesFailed) + } finally { + setSavingIPv6(false) + } + } + const filteredNat4 = useMemo(() => { const q = nat4Search.toLowerCase().trim() if (!q) return nat4Mappings @@ -189,11 +277,45 @@ export default function Routing() {
- + + + + } + />
+ {editingNAT4 && ( + setEditingNAT4(false)}> +
+
+ setNAT4Draft((draft) => ({ ...draft, start: value }))} min={1} max={65535} /> + setNAT4Draft((draft) => ({ ...draft, end: value }))} min={1} max={65535} /> +
+
+ + +
+
+
+ )} + )} - {ipv6Prefixes.length > 0 && ( - + + + {text.editPrefixes} + + } + > + {ipv6Prefixes.length === 0 ? ( + } /> + ) : (
@@ -354,7 +487,58 @@ export default function Routing() {
-
+ )} +
+ + {editingIPv6 && ( + setEditingIPv6(false)} wide> +
+
+ + + + + + + + + + + + {ipv6Draft.map((item, index) => ( + + + + + + + + ))} + {ipv6Draft.length === 0 && } + +
{text.prefix}{text.hostAddress}{text.interface}{text.gateway}{text.action}
updateIPv6Draft(index, { prefix: e.target.value })} placeholder="2001:db8:100::/64" className={smallInputClass} /> updateIPv6Draft(index, { address: e.target.value })} placeholder="2001:db8:100::1" className={smallInputClass} /> updateIPv6Draft(index, { interface: e.target.value })} placeholder={defaultIPv6Interface} className={smallInputClass} /> updateIPv6Draft(index, { gateway: e.target.value })} placeholder={text.gateway} className={smallInputClass} /> + +
+
+
+ +
+ + +
+
+
+
)} }> @@ -527,7 +711,7 @@ function Pagination({ page, totalPages, totalItems, pageSize, onPageChange, lang ) } -function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel }: { +function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel, detail, action }: { title: string watermark: string remaining: string @@ -535,6 +719,8 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab used: number label: string usedLabel: string + detail?: string + action?: ReactNode }) { return (
@@ -542,20 +728,46 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab {watermark}
-
+
+
{title}
{remaining} / {total}
+
+ {action}
{label}
{usedLabel} {used}
+ {detail &&
{detail}
}
) } +function LabeledNumberInput({ label, value, onChange, min, max }: { + label: string + value: number + onChange: (value: number) => void + min: number + max: number +}) { + return ( + + ) +} + function EmptyState({ icon, text }: { icon: ReactNode; text: string }) { return (
@@ -632,6 +844,11 @@ const routingText = { pageSubtitle: 'NAT4、公网 IPv4 池和 IPv6 地址分配', refresh: '刷新', nat4Ports: 'NAT4 端口', + editNAT4Range: '编辑 NAT4 范围', + rangeStart: '起始端口', + rangeEnd: '结束端口', + nat4RangeInvalid: 'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口', + saveNAT4RangeFailed: '保存 NAT4 范围失败', remainingTotal: '剩余 / 总数', publicIPv4: '公网 IPv4', publicIPv4Pool: '公网 IPv4 池', @@ -661,10 +878,17 @@ const routingText = { save: '保存', saving: '保存中...', detectedIPv6Prefixes: '检测到的 IPv6 前缀', + editPrefixes: '编辑前缀', + editIPv6Prefixes: '编辑 IPv6 前缀', + addIPv6Prefix: '添加 IPv6 前缀', + noIPv6Prefixes: '暂无 IPv6 前缀', + ipv6InterfaceRequired: 'IPv6 网卡不能为空', + saveIPv6PrefixesFailed: '保存 IPv6 前缀失败', prefix: '前缀', hostAddress: '宿主地址', source: '来源', local: '本机', + manual: '手动', ipv4NAT: 'IPv4 NAT', searchNAT: '搜索 NAT...', noIPv4NATMappings: '暂无 IPv4 NAT 映射', @@ -691,6 +915,11 @@ const routingText = { pageSubtitle: 'NAT4, public IPv4 pool, and IPv6 assignments', refresh: 'Refresh', nat4Ports: 'NAT4 ports', + editNAT4Range: 'Edit NAT4 range', + rangeStart: 'Start port', + rangeEnd: 'End port', + nat4RangeInvalid: 'NAT4 range must be 1-65535, and start cannot be greater than end', + saveNAT4RangeFailed: 'Save NAT4 range failed', remainingTotal: 'remaining / total', publicIPv4: 'Public IPv4', publicIPv4Pool: 'Public IPv4 pool', @@ -720,10 +949,17 @@ const routingText = { save: 'Save', saving: 'Saving...', detectedIPv6Prefixes: 'Detected IPv6 prefixes', + editPrefixes: 'Edit prefixes', + editIPv6Prefixes: 'Edit IPv6 prefixes', + addIPv6Prefix: 'Add IPv6 prefix', + noIPv6Prefixes: 'No IPv6 prefixes', + ipv6InterfaceRequired: 'IPv6 interface is required', + saveIPv6PrefixesFailed: 'Save IPv6 prefixes failed', prefix: 'Prefix', hostAddress: 'Host address', source: 'Source', local: 'local', + manual: 'manual', ipv4NAT: 'IPv4 NAT', searchNAT: 'Search NAT...', noIPv4NATMappings: 'No IPv4 NAT mappings', @@ -763,6 +999,10 @@ function formatDetectedPrefixCount(count: number, language: Language) { : `检测到 ${count} 个前缀` } +function formatNATRange(range: NAT4PortRange, language: Language) { + return language === 'en' ? `range ${range.start}-${range.end}` : `范围 ${range.start}-${range.end}` +} + function formatPrefixCount(count: number, language: Language) { return language === 'en' ? `${count} ${count === 1 ? 'prefix' : 'prefixes'}` : `${count} 个前缀` } @@ -801,6 +1041,7 @@ function formatContainerStatus(status: string, language: Language) { function formatSource(source: string | undefined, language: Language) { if (!source || source === 'local') return routingText[language].local + if (source === 'manual') return routingText[language].manual return source } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b6a8003..1fc2173 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -535,6 +535,11 @@ export interface RouteCapacity { total: string } +export interface NAT4PortRange { + start: number + end: number +} + export interface NAT4Route { container_id: number container_name: string @@ -571,6 +576,7 @@ export interface IPv6Route { export interface RoutingInfo { nat4: RouteCapacity + nat4_port_range: NAT4PortRange ipv4: RouteCapacity ipv6: RouteCapacity host_public_ipv4?: PublicIPv4Info @@ -590,7 +596,7 @@ export interface PublicIPv4ScanResult extends PublicIPv4Info { export const getRoutingInfo = () => api.get>('/routing') -export const updateRoutingPools = (payload: { items?: PublicIPv4Info[]; ipv6_prefixes?: IPv6PrefixInfo[] }) => +export const updateRoutingPools = (payload: { items?: PublicIPv4Info[]; ipv6_prefixes?: IPv6PrefixInfo[]; nat4_port_range?: NAT4PortRange }) => api.put>('/routing', payload) export const updateRoutingIPv4Pool = (items: PublicIPv4Info[]) => From 702d6975e502bd45eee0d7c531c83b619ba4028c Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:08:13 +0800 Subject: [PATCH 7/7] release: v1.1.20 --- backend/internal/version/version.go | 2 +- frontend/package.json | 2 +- frontend/src/pages/Login.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/version/version.go b/backend/internal/version/version.go index 0eeb0f7..6a6c9b7 100644 --- a/backend/internal/version/version.go +++ b/backend/internal/version/version.go @@ -1,7 +1,7 @@ package version var ( - Version = "1.1.19" + Version = "1.1.20" Repo = "MengMengCode/CLICD" ) diff --git a/frontend/package.json b/frontend/package.json index de64020..7ade112 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "clicd-frontend", "private": true, - "version": "1.1.19", + "version": "1.1.20", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 1e5197d..9535387 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -128,7 +128,7 @@ export default function Login() {
-

CLICD v1.1.19

+

CLICD v1.1.20

)