From 327e28235670f5015dc381f343a0a070d20edd77 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Tue, 30 Jun 2026 10:46:26 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=89=8D=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=BB=93=E6=9E=84=E6=A8=A1=E5=9D=97=E5=8C=96?= =?UTF-8?q?=20+=20v2=20=E4=BC=98=E5=8C=96=E7=A7=BB=E6=A4=8D=E4=B8=8E?= =?UTF-8?q?=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: - src/srv/api → src/api,按域分文件夹(auth/cluster/host/storage/deployment/collector) - 新增 http.ts 引擎(http.get/post/put/delete),域模块改用语义化调用 - index.html 增加 favicon(匹配应用 logo 的 emerald + Activity 脉冲) 后端分层增强(保留包名): - 新增 repository/:从 models 拆出 JsonDB 持久化层(InitDB/GetDB/Shutdown/RequestSave) - 新增 dto/:从 controllers 抽出各域请求契约(带 binding 校验) - 新增 pkg/crypto、pkg/jwt:从 utils 拆出安全原语(外部 jwt 别名 jwtv5 避免冲突) - models 只留领域实体 + sanitizer;utils 只留 gin/应用相关(response/errors) v2 优化移植与缺陷修复: - 移植密钥分离/SSH TOFU/并发写安全/Loki 生产级配置/Docker 加固 - 修复 deployment.go 并发丢更新、Helm values 重复键、EncryptField 哨兵、loki-gateway 端点等审计缺陷 - 实现真正的优雅关停(main.go http.Server + signal + repository.Shutdown) - 修复登录契约:后端返回 accessToken,前端 auth.ts/Login.tsx 对齐 - 补 crypto/jwt/repository 单元测试(含并发写回归) 验证: go build/vet/test 全绿;npm run build 通过;登录→鉴权→优雅关停冒烟通过 Co-Authored-By: Claude --- .dockerignore | 47 + .env.example | 35 + .gitignore | 4 + AGENTS.md | 126 +++ CLAUDE.md | 125 +++ Dockerfile | 183 +++- Makefile | 124 +++ README.md | 500 +++++++-- backend/config/config.go | 51 +- backend/controllers/auth.go | 55 +- backend/controllers/cluster.go | 13 +- backend/controllers/collector.go | 15 +- backend/controllers/deployment.go | 27 +- backend/controllers/host.go | 20 +- backend/controllers/storage.go | 16 +- backend/dto/auth.go | 25 + backend/dto/cluster.go | 12 + backend/dto/collector.go | 14 + backend/dto/deployment.go | 19 + backend/dto/host.go | 16 + backend/dto/storage.go | 15 + backend/main.go | 52 +- backend/middleware/jwt.go | 10 +- backend/models/cluster.go | 31 +- backend/models/db.go | 115 -- backend/models/deployment.go | 41 + backend/models/host.go | 91 +- backend/models/sanitizer.go | 34 +- backend/pkg/crypto/crypto.go | 173 +++ backend/pkg/crypto/crypto_test.go | 78 ++ backend/pkg/crypto/main_test.go | 15 + backend/pkg/jwt/jwt.go | 138 +++ backend/pkg/jwt/jwt_test.go | 66 ++ backend/pkg/jwt/main_test.go | 15 + backend/repository/db.go | 281 +++++ backend/repository/db_test.go | 85 ++ backend/repository/main_test.go | 32 + backend/routers/router.go | 36 +- backend/seed/seed.go | 9 +- backend/services/auth.go | 35 +- backend/services/cluster.go | 28 +- backend/services/collector.go | 21 +- backend/services/collector_deployer.go | 129 ++- backend/services/collector_templates.go | 172 ++- backend/services/deployment.go | 181 +++- backend/services/host.go | 46 +- backend/services/k8s_client.go | 45 +- backend/services/loki_deployer.go | 1106 ++++++++++++++++++-- backend/services/ssh.go | 199 +++- backend/services/storage.go | 37 +- backend/utils/crypto.go | 79 -- backend/utils/errors.go | 40 +- backend/utils/jwt.go | 62 -- backend/utils/response.go | 34 +- build.sh | 127 ++- docker-compose.prod.yml | 234 +++++ docker-compose.yml | 103 +- docker-entrypoint.sh | 97 ++ frontend/index.html | 2 + frontend/public/favicon.svg | 12 + frontend/src/App.tsx | 161 +-- frontend/src/api/auth/index.ts | 29 + frontend/src/api/cluster/index.ts | 25 + frontend/src/api/collector/index.ts | 18 + frontend/src/api/deployment/index.ts | 36 + frontend/src/api/host/index.ts | 28 + frontend/src/api/http.ts | 185 ++++ frontend/src/api/index.ts | 9 + frontend/src/api/storage/index.ts | 24 + frontend/src/components/EmptyState.tsx | 40 + frontend/src/components/Layout.tsx | 109 +- frontend/src/components/LoadingSpinner.tsx | 49 + frontend/src/components/PageHeader.tsx | 37 + frontend/src/components/StatusBadge.tsx | 152 +++ frontend/src/pages/Clusters.tsx | 744 +++++++------ frontend/src/pages/Collectors.tsx | 649 +++++++----- frontend/src/pages/Dashboard.tsx | 573 +++++----- frontend/src/pages/Deployments.tsx | 1105 ++++++++++--------- frontend/src/pages/Home.tsx | 20 - frontend/src/pages/Hosts.tsx | 824 +++++++++------ frontend/src/pages/Login.tsx | 108 +- frontend/src/pages/Settings.tsx | 130 ++- frontend/src/pages/Storage.tsx | 601 ++++++----- frontend/src/srv/api/auth.ts | 24 - frontend/src/srv/api/client.ts | 49 - frontend/src/srv/api/cluster.ts | 23 - frontend/src/srv/api/collector.ts | 20 - frontend/src/srv/api/deployment.ts | 28 - frontend/src/srv/api/host.ts | 29 - frontend/src/srv/api/index.ts | 7 - frontend/src/srv/api/storage.ts | 23 - nginx.conf | 91 ++ run.sh | 39 +- 93 files changed, 8433 insertions(+), 3089 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Makefile create mode 100644 backend/dto/auth.go create mode 100644 backend/dto/cluster.go create mode 100644 backend/dto/collector.go create mode 100644 backend/dto/deployment.go create mode 100644 backend/dto/host.go create mode 100644 backend/dto/storage.go delete mode 100644 backend/models/db.go create mode 100644 backend/pkg/crypto/crypto.go create mode 100644 backend/pkg/crypto/crypto_test.go create mode 100644 backend/pkg/crypto/main_test.go create mode 100644 backend/pkg/jwt/jwt.go create mode 100644 backend/pkg/jwt/jwt_test.go create mode 100644 backend/pkg/jwt/main_test.go create mode 100644 backend/repository/db.go create mode 100644 backend/repository/db_test.go create mode 100644 backend/repository/main_test.go delete mode 100644 backend/utils/crypto.go delete mode 100644 backend/utils/jwt.go mode change 100644 => 100755 build.sh create mode 100644 docker-compose.prod.yml create mode 100755 docker-entrypoint.sh create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/api/auth/index.ts create mode 100644 frontend/src/api/cluster/index.ts create mode 100644 frontend/src/api/collector/index.ts create mode 100644 frontend/src/api/deployment/index.ts create mode 100644 frontend/src/api/host/index.ts create mode 100644 frontend/src/api/http.ts create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/api/storage/index.ts create mode 100644 frontend/src/components/EmptyState.tsx create mode 100644 frontend/src/components/LoadingSpinner.tsx create mode 100644 frontend/src/components/PageHeader.tsx create mode 100644 frontend/src/components/StatusBadge.tsx delete mode 100644 frontend/src/pages/Home.tsx delete mode 100644 frontend/src/srv/api/auth.ts delete mode 100644 frontend/src/srv/api/client.ts delete mode 100644 frontend/src/srv/api/cluster.ts delete mode 100644 frontend/src/srv/api/collector.ts delete mode 100644 frontend/src/srv/api/deployment.ts delete mode 100644 frontend/src/srv/api/host.ts delete mode 100644 frontend/src/srv/api/index.ts delete mode 100644 frontend/src/srv/api/storage.ts create mode 100644 nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3f9b6c3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Git +.git +.gitignore + +# CI/CD +.github +.gitlab-ci.yml +Jenkinsfile + +# Documentation +*.md +!README.md + +# Backend +backend/bin/ +backend/*.log +backend/data/ + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.env.local +frontend/.env.*.local +frontend/npm-debug.log* +frontend/yarn-debug.log* +frontend/yarn-error.log* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Secrets +.env +secrets/ +*.pem +*.key + +# Build artifacts +data/ +logs/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..627d6d2 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# ============================================================ +# LokiDeploy - Environment Variables Example +# ============================================================ +# Copy this file to .env and fill in your values: +# cp .env.example .env +# +# IMPORTANT: Never commit .env to version control! +# .env should be in .gitignore +# ============================================================ + +# ---- Required: Security Keys ---- +# Generate with: openssl rand -hex 32 +JWT_SECRET= + +# Generate with: openssl rand -hex 32 +ENCRYPTION_KEY= + +# ---- Optional: Paths ---- +# Host path to kubeconfig directory (default: ~/.kube) +KUBECONFIG_PATH=~/.kube + +# Host path to SSH keys (default: ~/.ssh) +SSH_KEY_PATH=~/.ssh + +# ---- Optional: Network ---- +SERVER_PORT=8080 + +# ---- Optional: Build ---- +TARGETARCH=amd64 +# For Apple Silicon Macs: TARGETARCH=arm64 + +# ---- Production Only ---- +# Paths to secret files for docker-compose.prod.yml +JWT_SECRET_FILE_PATH=./secrets/jwt_secret.txt +ENCRYPTION_KEY_FILE_PATH=./secrets/encryption_key.txt diff --git a/.gitignore b/.gitignore index dee5615..27172e9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ backend/dist/ data/ *.pid +# Secrets — never commit +.env +secrets/ + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fa90510 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,126 @@ +# AGENTS.md + +本文件为各类 AI 编码代理(Cursor、Aider、Claude Code、Copilot 等)在本仓库中工作时的通用指引。 +内容与 `CLAUDE.md` 保持同步;两份文件描述的是同一套约定,修改其一请同步另一份。 + +## 项目概览 + +LokiDeploy 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana +Loki 日志聚合系统部署到 Kubernetes 集群(通过 `kubectl`/`helm`)或裸金属 Linux 主机(通过 SSH)。 +Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。 + +默认登录账号: `admin` / `admin123`(首次启动时由 `backend/seed/seed.go` 写入数据库)。 + +## 常用命令 + +### 启动后端前的必备环境变量 +后端在缺少以下变量时会 **拒绝启动**(由 `backend/config/config.go` 通过 `log.Fatal` 强制)。 +`./run.sh` 会自动注入开发用默认值;直接 `go run` 或 Docker 则不会。 + +```bash +export JWT_SECRET=$(openssl rand -hex 32) # >= 32 字符 +export ENCRYPTION_KEY=$(openssl rand -hex 32) # >= 32 字节(不是 16,详见「安全」一节) +``` + +### 开发模式(两个终端,热重载) +```bash +# 终端 1 — 后端(端口 8080) +cd backend && go run . + +# 终端 2 — 前端(端口 3000,会把 /api 代理到 :8080,见 vite.config.ts) +cd frontend && npm install && npm run dev +``` +也可以用 `./run.sh`,它会同时拉起前后端并注入开发用默认密钥。 + +### 构建单体二进制(前端 + 后端) +```bash +bash build.sh # 产物:backend/bin/lokideploy,并把 frontend/dist 复制到 backend/dist +cd backend && go build -o bin/lokideploy . # 仅构建后端 +cd frontend && npm run build # 仅构建前端(tsc -b && vite build) +``` + +### 测试与 lint +```bash +cd backend && go test ./... # 所有 Go 测试(utils、repository) +cd backend && go test ./utils/ -run TestName -v # 运行单个测试 +cd frontend && npm run lint # ESLint +``` +注意:`models/` 目录目前 **没有** 测试,测试集中在 `utils/` 和 `repository/`。 + +### Docker +```bash +export JWT_SECRET=... ENCRYPTION_KEY=... +docker compose up -d # 开发用 compose(docker-compose.yml) +make build start stop logs status clean # Makefile 快捷命令 +docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secrets/*.txt;先运行 `make secrets`) +``` + +## 架构 + +### 后端分层(`backend/`,模块路径 `lokideploy`,Go 1.24) +请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**。 + +- `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。 +- `routers/router.go` — 所有路由注册在 `/api/v1` 下;注册 SPA 静态资源服务,并用 `NoRoute` + 回退到 `index.html` 以支持前端路由。`findDistPath()` 负责相对于可执行文件定位 `dist/` + (兼容 `go run` 的临时目录和打包后的二进制两种情况)。 +- `controllers/` — 薄薄的 Gin handler,每个资源一个文件。职责:绑定/校验入参、调用 service、 + 用 `models.SanitizedJSON(...)` 包装返回结果。 +- `services/` — 业务逻辑层。部署流水线就在这里: + `DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按部署模式 + (`k8s-helm` / `k8s-yaml` / `host-binary`)分发,实际通过 shell 调用 `kubectl`/`helm` 或 SSH。 + 其它 service:`K8sClient`、`ssh`(+ `ssh_errors`)、`storage`、`cluster_info`, + 以及较新的 **collector(日志采集器)** 模块(`collector*.go` — 部署客户端日志采集器)。 +- `middleware/jwt.go` — `JWTAuth()`(`/authorized` 路由组必用)、`AdminAuth()`。 +- `utils/` — `response.go`(响应封装助手)、`crypto.go`(Argon2 + AES-GCM)、`jwt.go`。 +- `seed/` — 数据库为空时写入默认 admin 用户。 + +### 数据层 — JSON 文件数据库(唯一数据源) +持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/lokideploy.json`(默认 `./data/`)。 +**没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构: + +- **并发模型**(重要):写操作由 `Lock()/RLock()` 保护,随后 `RequestSave()` 会通过一个 + 单一的、串行化的 `saveWorker` goroutine 调度一次 **防抖(500ms)原子写** + (写临时文件 → `fsync` → rename)。worker 可能仍在运行时 **绝对不要** 直接调用 `Save()`; + 常规写入请用 `RequestSave()`。 +- **优雅关停的顺序很关键**(`main.go` + `repository.Shutdown`):停防抖定时器 → 关闭 worker 并 + 等待退出 → 最后一次 `doSave`。顺序颠倒会导致更新丢失。 +- 这意味着应用 **只能单实例运行** — 不可以水平扩展。 +- `NextID(table)` 为每个集合分配单调递增的整数 ID。 + +> **存在两份 `JsonDB` 实现。** 真正在用的是 `repository/db.go`,被 `main.go`、`seed` 以及所有 +> service 通过 `repository.GetDB()` 调用。`models/db.go` 是一份 **正在迁移、尚未清理的旧副本** +> — 它仍然会被编译(属于 `package models`),但已经没有任何代码调用 +> `models.GetDB/InitDB/Shutdown`。新增数据库代码请写到 `repository`,不要写到 `models`。 +> 在旧副本被删除前,修改其中一份时要保证另一份也能通过编译。 + +### 响应封装与脱敏 +- 所有 API 响应都是 `{ code, message, data }`。`code == 0` 表示成功;前端 + (`frontend/src/api/http.ts`)在 `code !== 0` 时抛错。`utils.Success/Error/...` 负责构造。 +- **永远不要直接返回原始 model。** 必须用 `models.SanitizedJSON(...)` 包装。各 model 实现 + `Sanitized()` 方法(`models/sanitizer.go`),用于剔除敏感字段(密码、kubeconfig、私钥、token)。 + 新增一个 model = 新增一个 `Sanitized()` 方法 + 在 `SanitizedJSON` 中补一个 case(单对象 + 切片各一个)。 + +### 凭证安全(`utils/crypto.go`) +- 密码用 **Argon2** 哈希。 +- 凭证(kubeconfig、SSH 密码/密钥、存储密钥)用 **AES-GCM** 加密;AES 密钥由 `ENCRYPTION_KEY` + 经 **HKDF 派生** 得到(原始密钥永远不会被直接使用)。 +- `ENCRYPTION_KEY` 必须 ≥ 32 字节。(较早的文档/脚本写的是 16 — 现在这样会导致启动失败。) + +## 前端(`frontend/`,React 19 + Vite 7 + TypeScript) + +- 技术栈:Tailwind、**shadcn/ui**(Radix 基础组件在 `src/components/ui/`)、react-router 7、 + react-hook-form + zod、recharts、sonner(通知)。 +- `src/api/` 是 **当前在用** 的 API 客户端(取代了已删除的 `src/srv/api/`)。`http.ts` 是 + fetch 封装:注入 `Authorization: Bearer`、30s 超时、401 → 清除 token 并跳转 `/login`、可选重试。 + 每个资源一个模块(`auth`、`cluster` ……)。 +- `src/App.tsx` — 路由通过 `React.lazy` 懒加载,`RequireAuth` 守卫在 localStorage 无 `token` + 时重定向到 `/login`。`@` → `src/` 别名(tsconfig + vite)。 +- 开发服务器跑在 `:3000`,把 `/api` 代理到 `:8080`;生产环境下由 Go 二进制直接提供 `dist/` 静态服务。 + +## Loki 部署相关细节 +- 合法的部署模式:`k8s-helm`、`k8s-yaml`、`host-binary`。K8s 模式需要一个 Cluster(kubeconfig); + `host-binary` 需要一个 SSH Host,会安装 Loki + systemd。 +- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) — + 见 `services/deployment.go` 中的 `ValidateLokiVersion`。 +- `kubectl`/`helm` 在 Docker 镜像中安装时做了 SHA256 校验(见 `Dockerfile`)。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5d78a39 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概览 + +LokiDeploy 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana +Loki 日志聚合系统部署到 Kubernetes 集群(通过 `kubectl`/`helm`)或裸金属 Linux 主机(通过 SSH)。 +Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。 + +默认登录账号: `admin` / `admin123`(首次启动时由 `backend/seed/seed.go` 写入数据库)。 + +## 常用命令 + +### 启动后端前的必备环境变量 +后端在缺少以下变量时会 **拒绝启动**(由 `backend/config/config.go` 通过 `log.Fatal` 强制)。 +`./run.sh` 会自动注入开发用默认值;直接 `go run` 或 Docker 则不会。 + +```bash +export JWT_SECRET=$(openssl rand -hex 32) # >= 32 字符 +export ENCRYPTION_KEY=$(openssl rand -hex 32) # >= 32 字节(不是 16,详见「安全」一节) +``` + +### 开发模式(两个终端,热重载) +```bash +# 终端 1 — 后端(端口 8080) +cd backend && go run . + +# 终端 2 — 前端(端口 3000,会把 /api 代理到 :8080,见 vite.config.ts) +cd frontend && npm install && npm run dev +``` +也可以用 `./run.sh`,它会同时拉起前后端并注入开发用默认密钥。 + +### 构建单体二进制(前端 + 后端) +```bash +bash build.sh # 产物:backend/bin/lokideploy,并把 frontend/dist 复制到 backend/dist +cd backend && go build -o bin/lokideploy . # 仅构建后端 +cd frontend && npm run build # 仅构建前端(tsc -b && vite build) +``` + +### 测试与 lint +```bash +cd backend && go test ./... # 所有 Go 测试(utils、repository) +cd backend && go test ./utils/ -run TestName -v # 运行单个测试 +cd frontend && npm run lint # ESLint +``` +注意:`models/` 目录目前 **没有** 测试,测试集中在 `utils/` 和 `repository/`。 + +### Docker +```bash +export JWT_SECRET=... ENCRYPTION_KEY=... +docker compose up -d # 开发用 compose(docker-compose.yml) +make build start stop logs status clean # Makefile 快捷命令 +docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secrets/*.txt;先运行 `make secrets`) +``` + +## 架构 + +### 后端分层(`backend/`,模块路径 `lokideploy`,Go 1.24) +请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**。 + +- `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。 +- `routers/router.go` — 所有路由注册在 `/api/v1` 下;注册 SPA 静态资源服务,并用 `NoRoute` + 回退到 `index.html` 以支持前端路由。`findDistPath()` 负责相对于可执行文件定位 `dist/` + (兼容 `go run` 的临时目录和打包后的二进制两种情况)。 +- `controllers/` — 薄薄的 Gin handler,每个资源一个文件。职责:绑定/校验入参、调用 service、 + 用 `models.SanitizedJSON(...)` 包装返回结果。 +- `services/` — 业务逻辑层。部署流水线就在这里: + `DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按部署模式 + (`k8s-helm` / `k8s-yaml` / `host-binary`)分发,实际通过 shell 调用 `kubectl`/`helm` 或 SSH。 + 其它 service:`K8sClient`、`ssh`(+ `ssh_errors`)、`storage`、`cluster_info`, + 以及较新的 **collector(日志采集器)** 模块(`collector*.go` — 部署客户端日志采集器)。 +- `middleware/jwt.go` — `JWTAuth()`(`/authorized` 路由组必用)、`AdminAuth()`。 +- `utils/` — `response.go`(响应封装助手)、`crypto.go`(Argon2 + AES-GCM)、`jwt.go`。 +- `seed/` — 数据库为空时写入默认 admin 用户。 + +### 数据层 — JSON 文件数据库(唯一数据源) +持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/lokideploy.json`(默认 `./data/`)。 +**没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构: + +- **并发模型**(重要):写操作由 `Lock()/RLock()` 保护,随后 `RequestSave()` 会通过一个 + 单一的、串行化的 `saveWorker` goroutine 调度一次 **防抖(500ms)原子写** + (写临时文件 → `fsync` → rename)。worker 可能仍在运行时 **绝对不要** 直接调用 `Save()`; + 常规写入请用 `RequestSave()`。 +- **优雅关停的顺序很关键**(`main.go` + `repository.Shutdown`):停防抖定时器 → 关闭 worker 并 + 等待退出 → 最后一次 `doSave`。顺序颠倒会导致更新丢失。 +- 这意味着应用 **只能单实例运行** — 不可以水平扩展。 +- `NextID(table)` 为每个集合分配单调递增的整数 ID。 + +> **存在两份 `JsonDB` 实现。** 真正在用的是 `repository/db.go`,被 `main.go`、`seed` 以及所有 +> service 通过 `repository.GetDB()` 调用。`models/db.go` 是一份 **正在迁移、尚未清理的旧副本** +> — 它仍然会被编译(属于 `package models`),但已经没有任何代码调用 +> `models.GetDB/InitDB/Shutdown`。新增数据库代码请写到 `repository`,不要写到 `models`。 +> 在旧副本被删除前,修改其中一份时要保证另一份也能通过编译。 + +### 响应封装与脱敏 +- 所有 API 响应都是 `{ code, message, data }`。`code == 0` 表示成功;前端 + (`frontend/src/api/http.ts`)在 `code !== 0` 时抛错。`utils.Success/Error/...` 负责构造。 +- **永远不要直接返回原始 model。** 必须用 `models.SanitizedJSON(...)` 包装。各 model 实现 + `Sanitized()` 方法(`models/sanitizer.go`),用于剔除敏感字段(密码、kubeconfig、私钥、token)。 + 新增一个 model = 新增一个 `Sanitized()` 方法 + 在 `SanitizedJSON` 中补一个 case(单对象 + 切片各一个)。 + +### 凭证安全(`utils/crypto.go`) +- 密码用 **Argon2** 哈希。 +- 凭证(kubeconfig、SSH 密码/密钥、存储密钥)用 **AES-GCM** 加密;AES 密钥由 `ENCRYPTION_KEY` + 经 **HKDF 派生** 得到(原始密钥永远不会被直接使用)。 +- `ENCRYPTION_KEY` 必须 ≥ 32 字节。(较早的文档/脚本写的是 16 — 现在这样会导致启动失败。) + +## 前端(`frontend/`,React 19 + Vite 7 + TypeScript) + +- 技术栈:Tailwind、**shadcn/ui**(Radix 基础组件在 `src/components/ui/`)、react-router 7、 + react-hook-form + zod、recharts、sonner(通知)。 +- `src/api/` 是 **当前在用** 的 API 客户端(取代了已删除的 `src/srv/api/`)。`http.ts` 是 + fetch 封装:注入 `Authorization: Bearer`、30s 超时、401 → 清除 token 并跳转 `/login`、可选重试。 + 每个资源一个模块(`auth`、`cluster` ……)。 +- `src/App.tsx` — 路由通过 `React.lazy` 懒加载,`RequireAuth` 守卫在 localStorage 无 `token` + 时重定向到 `/login`。`@` → `src/` 别名(tsconfig + vite)。 +- 开发服务器跑在 `:3000`,把 `/api` 代理到 `:8080`;生产环境下由 Go 二进制直接提供 `dist/` 静态服务。 + +## Loki 部署相关细节 +- 合法的部署模式:`k8s-helm`、`k8s-yaml`、`host-binary`。K8s 模式需要一个 Cluster(kubeconfig); + `host-binary` 需要一个 SSH Host,会安装 Loki + systemd。 +- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) — + 见 `services/deployment.go` 中的 `ValidateLokiVersion`。 +- `kubectl`/`helm` 在 Docker 镜像中安装时做了 SHA256 校验(见 `Dockerfile`)。 diff --git a/Dockerfile b/Dockerfile index f891568..f9fb274 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,53 +1,178 @@ -# Build stage - Frontend +# ============================================================ +# LokiDeploy - Secure Multi-Stage Dockerfile +# ============================================================ +# P0 Fixes: +# - Non-root user (lokideploy:1000) +# - No hardcoded JWT_SECRET (validated at runtime) +# - HEALTHCHECK instruction added +# - Binary installation verification +# P1 Fixes: +# - Supply chain risk reduction (--ignore-scripts where applicable) +# - File ownership with --chown +# - Security environment variables +# ============================================================ + +# --------------------------------------------------------------- +# Stage 1: Frontend Builder (Node.js) +# --------------------------------------------------------------- FROM node:20-alpine AS frontend-builder WORKDIR /app/frontend + +# Install dependencies separately to leverage layer caching COPY frontend/package*.json ./ -RUN npm ci +# Use --ignore-scripts to reduce supply chain attack surface +RUN npm ci --ignore-scripts && \ + npm cache clean --force + +# Copy source and build COPY frontend/ ./ RUN npm run build -# Build stage - Backend +# --------------------------------------------------------------- +# Stage 2: Backend Builder (Go) +# --------------------------------------------------------------- FROM golang:1.24-alpine AS backend-builder -RUN apk add --no-cache git ca-certificates +RUN apk add --no-cache git ca-certificates tzdata ENV GOTOOLCHAIN=local WORKDIR /app/backend + +# Download dependencies (cached layer) COPY backend/go.* ./ RUN go mod download + +# Copy source and build static binary COPY backend/ ./ -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o bin/lokideploy . +RUN CGO_ENABLED=0 GOOS=linux go build \ + -a -installsuffix cgo \ + -ldflags="-s -w -extldflags '-static'" \ + -o bin/lokideploy . + +# --------------------------------------------------------------- +# Stage 3: Final Runtime Image +# --------------------------------------------------------------- +FROM alpine:3.20 + +# Install runtime dependencies: +# ca-certificates: HTTPS TLS verification +# openssh-client: SSH key auth for host deployment +# curl: kubectl/helm download + runtime checks +# bash: Entrypoint script support +# wget: HEALTHCHECK (lighter than curl) +RUN apk --no-cache add \ + ca-certificates \ + openssh-client \ + curl \ + bash \ + wget -# Final stage - 内置 kubectl + helm,部署开箱即用 -FROM alpine:3.19 -RUN apk --no-cache add ca-certificates openssh-client curl bash ARG TARGETARCH=amd64 -# 安装 kubectl(兼容大多数集群版本,±1 版本原则) -RUN curl -fsSL -o /usr/local/bin/kubectl \ - "https://dl.k8s.io/release/v1.31.0/bin/linux/${TARGETARCH}/kubectl" \ - && chmod +x /usr/local/bin/kubectl -# 安装 helm -RUN mkdir -p /tmp/helm \ - && curl -fsSL "https://get.helm.sh/helm-v3.16.3-linux-${TARGETARCH}.tar.gz" \ - | tar -xz -C /tmp/helm \ - && mv "/tmp/helm/linux-${TARGETARCH}/helm" /usr/local/bin/helm \ - && chmod +x /usr/local/bin/helm \ - && rm -rf /tmp/helm + +# ---- Install kubectl with checksum verification ---- +# P1: curl downloads must have integrity verification +ENV KUBECTL_VERSION=v1.31.0 +ENV KUBECTL_SHA256_AMD64=e3c5b9c15e96cd8d70b3fdf87f38197d0b92e01fb920b5d8a40a3f4c5cb4e84a +ENV KUBECTL_SHA256_ARM64=f9147d84b7dc0630cf4f9d7769c36e65a0885bbc0a34c5dc65b9d93c287d83b9 +RUN set -eux; \ + if [ "$TARGETARCH" = "amd64" ]; then \ + KUBECTL_SHA256="$KUBECTL_SHA256_AMD64"; \ + elif [ "$TARGETARCH" = "arm64" ]; then \ + KUBECTL_SHA256="$KUBECTL_SHA256_ARM64"; \ + else \ + echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1; \ + fi; \ + curl -fsSL -o /tmp/kubectl \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl"; \ + echo "${KUBECTL_SHA256} /tmp/kubectl" | sha256sum -c -; \ + mv /tmp/kubectl /usr/local/bin/kubectl; \ + chmod +x /usr/local/bin/kubectl; \ + # P1: Verify installation + kubectl version --client || { echo "kubectl installation verification failed"; exit 1; } + +# ---- Install helm with checksum verification ---- +ENV HELM_VERSION=v3.16.3 +# Helm checksums from official releases (https://github.com/helm/helm/releases) +# helm-v3.16.3-linux-amd64.tar.gz +ENV HELM_SHA256_AMD64=ad01a74073a785c33121d277100b043246217790d8d30c9f039e9c2dd5769d91 +# helm-v3.16.3-linux-arm64.tar.gz +ENV HELM_SHA256_ARM64=5bd34b755e58f27f1c4bc8edaef44152d0e7b9f5978e6d0aac394a3c81951498 +RUN set -eux; \ + HELM_TAR="helm-${HELM_VERSION}-linux-${TARGETARCH}.tar.gz"; \ + if [ "$TARGETARCH" = "amd64" ]; then \ + HELM_SHA256="$HELM_SHA256_AMD64"; \ + elif [ "$TARGETARCH" = "arm64" ]; then \ + HELM_SHA256="$HELM_SHA256_ARM64"; \ + else \ + echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1; \ + fi; \ + curl -fsSL -o "/tmp/${HELM_TAR}" \ + "https://get.helm.sh/${HELM_TAR}"; \ + # P1: SHA256 integrity verification + echo "${HELM_SHA256} /tmp/${HELM_TAR}" | sha256sum -c -; \ + mkdir -p /tmp/helm; \ + tar -xzf "/tmp/${HELM_TAR}" -C /tmp/helm; \ + mv "/tmp/helm/linux-${TARGETARCH}/helm" /usr/local/bin/helm; \ + chmod +x /usr/local/bin/helm; \ + rm -rf /tmp/helm*; \ + # P1: Verify installation + helm version --client || { echo "helm installation verification failed"; exit 1; } + +# --------------------------------------------------------------- +# P0: Create non-root user and group +# --------------------------------------------------------------- +RUN addgroup -g 1000 lokideploy && \ + adduser -u 1000 -G lokideploy -s /bin/sh -D lokideploy + +# --------------------------------------------------------------- +# Working directory with proper ownership +# --------------------------------------------------------------- WORKDIR /app +RUN chown lokideploy:lokideploy /app -# Copy frontend build -COPY --from=frontend-builder /app/frontend/dist ./dist +# ---- Copy application files with correct ownership (P1) ---- +COPY --from=frontend-builder --chown=lokideploy:lokideploy /app/frontend/dist ./dist +COPY --from=backend-builder --chown=lokideploy:lokideploy /app/backend/bin/lokideploy ./lokideploy -# Copy backend binary -COPY --from=backend-builder /app/backend/bin/lokideploy ./lokideploy -RUN chmod +x ./lokideploy +# Create data directory with non-root ownership +RUN mkdir -p /app/data && chown -R lokideploy:lokideploy /app/data -# Create data directory -RUN mkdir -p /app/data +# Create .kube directory for non-root user (P0: not /root/.kube) +RUN mkdir -p /home/lokideploy/.kube && \ + chown -R lokideploy:lokideploy /home/lokideploy && \ + chmod 700 /home/lokideploy/.kube -# Environment +# ---- Security hardening: remove unnecessary tools ---- +RUN rm -rf /tmp/* /var/cache/apk/* + +# --------------------------------------------------------------- +# Security environment variables +# --------------------------------------------------------------- ENV DATA_DIR=/app/data ENV SERVER_PORT=8080 -ENV JWT_SECRET=lokideploy-secret-key-change-in-production +ENV KUBECONFIG=/home/lokideploy/.kube/config +# HOME must be set for kubectl/helm to find config +ENV HOME=/home/lokideploy + +# NOTE: JWT_SECRET and ENCRYPTION_KEY are NOT set here. +# They MUST be provided at runtime via docker-compose env or secrets. +# The entrypoint script will enforce this requirement. + +# ---- Copy and configure entrypoint script ---- +COPY --chown=lokideploy:lokideploy docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + EXPOSE 8080 -# Serve frontend as static files +# --------------------------------------------------------------- +# P0: HEALTHCHECK - verifies static file serving (no auth needed) +# --------------------------------------------------------------- +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider \ + http://localhost:${SERVER_PORT}/index.html || exit 1 + +# --------------------------------------------------------------- +# P0: Switch to non-root user +# --------------------------------------------------------------- +USER lokideploy + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["./lokideploy"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9eff6c3 --- /dev/null +++ b/Makefile @@ -0,0 +1,124 @@ +# ============================================================ +# LokiDeploy - Makefile +# Convenient shortcuts for common operations +# ============================================================ + +.PHONY: help build start stop status logs clean prod-setup secrets + +# Default target +help: + @echo "LokiDeploy v2 - Available Commands:" + @echo "" + @echo " make build - Build Docker image" + @echo " make start - Start with docker-compose (dev)" + @echo " make stop - Stop all services" + @echo " make status - Show container status and health" + @echo " make logs - Follow container logs" + @echo " make prod - Start production deployment" + @echo " make prod-stop - Stop production deployment" + @echo " make secrets - Generate secret files" + @echo " make clean - Remove containers, volumes, and images" + @echo " make shell - Open shell in running container" + @echo " make lint - Validate Docker and Compose files" + @echo "" + +# Build Docker image +build: + @echo "Building LokiDeploy v2 image..." + @docker compose build --no-cache + +# Development +start: + @echo "Starting LokiDeploy (dev mode)..." + @echo "" + @if [ -z "$${JWT_SECRET:-}" ]; then \ + echo "WARNING: JWT_SECRET not set!"; \ + echo "Run: export JWT_SECRET=\$$(openssl rand -hex 32)"; \ + echo " export ENCRYPTION_KEY=\$$(openssl rand -hex 32)"; \ + echo ""; \ + read -p "Continue without secrets? (y/N) " ans; \ + [ "$$ans" != "y" ] && [ "$$ans" != "Y" ] && exit 1; \ + fi + @docker compose up -d + @echo "" + @echo "LokiDeploy started!" + @echo " URL: http://localhost:$${SERVER_PORT:-8080}" + @echo " Check status: make status" + +stop: + @echo "Stopping LokiDeploy..." + @docker compose down --timeout 15 + +status: + @echo "=== Container Status ===" + @docker compose ps + @echo "" + @echo "=== Health Checks ===" + @docker inspect --format='{{.Name}}: {{.State.Health.Status}}' lokideploy 2>/dev/null || echo "lokideploy: container not found" + @echo "" + @echo "=== Resource Usage ===" + @docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}" lokideploy 2>/dev/null || true + +logs: + @docker compose logs -f --tail=50 + +# Production +prod-setup: secrets + @echo "Setting up production environment..." + @mkdir -p secrets + @echo "Production secrets created in ./secrets/" + +prod: + @echo "Starting LokiDeploy (production mode)..." + @if [ ! -f secrets/jwt_secret.txt ] || [ ! -f secrets/encryption_key.txt ]; then \ + echo "ERROR: Secrets not found. Run 'make secrets' first."; \ + exit 1; \ + fi + @docker compose -f docker-compose.prod.yml up -d + @echo "Production deployment started!" + @echo " Load Balancer: http://localhost:$${SERVER_PORT:-8080}" + @echo " Check status: docker compose -f docker-compose.prod.yml ps" + +prod-stop: + @echo "Stopping production deployment..." + @docker compose -f docker-compose.prod.yml down --timeout 30 + +# Secrets generation +secrets: + @echo "Generating secret files..." + @mkdir -p secrets + @chmod 700 secrets + @openssl rand -hex 32 > secrets/jwt_secret.txt + @openssl rand -hex 32 > secrets/encryption_key.txt + @chmod 600 secrets/*.txt + @echo "Secrets created:" + @echo " - secrets/jwt_secret.txt" + @echo " - secrets/encryption_key.txt" + @echo "" + @echo "IMPORTANT: Keep these files secure and never commit them!" + +# Maintenance +shell: + @docker compose exec lokideploy /bin/sh + +clean: + @echo "Cleaning up..." + @docker compose down --volumes --rmi local --timeout 15 2>/dev/null || true + @docker compose -f docker-compose.prod.yml down --volumes --rmi local --timeout 30 2>/dev/null || true + @echo "Cleanup complete." + +# Linting +lint: + @echo "Validating Dockerfile..." + @docker run --rm -i hadolint/hadolint < Dockerfile 2>/dev/null || echo "hadolint not available, install from: https://github.com/hadolint/hadolint" + @echo "" + @echo "Validating docker-compose.yml..." + @docker compose config > /dev/null && echo "docker-compose.yml: OK" + @echo "" + @echo "Validating docker-compose.prod.yml..." + @docker compose -f docker-compose.prod.yml config > /dev/null && echo "docker-compose.prod.yml: OK" + @echo "" + @echo "Checking shell scripts..." + @bash -n run.sh && echo "run.sh: syntax OK" + @bash -n build.sh && echo "build.sh: syntax OK" + @bash -n docker-entrypoint.sh && echo "docker-entrypoint.sh: syntax OK" diff --git a/README.md b/README.md index 18ae092..29cf963 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,463 @@ -# LokiDeploy - K8s Loki Deployment Platform +# LokiDeploy v2 — 安全的 K8s Loki 部署平台 -A lightweight **Gin + React + JSON file storage** platform for deploying Grafana Loki log aggregation on Kubernetes clusters or bare-metal hosts via SSH. +一个轻量级的 **Gin + React + JSON 文件存储** 平台,用于通过 SSH 或 kubeconfig 把 Grafana Loki +日志聚合系统部署到 Kubernetes 集群或裸金属主机上。 -## Features +> **v2 安全增强**:非 root 容器、运行时强制校验密钥、健康检查、资源限制、日志轮转、优雅关停。 +> +> **面向 AI 编码代理**:本仓库的 `CLAUDE.md` 与 `AGENTS.md` 记录了架构约定与常用命令,供 Claude Code、 +> Cursor、Aider 等工具参考。两份文件内容一致,修改其一请同步另一份。 -- **K8s Cluster Management** - Add clusters via kubeconfig, auto-validate connectivity -- **SSH Host Management** - Password or key-based authentication -- **Storage Configuration** - S3, OSS, MinIO, RustFS backends -- **One-Click Loki Deploy** - Helm chart / YAML manifest / Binary -- **Deployment Logs** - Real-time log streaming -- **Web Terminal** - Browser-based remote command execution -- **Single Binary** - 13MB zero-dependency executable -- **Security** - Argon2 password hashing, AES credential encryption, JWT auth +## 功能特性 -## Quick Start +- **K8s 集群管理** — 通过 kubeconfig 添加集群,自动校验连通性 +- **SSH 主机管理** — 支持密码或密钥认证 +- **存储配置** — 支持 S3、OSS、MinIO、RustFS 等后端 +- **一键部署 Loki** — Helm Chart / YAML 清单 / 二进制 三种模式 +- **部署日志** — 实时日志流 +- **Web 终端** — 在浏览器中执行远程命令 +- **单体二进制** — 约 13MB,零外部依赖 +- **安全** — Argon2 密码哈希、AES 凭证加密、JWT 鉴权、非 root 容器 -### Prerequisites -- Linux/macOS with `kubectl` and `helm` installed (for K8s deployment) -- `curl` or `wget` on target hosts -- Go 1.23+ (for building from source) -- Node.js 20+ (for building frontend) +--- -### Option 1: Build & Run (Recommended) +## 快速开始 + +### 前置条件 + +- Docker Engine 24.0+ 与 Docker Compose v2+ +- 宿主机上装有 `kubectl` 和 `helm`(用于向外部集群部署 K8s 资源) +- `openssl`(用于生成密钥) + +### 第 1 步:生成密钥(必需) + +启动前必须生成所需的安全密钥: + +```bash +# 必需:JWT 签名密钥(建议 >= 32 字符) +export JWT_SECRET=$(openssl rand -hex 32) + +# 必需:用于凭证存储的 AES 加密密钥(必须 >= 32 字节) +export ENCRYPTION_KEY=$(openssl rand -hex 32) + +# 可选:跨会话持久化 +echo "export JWT_SECRET=$JWT_SECRET" >> ~/.bashrc +echo "export ENCRYPTION_KEY=$ENCRYPTION_KEY" >> ~/.bashrc +``` + +> **警告**:如果未设置 `JWT_SECRET` 或 `ENCRYPTION_KEY`,容器将 **拒绝启动**,以避免不安全的默认部署。 + +### 第 2 步:用 Docker Compose 启动(推荐) ```bash git clone https://github.com/your/lokideploy.git cd lokideploy -# Build everything -bash build.sh +# 设置密钥(见第 1 步) +export JWT_SECRET=$(openssl rand -hex 32) +export ENCRYPTION_KEY=$(openssl rand -hex 32) -# Start frontend (:3000) + backend (:8080) -./run.sh - -# Open http://localhost:3000 -# Default: admin / admin123 -``` - -### Option 2: Docker Compose - -```bash -cd lokideploy +# 启动 docker compose up -d -# Open http://localhost:8080 +# 查看健康状态 +docker compose ps +docker compose logs -f + +# 打开 http://localhost:8080 +# 默认账号:admin / admin123 ``` -### Option 3: Development Mode (Hot Reload) +### 第 3 步:验证健康状态 ```bash -# Terminal 1 - Backend -cd backend -go run . +# 容器级健康检查(内部) +docker inspect --format='{{.State.Health.Status}}' lokideploy -# Terminal 2 - Frontend +# 期望输出:"healthy" + +# 手动检查 +curl http://localhost:8080/index.html +``` + +--- + +## 生产部署 + +### 方式 A:带密钥文件的 Docker Compose(推荐) + +```bash +# 创建密钥文件 +mkdir -p secrets +openssl rand -hex 32 > secrets/jwt_secret.txt +openssl rand -hex 32 > secrets/encryption_key.txt +chmod 600 secrets/*.txt + +# 使用 docker-compose.prod.yml +docker compose -f docker-compose.prod.yml up -d +``` + +### 方式 B:Docker Swarm + +```bash +# 初始化 swarm(如果尚未初始化) +docker swarm init + +# 创建 secrets +echo "$(openssl rand -hex 32)" | docker secret create lokideploy_jwt - +echo "$(openssl rand -hex 32)" | docker secret create lokideploy_key - + +# 部署 +docker stack deploy -c docker-compose.prod.yml lokideploy +``` + +### 安全清单 + +- [ ] `JWT_SECRET` 至少 32 字符且为密码学随机 +- [ ] `ENCRYPTION_KEY` 至少 32 字节且为密码学随机 +- [ ] 密钥存放在 Docker Secrets 或外部保险柜中(而非 env 文件) +- [ ] `.env` 与 `secrets/` 已加入 `.gitignore` +- [ ] 启用了 `no-new-privileges:true`(docker-compose 默认启用) +- [ ] kubeconfig 以只读方式挂载(`:ro`) +- [ ] SSH 密钥以只读方式挂载(`:ro`) +- [ ] 已合理配置资源限制 +- [ ] 健康检查通过 +- [ ] 已配置日志轮转 + +--- + +## 从源码构建 + +```bash +# 构建前端 + 后端 +bash build.sh + +# 用本地脚本启动(开发) +./run.sh +``` + +### 开发模式(热重载) + +```bash +# 终端 1 — 后端 +cd backend +go run . # 监听 :8080 + +# 终端 2 — 前端 cd frontend npm install -npm run dev -# Opens http://localhost:3000, proxies API to localhost:8080 +npm run dev # 打开 http://localhost:3000,API 代理到 localhost:8080 ``` -## Architecture +> 直接 `go run` 启动后端前,必须先 `export JWT_SECRET` 与 `ENCRYPTION_KEY`,否则 +> `backend/config/config.go` 会 `log.Fatal` 拒绝启动。`./run.sh` 会自动注入开发用默认值。 + +### 测试与 lint + +```bash +cd backend && go test ./... # 所有 Go 测试(utils、repository) +cd backend && go test ./utils/ -run TestName -v # 运行单个测试 +cd frontend && npm run lint # ESLint +``` + +--- + +## 架构 ``` -Frontend (React + TypeScript + Tailwind CSS) [Port 3000 dev / 8080 prod] +前端(React + TypeScript + Tailwind CSS) [开发 :3000 / 生产 :8080] | | HTTP/REST + JWT Bearer Token v -Backend (Go + Gin) [Port 8080] +后端(Go + Gin) [:8080] | - +---> JSON File DB (data/lokideploy.json) - +---> K8s CLI (kubectl / helm) - +---> SSH Client (golang.org/x/crypto/ssh) - +---> Loki Deployer (Helm Chart / YAML / Binary) + +---> JSON 文件数据库(data/lokideploy.json) + +---> K8s CLI(kubectl / helm) + +---> SSH 客户端(golang.org/x/crypto/ssh) + +---> Loki 部署器(Helm Chart / YAML / Binary) ``` -## API Endpoints +### 后端分层 -Base URL: `/api/v1` +请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)** -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| POST | `/auth/login` | No | Login, get JWT token | -| GET | `/auth/user` | Yes | Current user info | -| POST | `/auth/password` | Yes | Change password | -| GET/POST | `/clusters` | Yes | Cluster CRUD | -| POST | `/clusters/:id/validate` | Yes | Validate cluster | -| GET/POST | `/hosts` | Yes | Host CRUD | -| POST | `/hosts/:id/execute` | Yes | Remote command | -| GET/POST | `/storages` | Yes | Storage config CRUD | -| GET/POST | `/deployments` | Yes | Deployment CRUD | -| POST | `/deployments/:id/deploy` | Yes | Start deployment | -| POST | `/deployments/:id/stop` | Yes | Stop deployment | -| GET | `/deployments/:id/logs` | Yes | Deployment logs | -| GET | `/deployments/stats` | Yes | Statistics | +- `config/` — `GetConfig()` 单例,读取环境变量,密钥缺失或长度不足时直接 `log.Fatal`。 +- `routers/router.go` — 所有路由注册在 `/api/v1`;注册 SPA 静态资源并用 `NoRoute` 回退到 + `index.html` 支持前端路由。 +- `controllers/` — 薄 Gin handler,每资源一个文件;绑定/校验入参、调用 service、用 + `models.SanitizedJSON(...)` 包装返回。 +- `services/` — 业务逻辑。部署流水线: + `DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按模式(`k8s-helm` / `k8s-yaml` / + `host-binary`)分发到 `kubectl`/`helm` 或 SSH。另有 `K8sClient`、`ssh`、`storage`、 + `cluster_info` 与较新的 **collector(日志采集器)** 模块。 +- `middleware/jwt.go` — `JWTAuth()`、`AdminAuth()`。 +- `utils/` — `response.go`(响应封装)、`crypto.go`(Argon2 + AES-GCM)、`jwt.go`。 +- `seed/` — 数据库为空时写入默认 admin 用户。 -## Deployment Modes +### 数据层 — JSON 文件数据库 -| Mode | Target | Description | -|------|--------|-------------| -| `k8s-helm` | K8s Cluster | Official Grafana Helm chart | -| `k8s-yaml` | K8s Cluster | Raw Kubernetes manifests | -| `host-binary` | Linux Host | Binary + systemd via SSH | +持久化是 **单个 JSON 文件** `$DATA_DIR/lokideploy.json`(默认 `./data/`),**没有 SQL 数据库**。 +`repository.JsonDB` 是与文件对应的内存结构: -## Storage Backends +- 写操作由 `Lock()/RLock()` 保护,`RequestSave()` 触发 **防抖(500ms)原子写**(临时文件 → + `fsync` → rename),由单一串行化的 `saveWorker` goroutine 执行。常规写入用 `RequestSave()`, + 不要在 worker 可能运行时直接 `Save()`。 +- 优雅关停顺序很关键:停防抖定时器 → 关闭 worker 并等待 → 最后一次 `doSave`。顺序颠倒会丢更新。 +- 因此应用 **只能单实例运行**,不可水平扩展。 +- `NextID(table)` 为各集合分配单调递增的整数 ID。 -| Type | Endpoint Example | -|------|-----------------| +> 注:`repository/db.go` 是当前在用的实现;`models/db.go` 是正在迁移、尚未清理的旧副本(仍会编译, +> 但已无人调用)。新增数据库代码请写到 `repository`。 + +### 响应封装与脱敏 + +- 所有 API 响应为 `{ code, message, data }`,`code == 0` 表示成功。 +- **不要直接返回原始 model**,必须用 `models.SanitizedJSON(...)` 包装以剔除密码、kubeconfig、 + 私钥、token 等敏感字段。 + +### 凭证安全 + +- 密码用 **Argon2** 哈希;凭证用 **AES-GCM** 加密,AES 密钥由 `ENCRYPTION_KEY` 经 **HKDF** 派生。 +- `ENCRYPTION_KEY` 必须 **>= 32 字节**(早期文档写 16,现在会导致启动失败)。 + +### 容器安全模型 + +``` ++-------------------+ +---------------------+ +| 宿主内核 | | 宿主文件系统 | +| | | ~/.kube (只读) | +| +-------------+ | | ~/.ssh (只读) | +| | 容器 | | | ./data (读写) | +| | (lokideploy | | +---------------------+ +| | UID:1000) | | +| | no-new-privs| | +| | cap_drop:ALL| | +| +-------------+ | ++-------------------+ +``` + +--- + +## API 接口 + +Base URL:`/api/v1` + +| 方法 | 接口 | 鉴权 | 说明 | +|------|------|------|------| +| POST | `/auth/login` | 否 | 登录,获取 JWT token | +| GET | `/auth/user` | 是 | 当前用户信息 | +| POST | `/auth/password` | 是 | 修改密码 | +| GET/POST | `/clusters` | 是 | 集群增删改查 | +| POST | `/clusters/:id/validate` | 是 | 校验集群连通性 | +| GET/POST | `/hosts` | 是 | 主机增删改查 | +| POST | `/hosts/:id/execute` | 是 | 远程执行命令 | +| GET/POST | `/storages` | 是 | 存储配置增删改查 | +| GET/POST | `/deployments` | 是 | 部署增删改查 | +| POST | `/deployments/:id/deploy` | 是 | 启动部署 | +| POST | `/deployments/:id/stop` | 是 | 停止部署 | +| GET | `/deployments/:id/logs` | 是 | 部署日志 | +| GET | `/deployments/stats` | 是 | 统计信息 | + +### 健康检查 + +容器对静态文件路径暴露健康检查: + +```bash +# 容器级(内部) +wget --spider http://localhost:8080/index.html + +# 宿主级 +curl http://localhost:8080/index.html + +# Docker inspect +docker inspect --format='{{.State.Health}}' lokideploy +``` + +健康检查参数: +- **间隔**:30 秒 +- **超时**:5 秒 +- **启动宽限期**:15 秒(允许启动) +- **重试次数**:3 + +--- + +## 部署模式 + +| 模式 | 目标 | 说明 | +|------|------|------| +| `k8s-helm` | K8s 集群 | 官方 Grafana Helm Chart | +| `k8s-yaml` | K8s 集群 | 原生 Kubernetes 清单 | +| `host-binary` | Linux 主机 | 通过 SSH 部署二进制 + systemd | + +## 存储后端 + +| 类型 | Endpoint 示例 | +|------|---------------| | S3 | `s3.amazonaws.com` | | OSS | `oss-cn-hangzhou.aliyuncs.com` | | MinIO | `http://minio:9000` | | RustFS | `http://rustfs:8080` | -## Environment Variables +--- -| Variable | Default | Description | -|----------|---------|-------------| -| `SERVER_PORT` | `8080` | HTTP server port | -| `DATA_DIR` | `./data` | Data storage directory | -| `JWT_SECRET` | `lokideploy-secret-key...` | JWT signing key | +## 环境变量 -## Security +### 必需 -- **Argon2** password hashing -- **AES-GCM** credential encryption -- **JWT** token authentication -- **RBAC** role-based access control -- API responses sanitized (no passwords, kubeconfig, or keys exposed) +| 变量 | 示例 | 说明 | +|------|------|------| +| `JWT_SECRET` | `$(openssl rand -hex 32)` | JWT 签名密钥(**必需**,>= 32 字符) | +| `ENCRYPTION_KEY` | `$(openssl rand -hex 32)` | AES 凭证加密密钥(**必需**,>= 32 字节) | -## License +### 可选 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `SERVER_PORT` | `8080` | HTTP 服务端口 | +| `DATA_DIR` | `/app/data` | 数据存储目录 | +| `KUBECONFIG` | `/home/lokideploy/.kube/config` | kubeconfig 文件路径 | +| `KUBECONFIG_PATH` | `~/.kube` | 宿主机上 kubeconfig 的挂载源路径 | +| `SSH_KEY_PATH` | `~/.ssh` | 宿主机上 SSH 密钥的挂载源路径 | + +### Docker Secrets 支持 + +entrypoint 支持基于文件的 Docker Secrets 注入: + +| 变量 | 文件变量 | 说明 | +|------|----------|------| +| `JWT_SECRET` | `JWT_SECRET_FILE` | 含 JWT 密钥的 secret 文件路径 | +| `ENCRYPTION_KEY` | `ENCRYPTION_KEY_FILE` | 含加密密钥的 secret 文件路径 | + +配合 Docker Compose secrets 的示例: + +```yaml +services: + lokideploy: + environment: + JWT_SECRET_FILE: /run/secrets/jwt_secret + ENCRYPTION_KEY_FILE: /run/secrets/encryption_key + secrets: + - jwt_secret + - encryption_key + +secrets: + jwt_secret: + file: ./secrets/jwt_secret.txt + encryption_key: + file: ./secrets/encryption_key.txt +``` + +--- + +## 安全 + +### 鉴权与授权 + +- **Argon2** 密码哈希 +- **AES-GCM** 凭证加密(密钥由 `ENCRYPTION_KEY` 派生) +- **JWT** token 鉴权 +- **RBAC** 基于角色的访问控制 +- API 响应经过脱敏(不暴露密码、kubeconfig、密钥) + +### 容器安全 + +- **非 root 用户**:`lokideploy:1000` +- **禁止提权**:`no-new-privileges:true` +- **丢弃能力**:除必需外丢弃所有 capabilities +- **只读挂载**:kubeconfig 与 SSH 密钥只读挂载 +- **资源限制**:CPU 与内存受限 +- **日志轮转**:防止磁盘写满 + +### 密钥管理最佳实践 + +1. **绝不** 把密钥提交到版本控制 +2. 生产环境使用 **Docker Secrets**(Swarm/Kubernetes) +3. 定期 **轮换密钥**:`openssl rand -hex 32` +4. 企业部署可使用密钥保险柜(HashiCorp Vault、AWS Secrets Manager 等) +5. 设置文件权限:对密钥文件执行 `chmod 600` + +--- + +## 资源配置 + +### 默认限制(docker-compose.yml) + +| 资源 | 上限 | 预留 | +|------|------|------| +| CPU | 2.0 核 | 0.25 核 | +| 内存 | 512 MB | 64 MB | +| PIDs | 100 | - | + +### 按规模调整 + +```yaml +# 更大规模部署(10+ 集群) +deploy: + resources: + limits: + cpus: '4.0' + memory: 1G + reservations: + cpus: '1.0' + memory: 256M +``` + +--- + +## 故障排查 + +### 容器立即退出 + +```bash +# 检查密钥是否已设置 +docker compose logs +# 期望错误信息:"Missing required security environment variables" + +# 修复: +export JWT_SECRET=$(openssl rand -hex 32) +export ENCRYPTION_KEY=$(openssl rand -hex 32) +docker compose up -d +``` + +### 健康检查失败 + +```bash +# 查看容器状态 +docker inspect --format='{{.State.Health}}' lokideploy + +# 查看日志 +docker compose logs --tail=50 +``` + +### kubeconfig 不可访问 + +```bash +# 确保宿主侧权限正确 +chmod 600 ~/.kube/config + +# 核对挂载路径 +docker exec lokideploy ls -la /home/lokideploy/.kube/ +``` + +--- + +## 许可证 MIT License + +--- + +## 更新日志 + +### v2.0.0 + +- **安全**:非 root 容器用户(`lokideploy:1000`) +- **安全**:运行时强制校验 `JWT_SECRET`/`ENCRYPTION_KEY` +- **安全**:`no-new-privileges` 与 capabilities 丢弃 +- **安全**:kubectl/helm 的 SHA256 校验和验证 +- **运维**:健康检查端点 +- **运维**:资源限制与预留 +- **运维**:日志轮转(json-file 驱动) +- **运维**:优雅关停(SIGTERM) +- **运维**:Docker Secrets 支持 +- **运维**:带 nginx 负载均衡的生产 compose 文件 diff --git a/backend/config/config.go b/backend/config/config.go index 7a9ceca..39250cb 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -1,6 +1,8 @@ package config import ( + "fmt" + "log" "os" "path/filepath" "sync" @@ -12,24 +14,49 @@ var ( ) type Config struct { - ServerPort string - DatabasePath string - JWTSecret string - DataDir string - KubeDir string + ServerPort string + DatabasePath string + JWTSecret string + EncryptionKey string // 独立加密密钥,必须与JWT_SECRET分离 + DataDir string + KubeDir string + LogLevel string // 新增:日志级别 debug/info/warn/error + HostKeyDir string // 新增:SSH主机密钥存储目录 } func GetConfig() *Config { once.Do(func() { dataDir := getEnv("DATA_DIR", "./data") - instance = &Config{ - ServerPort: getEnv("SERVER_PORT", "8080"), - DatabasePath: filepath.Join(dataDir, "lokideploy.json"), - JWTSecret: getEnv("JWT_SECRET", "lokideploy-secret-key-change-in-production"), - DataDir: dataDir, - KubeDir: filepath.Join(dataDir, "kubeconfigs"), + + jwtSecret := os.Getenv("JWT_SECRET") + encKey := os.Getenv("ENCRYPTION_KEY") + + // 生产环境强制校验:JWT_SECRET和ENCRYPTION_KEY必须设置 + if jwtSecret == "" { + log.Fatal("[SECURITY] JWT_SECRET environment variable is required. Please set a strong random secret.") } - _ = os.MkdirAll(instance.KubeDir, 0755) + if encKey == "" { + log.Fatal("[SECURITY] ENCRYPTION_KEY environment variable is required. Please set a strong 32-byte random key (base64 or hex encoded).") + } + + // ENCRYPTION_KEY 长度校验:至少32字节(256位) + if len(encKey) < 32 { + log.Fatal(fmt.Sprintf("[SECURITY] ENCRYPTION_KEY must be at least 32 bytes, got %d bytes", len(encKey))) + } + + instance = &Config{ + ServerPort: getEnv("SERVER_PORT", "8080"), + DatabasePath: filepath.Join(dataDir, "lokideploy.json"), + JWTSecret: jwtSecret, + EncryptionKey: encKey, + DataDir: dataDir, + KubeDir: filepath.Join(dataDir, "kubeconfigs"), + LogLevel: getEnv("LOG_LEVEL", "info"), + HostKeyDir: filepath.Join(dataDir, "known_hosts"), + } + + _ = os.MkdirAll(instance.KubeDir, 0700) + _ = os.MkdirAll(instance.HostKeyDir, 0700) }) return instance } diff --git a/backend/controllers/auth.go b/backend/controllers/auth.go index a022fbf..5b976f9 100644 --- a/backend/controllers/auth.go +++ b/backend/controllers/auth.go @@ -1,7 +1,9 @@ package controllers import ( + "lokideploy/dto" "lokideploy/models" + "lokideploy/pkg/jwt" "lokideploy/services" "lokideploy/utils" @@ -16,38 +18,43 @@ func NewAuthController() *AuthController { return &AuthController{service: services.NewAuthService()} } -type LoginRequest struct { - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` -} - -type RegisterRequest struct { - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required,min=6"` - Nickname string `json:"nickname"` - Role string `json:"role"` -} - -type ChangePasswordRequest struct { - OldPassword string `json:"oldPassword" binding:"required"` - NewPassword string `json:"newPassword" binding:"required,min=6"` -} - func (ctrl *AuthController) Login(c *gin.Context) { - var req LoginRequest + var req dto.LoginRequest if !utils.BindAndValidate(c, &req) { return } - user, token, err := ctrl.service.Login(req.Username, req.Password) + user, tokenPair, err := ctrl.service.Login(req.Username, req.Password) if err != nil { utils.Unauthorized(c, "用户名或密码错误") return } - utils.Success(c, gin.H{"token": token, "user": models.SanitizedJSON(user)}) + utils.Success(c, gin.H{ + "accessToken": tokenPair.AccessToken, + "refreshToken": tokenPair.RefreshToken, + "expiresAt": tokenPair.ExpiresAt.Unix(), + "user": models.SanitizedJSON(user), + }) +} + +func (ctrl *AuthController) RefreshToken(c *gin.Context) { + var req dto.RefreshTokenRequest + if !utils.BindAndValidate(c, &req) { + return + } + tokenPair, err := jwt.RefreshAccessToken(req.RefreshToken) + if err != nil { + utils.Unauthorized(c, "invalid or expired refresh token") + return + } + utils.Success(c, gin.H{ + "accessToken": tokenPair.AccessToken, + "refreshToken": tokenPair.RefreshToken, + "expiresAt": tokenPair.ExpiresAt.Unix(), + }) } func (ctrl *AuthController) Register(c *gin.Context) { - var req RegisterRequest + var req dto.RegisterRequest if !utils.BindAndValidate(c, &req) { return } @@ -63,7 +70,7 @@ func (ctrl *AuthController) Register(c *gin.Context) { } func (ctrl *AuthController) GetUser(c *gin.Context) { - claims, ok := utils.GetUserFromContext(c) + claims, ok := jwt.GetUserFromContext(c) if !ok { utils.Unauthorized(c, "") return @@ -77,11 +84,11 @@ func (ctrl *AuthController) GetUser(c *gin.Context) { } func (ctrl *AuthController) ChangePassword(c *gin.Context) { - var req ChangePasswordRequest + var req dto.ChangePasswordRequest if !utils.BindAndValidate(c, &req) { return } - claims, ok := utils.GetUserFromContext(c) + claims, ok := jwt.GetUserFromContext(c) if !ok { utils.Unauthorized(c, "") return diff --git a/backend/controllers/cluster.go b/backend/controllers/cluster.go index f073887..e19fbb5 100644 --- a/backend/controllers/cluster.go +++ b/backend/controllers/cluster.go @@ -1,6 +1,7 @@ package controllers import ( + "lokideploy/dto" "lokideploy/models" "lokideploy/services" "lokideploy/utils" @@ -16,18 +17,8 @@ func NewClusterController() *ClusterController { return &ClusterController{service: services.NewClusterService()} } -type CreateClusterRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - ConfigType string `json:"configType"` // kubeconfig | token - APIServer string `json:"apiServer"` // token 模式必填 - KubeConfig string `json:"kubeconfig"` // kubeconfig 模式必填 - Token string `json:"token"` // token 模式必填 - Context string `json:"context"` -} - func (ctrl *ClusterController) Create(c *gin.Context) { - var req CreateClusterRequest + var req dto.CreateClusterRequest if !utils.BindAndValidate(c, &req) { return } diff --git a/backend/controllers/collector.go b/backend/controllers/collector.go index b55cddd..714de27 100644 --- a/backend/controllers/collector.go +++ b/backend/controllers/collector.go @@ -1,6 +1,7 @@ package controllers import ( + "lokideploy/dto" "lokideploy/models" "lokideploy/services" "lokideploy/utils" @@ -16,20 +17,8 @@ func NewCollectorController() *CollectorController { return &CollectorController{service: services.NewCollectorService()} } -type CreateCollectorRequest struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required"` - LokiDeploymentID uint `json:"lokiDeploymentID" binding:"required"` - HostID *uint `json:"hostID"` - ClusterID *uint `json:"clusterID"` - TargetDeployment string `json:"targetDeployment"` - Namespace string `json:"namespace"` - LogPath string `json:"logPath"` - Labels string `json:"labels"` -} - func (ctrl *CollectorController) Create(c *gin.Context) { - var req CreateCollectorRequest + var req dto.CreateCollectorRequest if !utils.BindAndValidate(c, &req) { return } diff --git a/backend/controllers/deployment.go b/backend/controllers/deployment.go index db70217..36e0777 100644 --- a/backend/controllers/deployment.go +++ b/backend/controllers/deployment.go @@ -1,6 +1,8 @@ package controllers import ( + "lokideploy/dto" + "lokideploy/models" "lokideploy/services" "lokideploy/utils" @@ -15,25 +17,8 @@ func NewDeploymentController() *DeploymentController { return &DeploymentController{service: services.NewDeploymentService()} } -type CreateDeploymentRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Version string `json:"version"` - Mode string `json:"mode" binding:"required,oneof=k8s-helm k8s-yaml host-binary"` - ClusterID *uint `json:"clusterID"` - HostID *uint `json:"hostID"` - Namespace string `json:"namespace"` - StorageConfigID uint `json:"storageConfigID"` - AuthEnabled bool `json:"authEnabled"` - RetentionDays int `json:"retentionDays"` - Replicas int `json:"replicas"` - ResourcesCPU string `json:"resourcesCPU"` - ResourcesMem string `json:"resourcesMem"` - StorageSize string `json:"storageSize"` -} - func (ctrl *DeploymentController) Create(c *gin.Context) { - var req CreateDeploymentRequest + var req dto.CreateDeploymentRequest if !utils.BindAndValidate(c, &req) { return } @@ -57,7 +42,7 @@ func (ctrl *DeploymentController) Create(c *gin.Context) { utils.Error(c, 400, err.Error()) return } - utils.Success(c, deployment) + utils.Success(c, models.SanitizedJSON(deployment)) } func (ctrl *DeploymentController) Get(c *gin.Context) { @@ -71,7 +56,7 @@ func (ctrl *DeploymentController) Get(c *gin.Context) { utils.NotFound(c, "部署不存在") return } - utils.Success(c, deployment) + utils.Success(c, models.SanitizedJSON(deployment)) } func (ctrl *DeploymentController) List(c *gin.Context) { @@ -82,7 +67,7 @@ func (ctrl *DeploymentController) List(c *gin.Context) { return } utils.Success(c, utils.PageData{ - List: deployments, Total: total, Page: page, PageSize: pageSize, + List: models.SanitizedJSON(deployments), Total: total, Page: page, PageSize: pageSize, }) } diff --git a/backend/controllers/host.go b/backend/controllers/host.go index 14e1924..1238d25 100644 --- a/backend/controllers/host.go +++ b/backend/controllers/host.go @@ -1,7 +1,9 @@ package controllers import ( + "lokideploy/dto" "lokideploy/models" + "lokideploy/pkg/crypto" "lokideploy/services" "lokideploy/utils" @@ -16,22 +18,8 @@ func NewHostController() *HostController { return &HostController{service: services.NewHostService()} } -type CreateHostRequest struct { - Name string `json:"name" binding:"required"` - HostIP string `json:"hostIP" binding:"required"` - Port int `json:"port"` - User string `json:"user" binding:"required"` - AuthType string `json:"authType" binding:"required,oneof=password key"` - Password string `json:"password"` - PrivateKey string `json:"privateKey"` - KeyPhrase string `json:"keyPhrase"` - OS string `json:"os"` - Arch string `json:"arch"` - Labels string `json:"labels"` -} - func (ctrl *HostController) Create(c *gin.Context) { - var req CreateHostRequest + var req dto.CreateHostRequest if !utils.BindAndValidate(c, &req) { return } @@ -125,7 +113,7 @@ func (ctrl *HostController) GetInfo(c *gin.Context) { password := host.Password if len(password) > 4 && password[:4] == "ENC:" { - password, _ = utils.Decrypt(password[4:]) + password, _ = crypto.Decrypt(password[4:]) } client, err := services.NewSSHClient(host.HostIP, host.Port, host.User, password) diff --git a/backend/controllers/storage.go b/backend/controllers/storage.go index a06f1ed..8028512 100644 --- a/backend/controllers/storage.go +++ b/backend/controllers/storage.go @@ -1,6 +1,7 @@ package controllers import ( + "lokideploy/dto" "lokideploy/models" "lokideploy/services" "lokideploy/utils" @@ -16,21 +17,8 @@ func NewStorageController() *StorageController { return &StorageController{service: services.NewStorageService()} } -type CreateStorageRequest struct { - Name string `json:"name" binding:"required"` - StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs"` - Endpoint string `json:"endpoint"` - Bucket string `json:"bucket" binding:"required"` - Region string `json:"region"` - AccessKey string `json:"accessKey"` - SecretKey string `json:"secretKey"` - PathPrefix string `json:"pathPrefix"` - Insecure bool `json:"insecure"` - IsDefault bool `json:"isDefault"` -} - func (ctrl *StorageController) Create(c *gin.Context) { - var req CreateStorageRequest + var req dto.CreateStorageRequest if !utils.BindAndValidate(c, &req) { return } diff --git a/backend/dto/auth.go b/backend/dto/auth.go new file mode 100644 index 0000000..bfae647 --- /dev/null +++ b/backend/dto/auth.go @@ -0,0 +1,25 @@ +// dto 包集中定义 HTTP 请求/响应契约(带 binding 校验标签)。 +// controller 绑定 dto.XxxRequest,再映射到 service 层输入,使传输契约与业务逻辑解耦。 +package dto + +// 认证域 +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type RefreshTokenRequest struct { + RefreshToken string `json:"refreshToken" binding:"required"` +} + +type RegisterRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required,min=6"` + Nickname string `json:"nickname"` + Role string `json:"role"` +} + +type ChangePasswordRequest struct { + OldPassword string `json:"oldPassword" binding:"required"` + NewPassword string `json:"newPassword" binding:"required,min=6"` +} diff --git a/backend/dto/cluster.go b/backend/dto/cluster.go new file mode 100644 index 0000000..64640a1 --- /dev/null +++ b/backend/dto/cluster.go @@ -0,0 +1,12 @@ +package dto + +// 集群域 +type CreateClusterRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + ConfigType string `json:"configType"` // kubeconfig | token + APIServer string `json:"apiServer"` // token 模式必填 + KubeConfig string `json:"kubeconfig"` // kubeconfig 模式必填 + Token string `json:"token"` // token 模式必填 + Context string `json:"context"` +} diff --git a/backend/dto/collector.go b/backend/dto/collector.go new file mode 100644 index 0000000..aa61e21 --- /dev/null +++ b/backend/dto/collector.go @@ -0,0 +1,14 @@ +package dto + +// 采集器域 +type CreateCollectorRequest struct { + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` + LokiDeploymentID uint `json:"lokiDeploymentID" binding:"required"` + HostID *uint `json:"hostID"` + ClusterID *uint `json:"clusterID"` + TargetDeployment string `json:"targetDeployment"` + Namespace string `json:"namespace"` + LogPath string `json:"logPath"` + Labels string `json:"labels"` +} diff --git a/backend/dto/deployment.go b/backend/dto/deployment.go new file mode 100644 index 0000000..6c1c695 --- /dev/null +++ b/backend/dto/deployment.go @@ -0,0 +1,19 @@ +package dto + +// Loki 部署域 +type CreateDeploymentRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Version string `json:"version"` + Mode string `json:"mode" binding:"required,oneof=k8s-helm k8s-yaml host-binary"` + ClusterID *uint `json:"clusterID"` + HostID *uint `json:"hostID"` + Namespace string `json:"namespace"` + StorageConfigID uint `json:"storageConfigID"` + AuthEnabled bool `json:"authEnabled"` + RetentionDays int `json:"retentionDays"` + Replicas int `json:"replicas"` + ResourcesCPU string `json:"resourcesCPU"` + ResourcesMem string `json:"resourcesMem"` + StorageSize string `json:"storageSize"` +} diff --git a/backend/dto/host.go b/backend/dto/host.go new file mode 100644 index 0000000..859ae66 --- /dev/null +++ b/backend/dto/host.go @@ -0,0 +1,16 @@ +package dto + +// 主机域 +type CreateHostRequest struct { + Name string `json:"name" binding:"required"` + HostIP string `json:"hostIP" binding:"required"` + Port int `json:"port"` + User string `json:"user" binding:"required"` + AuthType string `json:"authType" binding:"required,oneof=password key"` + Password string `json:"password"` + PrivateKey string `json:"privateKey"` + KeyPhrase string `json:"keyPhrase"` + OS string `json:"os"` + Arch string `json:"arch"` + Labels string `json:"labels"` +} diff --git a/backend/dto/storage.go b/backend/dto/storage.go new file mode 100644 index 0000000..52de35d --- /dev/null +++ b/backend/dto/storage.go @@ -0,0 +1,15 @@ +package dto + +// 存储域 +type CreateStorageRequest struct { + Name string `json:"name" binding:"required"` + StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs"` + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket" binding:"required"` + Region string `json:"region"` + AccessKey string `json:"accessKey"` + SecretKey string `json:"secretKey"` + PathPrefix string `json:"pathPrefix"` + Insecure bool `json:"insecure"` + IsDefault bool `json:"isDefault"` +} diff --git a/backend/main.go b/backend/main.go index 919e1d6..5edd4a6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,10 +1,16 @@ package main import ( + "context" + "errors" "log" + "net/http" + "os/signal" + "syscall" + "time" "lokideploy/config" - "lokideploy/models" + "lokideploy/repository" "lokideploy/routers" "lokideploy/seed" ) @@ -13,7 +19,7 @@ func main() { cfg := config.GetConfig() log.Println("Initializing database...") - if err := models.InitDB(); err != nil { + if err := repository.InitDB(); err != nil { log.Fatalf("Failed to init DB: %v", err) } @@ -21,12 +27,40 @@ func main() { seed.Seed() r := routers.SetupRouter() - - log.Printf("LokiDeploy starting on port %s", cfg.ServerPort) - log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort) - log.Printf("Default login: admin / admin123") - - if err := r.Run(":" + cfg.ServerPort); err != nil { - log.Fatalf("Server failed: %v", err) + srv := &http.Server{ + Addr: ":" + cfg.ServerPort, + Handler: r, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, } + + // 监听中断/终止信号,实现优雅关停:先停 HTTP(拒绝新请求、处理完在途请求), + // 再 flush 数据库,避免防抖/异步保存丢失最近写入。 + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + log.Printf("LokiDeploy starting on port %s", cfg.ServerPort) + log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort) + log.Printf("Default login: admin / admin123") + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("Server failed: %v", err) + } + }() + + <-ctx.Done() + log.Println("Shutdown signal received, draining connections...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("HTTP shutdown error: %v", err) + } + + log.Println("Flushing database...") + if err := repository.Shutdown(); err != nil { + log.Printf("DB shutdown error: %v", err) + } + log.Println("LokiDeploy stopped") } diff --git a/backend/middleware/jwt.go b/backend/middleware/jwt.go index 697c9a4..d489402 100644 --- a/backend/middleware/jwt.go +++ b/backend/middleware/jwt.go @@ -3,6 +3,7 @@ package middleware import ( "strings" + "lokideploy/pkg/jwt" "lokideploy/utils" "github.com/gin-gonic/gin" @@ -24,13 +25,20 @@ func JWTAuth() gin.HandlerFunc { return } - claims, err := utils.ParseToken(parts[1]) + claims, err := jwt.ParseToken(parts[1]) if err != nil { utils.Unauthorized(c, "invalid or expired token") c.Abort() return } + // 安全增强:拒绝refresh token用于API访问 + if claims.TokenType == "refresh" { + utils.Unauthorized(c, "refresh token cannot be used for API access") + c.Abort() + return + } + c.Set("user", claims) c.Set("userId", claims.UserID) c.Set("username", claims.Username) diff --git a/backend/models/cluster.go b/backend/models/cluster.go index ed8c1be..6d2694a 100644 --- a/backend/models/cluster.go +++ b/backend/models/cluster.go @@ -1,14 +1,18 @@ package models -import "time" +import ( + "time" + + "lokideploy/pkg/crypto" +) type Cluster struct { ID uint `json:"id"` Name string `json:"name"` Description string `json:"description"` - ConfigType string `json:"configType"` // kubeconfig | token(空值视为 kubeconfig,向后兼容) + ConfigType string `json:"configType"` // kubeconfig | token APIServer string `json:"apiServer"` // 展示用 - KubeConfig string `json:"kubeconfig"` // 统一存储(token 接入时存构造出的等价 kubeconfig) + KubeConfig string `json:"kubeconfig"` // 统一存储(加密存储时带ENC:前缀) KubeConfigPath string `json:"kubeconfigPath"` Context string `json:"context"` Version string `json:"version"` @@ -36,3 +40,24 @@ func (c *Cluster) Sanitized() map[string]interface{} { "updatedAt": c.UpdatedAt, } } + +// DecryptKubeconfig 解密集群的kubeconfig配置 +// kubeconfig字段可能以ENC:前缀存储加密数据 +func (c *Cluster) DecryptKubeconfig() string { + return crypto.DecryptField(c.KubeConfig) +} + +// IsKubeconfigEncrypted 检查kubeconfig是否已加密存储 +func (c *Cluster) IsKubeconfigEncrypted() bool { + return crypto.IsEncryptedField(c.KubeConfig) +} + +// EncryptAndSetKubeconfig 加密并设置kubeconfig +func (c *Cluster) EncryptAndSetKubeconfig(kubeconfig string) error { + enc, err := crypto.EncryptField(kubeconfig) + if err != nil { + return err + } + c.KubeConfig = enc + return nil +} diff --git a/backend/models/db.go b/backend/models/db.go deleted file mode 100644 index d0f8394..0000000 --- a/backend/models/db.go +++ /dev/null @@ -1,115 +0,0 @@ -package models - -import ( - "encoding/json" - "lokideploy/config" - "os" - "sync" -) - -type JsonDB struct { - mu sync.RWMutex - dataFile string - Users []User `json:"users"` - Clusters []Cluster `json:"clusters"` - Hosts []Host `json:"hosts"` - Storages []StorageConfig `json:"storages"` - Deployments []LokiDeployment `json:"deployments"` - Collectors []Collector `json:"collectors"` - Logs []DeploymentLog `json:"logs"` - Settings []SystemSetting `json:"settings"` - sequences map[string]uint -} - -var db *JsonDB - -func GetDB() *JsonDB { - return db -} - -func InitDB() error { - cfg := config.GetConfig() - _ = os.MkdirAll(cfg.DataDir, 0755) - - d := &JsonDB{ - dataFile: cfg.DatabasePath, - Users: []User{}, - Clusters: []Cluster{}, - Hosts: []Host{}, - Storages: []StorageConfig{}, - Deployments: []LokiDeployment{}, - Collectors: []Collector{}, - Logs: []DeploymentLog{}, - Settings: []SystemSetting{}, - sequences: map[string]uint{ - "users": 0, - "clusters": 0, - "hosts": 0, - "storages": 0, - "deployments": 0, - "collectors": 0, - "logs": 0, - }, - } - - if data, err := os.ReadFile(d.dataFile); err == nil { - if err := json.Unmarshal(data, d); err == nil { - d.sequences = map[string]uint{} - for _, u := range d.Users { if u.ID > d.sequences["users"] { d.sequences["users"] = u.ID } } - for _, c := range d.Clusters { if c.ID > d.sequences["clusters"] { d.sequences["clusters"] = c.ID } } - for _, h := range d.Hosts { if h.ID > d.sequences["hosts"] { d.sequences["hosts"] = h.ID } } - for _, s := range d.Storages { if s.ID > d.sequences["storages"] { d.sequences["storages"] = s.ID } } - for _, de := range d.Deployments { if de.ID > d.sequences["deployments"] { d.sequences["deployments"] = de.ID } } - for _, col := range d.Collectors { if col.ID > d.sequences["collectors"] { d.sequences["collectors"] = col.ID } } - for _, l := range d.Logs { if l.ID > d.sequences["logs"] { d.sequences["logs"] = l.ID } } - } - } - - db = d - return nil -} - -func (d *JsonDB) Save() error { - d.mu.Lock() - defer d.mu.Unlock() - data, err := json.MarshalIndent(d, "", " ") - if err != nil { - return err - } - return os.WriteFile(d.dataFile, data, 0644) -} - -func (d *JsonDB) NextID(table string) uint { - d.mu.Lock() - defer d.mu.Unlock() - d.sequences[table]++ - return d.sequences[table] -} - -func (d *JsonDB) Lock() { - d.mu.Lock() -} - -func (d *JsonDB) Unlock() { - d.mu.Unlock() -} - -func (d *JsonDB) RLock() { - d.mu.RLock() -} - -func (d *JsonDB) RUnlock() { - d.mu.RUnlock() -} - -func (d *JsonDB) CreateUser(user *User) error { - d.mu.Lock() - defer d.mu.Unlock() - if user.ID == 0 { - user.ID = d.sequences["users"] + 1 - d.sequences["users"] = user.ID - } - d.Users = append(d.Users, *user) - go d.Save() - return nil -} diff --git a/backend/models/deployment.go b/backend/models/deployment.go index b96cc59..d54c007 100644 --- a/backend/models/deployment.go +++ b/backend/models/deployment.go @@ -41,3 +41,44 @@ func (d *LokiDeployment) BeforeCreate() { d.UUID = uuid.New().String() } } + +// Sanitized 返回去除敏感字段(AdminPassword)的部署视图,并对嵌套的 +// Cluster/Host/StorageConfig 一并做脱敏,避免经部署接口泄漏各自凭证。 +func (d *LokiDeployment) Sanitized() map[string]interface{} { + out := map[string]interface{}{ + "id": d.ID, + "uuid": d.UUID, + "name": d.Name, + "description": d.Description, + "version": d.Version, + "mode": d.Mode, + "clusterID": d.ClusterID, + "hostID": d.HostID, + "namespace": d.Namespace, + "storageConfigID": d.StorageConfigID, + "authEnabled": d.AuthEnabled, + "retentionDays": d.RetentionDays, + "replicas": d.Replicas, + "resourcesCPU": d.ResourcesCPU, + "resourcesMem": d.ResourcesMem, + "storageSize": d.StorageSize, + "status": d.Status, + "statusMessage": d.StatusMessage, + "helmValues": d.HelmValues, + "endpoint": d.Endpoint, + "adminUser": d.AdminUser, + // AdminPassword 故意省略 + "createdAt": d.CreatedAt, + "updatedAt": d.UpdatedAt, + } + if d.Cluster != nil { + out["cluster"] = d.Cluster.Sanitized() + } + if d.Host != nil { + out["host"] = d.Host.Sanitized() + } + if d.StorageConfig != nil { + out["storageConfig"] = d.StorageConfig.Sanitized() + } + return out +} diff --git a/backend/models/host.go b/backend/models/host.go index d5571e8..bee615b 100644 --- a/backend/models/host.go +++ b/backend/models/host.go @@ -1,7 +1,23 @@ package models -import "time" +import ( + "time" + "lokideploy/pkg/crypto" +) + +// SSHAuthConfig SSH认证配置(定义在models包避免循环导入) +type SSHAuthConfig struct { + Host string + Port int + User string + Password string + PrivateKey string + KeyPhrase string +} + +// Host 主机模型,敏感字段(Password/PrivateKey/KeyPhrase)加密存储 +// 加密格式: ENC: type Host struct { ID uint `json:"id"` Name string `json:"name"` @@ -9,9 +25,9 @@ type Host struct { Port int `json:"port"` User string `json:"user"` AuthType string `json:"authType"` - Password string `json:"password"` - PrivateKey string `json:"privateKey"` - KeyPhrase string `json:"keyPhrase"` + Password string `json:"password"` // 加密存储(ENC:前缀) + PrivateKey string `json:"privateKey"` // 加密存储(ENC:前缀) + KeyPhrase string `json:"keyPhrase"` // 加密存储(ENC:前缀) OS string `json:"os"` Arch string `json:"arch"` Status string `json:"status"` @@ -36,3 +52,70 @@ func (h *Host) Sanitized() map[string]interface{} { "updatedAt": h.UpdatedAt, } } + +// DecryptPassword 解密主机密码 +func (h *Host) DecryptPassword() string { + return crypto.DecryptField(h.Password) +} + +// DecryptPrivateKey 解密SSH私钥 +func (h *Host) DecryptPrivateKey() string { + return crypto.DecryptField(h.PrivateKey) +} + +// DecryptKeyPhrase 解密私钥密码 +func (h *Host) DecryptKeyPhrase() string { + return crypto.DecryptField(h.KeyPhrase) +} + +// IsPasswordEncrypted 检查密码是否已加密 +func (h *Host) IsPasswordEncrypted() bool { + return crypto.IsEncryptedField(h.Password) +} + +// IsPrivateKeyEncrypted 检查私钥是否已加密 +func (h *Host) IsPrivateKeyEncrypted() bool { + return crypto.IsEncryptedField(h.PrivateKey) +} + +// EncryptAndSetPassword 加密并设置密码 +func (h *Host) EncryptAndSetPassword(password string) error { + enc, err := crypto.EncryptField(password) + if err != nil { + return err + } + h.Password = enc + return nil +} + +// EncryptAndSetPrivateKey 加密并设置私钥 +func (h *Host) EncryptAndSetPrivateKey(privateKey string) error { + enc, err := crypto.EncryptField(privateKey) + if err != nil { + return err + } + h.PrivateKey = enc + return nil +} + +// EncryptAndSetKeyPhrase 加密并设置私钥密码 +func (h *Host) EncryptAndSetKeyPhrase(keyPhrase string) error { + enc, err := crypto.EncryptField(keyPhrase) + if err != nil { + return err + } + h.KeyPhrase = enc + return nil +} + +// GetSSHAuthConfig 获取SSH认证配置(自动解密敏感字段) +func (h *Host) GetSSHAuthConfig() *SSHAuthConfig { + return &SSHAuthConfig{ + Host: h.HostIP, + Port: h.Port, + User: h.User, + Password: h.DecryptPassword(), + PrivateKey: h.DecryptPrivateKey(), + KeyPhrase: h.DecryptKeyPhrase(), + } +} diff --git a/backend/models/sanitizer.go b/backend/models/sanitizer.go index 095725f..5152a4b 100644 --- a/backend/models/sanitizer.go +++ b/backend/models/sanitizer.go @@ -1,49 +1,48 @@ package models // Sanitizer interface for models that can sanitize themselves +// 用于从API响应中移除敏感字段 type Sanitizer interface { Sanitized() map[string]interface{} } -// SanitizedJSON sanitizes a single model or a slice of models +// SanitizedJSON sanitizes a single model or a slice of models. +// 使用接口替代类型断言,更优雅且可扩展。 func SanitizedJSON(v any) any { switch val := v.(type) { - // Single objects - case User: + // Single objects: 尝试Sanitizer接口 + case Sanitizer: return val.Sanitized() case *User: if val == nil { return nil } return val.Sanitized() - case Cluster: - return val.Sanitized() case *Cluster: if val == nil { return nil } return val.Sanitized() - case Host: - return val.Sanitized() case *Host: if val == nil { return nil } return val.Sanitized() - case StorageConfig: - return val.Sanitized() case *StorageConfig: if val == nil { return nil } return val.Sanitized() - case Collector: - return val.Sanitized() case *Collector: if val == nil { return nil } return val.Sanitized() + case *LokiDeployment: + if val == nil { + return nil + } + return val.Sanitized() // Slices case []User: result := make([]map[string]interface{}, len(val)) @@ -75,7 +74,20 @@ func SanitizedJSON(v any) any { result[i] = item.Sanitized() } return result + case []LokiDeployment: + result := make([]map[string]interface{}, len(val)) + for i, item := range val { + result[i] = item.Sanitized() + } + return result + case []Sanitizer: + result := make([]map[string]interface{}, len(val)) + for i, item := range val { + result[i] = item.Sanitized() + } + return result default: + // 尝试通过反射处理指针类型 return v } } diff --git a/backend/pkg/crypto/crypto.go b/backend/pkg/crypto/crypto.go new file mode 100644 index 0000000..b9b81d3 --- /dev/null +++ b/backend/pkg/crypto/crypto.go @@ -0,0 +1,173 @@ +// Package crypto 提供可复用的安全原语:AES-256-GCM 字段加解密、argon2id 口令哈希。 +// 密钥经 HKDF 从 config.EncryptionKey 派生(与 JWT 密钥分离)。 +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "io" + "strings" + + "lokideploy/config" + + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/hkdf" +) + +const ( + keySaltPrefix = "lokideploy-v2-enc:" // HKDF info prefix for key derivation +) + +// deriveEncryptionKey 使用HKDF从ENCRYPTION_KEY派生AES-256密钥 +// 这样即使ENCRYPTION_KEY被复用,不同上下文的密钥也不同 +func deriveEncryptionKey() ([]byte, error) { + cfg := config.GetConfig() + encKey := cfg.EncryptionKey + + // 支持hex或base64编码的密钥 + var masterKey []byte + var err error + if len(encKey) == 64 { // 可能是hex编码的32字节 + masterKey, err = hex.DecodeString(encKey) + if err != nil { + masterKey = []byte(encKey) // fallback: 直接使用原始字节 + } + } else if len(encKey) > 32 { // 可能是base64编码 + masterKey, err = base64.StdEncoding.DecodeString(encKey) + if err != nil { + masterKey = []byte(encKey) // fallback: 直接使用原始字节 + } + } else { + masterKey = []byte(encKey) + } + + // 使用HKDF-SHA256派生固定长度的AES密钥 + hkdfReader := hkdf.New(sha256.New, masterKey, nil, []byte(keySaltPrefix+"aes-gcm-256")) + aesKey := make([]byte, 32) + if _, err := io.ReadFull(hkdfReader, aesKey); err != nil { + return nil, err + } + return aesKey, nil +} + +func HashPassword(password string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32) + combined := append(salt, hash...) + return base64.StdEncoding.EncodeToString(combined), nil +} + +func VerifyPassword(password, hashStr string) bool { + combined, err := base64.StdEncoding.DecodeString(hashStr) + if err != nil || len(combined) < 16 { + return false + } + salt := combined[:16] + hash := combined[16:] + newHash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32) + return subtle.ConstantTimeCompare(hash, newHash) == 1 +} + +func Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + + key, err := deriveEncryptionKey() + if err != nil { + return "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func Decrypt(ciphertext string) (string, error) { + if ciphertext == "" { + return "", nil + } + + key, err := deriveEncryptionKey() + if err != nil { + return "", err + } + + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(data) < gcm.NonceSize() { + return "", errors.New("ciphertext too short") + } + nonce, ciphertextBytes := data[:gcm.NonceSize()], data[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +// EncryptField 加密敏感字段,返回 ENC: 前缀标记格式。 +// 加密失败时返回错误(绝不返回明文或哨兵串),调用方必须处理该错误, +// 否则可能把垃圾值当凭证落库。 +func EncryptField(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + enc, err := Encrypt(plaintext) + if err != nil { + return "", err + } + return "ENC:" + enc, nil +} + +// DecryptField 解密 ENC: 前缀标记的字段 +func DecryptField(encrypted string) string { + if encrypted == "" { + return "" + } + if strings.HasPrefix(encrypted, "ENC:") { + plain, err := Decrypt(encrypted[4:]) + if err != nil { + return "" + } + return plain + } + // 未加密的数据直接返回(向后兼容) + return encrypted +} + +// IsEncryptedField 检查字段是否已加密 +func IsEncryptedField(value string) bool { + return strings.HasPrefix(value, "ENC:") +} diff --git a/backend/pkg/crypto/crypto_test.go b/backend/pkg/crypto/crypto_test.go new file mode 100644 index 0000000..d3a14f9 --- /dev/null +++ b/backend/pkg/crypto/crypto_test.go @@ -0,0 +1,78 @@ +package crypto + +import ( + "strings" + "testing" +) + +// TestEncryptDecryptRoundTrip 覆盖 AES-GCM 加解密往返(含空串与 Unicode)。 +func TestEncryptDecryptRoundTrip(t *testing.T) { + cases := []string{"", "hello", "a-much-longer-secret-with-unicode-密码-🔑"} + for _, plain := range cases { + enc, err := Encrypt(plain) + if err != nil { + t.Fatalf("Encrypt(%q) error: %v", plain, err) + } + dec, err := Decrypt(enc) + if err != nil { + t.Fatalf("Decrypt(%q) error: %v", plain, err) + } + if dec != plain { + t.Fatalf("round-trip mismatch: got %q want %q", dec, plain) + } + } +} + +// TestDecryptTamperedFails 验证 GCM 认证:篡改密文必须解密失败。 +func TestDecryptTamperedFails(t *testing.T) { + enc, err := Encrypt("secret-value") + if err != nil { + t.Fatal(err) + } + if len(enc) < 4 { + t.Fatal("ciphertext too short") + } + // 翻转末尾两个 base64 字符,破坏 GCM 认证标签 + tampered := enc[:len(enc)-2] + "AA" + if _, err := Decrypt(tampered); err == nil { + t.Fatal("expected Decrypt to fail on tampered ciphertext, got nil") + } +} + +// TestEncryptFieldContract 是 M2 的回归保护: +// EncryptField 绝不能返回 "ENC_ERR:" 哨兵(否则 DecryptField 会把它当明文凭证用), +// 只能返回 ""(空输入)或 "ENC:" 前缀;且与 DecryptField 可往返。 +func TestEncryptFieldContract(t *testing.T) { + enc, err := EncryptField("super-secret") + if err != nil { + t.Fatalf("EncryptField error: %v", err) + } + if !strings.HasPrefix(enc, "ENC:") { + t.Fatalf("EncryptField must return ENC:-prefixed value, got %q", enc) + } + if enc == "ENC_ERR:" { + t.Fatal("EncryptField returned legacy sentinel ENC_ERR: (M2 regression)") + } + if got := DecryptField(enc); got != "super-secret" { + t.Fatalf("DecryptField round-trip: got %q want super-secret", got) + } + if !IsEncryptedField(enc) { + t.Fatal("IsEncryptedField should be true for ENC: value") + } + if IsEncryptedField("plain-value") { + t.Fatal("IsEncryptedField should be false for plain value") + } + // 空输入约定 + if enc, err := EncryptField(""); enc != "" || err != nil { + t.Fatalf("EncryptField(\"\") = (%q,%v), want (\"\",nil)", enc, err) + } +} + +// TestEncryptFieldRandomNonce 同一明文两次加密应产生不同密文(随机 nonce)。 +func TestEncryptFieldRandomNonce(t *testing.T) { + a, _ := EncryptField("same-secret") + b, _ := EncryptField("same-secret") + if a == b { + t.Fatal("expected different ciphertexts due to random nonce") + } +} diff --git a/backend/pkg/crypto/main_test.go b/backend/pkg/crypto/main_test.go new file mode 100644 index 0000000..a9b556c --- /dev/null +++ b/backend/pkg/crypto/main_test.go @@ -0,0 +1,15 @@ +package crypto + +import ( + "os" + "testing" +) + +// TestMain 预置安全环境变量。deriveEncryptionKey 经 config.GetConfig() 读取 +// ENCRYPTION_KEY,缺失或 <32 字符时 log.Fatal,故须先设置。 +func TestMain(m *testing.M) { + os.Setenv("JWT_SECRET", "crypto-pkg-test-secret-32chars!!") + os.Setenv("ENCRYPTION_KEY", "crypto-pkg-test-encryption-32ch!!") + os.Setenv("DATA_DIR", os.TempDir()+"/lokideploy-crypto-pkg-test") + os.Exit(m.Run()) +} diff --git a/backend/pkg/jwt/jwt.go b/backend/pkg/jwt/jwt.go new file mode 100644 index 0000000..5552ccb --- /dev/null +++ b/backend/pkg/jwt/jwt.go @@ -0,0 +1,138 @@ +// Package jwt 提供可复用的 JWT 签发/解析(HS256)、access/refresh 双 token、 +// 算法混淆防护与 gin 上下文用户提取。 +// 外部依赖 github.com/golang-jwt/jwt/v5 包名同为 jwt,故在此包内别名为 jwtv5 以避免冲突。 +package jwt + +import ( + "errors" + "strconv" + "time" + + "lokideploy/config" + + "github.com/gin-gonic/gin" + jwtv5 "github.com/golang-jwt/jwt/v5" +) + +const ( + // AccessToken有效期2小时(较短的过期时间增强安全性) + accessTokenExpiry = 2 * time.Hour + // RefreshToken有效期7天 + refreshTokenExpiry = 7 * 24 * time.Hour +) + +type Claims struct { + UserID uint `json:"userId"` + Username string `json:"username"` + Role string `json:"role"` + TokenType string `json:"tokenType"` // access | refresh + jwtv5.RegisteredClaims +} + +// TokenPair 包含access token和refresh token +type TokenPair struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt time.Time `json:"expiresAt"` +} + +func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) { + cfg := config.GetConfig() + now := time.Now() + + // Access Token + accessClaims := Claims{ + UserID: userID, + Username: username, + Role: role, + TokenType: "access", + RegisteredClaims: jwtv5.RegisteredClaims{ + ExpiresAt: jwtv5.NewNumericDate(now.Add(accessTokenExpiry)), + IssuedAt: jwtv5.NewNumericDate(now), + Subject: username, + ID: generateTokenID(), + }, + } + accessToken := jwtv5.NewWithClaims(jwtv5.SigningMethodHS256, accessClaims) + accessTokenString, err := accessToken.SignedString([]byte(cfg.JWTSecret)) + if err != nil { + return nil, err + } + + // Refresh Token + refreshClaims := Claims{ + UserID: userID, + Username: username, + Role: role, + TokenType: "refresh", + RegisteredClaims: jwtv5.RegisteredClaims{ + ExpiresAt: jwtv5.NewNumericDate(now.Add(refreshTokenExpiry)), + IssuedAt: jwtv5.NewNumericDate(now), + Subject: username, + ID: generateTokenID(), + }, + } + refreshToken := jwtv5.NewWithClaims(jwtv5.SigningMethodHS256, refreshClaims) + refreshTokenString, err := refreshToken.SignedString([]byte(cfg.JWTSecret)) + if err != nil { + return nil, err + } + + return &TokenPair{ + AccessToken: accessTokenString, + RefreshToken: refreshTokenString, + ExpiresAt: now.Add(accessTokenExpiry), + }, nil +} + +// RefreshAccessToken 使用refresh token换取新的access token +func RefreshAccessToken(refreshTokenString string) (*TokenPair, error) { + claims, err := ParseToken(refreshTokenString) + if err != nil { + return nil, err + } + + // 验证token类型必须是refresh + if claims.TokenType != "refresh" { + return nil, errors.New("invalid token type") + } + + return GenerateTokenPair(claims.UserID, claims.Username, claims.Role) +} + +func ParseToken(tokenString string) (*Claims, error) { + cfg := config.GetConfig() + token, err := jwtv5.ParseWithClaims(tokenString, &Claims{}, func(token *jwtv5.Token) (interface{}, error) { + // 强制校验签名算法,防止算法混淆攻击 + if _, ok := token.Method.(*jwtv5.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return []byte(cfg.JWTSecret), nil + }) + if err != nil { + return nil, err + } + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + return nil, errors.New("invalid token") +} + +func GetUserFromContext(c *gin.Context) (*Claims, bool) { + user, exists := c.Get("user") + if !exists { + return nil, false + } + claims, ok := user.(*Claims) + return claims, ok +} + +func IsAdmin(c *gin.Context) bool { + claims, ok := GetUserFromContext(c) + return ok && claims.Role == "admin" +} + +// generateTokenID 生成简单的token ID(基于纳秒时间戳) +func generateTokenID() string { + return strconv.FormatInt(time.Now().UnixNano(), 36) +} diff --git a/backend/pkg/jwt/jwt_test.go b/backend/pkg/jwt/jwt_test.go new file mode 100644 index 0000000..1324cf5 --- /dev/null +++ b/backend/pkg/jwt/jwt_test.go @@ -0,0 +1,66 @@ +package jwt + +import ( + "testing" + + jwtv5 "github.com/golang-jwt/jwt/v5" +) + +// TestTokenPairRoundTrip 覆盖签发 + 解析,并校验 access token 类型。 +func TestTokenPairRoundTrip(t *testing.T) { + pair, err := GenerateTokenPair(42, "alice", "admin") + if err != nil { + t.Fatalf("GenerateTokenPair: %v", err) + } + if pair.AccessToken == "" || pair.RefreshToken == "" { + t.Fatal("tokens must not be empty") + } + claims, err := ParseToken(pair.AccessToken) + if err != nil { + t.Fatalf("ParseToken(access): %v", err) + } + if claims.UserID != 42 || claims.Username != "alice" || claims.Role != "admin" { + t.Fatalf("unexpected claims: %+v", claims) + } + if claims.TokenType != "access" { + t.Fatalf("access token should have tokenType=access, got %q", claims.TokenType) + } +} + +// TestRefreshRequiresRefreshToken 覆盖 token 类型强制: +// 用 access token 刷新必须被拒;用 refresh token 刷新应成功。 +func TestRefreshRequiresRefreshToken(t *testing.T) { + pair, _ := GenerateTokenPair(1, "bob", "user") + + if _, err := RefreshAccessToken(pair.AccessToken); err == nil { + t.Fatal("RefreshAccessToken must reject access tokens") + } + newPair, err := RefreshAccessToken(pair.RefreshToken) + if err != nil { + t.Fatalf("RefreshAccessToken(refresh): %v", err) + } + if newPair.AccessToken == "" { + t.Fatal("new access token must not be empty") + } +} + +// TestParseTokenRejectsBogus 解析任意非 JWT 字符串必须失败。 +func TestParseTokenRejectsBogus(t *testing.T) { + if _, err := ParseToken("not-a-jwt"); err == nil { + t.Fatal("ParseToken must reject bogus token") + } +} + +// TestParseTokenRejectsNoneAlgo 覆盖算法混淆攻击防护: +// ParseToken 的 keyfunc 仅接受 HMAC,alg=none 构造的 token 必须被拒。 +func TestParseTokenRejectsNoneAlgo(t *testing.T) { + tok := jwtv5.NewWithClaims(jwtv5.SigningMethodNone, Claims{UserID: 1, TokenType: "access"}) + unsigned, err := tok.SigningString() + if err != nil { + t.Fatal(err) + } + bogus := unsigned + "." // alg=none 的无签名形式 + if _, err := ParseToken(bogus); err == nil { + t.Fatal("ParseToken must reject alg=none (algorithm confusion)") + } +} diff --git a/backend/pkg/jwt/main_test.go b/backend/pkg/jwt/main_test.go new file mode 100644 index 0000000..be171d9 --- /dev/null +++ b/backend/pkg/jwt/main_test.go @@ -0,0 +1,15 @@ +package jwt + +import ( + "os" + "testing" +) + +// TestMain 预置安全环境变量。config.GetConfig() 在 JWT_SECRET/ENCRYPTION_KEY +// 缺失时会 log.Fatal,故必须在任何测试逻辑之前设置。 +func TestMain(m *testing.M) { + os.Setenv("JWT_SECRET", "jwt-pkg-test-secret-32chars-min!!") + os.Setenv("ENCRYPTION_KEY", "jwt-pkg-test-encryption-32chars!!") + os.Setenv("DATA_DIR", os.TempDir()+"/lokideploy-jwt-pkg-test") + os.Exit(m.Run()) +} diff --git a/backend/repository/db.go b/backend/repository/db.go new file mode 100644 index 0000000..adfdd4d --- /dev/null +++ b/backend/repository/db.go @@ -0,0 +1,281 @@ +package repository + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "lokideploy/config" + "lokideploy/models" +) + +// saveRequest 表示一个保存请求,通过channel序列化 +type saveRequest struct { + done chan error // 用于同步等待保存完成 +} + +// JsonDB 使用基于channel的序列化保存机制,防止并发数据丢失。 +// 该包只负责持久化(落盘 + 并发安全),领域实体定义在 models 包。 +type JsonDB struct { + mu sync.RWMutex + dataFile string + Users []models.User `json:"users"` + Clusters []models.Cluster `json:"clusters"` + Hosts []models.Host `json:"hosts"` + Storages []models.StorageConfig `json:"storages"` + Deployments []models.LokiDeployment `json:"deployments"` + Collectors []models.Collector `json:"collectors"` + Logs []models.DeploymentLog `json:"logs"` + Settings []models.SystemSetting `json:"settings"` + sequences map[string]uint + + // 防抖保存机制 + saveTimer *time.Timer + saveMu sync.Mutex + saveChan chan saveRequest + stopChan chan struct{} + wg sync.WaitGroup +} + +var db *JsonDB + +func GetDB() *JsonDB { + return db +} + +func InitDB() error { + cfg := config.GetConfig() + _ = os.MkdirAll(cfg.DataDir, 0755) + + d := &JsonDB{ + dataFile: cfg.DatabasePath, + Users: []models.User{}, + Clusters: []models.Cluster{}, + Hosts: []models.Host{}, + Storages: []models.StorageConfig{}, + Deployments: []models.LokiDeployment{}, + Collectors: []models.Collector{}, + Logs: []models.DeploymentLog{}, + Settings: []models.SystemSetting{}, + sequences: map[string]uint{ + "users": 0, + "clusters": 0, + "hosts": 0, + "storages": 0, + "deployments": 0, + "collectors": 0, + "logs": 0, + }, + saveChan: make(chan saveRequest, 10), + stopChan: make(chan struct{}), + } + + if data, err := os.ReadFile(d.dataFile); err == nil { + if err := json.Unmarshal(data, d); err == nil { + d.sequences = map[string]uint{} + for _, u := range d.Users { + if u.ID > d.sequences["users"] { + d.sequences["users"] = u.ID + } + } + for _, c := range d.Clusters { + if c.ID > d.sequences["clusters"] { + d.sequences["clusters"] = c.ID + } + } + for _, h := range d.Hosts { + if h.ID > d.sequences["hosts"] { + d.sequences["hosts"] = h.ID + } + } + for _, s := range d.Storages { + if s.ID > d.sequences["storages"] { + d.sequences["storages"] = s.ID + } + } + for _, de := range d.Deployments { + if de.ID > d.sequences["deployments"] { + d.sequences["deployments"] = de.ID + } + } + for _, col := range d.Collectors { + if col.ID > d.sequences["collectors"] { + d.sequences["collectors"] = col.ID + } + } + for _, l := range d.Logs { + if l.ID > d.sequences["logs"] { + d.sequences["logs"] = l.ID + } + } + } + } + + // 启动序列化保存goroutine + d.wg.Add(1) + go d.saveWorker() + + db = d + return nil +} + +// Shutdown 优雅关闭数据库,确保所有数据已落盘。 +// 顺序很关键:先停防抖定时器(不再产生新请求)→ 关闭 worker 并等其退出 +// (worker 可能正在处理已入队的请求)→ 此时已无并发,做最后一次 doSave 把 +// 最新内存状态原子写入。注意绝不能在 worker 仍在运行时直接 Save(),否则 +// 两个 doSave 并发改写同一文件会发生"后写覆盖先写"的丢更新。 +func Shutdown() error { + if db == nil { + return nil + } + + // 1. 取消防抖定时器,阻止新的保存请求进入 channel + db.saveMu.Lock() + if db.saveTimer != nil { + db.saveTimer.Stop() + db.saveTimer = nil + } + db.saveMu.Unlock() + + // 2. 通知 worker 停止并等待其排空 saveChan、完成进行中的 doSave 后退出 + close(db.stopChan) + db.wg.Wait() + + // 3. worker 已退出,无并发,落盘最终状态 + if err := db.doSave(); err != nil { + return fmt.Errorf("final save failed: %w", err) + } + return nil +} + +// saveWorker 通过channel序列化所有保存操作,防止并发写入冲突 +func (d *JsonDB) saveWorker() { + defer d.wg.Done() + for { + select { + case req, ok := <-d.saveChan: + if !ok { + return + } + err := d.doSave() + if req.done != nil { + req.done <- err + } + case <-d.stopChan: + return + } + } +} + +// doSave 执行实际的原子文件写入 +func (d *JsonDB) doSave() error { + d.mu.RLock() + data, err := json.MarshalIndent(d, "", " ") + d.mu.RUnlock() + if err != nil { + return err + } + + // 原子写入:先写入临时文件,再重命名 + dir := filepath.Dir(d.dataFile) + tmpFile := filepath.Join(dir, fmt.Sprintf(".lokideploy-%d.tmp", time.Now().UnixNano())) + + if err := os.WriteFile(tmpFile, data, 0644); err != nil { + return err + } + + // 确保数据落盘 + f, err := os.Open(tmpFile) + if err != nil { + os.Remove(tmpFile) + return err + } + _ = f.Sync() + f.Close() + + if err := os.Rename(tmpFile, d.dataFile); err != nil { + os.Remove(tmpFile) + return err + } + + return nil +} + +// Save 同步保存数据库到文件(原子写入) +func (d *JsonDB) Save() error { + return d.doSave() +} + +// RequestSave 请求异步保存(防抖机制+序列化channel) +// 多次快速调用会在500ms内合并为一次保存 +func (d *JsonDB) RequestSave() { + d.saveMu.Lock() + defer d.saveMu.Unlock() + + // 如果已有待触发的定时器,重置它(防抖) + if d.saveTimer != nil { + d.saveTimer.Stop() + } + + d.saveTimer = time.AfterFunc(500*time.Millisecond, func() { + d.saveMu.Lock() + d.saveTimer = nil + d.saveMu.Unlock() + + // 通过channel发送到保存worker,确保序列化 + select { + case d.saveChan <- saveRequest{}: + case <-d.stopChan: + } + }) +} + +// RequestSaveSync 请求同步保存,等待保存完成(用于shutdown场景) +func (d *JsonDB) RequestSaveSync() error { + req := saveRequest{done: make(chan error, 1)} + select { + case d.saveChan <- req: + case <-d.stopChan: + return d.Save() // worker已停止,直接同步保存 + } + return <-req.done +} + +func (d *JsonDB) NextID(table string) uint { + d.mu.Lock() + defer d.mu.Unlock() + d.sequences[table]++ + return d.sequences[table] +} + +func (d *JsonDB) Lock() { + d.mu.Lock() +} + +func (d *JsonDB) Unlock() { + d.mu.Unlock() +} + +func (d *JsonDB) RLock() { + d.mu.RLock() +} + +func (d *JsonDB) RUnlock() { + d.mu.RUnlock() +} + +// CreateUser 创建用户并触发防抖保存 +func (d *JsonDB) CreateUser(user *models.User) error { + d.mu.Lock() + defer d.mu.Unlock() + if user.ID == 0 { + user.ID = d.sequences["users"] + 1 + d.sequences["users"] = user.ID + } + d.Users = append(d.Users, *user) + d.RequestSave() + return nil +} diff --git a/backend/repository/db_test.go b/backend/repository/db_test.go new file mode 100644 index 0000000..28667c0 --- /dev/null +++ b/backend/repository/db_test.go @@ -0,0 +1,85 @@ +package repository + +import ( + "encoding/json" + "os" + "sync" + "testing" + + "lokideploy/models" +) + +// TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试: +// N 个 goroutine 并发修改 db 并各自 RequestSave(经防抖 channel 序列化), +// 随后同步刷盘,直接读取磁盘文件校验写入数量与 ID 唯一性—— +// 证明 channel 序列化 + 原子重命名写入不会发生"后写覆盖先写"的丢更新。 +func TestConcurrentSaveNoDataLoss(t *testing.T) { + const N = 50 + var wg sync.WaitGroup + for i := 0; i < N; i++ { + wg.Add(1) + go func() { + defer wg.Done() + db := GetDB() + // NextID 内部自行加锁;切勿在持有 db.Lock() 时调用(mu 不可重入,会自死锁) + id := db.NextID("hosts") + db.Lock() + db.Hosts = append(db.Hosts, models.Host{ID: id, Name: "concurrent-host"}) + db.Unlock() + db.RequestSave() + }() + } + wg.Wait() + + // 同步刷盘一次(绕过 500ms 防抖),确保最新内存状态落盘 + if err := GetDB().RequestSaveSync(); err != nil { + t.Fatalf("RequestSaveSync: %v", err) + } + + data, err := os.ReadFile(dbPath()) + if err != nil { + t.Fatalf("read db file: %v", err) + } + var disk JsonDB + if err := json.Unmarshal(data, &disk); err != nil { + t.Fatalf("unmarshal db file: %v", err) + } + if len(disk.Hosts) != N { + t.Fatalf("DATA LOSS: disk has %d hosts, want %d (concurrent saves lost writes)", len(disk.Hosts), N) + } + + // ID 唯一性:NextID 在 mutex 保护下递增,不应出现重复 + seen := make(map[uint]bool, N) + for _, h := range disk.Hosts { + if seen[h.ID] { + t.Fatalf("duplicate host ID %d (NextID race)", h.ID) + } + seen[h.ID] = true + } +} + +// TestRequestSaveDebounce 校验防抖行为:短时间内多次 RequestSave 不会撑爆 channel。 +func TestRequestSaveDebounce(t *testing.T) { + db := GetDB() + startLen := len(db.Hosts) + // 连续触发 100 次防抖保存(channel 容量 10,防抖应合并而非阻塞) + for i := 0; i < 100; i++ { + id := db.NextID("hosts") // 自行加锁,勿在 db.Lock() 内调用 + db.Lock() + db.Hosts = append(db.Hosts, models.Host{ID: id, Name: "debounce"}) + db.Unlock() + db.RequestSave() + } + if err := db.RequestSaveSync(); err != nil { + t.Fatalf("RequestSaveSync after debounce: %v", err) + } + data, _ := os.ReadFile(dbPath()) + var disk JsonDB + if err := json.Unmarshal(data, &disk); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // 100 次追加 + 之前测试的 N 个 host,必须全部落盘 + if len(disk.Hosts) != startLen+100 { + t.Fatalf("debounce lost writes: disk has %d hosts, want %d", len(disk.Hosts), startLen+100) + } +} diff --git a/backend/repository/main_test.go b/backend/repository/main_test.go new file mode 100644 index 0000000..509be08 --- /dev/null +++ b/backend/repository/main_test.go @@ -0,0 +1,32 @@ +package repository + +import ( + "os" + "testing" + + "lokideploy/config" +) + +// TestMain 为 repository 包预置安全环境变量与临时 DATA_DIR,并管理 JsonDB 生命周期。 +// config.GetConfig() 在 JWT_SECRET/ENCRYPTION_KEY 缺失时会 log.Fatal,故须先设置。 +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "lokideploy-repository-test") + if err != nil { + panic(err) + } + os.Setenv("JWT_SECRET", "repo-test-jwt-secret-32chars-min!!") + os.Setenv("ENCRYPTION_KEY", "repo-test-encryption-key-32chars!!") + os.Setenv("DATA_DIR", dir) + + if err := InitDB(); err != nil { + panic(err) + } + + code := m.Run() + _ = Shutdown() // 优雅刷盘 + 停止 saveWorker + _ = os.RemoveAll(dir) + os.Exit(code) +} + +// dbPath 返回当前测试用的数据库文件路径。 +func dbPath() string { return config.GetConfig().DatabasePath } diff --git a/backend/routers/router.go b/backend/routers/router.go index f55b89d..4e87aef 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -1,9 +1,11 @@ package routers import ( + "net/http" "os" "path/filepath" "strings" + "time" "lokideploy/controllers" "lokideploy/middleware" @@ -33,8 +35,25 @@ func SetupRouter() *gin.Engine { // API v1 api := r.Group("/api/v1") + // 健康检查(公开访问,用于负载均衡和监控) + api.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "timestamp": time.Now().Unix(), + "version": "2.0.0", + }) + }) + + // Prometheus metrics 端点(公开访问,可被Prometheus抓取) + api.GET("/metrics", func(c *gin.Context) { + // 基础metrics输出(符合Prometheus文本格式) + c.Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + c.String(http.StatusOK, "# LokiDeploy Metrics\n") + }) + // Public api.POST("/auth/login", authCtrl.Login) + api.POST("/auth/refresh", authCtrl.RefreshToken) // Auth required authorized := api.Group("") @@ -84,7 +103,7 @@ func SetupRouter() *gin.Engine { authorized.GET("/deployments/:id/logs", deploymentCtrl.GetLogs) authorized.GET("/deployments/stats", deploymentCtrl.GetStats) - // Collectors(日志采集器 / 部署客户端) + // Collectors authorized.GET("/collectors", collectorCtrl.List) authorized.GET("/collectors/:id", collectorCtrl.Get) authorized.POST("/collectors", collectorCtrl.Create) @@ -98,6 +117,21 @@ func SetupRouter() *gin.Engine { if distPath != "" { r.Static("/assets", filepath.Join(distPath, "assets")) + + // favicon:NoRoute 会把所有非 /api/ 请求回退到 index.html, + // 故此处显式注册 /favicon.svg 与 /favicon.ico,否则浏览器请求图标只会拿到 HTML。 + faviconSVG := filepath.Join(distPath, "favicon.svg") + serveFavicon := func(c *gin.Context) { + if _, err := os.Stat(faviconSVG); err != nil { + c.Status(404) + return + } + c.Header("Content-Type", "image/svg+xml") + c.File(faviconSVG) + } + r.GET("/favicon.svg", serveFavicon) + r.GET("/favicon.ico", serveFavicon) + r.NoRoute(func(c *gin.Context) { if strings.HasPrefix(c.Request.URL.Path, "/api/") { c.JSON(404, gin.H{"code": 404, "message": "API endpoint not found"}) diff --git a/backend/seed/seed.go b/backend/seed/seed.go index 7727a9b..c604612 100644 --- a/backend/seed/seed.go +++ b/backend/seed/seed.go @@ -2,18 +2,19 @@ package seed import ( "lokideploy/models" - "lokideploy/utils" + "lokideploy/pkg/crypto" + "lokideploy/repository" ) func Seed() { - db := models.GetDB() + db := repository.GetDB() db.RLock() hasUsers := len(db.Users) > 0 db.RUnlock() if !hasUsers { - hash, _ := utils.HashPassword("admin123") - models.GetDB().CreateUser(&models.User{ + hash, _ := crypto.HashPassword("admin123") + repository.GetDB().CreateUser(&models.User{ Username: "admin", Password: hash, Nickname: "Administrator", diff --git a/backend/services/auth.go b/backend/services/auth.go index 24f4045..c7dbccf 100644 --- a/backend/services/auth.go +++ b/backend/services/auth.go @@ -2,6 +2,9 @@ package services import ( "lokideploy/models" + "lokideploy/pkg/crypto" + "lokideploy/pkg/jwt" + "lokideploy/repository" "lokideploy/utils" ) @@ -11,45 +14,45 @@ func NewAuthService() *AuthService { return &AuthService{} } -func (s *AuthService) Login(username, password string) (*models.User, string, error) { - db := models.GetDB() +func (s *AuthService) Login(username, password string) (*models.User, *jwt.TokenPair, error) { + db := repository.GetDB() db.RLock() defer db.RUnlock() for i := range db.Users { if db.Users[i].Username == username { - if !utils.VerifyPassword(password, db.Users[i].Password) { - return nil, "", utils.ErrInvalidCredentials + if !crypto.VerifyPassword(password, db.Users[i].Password) { + return nil, nil, utils.ErrInvalidCredentials } - token, err := utils.GenerateToken(db.Users[i].ID, db.Users[i].Username, db.Users[i].Role) + tokenPair, err := jwt.GenerateTokenPair(db.Users[i].ID, db.Users[i].Username, db.Users[i].Role) if err != nil { - return nil, "", err + return nil, nil, err } - return &db.Users[i], token, nil + return &db.Users[i], tokenPair, nil } } - return nil, "", utils.ErrInvalidCredentials + return nil, nil, utils.ErrInvalidCredentials } func (s *AuthService) Register(username, password, nickname, role string) (*models.User, error) { - hash, err := utils.HashPassword(password) + hash, err := crypto.HashPassword(password) if err != nil { return nil, err } user := &models.User{ - ID: models.GetDB().NextID("users"), + ID: repository.GetDB().NextID("users"), Username: username, Password: hash, Nickname: nickname, Role: role, } - if err := models.GetDB().CreateUser(user); err != nil { + if err := repository.GetDB().CreateUser(user); err != nil { return nil, err } return user, nil } func (s *AuthService) GetUserByID(id uint) (*models.User, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() for i := range db.Users { @@ -62,20 +65,20 @@ func (s *AuthService) GetUserByID(id uint) (*models.User, error) { } func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error { - db := models.GetDB() + db := repository.GetDB() db.Lock() defer db.Unlock() for i := range db.Users { if db.Users[i].ID == userID { - if !utils.VerifyPassword(oldPassword, db.Users[i].Password) { + if !crypto.VerifyPassword(oldPassword, db.Users[i].Password) { return utils.ErrInvalidCredentials } - hash, err := utils.HashPassword(newPassword) + hash, err := crypto.HashPassword(newPassword) if err != nil { return err } db.Users[i].Password = hash - go db.Save() + db.RequestSave() return nil } } diff --git a/backend/services/cluster.go b/backend/services/cluster.go index e75cf9e..2720b8c 100644 --- a/backend/services/cluster.go +++ b/backend/services/cluster.go @@ -7,7 +7,9 @@ import ( "path/filepath" "time" + "lokideploy/config" "lokideploy/models" + "lokideploy/repository" "lokideploy/utils" ) @@ -18,8 +20,8 @@ func NewClusterService() *ClusterService { } func SaveKubeConfig(name, content string) (string, error) { - dir := filepath.Join("data", "kubeconfigs") - if err := os.MkdirAll(dir, 0755); err != nil { + dir := config.GetConfig().KubeDir + if err := os.MkdirAll(dir, 0700); err != nil { return "", err } path := filepath.Join(dir, fmt.Sprintf("%s.yaml", name)) @@ -37,7 +39,9 @@ func (s *ClusterService) Create(name, description, configType, apiServer, kubeco finalKubeconfig := kubeconfig if configType == "token" { - finalKubeconfig = BuildKubeconfigFromToken(apiServer, token) + // 安全默认:不嵌入 CA、不跳过 TLS 验证,依赖系统 CA 池。 + // 自签证书的集群请改用 kubeconfig 模式(其内含 CA 证书)。 + finalKubeconfig = BuildKubeconfigFromToken(apiServer, token, "", false) } // 展示用 APIServer:token 模式取入参,kubeconfig 模式从内容提取 if apiServer == "" { @@ -50,7 +54,7 @@ func (s *ClusterService) Create(name, description, configType, apiServer, kubeco } cluster := models.Cluster{ - ID: models.GetDB().NextID("clusters"), + ID: repository.GetDB().NextID("clusters"), Name: name, Description: description, ConfigType: configType, @@ -63,18 +67,18 @@ func (s *ClusterService) Create(name, description, configType, apiServer, kubeco UpdatedAt: time.Now(), } - db := models.GetDB() + db := repository.GetDB() db.Lock() db.Clusters = append(db.Clusters, cluster) db.Unlock() - go db.Save() + db.RequestSave() go s.Validate(cluster.ID) return &cluster, nil } func (s *ClusterService) Get(id uint) (*models.Cluster, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() for i := range db.Clusters { @@ -87,7 +91,7 @@ func (s *ClusterService) Get(id uint) (*models.Cluster, error) { } func (s *ClusterService) List(page, pageSize int) ([]models.Cluster, int64, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() @@ -106,7 +110,7 @@ func (s *ClusterService) List(page, pageSize int) ([]models.Cluster, int64, erro } func (s *ClusterService) Update(id uint, updates map[string]interface{}) error { - db := models.GetDB() + db := repository.GetDB() db.Lock() defer db.Unlock() for i := range db.Clusters { @@ -147,7 +151,7 @@ func (s *ClusterService) Update(id uint, updates map[string]interface{}) error { } } db.Clusters[i].UpdatedAt = time.Now() - go db.Save() + db.RequestSave() return nil } } @@ -155,7 +159,7 @@ func (s *ClusterService) Update(id uint, updates map[string]interface{}) error { } func (s *ClusterService) Delete(id uint) error { - db := models.GetDB() + db := repository.GetDB() db.Lock() defer db.Unlock() for i, c := range db.Clusters { @@ -164,7 +168,7 @@ func (s *ClusterService) Delete(id uint) error { _ = os.Remove(c.KubeConfigPath) } db.Clusters = append(db.Clusters[:i], db.Clusters[i+1:]...) - go db.Save() + db.RequestSave() return nil } } diff --git a/backend/services/collector.go b/backend/services/collector.go index 12c139b..932e95d 100644 --- a/backend/services/collector.go +++ b/backend/services/collector.go @@ -6,6 +6,7 @@ import ( "time" "lokideploy/models" + "lokideploy/repository" "lokideploy/utils" ) @@ -29,7 +30,7 @@ type CreateCollectorRequest struct { // fillAssociations 填充关联的 Loki / Host / Cluster(用于配置生成与展示) func (s *CollectorService) fillAssociations(c *models.Collector) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() for i := range db.Deployments { @@ -107,7 +108,7 @@ func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collecto } collector := models.Collector{ - ID: models.GetDB().NextID("collectors"), + ID: repository.GetDB().NextID("collectors"), Name: req.Name, Type: req.Type, LokiDeploymentID: req.LokiDeploymentID, @@ -129,18 +130,18 @@ func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collecto } collector.Config = s.generateConfigSnapshot(&collector) - db := models.GetDB() + db := repository.GetDB() db.Lock() db.Collectors = append(db.Collectors, collector) db.Unlock() - go db.Save() + db.RequestSave() go s.Install(collector.ID) return &collector, nil } func (s *CollectorService) Get(id uint) (*models.Collector, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() var found *models.Collector for i := range db.Collectors { @@ -159,7 +160,7 @@ func (s *CollectorService) Get(id uint) (*models.Collector, error) { } func (s *CollectorService) List(page, pageSize int) ([]models.Collector, int64, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() total := int64(len(db.Collectors)) start := (page - 1) * pageSize @@ -181,7 +182,7 @@ func (s *CollectorService) List(page, pageSize int) ([]models.Collector, int64, } func (s *CollectorService) Update(id uint, updates map[string]interface{}) error { - db := models.GetDB() + db := repository.GetDB() db.Lock() defer db.Unlock() for i := range db.Collectors { @@ -202,7 +203,7 @@ func (s *CollectorService) Update(id uint, updates map[string]interface{}) error db.Collectors[i].Labels = v } db.Collectors[i].UpdatedAt = time.Now() - go db.Save() + db.RequestSave() return nil } } @@ -214,7 +215,7 @@ func (s *CollectorService) Delete(id uint) error { if err != nil { return utils.ErrNotFound } - db := models.GetDB() + db := repository.GetDB() db.Lock() for i, c := range db.Collectors { if c.ID == id { @@ -223,7 +224,7 @@ func (s *CollectorService) Delete(id uint) error { } } db.Unlock() - go db.Save() + db.RequestSave() // 异步卸载(用拷贝的 collector,避免删除后取不到) go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) diff --git a/backend/services/collector_deployer.go b/backend/services/collector_deployer.go index b1aebb1..4713f10 100644 --- a/backend/services/collector_deployer.go +++ b/backend/services/collector_deployer.go @@ -35,10 +35,15 @@ func kubeconfigEnv(cluster *models.Cluster) (env []string, cleanup func(), err e } kubeFile, err := os.CreateTemp("", "kube-*.yaml") if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("create kubeconfig temp file failed: %w", err) + } + if _, err := kubeFile.WriteString(kubeconfig); err != nil { + kubeFile.Close() + os.Remove(kubeFile.Name()) + return nil, nil, fmt.Errorf("write kubeconfig temp file failed: %w", err) } - kubeFile.WriteString(kubeconfig) kubeFile.Close() + os.Chmod(kubeFile.Name(), 0600) env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name())) cleanup = func() { os.Remove(kubeFile.Name()) } return env, cleanup, nil @@ -73,6 +78,7 @@ func (d *CollectorDeployer) Install(ctx context.Context) error { // Uninstall 按采集器类型分发卸载 func (d *CollectorDeployer) Uninstall(ctx context.Context) error { + d.log("info", fmt.Sprintf("开始卸载采集器 %s(类型 %s)", d.collector.Name, d.collector.Type), "") switch d.collector.Type { case "host-promtail": return d.uninstallHost(ctx) @@ -85,6 +91,7 @@ func (d *CollectorDeployer) Uninstall(ctx context.Context) error { } // ===== host-promtail:SSH 安装 Promtail 二进制 + systemd ===== + func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) error { host := d.collector.Host if host == nil { @@ -100,7 +107,7 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er } defer client.Close() - // 架构 + // 检测架构 archOut, _, _ := client.Execute(ctx, "uname -m") arch := strings.TrimSpace(archOut) switch arch { @@ -112,41 +119,70 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er return fmt.Errorf("不支持的主机架构: %s", arch) } + // 确定 Promtail 版本(优先使用关联的 Loki 版本) + version := "3.0.0" + if d.loki != nil && d.loki.Version != "" { + version = d.loki.Version + } + + // 创建必要目录 + d.log("info", "准备目录 /opt/promtail /etc/promtail /var/lib/promtail", "") + if _, _, err := client.Execute(ctx, "mkdir -p /opt/promtail /etc/promtail /var/lib/promtail"); err != nil { + return fmt.Errorf("创建 Promtail 目录失败: %w", err) + } + // 下载 Promtail - d.log("info", fmt.Sprintf("下载 Promtail(%s)", arch), "") - url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v3.0.0/promtail-linux-%s.zip", arch) - dl := fmt.Sprintf("mkdir -p /opt/promtail /etc/promtail && cd /tmp && curl -fsSL -o p.zip '%s' && unzip -o p.zip -d /opt/promtail/ && chmod +x /opt/promtail/promtail-linux-%s", url, arch) + d.log("info", fmt.Sprintf("下载 Promtail %s(%s)", version, arch), "") + url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v%s/promtail-linux-%s.zip", version, arch) + dl := fmt.Sprintf("cd /tmp && curl -fsSL -o p.zip '%s' && unzip -o p.zip -d /opt/promtail/ && chmod +x /opt/promtail/promtail-linux-%s", url, arch) if _, _, err := client.Execute(ctx, dl); err != nil { - return fmt.Errorf("下载 Promtail 失败: %v", err) + return fmt.Errorf("下载 Promtail 失败: %w", err) } client.Execute(ctx, "ln -sf /opt/promtail/promtail-linux-* /usr/local/bin/promtail 2>/dev/null || true") - // 配置 + // 生成并上传配置 logPath := d.collector.LogPath if logPath == "" { logPath = "/var/log/*.log" } conf := PromtailConfig(endpoint, logPath) - client.Execute(ctx, fmt.Sprintf("cat > /etc/promtail/promtail.yaml << 'EOF'\n%s\nEOF", conf)) + d.log("info", "上传 Promtail 配置", "") + if _, _, err := client.Execute(ctx, fmt.Sprintf("cat > /etc/promtail/promtail.yaml << 'EOF'\n%s\nEOF", conf)); err != nil { + return fmt.Errorf("上传配置失败: %w", err) + } - // systemd 服务 + // systemd 服务(生产级) service := `[Unit] Description=Promtail Log Collector -After=network.target +Wants=network-online.target +After=network-online.target [Service] Type=simple -ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/promtail.yaml +User=root +Group=root +ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/promtail.yaml -positions.enabled=true +ExecReload=/bin/kill -HUP $MAINPID Restart=always RestartSec=10 +LimitNOFILE=65536 +StandardOutput=append:/var/log/promtail.log +StandardError=append:/var/log/promtail.log [Install] WantedBy=multi-user.target ` - client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/promtail.service << 'EOF'\n%s\nEOF", service)) - if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable promtail && systemctl restart promtail"); err != nil { - return fmt.Errorf("启动 Promtail 失败: %v", err) + d.log("info", "安装 systemd 服务", "") + if _, _, err := client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/promtail.service << 'EOF'\n%s\nEOF", service)); err != nil { + return fmt.Errorf("创建 systemd 服务失败: %w", err) } + if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable promtail && systemctl restart promtail"); err != nil { + return fmt.Errorf("启动 Promtail 失败: %w", err) + } + + // 保存配置快照 + d.collector.Config = conf + d.log("success", "Promtail 部署完成", "") return nil } @@ -161,11 +197,15 @@ func (d *CollectorDeployer) uninstallHost(ctx context.Context) error { return err } defer client.Close() - client.Execute(ctx, "systemctl stop promtail 2>/dev/null || true; systemctl disable promtail 2>/dev/null || true; rm -f /etc/systemd/system/promtail.service /usr/local/bin/promtail; systemctl daemon-reload") + client.Execute(ctx, "systemctl stop promtail 2>/dev/null || true") + client.Execute(ctx, "systemctl disable promtail 2>/dev/null || true") + client.Execute(ctx, "rm -rf /etc/systemd/system/promtail.service /usr/local/bin/promtail /etc/promtail /opt/promtail /var/lib/promtail") + client.Execute(ctx, "systemctl daemon-reload") return nil } // ===== k8s-daemonset:helm 安装 Promtail DaemonSet ===== + func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint string) error { cluster := d.collector.Cluster if cluster == nil { @@ -183,27 +223,27 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin } values := PromtailHelmValues(endpoint) - valuesFile, err := os.CreateTemp("", "promtail-values-*.yaml") + valuesFile, valuesCleanup, err := writeTempFile("promtail-values-*.yaml", values, 0600) if err != nil { return err } - defer os.Remove(valuesFile.Name()) - valuesFile.WriteString(values) - valuesFile.Close() + defer valuesCleanup() d.log("info", fmt.Sprintf("helm install promtail(命名空间 %s)", ns), "") - // 先添加 grafana repo(幂等) + // 添加 grafana repo(幂等) repo := exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts") repo.Env = env repo.CombinedOutput() - exec.CommandContext(ctx, "helm", "repo", "update").CombinedOutput() + upd := exec.CommandContext(ctx, "helm", "repo", "update") + upd.Env = env + upd.CombinedOutput() cmd := exec.CommandContext(ctx, "helm", "upgrade", "--install", "promtail", "grafana/promtail", - "-f", valuesFile.Name(), "--namespace", ns, "--create-namespace", "--wait", "--timeout", "5m") + "-f", valuesFile, "--namespace", ns, "--create-namespace", "--wait", "--timeout", "5m") cmd.Env = env out, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("helm 安装 Promtail 失败: %v\n%s", err, string(out)) + return fmt.Errorf("helm 安装 Promtail 失败: %w\n%s", err, string(out)) } return nil } @@ -229,6 +269,7 @@ func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error { } // ===== k8s-fluentbit-sidecar:kubectl patch 注入 Fluent Bit sidecar ===== + func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string) error { cluster := d.collector.Cluster if cluster == nil { @@ -275,27 +316,31 @@ func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string) lokiHost, lokiPort := parseLokiURL(endpoint) conf := FluentbitConfig(lokiHost, lokiPort, logPath, app, ns) cm := FluentbitConfigMapYAML(conf, ns) - cmFile, _ := os.CreateTemp("", "fluent-bit-cm-*.yaml") - defer os.Remove(cmFile.Name()) - cmFile.WriteString(cm) - cmFile.Close() + cmFile, cmCleanup, err := writeTempFile("fluent-bit-cm-*.yaml", cm, 0600) + if err != nil { + return err + } + defer cmCleanup() + d.log("info", "部署 Fluent Bit ConfigMap", "") - applyCm := exec.CommandContext(ctx, "kubectl", "apply", "-f", cmFile.Name()) + applyCm := exec.CommandContext(ctx, "kubectl", "apply", "-f", cmFile) applyCm.Env = env if out, err := applyCm.CombinedOutput(); err != nil { - return fmt.Errorf("ConfigMap 部署失败: %v\n%s", err, string(out)) + return fmt.Errorf("ConfigMap 部署失败: %w\n%s", err, string(out)) } // 4. patch 注入 sidecar d.log("info", fmt.Sprintf("为 %s 注入 Fluent Bit Sidecar", target), "") - patchFile, _ := os.CreateTemp("", "sidecar-patch-*.yaml") - defer os.Remove(patchFile.Name()) - patchFile.WriteString(SidecarPatchYAML) - patchFile.Close() - patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", target, "-n", ns, "--type=strategic", "--patch-file", patchFile.Name()) + patchFile, patchCleanup, err := writeTempFile("sidecar-patch-*.yaml", SidecarPatchYAML, 0600) + if err != nil { + return err + } + defer patchCleanup() + + patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", target, "-n", ns, "--type=strategic", "--patch-file", patchFile) patch.Env = env if out, err := patch.CombinedOutput(); err != nil { - return fmt.Errorf("Sidecar 注入失败: %v\n%s", err, string(out)) + return fmt.Errorf("Sidecar 注入失败: %w\n%s", err, string(out)) } // 5. 等待 rollout @@ -320,11 +365,13 @@ func (d *CollectorDeployer) uninstallSidecar(ctx context.Context) error { if ns == "" { ns = "default" } - patchFile, _ := os.CreateTemp("", "sidecar-rm-*.yaml") - defer os.Remove(patchFile.Name()) - patchFile.WriteString(SidecarRemovePatchYAML) - patchFile.Close() - patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", d.collector.TargetDeployment, "-n", ns, "--type=strategic", "--patch-file", patchFile.Name()) + patchFile, patchCleanup, err := writeTempFile("sidecar-rm-*.yaml", SidecarRemovePatchYAML, 0600) + if err != nil { + return err + } + defer patchCleanup() + + patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", d.collector.TargetDeployment, "-n", ns, "--type=strategic", "--patch-file", patchFile) patch.Env = env patch.CombinedOutput() return nil diff --git a/backend/services/collector_templates.go b/backend/services/collector_templates.go index c97ab1f..5f39212 100644 --- a/backend/services/collector_templates.go +++ b/backend/services/collector_templates.go @@ -6,17 +6,35 @@ import ( ) // PromtailConfig 主机 Promtail 配置(推送至 lokiEndpoint,采集 logPath) +// 生产级配置:持久化位置文件、批处理、重试、CRI日志格式解析 func PromtailConfig(lokiEndpoint, logPath string) string { return fmt.Sprintf(`server: http_listen_port: 9080 grpc_listen_port: 0 log_level: info + log_format: logfmt positions: - filename: /tmp/positions.yaml + filename: /var/lib/promtail/positions.yaml + sync_period: 10s clients: - url: %s/loki/api/v1/push + batchwait: 1s + batchsize: 1048576 + timeout: 10s + backoff_config: + min_period: 500ms + max_period: 5m + max_retries: 10 + external_labels: + job: host-logs + +limits_config: + readline_rate_enabled: false + readline_rate: 1000 + readline_burst: 10000 + max_streams: 0 scrape_configs: - job_name: app @@ -26,28 +44,104 @@ scrape_configs: job: app host: ${HOSTNAME} __path__: %s + pipeline_stages: + - cri: {} + - match: + selector: '{job="app"}' + stages: + - timestamp: + source: time + format: RFC3339Nano + - limit: + rate: 1000 + burst: 10000 + drop: true `, lokiEndpoint, logPath) } -// PromtailHelmValues DaemonSet 模式 Promtail Helm values +// PromtailHelmValues DaemonSet 模式 Promtail Helm values(生产级) +// 包含:cri解析、kubernetes标签、批处理配置、资源限制 func PromtailHelmValues(lokiEndpoint string) string { return fmt.Sprintf(`config: clients: - url: %s/loki/api/v1/push + batchwait: 1s + batchsize: 1048576 + timeout: 10s + backoff_config: + min_period: 500ms + max_period: 5m + max_retries: 10 + external_labels: + cluster: ${CLUSTER_NAME:-unknown} + positions: + filename: /run/promtail/positions.yaml + scrape_configs: + - job_name: kubernetes-pods + kubernetes_sd_configs: + - role: pod + pipeline_stages: + - cri: {} + - match: + selector: '{app!=""}' + stages: + - docker: {} + - match: + selector: '{app!=""}' + stages: + - labels: + app: + namespace: + pod: + container: + - limit: + rate: 1000 + burst: 10000 + drop: true + relabel_configs: + - source_labels: [__meta_kubernetes_pod_controller_name] + regex: ([0-9a-z-.]+?)(-[0-9a-f]{8,10})? + action: replace + target_label: __tmp_controller_name + - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name] + regex: .+ + action: replace + target_label: app + - source_labels: [__meta_kubernetes_pod_label_app] + regex: .+ + action: replace + target_label: app + - source_labels: [__meta_kubernetes_pod_annotation_kubernetes_io_config_hash] + regex: .+ + action: replace + target_label: config_hash + - action: replace + source_labels: ['__meta_kubernetes__pod_name'] + target_label: 'pod' + - action: replace + source_labels: ['__meta_kubernetes_namespace'] + target_label: 'namespace' + - action: replace + source_labels: ['__meta_kubernetes_pod_container_name'] + target_label: 'container' + - action: replace + source_labels: ['__meta_kubernetes_pod_node_name'] + target_label: 'node_name' + - replacement: /var/log/pods/*$1/*.log + separator: / + source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name] + target_label: __path__ daemonset: enabled: true -serviceAccount: - create: true - resources: limits: cpu: 200m - memory: 128Mi + memory: 256Mi requests: cpu: 100m - memory: 64Mi + memory: 128Mi extraVolumes: - name: varlog @@ -56,6 +150,9 @@ extraVolumes: - name: varlibdockercontainers hostPath: path: /var/lib/docker/containers + - name: promtail-run + hostPath: + path: /run/promtail extraVolumeMounts: - name: varlog @@ -64,33 +161,63 @@ extraVolumeMounts: - name: varlibdockercontainers mountPath: /var/lib/docker/containers readOnly: true + - name: promtail-run + mountPath: /run/promtail `, lokiEndpoint) } // FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort) +// 生产级配置:kubernetes过滤器、内存缓冲、健康检查 func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string { return fmt.Sprintf(`[SERVICE] - Flush 5 + Flush 1 Log_Level info HTTP_Server On HTTP_Listen 0.0.0.0 HTTP_Port 2020 + Parsers_File parsers.conf + storage.path /tmp/flb-storage/ + storage.sync normal + storage.checksum off + storage.max_chunks_pause On + storage.backlog.mem_limit 5M + Health_Check On + HC_Errors_Count 5 + HC_Retry_Failure_Count 5 + HC_Period 60 [INPUT] Name tail Path %s Tag app.%s Refresh_Interval 5 - Mem_Buf_Limit 5MB + Mem_Buf_Limit 10MB Skip_Long_Lines On Read_from_Head True DB /tmp/flb.db + DB.Sync Normal + Buffer_Max_Size 1MB + storage.type filesystem [FILTER] Name modify Match app.* Add namespace %s Add app %s + Add source fluent-bit + +[FILTER] + Name kubernetes + Match app.* + Kube_URL https://kubernetes.default.svc:443 + Kube_CA_File /var/run/secrets/kubernetes.io/serviceaccount/ca.crt + Kube_Token_File /var/run/secrets/kubernetes.io/serviceaccount/token + Merge_Log On + Keep_Log Off + K8S-Logging.Parser On + K8S-Logging.Exclude On + Labels On + Annotations Off [OUTPUT] Name loki @@ -98,6 +225,10 @@ func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string Host %s Port %s Labels job=fluent-bit, app=%s, namespace=%s + Drop_Records_Without_Labels On + Line_Format json + Auto_Kubernetes_Labels On + Retry_Limit 10 `, logPath, app, namespace, app, lokiHost, lokiPort, app, namespace) } @@ -115,6 +246,7 @@ data: } // SidecarPatchYAML strategic merge patch:为 Deployment 注入 Fluent Bit sidecar 容器 +// 生产级配置:资源限制、健康检查、安全上下文 const SidecarPatchYAML = `spec: template: spec: @@ -128,6 +260,10 @@ const SidecarPatchYAML = `spec: - name: fluent-bit image: cr.fluentbit.io/fluent/fluent-bit:3.1.1 imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 2020 + protocol: TCP volumeMounts: - name: app-logs mountPath: /var/log/app @@ -141,6 +277,24 @@ const SidecarPatchYAML = `spec: requests: cpu: 100m memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 5 ` // SidecarRemovePatchYAML strategic merge patch:从 Deployment 移除 Fluent Bit sidecar(卸载) diff --git a/backend/services/deployment.go b/backend/services/deployment.go index a6da0bf..15f45a9 100644 --- a/backend/services/deployment.go +++ b/backend/services/deployment.go @@ -3,12 +3,32 @@ package services import ( "context" "fmt" + "strconv" + "strings" + "sync" "time" "lokideploy/models" + "lokideploy/repository" "lokideploy/utils" ) +// LokiVersionList 支持的 Loki 版本列表(按推荐程度排序) +var LokiVersionList = []string{ + "3.4.0", + "3.3.0", + "3.2.0", + "3.1.0", + "3.0.0", + "2.9.8", + "2.9.0", + "2.8.9", +} + +// minSupportedMajorVersion 最低支持的主版本号 +const minSupportedMajorVersion = 2 + +// DeploymentService 部署业务逻辑层 type DeploymentService struct{} func NewDeploymentService() *DeploymentService { @@ -33,7 +53,14 @@ type CreateDeploymentRequest struct { } func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDeployment, error) { - db := models.GetDB() + // 版本兼容性预检查 + if req.Version != "" { + if err := ValidateLokiVersion(req.Version); err != nil { + return nil, err + } + } + + db := repository.GetDB() d := models.LokiDeployment{ ID: db.NextID("deployments"), Name: req.Name, @@ -88,13 +115,13 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe db.Lock() db.Deployments = append(db.Deployments, d) db.Unlock() - go db.Save() + db.RequestSave() return &d, nil } func (s *DeploymentService) Get(id uint) (*models.LokiDeployment, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() for i := range db.Deployments { @@ -108,7 +135,7 @@ func (s *DeploymentService) Get(id uint) (*models.LokiDeployment, error) { } func (s *DeploymentService) List(page, pageSize int) ([]models.LokiDeployment, int64, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() @@ -130,7 +157,7 @@ func (s *DeploymentService) List(page, pageSize int) ([]models.LokiDeployment, i } func (s *DeploymentService) Update(id uint, updates map[string]interface{}) error { - db := models.GetDB() + db := repository.GetDB() db.Lock() defer db.Unlock() for i := range db.Deployments { @@ -145,7 +172,7 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro db.Deployments[i].Endpoint = v } db.Deployments[i].UpdatedAt = time.Now() - go db.Save() + db.RequestSave() return nil } } @@ -153,19 +180,32 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro } func (s *DeploymentService) Delete(id uint) error { - db := models.GetDB() + db := repository.GetDB() db.Lock() defer db.Unlock() for i, d := range db.Deployments { if d.ID == id { db.Deployments = append(db.Deployments[:i], db.Deployments[i+1:]...) - go db.Save() + db.RequestSave() return nil } } return utils.ErrNotFound } +// ==================== Goroutine 管理改进 ==================== + +// deployCtxEntry 保存正在进行的部署上下文 +type deployCtxEntry struct { + cancel context.CancelFunc + done chan struct{} +} + +var ( + deployCtxMap = make(map[uint]*deployCtxEntry) + deployCtxMu sync.Mutex +) + func (s *DeploymentService) StartDeploy(id uint) error { deployment, err := s.Get(id) if err != nil { @@ -174,19 +214,41 @@ func (s *DeploymentService) StartDeploy(id uint) error { if deployment.Status == "deploying" { return fmt.Errorf("部署正在进行中,请勿重复操作") } - s.Update(id, map[string]interface{}{"status": "deploying"}) + + // 检查已有 goroutine 状态,防止重复部署 + deployCtxMu.Lock() + if entry, exists := deployCtxMap[id]; exists { + select { + case <-entry.done: + // 已完成,可以重新部署 + delete(deployCtxMap, id) + default: + deployCtxMu.Unlock() + return fmt.Errorf("部署正在进行中,请勿重复操作") + } + } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) - defer cancel() + done := make(chan struct{}) + deployCtxMap[id] = &deployCtxEntry{cancel: cancel, done: done} + deployCtxMu.Unlock() + + s.Update(id, map[string]interface{}{"status": "deploying"}) logFunc := func(level, message, detail string) { s.AddLog(id, level, message, detail) } - deployer := NewLokiDeployer(deployment, logFunc) go func() { - err := deployer.Deploy(ctx) - if err != nil { + defer close(done) + defer func() { + deployCtxMu.Lock() + delete(deployCtxMap, id) + deployCtxMu.Unlock() + }() + + deployer := NewLokiDeployer(deployment, logFunc) + if err := deployer.Deploy(ctx); err != nil { s.Update(id, map[string]interface{}{ "status": "failed", "statusMessage": fmt.Sprintf("Deployment failed: %v", err), @@ -201,6 +263,7 @@ func (s *DeploymentService) StartDeploy(id uint) error { s.AddLog(id, "success", "Deployment completed successfully", "") } }() + return nil } @@ -212,6 +275,14 @@ func (s *DeploymentService) StopDeployment(id uint) error { if deployment.Status != "running" && deployment.Status != "deploying" { return fmt.Errorf("部署未在运行,无法停止") } + + // 取消正在进行的部署 goroutine + deployCtxMu.Lock() + if entry, exists := deployCtxMap[id]; exists { + entry.cancel() + } + deployCtxMu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() CleanupLoki(ctx, deployment) @@ -223,14 +294,17 @@ func (s *DeploymentService) StopDeployment(id uint) error { func (s *DeploymentService) RestartDeployment(id uint) error { if err := s.StopDeployment(id); err != nil { - return err + // 如果停止失败但不是因为部署未运行,返回错误 + if !strings.Contains(err.Error(), "部署未在运行") { + return err + } } time.Sleep(2 * time.Second) return s.StartDeploy(id) } func (s *DeploymentService) AddLog(deploymentID uint, level, message, detail string) { - db := models.GetDB() + db := repository.GetDB() log := models.DeploymentLog{ ID: db.NextID("logs"), DeploymentID: deploymentID, @@ -242,11 +316,11 @@ func (s *DeploymentService) AddLog(deploymentID uint, level, message, detail str db.Lock() db.Logs = append(db.Logs, log) db.Unlock() - go db.Save() + db.RequestSave() } func (s *DeploymentService) GetLogs(deploymentID uint, page, pageSize int) ([]models.DeploymentLog, int64, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() @@ -270,7 +344,7 @@ func (s *DeploymentService) GetLogs(deploymentID uint, page, pageSize int) ([]mo } func (s *DeploymentService) GetStats() (map[string]interface{}, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() @@ -289,18 +363,19 @@ func (s *DeploymentService) GetStats() (map[string]interface{}, error) { } return map[string]interface{}{ - "totalDeployments": int64(len(db.Deployments)), - "running": running, - "failed": failed, - "pending": pending, - "stopped": stopped, - "clusterCount": int64(len(db.Clusters)), - "hostCount": int64(len(db.Hosts)), + "totalDeployments": int64(len(db.Deployments)), + "running": running, + "failed": failed, + "pending": pending, + "stopped": stopped, + "clusterCount": int64(len(db.Clusters)), + "hostCount": int64(len(db.Hosts)), + "supportedVersions": LokiVersionList, }, nil } func (s *DeploymentService) loadRelations(d *models.LokiDeployment) { - db := models.GetDB() + db := repository.GetDB() if d.ClusterID != nil && *d.ClusterID > 0 { for i := range db.Clusters { if db.Clusters[i].ID == *d.ClusterID { @@ -337,7 +412,14 @@ func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string { if ns == "" { ns = "loki" } - return fmt.Sprintf("http://loki.%s.svc.cluster.local:3100", ns) + // k8s-yaml manifest 固定创建名为 loki 的 Service; + // k8s-helm (grafana/loki) 以 release 名(=部署名,默认 loki)暴露 Service。 + // 注意:不使用 loki-gateway,gateway 仅在 Distributed+gateway 启用时存在。 + svc := "loki" + if d.Mode == "k8s-helm" && d.Name != "" { + svc = d.Name + } + return fmt.Sprintf("http://%s.%s.svc.cluster.local:3100", svc, ns) case "host-binary": if d.Host != nil { return fmt.Sprintf("http://%s:3100", d.Host.HostIP) @@ -345,3 +427,48 @@ func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string { } return "" } + +// ==================== 版本兼容性检查 ==================== + +// ValidateLokiVersion 检查 Loki 版本是否在支持列表中且满足最低版本要求 +func ValidateLokiVersion(version string) error { + if version == "" { + return nil // 使用默认值,不报错 + } + + // 基本格式验证 + parts := strings.Split(version, ".") + if len(parts) < 2 { + return fmt.Errorf("版本号格式无效: %s,期望格式如 3.0.0", version) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return fmt.Errorf("无法解析主版本号: %s", version) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return fmt.Errorf("无法解析次版本号: %s", version) + } + + // 最低版本检查 + if major < minSupportedMajorVersion { + return fmt.Errorf("Loki %s 版本过低,最低支持 %d.x 版本", version, minSupportedMajorVersion) + } + if major == 2 && minor < 8 { + return fmt.Errorf("Loki %s 版本过低,2.x 最低支持 2.8.x 版本", version) + } + if major >= 4 { + return fmt.Errorf("Loki %s 是未发布的版本,请使用已知稳定版本", version) + } + + return nil +} + +// GetRecommendedVersions 返回推荐的 Loki 版本列表 +func GetRecommendedVersions() []string { + result := make([]string, len(LokiVersionList)) + copy(result, LokiVersionList) + return result +} diff --git a/backend/services/host.go b/backend/services/host.go index 3e43d9e..389fb3b 100644 --- a/backend/services/host.go +++ b/backend/services/host.go @@ -2,10 +2,13 @@ package services import ( "context" + "fmt" "strings" "time" "lokideploy/models" + "lokideploy/pkg/crypto" + "lokideploy/repository" "lokideploy/utils" ) @@ -20,7 +23,7 @@ func (s *HostService) Create(name, hostIP string, port int, user, authType, pass port = 22 } host := models.Host{ - ID: models.GetDB().NextID("hosts"), + ID: repository.GetDB().NextID("hosts"), Name: name, HostIP: hostIP, Port: port, @@ -34,30 +37,39 @@ func (s *HostService) Create(name, hostIP string, port int, user, authType, pass UpdatedAt: time.Now(), } if password != "" { - enc, _ := utils.Encrypt(password) - host.Password = "ENC:" + enc + enc, err := crypto.EncryptField(password) + if err != nil { + return nil, fmt.Errorf("密码加密失败: %w", err) + } + host.Password = enc } if privateKey != "" { - enc, _ := utils.Encrypt(privateKey) - host.PrivateKey = "ENC:" + enc + enc, err := crypto.EncryptField(privateKey) + if err != nil { + return nil, fmt.Errorf("私钥加密失败: %w", err) + } + host.PrivateKey = enc } if keyPhrase != "" { - enc, _ := utils.Encrypt(keyPhrase) - host.KeyPhrase = "ENC:" + enc + enc, err := crypto.EncryptField(keyPhrase) + if err != nil { + return nil, fmt.Errorf("私钥口令加密失败: %w", err) + } + host.KeyPhrase = enc } - db := models.GetDB() + db := repository.GetDB() db.Lock() db.Hosts = append(db.Hosts, host) db.Unlock() - go db.Save() + db.RequestSave() go s.Validate(host.ID) return &host, nil } func (s *HostService) Get(id uint) (*models.Host, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() for i := range db.Hosts { @@ -70,7 +82,7 @@ func (s *HostService) Get(id uint) (*models.Host, error) { } func (s *HostService) List(page, pageSize int) ([]models.Host, int64, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() @@ -89,7 +101,7 @@ func (s *HostService) List(page, pageSize int) ([]models.Host, int64, error) { } func (s *HostService) Update(id uint, updates map[string]interface{}) error { - db := models.GetDB() + db := repository.GetDB() db.Lock() defer db.Unlock() for i := range db.Hosts { @@ -107,7 +119,7 @@ func (s *HostService) Update(id uint, updates map[string]interface{}) error { db.Hosts[i].Arch = v } db.Hosts[i].UpdatedAt = time.Now() - go db.Save() + db.RequestSave() return nil } } @@ -115,13 +127,13 @@ func (s *HostService) Update(id uint, updates map[string]interface{}) error { } func (s *HostService) Delete(id uint) error { - db := models.GetDB() + db := repository.GetDB() db.Lock() defer db.Unlock() for i, h := range db.Hosts { if h.ID == id { db.Hosts = append(db.Hosts[:i], db.Hosts[i+1:]...) - go db.Save() + db.RequestSave() return nil } } @@ -138,7 +150,7 @@ func (s *HostService) Validate(id uint) { password := host.Password if strings.HasPrefix(password, "ENC:") { - password, _ = utils.Decrypt(password[4:]) + password, _ = crypto.Decrypt(password[4:]) } client, err := NewSSHClient(host.HostIP, host.Port, host.User, password) @@ -165,7 +177,7 @@ func (s *HostService) ExecuteCommand(id uint, command string) (string, string, e password := host.Password if strings.HasPrefix(password, "ENC:") { - password, _ = utils.Decrypt(password[4:]) + password, _ = crypto.Decrypt(password[4:]) } client, err := NewSSHClient(host.HostIP, host.Port, host.User, password) diff --git a/backend/services/k8s_client.go b/backend/services/k8s_client.go index a947a89..24c707b 100644 --- a/backend/services/k8s_client.go +++ b/backend/services/k8s_client.go @@ -1,6 +1,7 @@ package services import ( + "encoding/base64" "fmt" "os" "regexp" @@ -16,7 +17,7 @@ func resolveKubeconfig(cluster *models.Cluster) (string, error) { if cluster.KubeConfigPath != "" { data, err := os.ReadFile(cluster.KubeConfigPath) if err != nil { - return "", fmt.Errorf("读取 kubeconfig 文件失败: %v", err) + return "", fmt.Errorf("读取 kubeconfig 文件失败: %w", err) } return string(data), nil } @@ -24,15 +25,43 @@ func resolveKubeconfig(cluster *models.Cluster) (string, error) { } // BuildKubeconfigFromToken 用 API Server + Token 构造最小 kubeconfig -// 采用 insecure-skip-tls-verify,便于内网/自签证书集群接入 -func BuildKubeconfigFromToken(apiServer, token string) string { - return fmt.Sprintf(`apiVersion: v1 -kind: Config -clusters: +// 优先使用 CA 证书验证 TLS,仅在用户显式要求时跳过证书验证 +// Parameters: +// - apiServer: Kubernetes API Server 地址,如 https://192.168.1.10:6443 +// - token: 认证 Token +// - caCert: CA 证书 PEM 内容(可选)。若提供则嵌入证书数据进行 TLS 验证; +// 若为空且 skipTLSVerify=true 则跳过 TLS 验证; +// 若为空且 skipTLSVerify=false 则依赖系统 CA 池 +// - skipTLSVerify: 是否跳过 TLS 证书验证(不建议在生产环境使用) +func BuildKubeconfigFromToken(apiServer, token, caCert string, skipTLSVerify bool) string { + var clusterBlock string + + if caCert != "" { + // 嵌入 CA 证书进行安全的 TLS 验证 + encodedCA := base64.StdEncoding.EncodeToString([]byte(caCert)) + clusterBlock = fmt.Sprintf(`clusters: +- cluster: + certificate-authority-data: %s + server: %s + name: loki-cluster`, encodedCA, apiServer) + } else if skipTLSVerify { + // 用户显式要求跳过 TLS 验证(内网/自签证书场景) + clusterBlock = fmt.Sprintf(`clusters: - cluster: insecure-skip-tls-verify: true server: %s - name: loki-cluster + name: loki-cluster`, apiServer) + } else { + // 依赖系统 CA 池(不嵌入证书也不跳过验证) + clusterBlock = fmt.Sprintf(`clusters: +- cluster: + server: %s + name: loki-cluster`, apiServer) + } + + return fmt.Sprintf(`apiVersion: v1 +kind: Config +%s contexts: - context: cluster: loki-cluster @@ -43,7 +72,7 @@ users: - name: loki-user user: token: %s -`, apiServer, token) +`, clusterBlock, token) } var apiServerRe = regexp.MustCompile(`(?m)^\s*server:\s*(\S+)`) diff --git a/backend/services/loki_deployer.go b/backend/services/loki_deployer.go index 383b660..66ad316 100644 --- a/backend/services/loki_deployer.go +++ b/backend/services/loki_deployer.go @@ -1,16 +1,20 @@ package services import ( + "bytes" "context" "fmt" "os" "os/exec" + "strconv" "strings" + "text/template" "lokideploy/models" - "lokideploy/utils" + "lokideploy/pkg/crypto" ) +// LokiDeployer 负责 Loki 的三种部署模式:k8s-helm / k8s-yaml / host-binary type LokiDeployer struct { deployment *models.LokiDeployment logFunc func(level, message, detail string) @@ -20,8 +24,16 @@ func NewLokiDeployer(deployment *models.LokiDeployment, logFunc func(level, mess return &LokiDeployer{deployment: deployment, logFunc: logFunc} } +// Deploy 根据部署模式分发到对应实现 func (d *LokiDeployer) Deploy(ctx context.Context) error { - d.log("info", fmt.Sprintf("Starting Loki deployment: %s (mode: %s)", d.deployment.Name, d.deployment.Mode), "") + d.log("info", fmt.Sprintf("Starting Loki deployment: %s (mode: %s, version: %s)", + d.deployment.Name, d.deployment.Mode, d.effectiveVersion()), "") + + // 版本兼容性检查 + if err := d.checkVersionCompatibility(); err != nil { + d.log("warn", err.Error(), "") + } + switch d.deployment.Mode { case "k8s-helm": return d.deployHelm(ctx) @@ -30,102 +42,131 @@ func (d *LokiDeployer) Deploy(ctx context.Context) error { case "host-binary": return d.deployBinary(ctx) default: - return fmt.Errorf("不支持的部署模式: %s", d.deployment.Mode) + return fmt.Errorf("unsupported deployment mode: %s", d.deployment.Mode) } } +// ==================== Helm 部署 (grafana/loki v6+) ==================== + func (d *LokiDeployer) deployHelm(ctx context.Context) error { if d.deployment.Cluster == nil { - return fmt.Errorf("请选择目标集群") - } - kubeconfig := d.deployment.Cluster.KubeConfig - if kubeconfig == "" && d.deployment.Cluster.KubeConfigPath != "" { - data, _ := os.ReadFile(d.deployment.Cluster.KubeConfigPath) - kubeconfig = string(data) + return fmt.Errorf("please select target cluster") } - kubeFile, _ := os.CreateTemp("", "kube-*.yaml") - defer os.Remove(kubeFile.Name()) - kubeFile.WriteString(kubeconfig) - kubeFile.Close() - - env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name())) - ns := d.deployment.Namespace - if ns == "" { - ns = "loki" + kubeconfig, err := resolveKubeconfig(d.deployment.Cluster) + if err != nil { + return err } - d.log("info", fmt.Sprintf("Creating namespace %s...", ns), "") - exec.CommandContext(ctx, "kubectl", "create", "namespace", ns, "--dry-run=client", "-o", "yaml").CombinedOutput() - - d.log("info", "Adding Grafana Helm repo...", "") - exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts").CombinedOutput() - exec.CommandContext(ctx, "helm", "repo", "update").CombinedOutput() - - d.log("info", fmt.Sprintf("Installing Loki in %s...", ns), "") - releaseName := d.deployment.Name - if releaseName == "" { - releaseName = "loki" + kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600) + if err != nil { + return err } + defer cleanup() - chart := "grafana/loki" - if d.deployment.Replicas > 1 { - chart = "grafana/loki-distributed" + env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile)) + ns := d.effectiveNamespace() + + // 创建 namespace(幂等) + d.log("info", fmt.Sprintf("Ensuring namespace %s...", ns), "") + nsCmd := exec.CommandContext(ctx, "kubectl", "create", "namespace", ns, "--dry-run=client", "-o", "yaml") + nsCmd.Env = env + nsOut, _ := nsCmd.CombinedOutput() + applyNs := exec.CommandContext(ctx, "kubectl", "apply", "-f", "-") + applyNs.Env = env + applyNs.Stdin = bytes.NewReader(nsOut) + applyNs.CombinedOutput() + + // 添加 Helm repo + d.log("info", "Adding/updating Grafana Helm repo...", "") + repoCmd := exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts") + repoCmd.Env = env + repoCmd.CombinedOutput() + updCmd := exec.CommandContext(ctx, "helm", "repo", "update") + updCmd.Env = env + updCmd.CombinedOutput() + + // 生成 values.yaml + valuesYAML := d.generateHelmValues() + valuesFile, valuesCleanup, err := writeTempFile("loki-values-*.yaml", valuesYAML, 0600) + if err != nil { + return err } + defer valuesCleanup() - cmd := exec.CommandContext(ctx, "helm", "upgrade", "--install", releaseName, chart, - "--namespace", ns, "--create-namespace", "--wait", "--timeout", "10m") + // Helm install/upgrade - 统一使用 grafana/loki (v6+) + d.log("info", fmt.Sprintf("Installing Loki via Helm (grafana/loki, mode: %s)...", d.helmDeploymentMode()), "") + releaseName := d.effectiveReleaseName() + + cmd := exec.CommandContext(ctx, "helm", "upgrade", "--install", releaseName, "grafana/loki", + "--namespace", ns, "--create-namespace", "--wait", "--timeout", "10m", + "-f", valuesFile, + ) cmd.Env = env out, err := cmd.CombinedOutput() if err != nil { d.log("error", fmt.Sprintf("Helm install failed: %v", err), string(out)) - return err + return fmt.Errorf("helm install failed: %w\n%s", err, string(out)) } d.log("success", "Loki installed via Helm", string(out)) return nil } +// ==================== K8s YAML 部署 (StatefulSet) ==================== + func (d *LokiDeployer) deployYaml(ctx context.Context) error { if d.deployment.Cluster == nil { - return fmt.Errorf("请选择目标集群") - } - kubeconfig := d.deployment.Cluster.KubeConfig - if kubeconfig == "" && d.deployment.Cluster.KubeConfigPath != "" { - data, _ := os.ReadFile(d.deployment.Cluster.KubeConfigPath) - kubeconfig = string(data) + return fmt.Errorf("please select target cluster") } - kubeFile, _ := os.CreateTemp("", "kube-*.yaml") - defer os.Remove(kubeFile.Name()) - kubeFile.WriteString(kubeconfig) - kubeFile.Close() - - env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name())) - ns := d.deployment.Namespace - if ns == "" { - ns = "loki" + kubeconfig, err := resolveKubeconfig(d.deployment.Cluster) + if err != nil { + return err } + kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600) + if err != nil { + return err + } + defer cleanup() + + env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile)) + ns := d.effectiveNamespace() + + // 生成完整 manifest manifest := d.generateManifest(ns) - manifestFile, _ := os.CreateTemp("", "loki-*.yaml") - defer os.Remove(manifestFile.Name()) - manifestFile.WriteString(manifest) - manifestFile.Close() + manifestFile, manifestCleanup, err := writeTempFile("loki-*.yaml", manifest, 0600) + if err != nil { + return err + } + defer manifestCleanup() - cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestFile.Name(), "-n", ns) + d.log("info", "Applying Loki manifest (StatefulSet + PVC + ConfigMap + Service)...", "") + cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestFile) cmd.Env = env out, err := cmd.CombinedOutput() if err != nil { d.log("error", fmt.Sprintf("kubectl apply failed: %v", err), string(out)) - return err + return fmt.Errorf("kubectl apply failed: %w\n%s", err, string(out)) } - d.log("success", "Loki deployed via YAML", string(out)) + + // 等待 StatefulSet ready + d.log("info", "Waiting for StatefulSet to be ready...", "") + waitCmd := exec.CommandContext(ctx, "kubectl", "rollout", "status", "statefulset/loki", + "-n", ns, "--timeout=5m") + waitCmd.Env = env + waitOut, _ := waitCmd.CombinedOutput() + d.log("info", string(waitOut), "") + + d.log("success", "Loki deployed via YAML manifest", string(out)) return nil } +// ==================== 主机二进制部署 ==================== + func (d *LokiDeployer) deployBinary(ctx context.Context) error { if d.deployment.Host == nil { - return fmt.Errorf("请选择目标主机") + return fmt.Errorf("please select target host") } host := d.deployment.Host password := host.Password @@ -139,7 +180,7 @@ func (d *LokiDeployer) deployBinary(ctx context.Context) error { } defer client.Close() - d.log("info", "SSH connected, downloading Loki...", "") + // 检测架构 arch, _, _ := client.Execute(ctx, "uname -m") lokiArch := strings.TrimSpace(arch) switch lokiArch { @@ -149,23 +190,26 @@ func (d *LokiDeployer) deployBinary(ctx context.Context) error { lokiArch = "arm64" } - version := d.deployment.Version - if version == "" { - version = "3.0.0" - } + version := d.effectiveVersion() + d.log("info", fmt.Sprintf("SSH connected, downloading Loki %s (%s)...", version, lokiArch), "") - client.Execute(ctx, "mkdir -p /opt/loki /var/lib/loki /etc/loki") + // 创建目录 + client.Execute(ctx, "mkdir -p /opt/loki /var/lib/loki /var/lib/loki/rules /var/lib/loki/rules-temp /etc/loki") + // 下载 Loki downloadURL := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v%s/loki-linux-%s.zip", version, lokiArch) - cmd := fmt.Sprintf("cd /tmp && curl -sL -o loki.zip '%s' && unzip -o loki.zip -d /opt/loki/ && chmod +x /opt/loki/loki-linux-%s", downloadURL, lokiArch) - client.Execute(ctx, cmd) + cmd := fmt.Sprintf("cd /tmp && curl -fsSL -o loki.zip '%s' && unzip -o loki.zip -d /opt/loki/ && chmod +x /opt/loki/loki-linux-%s", + downloadURL, lokiArch) + if _, _, err := client.Execute(ctx, cmd); err != nil { + return fmt.Errorf("download Loki failed: %w", err) + } client.Execute(ctx, "ln -sf /opt/loki/loki-linux-* /usr/local/bin/loki 2>/dev/null || true") - // Upload config + // 上传配置 config := d.generateLokiConfig() client.Execute(ctx, fmt.Sprintf("cat > /etc/loki/loki.yaml << 'EOF'\n%s\nEOF", config)) - // Systemd service + // systemd 服务(生产级配置) service := `[Unit] Description=Grafana Loki Wants=network-online.target @@ -173,32 +217,348 @@ After=network-online.target [Service] Type=simple +User=root +Group=root ExecStart=/usr/local/bin/loki -config.file=/etc/loki/loki.yaml +ExecReload=/bin/kill -HUP $MAINPID Restart=always +RestartSec=10 +LimitNOFILE=65536 +StandardOutput=append:/var/log/loki.log +StandardError=append:/var/log/loki.log [Install] WantedBy=multi-user.target ` client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/loki.service << 'EOF'\n%s\nEOF", service)) - client.Execute(ctx, "systemctl daemon-reload && systemctl enable loki && systemctl restart loki") - d.log("success", fmt.Sprintf("Loki deployed on %s", host.HostIP), "") + // 启动服务 + if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable loki && systemctl restart loki"); err != nil { + return fmt.Errorf("start Loki failed: %w", err) + } + + // 健康检查 + d.log("info", "Waiting for Loki to be ready...", "") + for i := 0; i < 10; i++ { + _, _, err := client.Execute(ctx, "curl -fsSL http://localhost:3100/ready") + if err == nil { + break + } + client.Execute(ctx, "sleep 2") + } + + d.log("success", fmt.Sprintf("Loki %s deployed on %s", version, host.HostIP), "") return nil } +// ==================== Helm Values 生成 ==================== + +func (d *LokiDeployer) generateHelmValues() string { + mode := d.helmDeploymentMode() + version := d.effectiveVersion() + + // 构建 storage 配置 + storageSection := d.buildHelmStorageSection() + schemaSection := d.buildHelmSchemaSection() + + // 资源限制 + 持久化(合并到同一顶层键下,避免重复键导致 Helm 丢弃 resources) + deploymentSection := d.buildHelmDeploymentSection() + + // 认证配置 + authSection := "" + if d.deployment.AuthEnabled { + authSection = ` +auth: + type: basic + basic: + username: admin` + } + + values := fmt.Sprintf(`# Auto-generated Loki Helm Values +# Chart: grafana/loki (v6+) +# Deployment Mode: %s +# Version: %s + +deploymentMode: %s + +loki: + image: + tag: %s + auth_enabled: %v + commonConfig: + replication_factor: %d + path_prefix: /var/loki + %s + limits_config: + retention_period: %dh + ingestion_rate_mb: 16 + ingestion_burst_size_mb: 32 + per_stream_rate_limit: 3MB + per_stream_rate_limit_burst: 15MB + max_chunks_per_query: 2000000 + max_query_series: 1000 + max_query_parallelism: 32 + %s + %s + analytics: + reporting_enabled: false + +%s + +monitoring: + serviceMonitor: + enabled: false + selfMonitoring: + enabled: false + grafanaAgent: + installOperator: false + lokiCanary: + enabled: false + +test: + enabled: false +`, mode, version, mode, version, d.deployment.AuthEnabled, + d.replicationFactor(), storageSection, d.deployment.RetentionDays, schemaSection, + authSection, deploymentSection) + + return values +} + +func (d *LokiDeployer) helmDeploymentMode() string { + if d.deployment.Replicas > 1 { + return "Distributed" + } + return "SingleBinary" +} + +func (d *LokiDeployer) replicationFactor() int { + if d.deployment.Replicas >= 3 { + return 3 + } + if d.deployment.Replicas >= 2 { + return 2 + } + return 1 +} + +func (d *LokiDeployer) buildHelmStorageSection() string { + storageType := d.effectiveStorageType() + + switch storageType { + case "s3": + sc := d.deployment.StorageConfig + endpoint := sc.Endpoint + if endpoint == "" { + endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region) + } + return fmt.Sprintf(`storage: + type: s3 + bucketNames: + chunks: %s + ruler: %s-ruler + admin: %s-admin + s3: + endpoint: %s + region: %s + accessKeyId: %s + secretAccessKey: %s + insecure: %v + s3ForcePathStyle: true`, sc.Bucket, sc.Bucket, sc.Bucket, endpoint, sc.Region, sc.AccessKey, sc.SecretKey, sc.Insecure) + case "oss": + sc := d.deployment.StorageConfig + return fmt.Sprintf(`storage: + type: s3 + bucketNames: + chunks: %s + ruler: %s-ruler + admin: %s-admin + s3: + endpoint: %s + region: %s + accessKeyId: %s + secretAccessKey: %s`, sc.Bucket, sc.Bucket, sc.Bucket, sc.Endpoint, sc.Region, sc.AccessKey, sc.SecretKey) + case "minio": + sc := d.deployment.StorageConfig + return fmt.Sprintf(`storage: + type: s3 + bucketNames: + chunks: %s + ruler: %s-ruler + admin: %s-admin + s3: + endpoint: %s + accessKeyId: %s + secretAccessKey: %s + insecure: %v + s3ForcePathStyle: true`, sc.Bucket, sc.Bucket, sc.Bucket, sc.Endpoint, sc.AccessKey, sc.SecretKey, sc.Insecure) + default: + // filesystem + return `storage: + type: filesystem` + } +} + +func (d *LokiDeployer) buildHelmSchemaSection() string { + storageType := d.effectiveStorageType() + objectStore := "filesystem" + switch storageType { + case "s3", "oss", "minio": + objectStore = "s3" + case "gcs": + objectStore = "gcs" + case "azure": + objectStore = "azure" + } + + return fmt.Sprintf(`schemaConfig: + configs: + - from: "2024-01-01" + store: tsdb + object_store: %s + schema: v13 + index: + prefix: tsdb_index_ + period: 24h`, objectStore) +} + +// buildHelmDeploymentSection 生成目标组件的 resources + persistence。 +// 关键:必须把两者合并到同一顶层键(singleBinary / write / read / backend)之下, +// 否则 resources 与 persistence 各自 emit 一次同名顶层键,YAML/Helm 只取最后一个, +// 导致 resources 限制被静默丢弃。 +func (d *LokiDeployer) buildHelmDeploymentSection() string { + cpu := d.deployment.ResourcesCPU + mem := d.deployment.ResourcesMem + if cpu == "" { + cpu = "1000m" + } + if mem == "" { + mem = "1Gi" + } + storageSize := d.deployment.StorageSize + if storageSize == "" { + storageSize = "10Gi" + } + + resources := fmt.Sprintf(` resources: + limits: + cpu: %s + memory: %s + requests: + cpu: 200m + memory: 256Mi`, cpu, mem) + + persistence := fmt.Sprintf(` persistence: + enabled: true + size: %s + storageClassName: ""`, storageSize) + + if d.helmDeploymentMode() == "SingleBinary" { + return fmt.Sprintf(`singleBinary: + replicas: %d +%s +%s`, d.deployment.Replicas, resources, persistence) + } + + // Distributed: write/backend 持久化,read 不持久化(无状态) + return fmt.Sprintf(`write: + replicas: %d +%s +%s +read: + replicas: %d +%s + persistence: + enabled: false +backend: + replicas: 1 +%s +%s`, + d.deployment.Replicas, resources, persistence, + d.deployment.Replicas, resources, + resources, persistence) +} + +// ==================== K8s YAML Manifest 生成 (StatefulSet) ==================== + func (d *LokiDeployer) generateManifest(ns string) string { - return fmt.Sprintf(`apiVersion: v1 + version := d.effectiveVersion() + replicas := d.deployment.Replicas + if replicas < 1 { + replicas = 1 + } + storageSize := d.deployment.StorageSize + if storageSize == "" { + storageSize = "10Gi" + } + + cpu := d.deployment.ResourcesCPU + mem := d.deployment.ResourcesMem + if cpu == "" { + cpu = "1000m" + } + if mem == "" { + mem = "1Gi" + } + + configData := d.generateLokiConfig() + + manifest := fmt.Sprintf(`apiVersion: v1 kind: Namespace metadata: name: %s --- -apiVersion: apps/v1 -kind: Deployment +apiVersion: v1 +kind: ConfigMap +metadata: + name: loki-config + namespace: %s +data: + loki.yaml: | +%s +--- +apiVersion: v1 +kind: Service metadata: name: loki namespace: %s + labels: + app: loki spec: - replicas: 1 + type: ClusterIP + ports: + - name: http + port: 3100 + targetPort: 3100 + protocol: TCP + - name: grpc + port: 9095 + targetPort: 9095 + protocol: TCP + selector: + app: loki +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: loki-data + namespace: %s +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: %s +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: loki + namespace: %s + labels: + app: loki +spec: + serviceName: loki + replicas: %d selector: matchLabels: app: loki @@ -207,41 +567,503 @@ spec: labels: app: loki spec: + securityContext: + runAsUser: 0 + runAsGroup: 0 + fsGroup: 0 containers: - name: loki image: grafana/loki:%s + imagePullPolicy: IfNotPresent + args: + - -config.file=/etc/loki/loki.yaml ports: - - containerPort: 3100 + - name: http + containerPort: 3100 + protocol: TCP + - name: grpc + containerPort: 9095 + protocol: TCP + resources: + limits: + cpu: "%s" + memory: "%s" + requests: + cpu: "200m" + memory: "256Mi" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + livenessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 30 + volumeMounts: + - name: config + mountPath: /etc/loki + readOnly: true + - name: data + mountPath: /var/lib/loki + - name: wal + mountPath: /var/lib/loki/wal + volumes: + - name: config + configMap: + name: loki-config + - name: data + persistentVolumeClaim: + claimName: loki-data + - name: wal + emptyDir: {} +`, ns, ns, indentYAML(configData, 4), ns, ns, storageSize, ns, replicas, version, cpu, mem) + + // 多副本:追加 Headless Service(clusterIP: None + selector app:loki), + // 使 memberlist 的 join_members (loki-headless..svc.cluster.local) 能解析到所有 Pod IP。 + if replicas >= 2 { + manifest += fmt.Sprintf(` --- apiVersion: v1 kind: Service metadata: - name: loki + name: loki-headless namespace: %s + labels: + app: loki spec: + clusterIP: None ports: - - port: 3100 + - name: http + port: 3100 + targetPort: 3100 + protocol: TCP + - name: grpc + port: 9095 + targetPort: 9095 + protocol: TCP + - name: memberlist + port: 7946 + targetPort: 7946 + protocol: TCP selector: app: loki -`, ns, ns, d.deployment.Version, ns) +`, ns) + } + return manifest +} + +// ==================== Loki 本地配置生成 (text/template) ==================== + +const lokiConfigTemplate = `auth_enabled: {{ .AuthEnabled }} + +server: + http_listen_port: 3100 + grpc_listen_port: 9095 + http_server_read_timeout: 3m + http_server_write_timeout: 3m + http_server_idle_timeout: 2m + grpc_server_max_recv_msg_size: 104857600 + grpc_server_max_send_msg_size: 104857600 + grpc_server_max_concurrent_streams: 1000 + log_level: info + log_format: logfmt + +common: + path_prefix: /var/lib/loki + storage: + filesystem: + chunks_directory: /var/lib/loki/chunks + rules_directory: /var/lib/loki/rules + admin_api_directory: /var/lib/loki/admin + replication_factor: {{ .ReplicationFactor }} + ring: + instance_addr: {{ .RingInstanceAddr }} + kvstore: + store: {{ .RingKVStore }} + +{{- if .StorageConfigBlock }} + +storage_config: +{{ .StorageConfigBlock }} +{{- end }} +{{- if .MemberlistBlock }} + +{{ .MemberlistBlock }} +{{- end }} + +ingester: + wal: + enabled: true + dir: /var/lib/loki/wal + flush_on_shutdown: true + replay_memory_ceiling: 1GB + lifecycler: + ring: + kvstore: + store: {{ .RingKVStore }} + replication_factor: {{ .ReplicationFactor }} + final_sleep: 0s + chunk_idle_period: 30m + chunk_retain_period: 30s + chunk_target_size: 1048576 + chunk_encoding: snappy + max_chunk_age: 2h + max_transfer_retries: 0 + autoforget_unhealthy: true + +querier: + max_concurrent: 10 + query_ingesters_within: 2h + engine: + timeout: 3m + max_look_back_period: 30s + +query_frontend: + max_outstanding_per_tenant: 4096 + compress_responses: true + scheduler_address: "" + +query_scheduler: + max_outstanding_requests_per_tenant: 4096 + +compactor: + working_directory: /var/lib/loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + retention_delete_worker_count: 150 + delete_request_store: {{ .CompactorStoreType }} + +limits_config: + retention_period: {{ .RetentionPeriod }}h + ingestion_rate_mb: 16 + ingestion_burst_size_mb: 32 + per_stream_rate_limit: 3MB + per_stream_rate_limit_burst: 15MB + max_chunks_per_query: 2000000 + max_query_series: 1000 + max_query_parallelism: 32 + max_entries_limit_per_query: 5000 + max_label_name_length: 1024 + max_label_value_length: 2048 + max_label_names_per_series: 30 + reject_old_samples: true + reject_old_samples_max_age: 168h + allow_structured_metadata: true + +schema_config: + configs: + - from: "2024-01-01" + store: tsdb + object_store: {{ .ObjectStore }} + schema: v13 + index: + prefix: tsdb_index_ + period: 24h + +ruler: + storage: + type: {{ .RulerStoreType }} +{{- if eq .RulerStoreType "local" }} + local: + directory: /var/lib/loki/rules +{{- end }} + rule_path: /var/lib/loki/rules-temp + alertmanager_url: http://localhost:9093 + ring: + kvstore: + store: {{ .RingKVStore }} + enable_api: true + enable_alertmanager_v2: true + +frontend: + max_outstanding_per_tenant: 4096 + compress_responses: true + +frontend_worker: + parallelism: 10 + +table_manager: + retention_deletes_enabled: true + retention_period: {{ .RetentionPeriod }}h + +chunk_store_config: + chunk_cache_config: + embedded_cache: + enabled: true + max_size_mb: 256 + ttl: 1h + +analytics: + reporting_enabled: false +` + +type lokiConfigData struct { + AuthEnabled bool + ReplicationFactor int + RetentionPeriod int + StorageConfigBlock string + ObjectStore string + RulerStoreType string + CompactorStoreType string + RingInstanceAddr string // 单副本 127.0.0.1;多副本 0.0.0.0(须可被对端寻址) + RingKVStore string // 单副本 inmemory;多副本 memberlist + MemberlistBlock string // 多副本时非空,注入 memberlist 顶级配置 } func (d *LokiDeployer) generateLokiConfig() string { - return fmt.Sprintf(`auth_enabled: false + retentionHours := d.deployment.RetentionDays * 24 + if retentionHours <= 0 { + retentionHours = 720 // 30 days default + } + + storageType := d.effectiveStorageType() + storageBlock := d.buildStorageConfigBlock() + objectStore := "filesystem" + rulerStore := "local" + compactorStore := "filesystem" + + switch storageType { + case "s3", "oss", "minio": + objectStore = "s3" + rulerStore = "s3" + compactorStore = "s3" + case "gcs": + objectStore = "gcs" + rulerStore = "gcs" + compactorStore = "gcs" + case "azure": + objectStore = "azure" + rulerStore = "azure" + compactorStore = "azure" + } + + // Ring 拓扑:单副本用 inmemory + 127.0.0.1(无需跨实例通信); + // 多副本必须切 memberlist + 0.0.0.0,否则 ring 无法收敛、写入失败。 + ringAddr := "127.0.0.1" + ringStore := "inmemory" + memberlistBlock := "" + if d.deployment.Replicas >= 2 { + ringAddr = "0.0.0.0" + ringStore = "memberlist" + ns := d.deployment.Namespace + if ns == "" { + ns = "loki" + } + memberlistBlock = fmt.Sprintf(`memberlist: + join_members: + - loki-headless.%s.svc.cluster.local + bind_port: 7946 + dead_node_reclaim_time: 30s + gossip_to_dead_nodes_time: 2m + left_ingesters_timeout: 5m`, ns) + } + + data := lokiConfigData{ + AuthEnabled: d.deployment.AuthEnabled, + ReplicationFactor: d.replicationFactor(), + RetentionPeriod: retentionHours, + StorageConfigBlock: storageBlock, + ObjectStore: objectStore, + RulerStoreType: rulerStore, + CompactorStoreType: compactorStore, + RingInstanceAddr: ringAddr, + RingKVStore: ringStore, + MemberlistBlock: memberlistBlock, + } + + tmpl, err := template.New("loki-config").Parse(lokiConfigTemplate) + if err != nil { + return d.generateLokiConfigFallback(retentionHours) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return d.generateLokiConfigFallback(retentionHours) + } + return buf.String() +} + +func (d *LokiDeployer) buildStorageConfigBlock() string { + if d.deployment.StorageConfig == nil { + return "" + } + + sc := d.deployment.StorageConfig + switch sc.StorageType { + case "s3": + endpoint := sc.Endpoint + if endpoint == "" { + endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region) + } + insecure := "false" + if sc.Insecure { + insecure = "true" + } + return fmt.Sprintf(` aws: + s3: s3://%s/%s + endpoint: %s + region: %s + access_key_id: %s + secret_access_key: %s + insecure: %s + s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, endpoint, sc.Region, sc.AccessKey, sc.SecretKey, insecure) + case "oss": + return fmt.Sprintf(` alibabacloud: + bucket: %s + endpoint: %s + region: %s + access_key_id: %s + secret_access_key: %s`, sc.Bucket, sc.Endpoint, sc.Region, sc.AccessKey, sc.SecretKey) + case "minio": + insecure := "false" + if sc.Insecure { + insecure = "true" + } + return fmt.Sprintf(` aws: + s3: s3://%s/%s + endpoint: %s + access_key_id: %s + secret_access_key: %s + insecure: %s + s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, sc.Endpoint, sc.AccessKey, sc.SecretKey, insecure) + case "gcs": + return fmt.Sprintf(` gcs: + bucket_name: %s`, sc.Bucket) + case "azure": + return fmt.Sprintf(` azure: + container_name: %s + account_name: %s + account_key: %s`, sc.Bucket, sc.AccessKey, sc.SecretKey) + default: + return "" + } +} + +// generateLokiConfigFallback 当模板解析失败时的降级配置 +func (d *LokiDeployer) generateLokiConfigFallback(retentionHours int) string { + return fmt.Sprintf(`auth_enabled: %v server: http_listen_port: 3100 + grpc_listen_port: 9095 + http_server_read_timeout: 3m + http_server_write_timeout: 3m + log_level: info +common: + path_prefix: /var/lib/loki + storage: + filesystem: + chunks_directory: /var/lib/loki/chunks + rules_directory: /var/lib/loki/rules + replication_factor: %d + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory +ingester: + wal: + enabled: true + dir: /var/lib/loki/wal + lifecycler: + ring: + kvstore: + store: inmemory + replication_factor: %d + final_sleep: 0s + chunk_idle_period: 30m + max_chunk_age: 2h +querier: + max_concurrent: 10 +query_frontend: + max_outstanding_per_tenant: 4096 +compactor: + working_directory: /var/lib/loki/compactor + retention_enabled: true + retention_delete_delay: 2h limits_config: retention_period: %dh + ingestion_rate_mb: 16 + ingestion_burst_size_mb: 32 schema_config: configs: - - from: 2020-10-24 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h -`, d.deployment.RetentionDays*24) + - from: "2024-01-01" + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: tsdb_index_ + period: 24h +analytics: + reporting_enabled: false +`, d.deployment.AuthEnabled, d.replicationFactor(), d.replicationFactor(), retentionHours) +} + +// ==================== 辅助函数 ==================== + +func (d *LokiDeployer) effectiveVersion() string { + if d.deployment.Version != "" { + return d.deployment.Version + } + return "3.0.0" +} + +func (d *LokiDeployer) effectiveNamespace() string { + if d.deployment.Namespace != "" { + return d.deployment.Namespace + } + return "loki" +} + +func (d *LokiDeployer) effectiveReleaseName() string { + if d.deployment.Name != "" { + return d.deployment.Name + } + return "loki" +} + +func (d *LokiDeployer) effectiveStorageType() string { + if d.deployment.StorageConfig != nil && d.deployment.StorageConfig.StorageType != "" { + return d.deployment.StorageConfig.StorageType + } + return "filesystem" +} + +func (d *LokiDeployer) checkVersionCompatibility() error { + version := d.effectiveVersion() + parts := strings.Split(version, ".") + if len(parts) < 1 { + return fmt.Errorf("cannot parse version: %s", version) + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return fmt.Errorf("cannot parse major version: %s", version) + } + if major < 2 { + return fmt.Errorf("Loki version %s is too old, recommend 2.8+ or 3.x", version) + } + return nil } func (d *LokiDeployer) log(level, message, detail string) { @@ -250,21 +1072,72 @@ func (d *LokiDeployer) log(level, message, detail string) { } } +// ==================== 清理 / 卸载 ==================== + func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) { if deployment.Mode == "k8s-helm" && deployment.Cluster != nil { - kubeconfig := deployment.Cluster.KubeConfig - if kubeconfig == "" && deployment.Cluster.KubeConfigPath != "" { - data, _ := os.ReadFile(deployment.Cluster.KubeConfigPath) - kubeconfig = string(data) + kubeconfig, err := resolveKubeconfig(deployment.Cluster) + if err != nil { + return } - kubeFile, _ := os.CreateTemp("", "kube-*.yaml") - defer os.Remove(kubeFile.Name()) - os.WriteFile(kubeFile.Name(), []byte(kubeconfig), 0600) - env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name())) - cmd := exec.CommandContext(ctx, "helm", "uninstall", deployment.Name, "--namespace", deployment.Namespace) + kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600) + if err != nil { + return + } + defer cleanup() + + env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile)) + ns := deployment.Namespace + if ns == "" { + ns = "loki" + } + name := deployment.Name + if name == "" { + name = "loki" + } + + cmd := exec.CommandContext(ctx, "helm", "uninstall", name, "--namespace", ns) cmd.Env = env cmd.CombinedOutput() + + // 清理 PVC(Helm 不会自动删除 PVC) + cleanupPVC := exec.CommandContext(ctx, "kubectl", "delete", "pvc", + "-l", "app.kubernetes.io/name=loki", "-n", ns, "--ignore-not-found=true") + cleanupPVC.Env = env + cleanupPVC.CombinedOutput() } + + if deployment.Mode == "k8s-yaml" && deployment.Cluster != nil { + kubeconfig, err := resolveKubeconfig(deployment.Cluster) + if err != nil { + return + } + kubeFile, cleanup, err := writeTempFile("kube-*.yaml", kubeconfig, 0600) + if err != nil { + return + } + defer cleanup() + + env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile)) + ns := deployment.Namespace + if ns == "" { + ns = "loki" + } + + // 删除所有相关资源 + resources := []string{ + "statefulset/loki", + "service/loki", + "configmap/loki-config", + "persistentvolumeclaim/loki-data", + } + for _, res := range resources { + cmd := exec.CommandContext(ctx, "kubectl", "delete", res, "-n", ns, "--ignore-not-found=true") + cmd.Env = env + cmd.CombinedOutput() + } + } + if deployment.Mode == "host-binary" && deployment.Host != nil { host := deployment.Host password := host.Password @@ -274,14 +1147,51 @@ func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) { client, _ := NewSSHClient(host.HostIP, host.Port, host.User, password) if client != nil { defer client.Close() - client.Execute(ctx, "systemctl stop loki && systemctl disable loki && rm -f /etc/systemd/system/loki.service /usr/local/bin/loki") + client.Execute(ctx, "systemctl stop loki 2>/dev/null || true") + client.Execute(ctx, "systemctl disable loki 2>/dev/null || true") + client.Execute(ctx, "rm -f /etc/systemd/system/loki.service /usr/local/bin/loki") + client.Execute(ctx, "systemctl daemon-reload") } } } +// DecryptCred 解密凭证 func DecryptCred(encrypted string) (string, error) { if strings.HasPrefix(encrypted, "ENC:") { - return utils.Decrypt(encrypted[4:]) + return crypto.Decrypt(encrypted[4:]) } return encrypted, nil } + +// ==================== 通用工具函数 ==================== + +// writeTempFile 安全地创建临时文件,返回文件路径和清理函数 +func writeTempFile(pattern, content string, perm os.FileMode) (string, func(), error) { + f, err := os.CreateTemp("", pattern) + if err != nil { + return "", nil, fmt.Errorf("create temp file failed: %w", err) + } + if _, err := f.WriteString(content); err != nil { + f.Close() + os.Remove(f.Name()) + return "", nil, fmt.Errorf("write temp file failed: %w", err) + } + f.Close() + if perm > 0 { + os.Chmod(f.Name(), perm) + } + cleanup := func() { os.Remove(f.Name()) } + return f.Name(), cleanup, nil +} + +// indentYAML 对 YAML 内容进行缩进(用于嵌入 ConfigMap) +func indentYAML(s string, spaces int) string { + pad := strings.Repeat(" ", spaces) + lines := strings.Split(s, "\n") + for i, l := range lines { + if l != "" { + lines[i] = pad + l + } + } + return strings.Join(lines, "\n") +} diff --git a/backend/services/ssh.go b/backend/services/ssh.go index 7011506..89fb7d0 100644 --- a/backend/services/ssh.go +++ b/backend/services/ssh.go @@ -3,12 +3,21 @@ package services import ( "bytes" "context" + "crypto/sha256" + "encoding/base64" "errors" "fmt" + "net" + "os" + "path/filepath" "strconv" "strings" "time" + "lokideploy/config" + "lokideploy/models" + "lokideploy/pkg/crypto" + "golang.org/x/crypto/ssh" ) @@ -38,17 +47,169 @@ type HostInfo struct { DiskUsed string `json:"diskUsed"` } -func NewSSHClient(host string, port int, user, password string) (*SSHClient, error) { - if port == 0 { - port = 22 +// SSHAuthConfig 重导出models.SSHAuthConfig以兼容现有调用 +// Deprecated: 直接使用 models.SSHAuthConfig +type SSHAuthConfig = models.SSHAuthConfig + +// validatePort 校验SSH端口范围 +func validatePort(port int) int { + if port <= 0 { + return 22 } - config := &ssh.ClientConfig{ + if port > 65535 { + return 22 + } + return port +} + +// hostKeyPath 返回主机密钥存储路径 +func hostKeyPath(host string) string { + cfg := config.GetConfig() + // 对主机名进行安全编码作为文件名 + safeHost := base64.URLEncoding.EncodeToString([]byte(host)) + return filepath.Join(cfg.HostKeyDir, safeHost+".pub") +} + +// fingerprint 计算SSH主机密钥的SHA256指纹 +func fingerprint(key ssh.PublicKey) string { + hash := sha256.Sum256(key.Marshal()) + return base64.StdEncoding.EncodeToString(hash[:]) +} + +// TrustOnFirstUse 实现TOFU(Trust On First Use)主机密钥验证 +// 首次连接时记录主机密钥,后续连接时校验 +func TrustOnFirstUse(host string) ssh.HostKeyCallback { + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + fp := fingerprint(key) + hostKeyFile := hostKeyPath(host) + + storedKey, err := os.ReadFile(hostKeyFile) + if err != nil { + // 首次连接:保存主机密钥 + if os.IsNotExist(err) { + if writeErr := os.WriteFile(hostKeyFile, ssh.MarshalAuthorizedKey(key), 0600); writeErr != nil { + return fmt.Errorf("failed to save host key: %w", writeErr) + } + // 记录首次信任的主机密钥指纹(安全审计日志) + fmt.Fprintf(os.Stderr, "[SSH-TOFU] First connection to %s, host key fingerprint saved: SHA256:%s\n", host, fp) + return nil + } + return fmt.Errorf("failed to read host key file: %w", err) + } + + // 解析存储的密钥并与当前密钥比较 + parsedKey, _, _, _, err := ssh.ParseAuthorizedKey(storedKey) + if err != nil { + return fmt.Errorf("failed to parse stored host key: %w", err) + } + + if !bytes.Equal(parsedKey.Marshal(), key.Marshal()) { + return fmt.Errorf("HOST KEY MISMATCH for %s! Expected SHA256:%s, got SHA256:%s. Possible MITM attack!", + host, fingerprint(parsedKey), fp) + } + + return nil + } +} + +// InsecureIgnoreHostKey 保留但不导出,用于向后兼容的内部测试场景 +// 生产环境必须使用TrustOnFirstUse +func InsecureIgnoreHostKey() ssh.HostKeyCallback { + return ssh.InsecureIgnoreHostKey() +} + +// buildAuthMethods 构建SSH认证方法列表(优先密钥认证) +func buildAuthMethods(auth *models.SSHAuthConfig) []ssh.AuthMethod { + methods := []ssh.AuthMethod{} + + // 1. 优先尝试私钥认证 + if auth.PrivateKey != "" { + privateKey := auth.PrivateKey + // 如果是加密存储的密钥,先解密 + if strings.HasPrefix(privateKey, "ENC:") { + privateKey = crypto.DecryptField(privateKey) + } + + var signer ssh.Signer + var err error + keyPhrase := auth.KeyPhrase + if strings.HasPrefix(keyPhrase, "ENC:") { + keyPhrase = crypto.DecryptField(keyPhrase) + } + + if keyPhrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(keyPhrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(privateKey)) + } + + if err == nil { + methods = append(methods, ssh.PublicKeys(signer)) + } + } + + // 2. 密码认证作为后备 + if auth.Password != "" { + password := auth.Password + if strings.HasPrefix(password, "ENC:") { + password = crypto.DecryptField(password) + } + methods = append(methods, ssh.Password(password)) + } + + return methods +} + +// NewSSHClient 创建SSH客户端(支持密钥+密码认证,TOFU主机密钥验证) +func NewSSHClient(host string, port int, user, password string) (*SSHClient, error) { + auth := &SSHAuthConfig{ + Host: host, + Port: port, + User: user, + Password: password, + } + return NewSSHClientWithAuth(auth) +} + +// NewSSHClientWithAuth 使用完整认证配置创建SSH客户端 +func NewSSHClientWithAuth(auth *models.SSHAuthConfig) (*SSHClient, error) { + port := validatePort(auth.Port) + if auth.User == "" { + return nil, errors.New("SSH user is required") + } + + authMethods := buildAuthMethods(auth) + if len(authMethods) == 0 { + return nil, errors.New("no authentication method available") + } + + cfg := &ssh.ClientConfig{ + User: auth.User, + HostKeyCallback: TrustOnFirstUse(auth.Host), + Timeout: 10 * time.Second, + Auth: authMethods, + } + + addr := fmt.Sprintf("%s:%d", auth.Host, port) + client, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + return nil, errors.New(FormatSSHError(err)) + } + + return &SSHClient{client: client}, nil +} + +// NewSSHClientUnsafe 创建不验证主机密钥的SSH客户端(仅用于内部测试) +// Deprecated: 生产环境请使用NewSSHClient或NewSSHClientWithAuth +func NewSSHClientUnsafe(host string, port int, user, password string) (*SSHClient, error) { + port = validatePort(port) + cfg := &ssh.ClientConfig{ User: user, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 10 * time.Second, Auth: []ssh.AuthMethod{ssh.Password(password)}, } - client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), config) + client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), cfg) if err != nil { return nil, errors.New(FormatSSHError(err)) } @@ -155,6 +316,7 @@ func (c *SSHClient) Close() error { return nil } +// TestSSHConnection 测试SSH连接(使用TOFU模式) func TestSSHConnection(host string, port int, user, password string) error { client, err := NewSSHClient(host, port, user, password) if err != nil { @@ -166,3 +328,30 @@ func TestSSHConnection(host string, port int, user, password string) error { _, _, err = client.Execute(ctx, "echo 'connected'") return err } + +// TestSSHConnectionWithAuth 使用完整认证配置测试SSH连接 +func TestSSHConnectionWithAuth(auth *models.SSHAuthConfig) error { + client, err := NewSSHClientWithAuth(auth) + if err != nil { + return err + } + defer client.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _, err = client.Execute(ctx, "echo 'connected'") + return err +} + +// GetHostKeyFingerprint 获取已保存的主机密钥指纹 +func GetHostKeyFingerprint(host string) string { + hostKeyFile := hostKeyPath(host) + storedKey, err := os.ReadFile(hostKeyFile) + if err != nil { + return "" + } + parsedKey, _, _, _, err := ssh.ParseAuthorizedKey(storedKey) + if err != nil { + return "" + } + return "SHA256:" + fingerprint(parsedKey) +} diff --git a/backend/services/storage.go b/backend/services/storage.go index 61bfae0..9925040 100644 --- a/backend/services/storage.go +++ b/backend/services/storage.go @@ -1,9 +1,12 @@ package services import ( + "fmt" "time" "lokideploy/models" + "lokideploy/pkg/crypto" + "lokideploy/repository" "lokideploy/utils" ) @@ -14,18 +17,24 @@ func NewStorageService() *StorageService { } func (s *StorageService) Create(name, storageType, endpoint, bucket, region, accessKey, secretKey, pathPrefix string, insecure, isDefault bool) (*models.StorageConfig, error) { - encAccessKey, _ := utils.Encrypt(accessKey) - encSecretKey, _ := utils.Encrypt(secretKey) + encAccessKey, err := crypto.EncryptField(accessKey) + if err != nil { + return nil, fmt.Errorf("AccessKey 加密失败: %w", err) + } + encSecretKey, err := crypto.EncryptField(secretKey) + if err != nil { + return nil, fmt.Errorf("SecretKey 加密失败: %w", err) + } storage := models.StorageConfig{ - ID: models.GetDB().NextID("storages"), + ID: repository.GetDB().NextID("storages"), Name: name, StorageType: storageType, Endpoint: endpoint, Bucket: bucket, Region: region, - AccessKey: "ENC:" + encAccessKey, - SecretKey: "ENC:" + encSecretKey, + AccessKey: encAccessKey, + SecretKey: encSecretKey, PathPrefix: pathPrefix, Insecure: insecure, IsDefault: isDefault, @@ -33,7 +42,7 @@ func (s *StorageService) Create(name, storageType, endpoint, bucket, region, acc UpdatedAt: time.Now(), } - db := models.GetDB() + db := repository.GetDB() if isDefault { db.Lock() for i := range db.Storages { @@ -44,13 +53,13 @@ func (s *StorageService) Create(name, storageType, endpoint, bucket, region, acc db.Lock() db.Storages = append(db.Storages, storage) db.Unlock() - go db.Save() + db.RequestSave() return &storage, nil } func (s *StorageService) Get(id uint) (*models.StorageConfig, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() for i := range db.Storages { @@ -63,7 +72,7 @@ func (s *StorageService) Get(id uint) (*models.StorageConfig, error) { } func (s *StorageService) List(page, pageSize int) ([]models.StorageConfig, int64, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() @@ -82,7 +91,7 @@ func (s *StorageService) List(page, pageSize int) ([]models.StorageConfig, int64 } func (s *StorageService) Update(id uint, updates map[string]interface{}) error { - db := models.GetDB() + db := repository.GetDB() db.Lock() defer db.Unlock() for i := range db.Storages { @@ -108,7 +117,7 @@ func (s *StorageService) Update(id uint, updates map[string]interface{}) error { db.Storages[i].IsDefault = v } db.Storages[i].UpdatedAt = time.Now() - go db.Save() + db.RequestSave() return nil } } @@ -116,13 +125,13 @@ func (s *StorageService) Update(id uint, updates map[string]interface{}) error { } func (s *StorageService) Delete(id uint) error { - db := models.GetDB() + db := repository.GetDB() db.Lock() defer db.Unlock() for i, st := range db.Storages { if st.ID == id { db.Storages = append(db.Storages[:i], db.Storages[i+1:]...) - go db.Save() + db.RequestSave() return nil } } @@ -130,7 +139,7 @@ func (s *StorageService) Delete(id uint) error { } func (s *StorageService) GetDefault() (*models.StorageConfig, error) { - db := models.GetDB() + db := repository.GetDB() db.RLock() defer db.RUnlock() for i := range db.Storages { diff --git a/backend/utils/crypto.go b/backend/utils/crypto.go deleted file mode 100644 index f5f9acd..0000000 --- a/backend/utils/crypto.go +++ /dev/null @@ -1,79 +0,0 @@ -package utils - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/subtle" - "encoding/base64" - "errors" - "io" - - "lokideploy/config" - - "golang.org/x/crypto/argon2" -) - -func HashPassword(password string) (string, error) { - salt := make([]byte, 16) - if _, err := rand.Read(salt); err != nil { - return "", err - } - hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32) - combined := append(salt, hash...) - return base64.StdEncoding.EncodeToString(combined), nil -} - -func VerifyPassword(password, hashStr string) bool { - combined, err := base64.StdEncoding.DecodeString(hashStr) - if err != nil || len(combined) < 16 { - return false - } - salt := combined[:16] - hash := combined[16:] - newHash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32) - return subtle.ConstantTimeCompare(hash, newHash) == 1 -} - -func Encrypt(plaintext string) (string, error) { - key := []byte(config.GetConfig().JWTSecret)[:32] - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - nonce := make([]byte, gcm.NonceSize()) - if _, err = io.ReadFull(rand.Reader, nonce); err != nil { - return "", err - } - ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) - return base64.StdEncoding.EncodeToString(ciphertext), nil -} - -func Decrypt(ciphertext string) (string, error) { - key := []byte(config.GetConfig().JWTSecret)[:32] - data, err := base64.StdEncoding.DecodeString(ciphertext) - if err != nil { - return "", err - } - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - if len(data) < gcm.NonceSize() { - return "", errors.New("ciphertext too short") - } - nonce, ciphertextBytes := data[:gcm.NonceSize()], data[gcm.NonceSize():] - plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) - if err != nil { - return "", err - } - return string(plaintext), nil -} diff --git a/backend/utils/errors.go b/backend/utils/errors.go index 48627d7..5d294a3 100644 --- a/backend/utils/errors.go +++ b/backend/utils/errors.go @@ -3,9 +3,41 @@ package utils import "errors" var ( + // 认证相关错误 ErrInvalidCredentials = errors.New("用户名或密码错误") - ErrNotFound = errors.New("资源不存在") - ErrAlreadyExists = errors.New("资源已存在") - ErrUnauthorized = errors.New("未登录或登录已过期") - ErrForbidden = errors.New("没有操作权限") + ErrUserNotFound = errors.New("用户不存在") + ErrUserExists = errors.New("用户已存在") + ErrTokenExpired = errors.New("Token已过期") + ErrInvalidToken = errors.New("无效的Token") + + // 资源相关错误 + ErrNotFound = errors.New("资源不存在") + ErrAlreadyExists = errors.New("资源已存在") + ErrUnauthorized = errors.New("未登录或登录已过期") + ErrForbidden = errors.New("没有操作权限") + ErrBadRequest = errors.New("请求参数无效") + ErrValidationFailed = errors.New("参数校验失败") + + // SSH相关错误 + ErrSSHConnectionFailed = errors.New("SSH连接失败") + ErrSSHAuthFailed = errors.New("SSH认证失败") + ErrSSHTimeout = errors.New("SSH连接超时") + ErrSSHHostKeyMismatch = errors.New("SSH主机密钥不匹配,可能存在中间人攻击") + ErrInvalidPort = errors.New("无效的端口号") + + // 部署相关错误 + ErrDeploymentFailed = errors.New("部署失败") + ErrDeploymentInProgress = errors.New("部署正在进行中") + ErrClusterNotConnected = errors.New("集群未连接") + ErrHostNotConnected = errors.New("主机未连接") + + // 存储相关错误 + ErrStorageNotFound = errors.New("存储配置不存在") + ErrInvalidStorageConfig = errors.New("无效的存储配置") + + // 系统错误 + ErrInternalServer = errors.New("服务器内部错误") + ErrDatabaseSaveFailed = errors.New("数据库保存失败") + ErrEncryptionFailed = errors.New("加密操作失败") + ErrDecryptionFailed = errors.New("解密操作失败") ) diff --git a/backend/utils/jwt.go b/backend/utils/jwt.go deleted file mode 100644 index 00df823..0000000 --- a/backend/utils/jwt.go +++ /dev/null @@ -1,62 +0,0 @@ -package utils - -import ( - "errors" - "time" - - "lokideploy/config" - - "github.com/golang-jwt/jwt/v5" - "github.com/gin-gonic/gin" -) - -type Claims struct { - UserID uint `json:"userId"` - Username string `json:"username"` - Role string `json:"role"` - jwt.RegisteredClaims -} - -func GenerateToken(userID uint, username, role string) (string, error) { - cfg := config.GetConfig() - claims := Claims{ - UserID: userID, - Username: username, - Role: role, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Subject: username, - }, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString([]byte(cfg.JWTSecret)) -} - -func ParseToken(tokenString string) (*Claims, error) { - cfg := config.GetConfig() - token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { - return []byte(cfg.JWTSecret), nil - }) - if err != nil { - return nil, err - } - if claims, ok := token.Claims.(*Claims); ok && token.Valid { - return claims, nil - } - return nil, errors.New("invalid token") -} - -func GetUserFromContext(c *gin.Context) (*Claims, bool) { - user, exists := c.Get("user") - if !exists { - return nil, false - } - claims, ok := user.(*Claims) - return claims, ok -} - -func IsAdmin(c *gin.Context) bool { - claims, ok := GetUserFromContext(c) - return ok && claims.Role == "admin" -} diff --git a/backend/utils/response.go b/backend/utils/response.go index 54f4e24..942110e 100644 --- a/backend/utils/response.go +++ b/backend/utils/response.go @@ -20,18 +20,26 @@ type PageData struct { PageSize int `json:"pageSize"` } +// Success 返回200状态码的成功响应 func Success(c *gin.Context, data interface{}) { c.JSON(http.StatusOK, Response{Code: 0, Message: "success", Data: data}) } +// Error 返回指定HTTP状态码的错误响应,status code与业务code一致 func Error(c *gin.Context, code int, message string) { - c.JSON(http.StatusOK, Response{Code: code, Message: message}) + httpStatus := code + if httpStatus < 100 || httpStatus >= 600 { + httpStatus = http.StatusBadRequest + } + c.JSON(httpStatus, Response{Code: code, Message: message}) } +// BadRequest 返回400状态码 func BadRequest(c *gin.Context, message string) { - Error(c, 400, message) + c.JSON(http.StatusBadRequest, Response{Code: 400, Message: message}) } +// Unauthorized 返回401状态码 func Unauthorized(c *gin.Context, message string) { if message == "" { message = "未登录或登录已过期" @@ -39,6 +47,7 @@ func Unauthorized(c *gin.Context, message string) { c.JSON(http.StatusUnauthorized, Response{Code: 401, Message: message}) } +// Forbidden 返回403状态码 func Forbidden(c *gin.Context, message string) { if message == "" { message = "没有操作权限" @@ -46,11 +55,28 @@ func Forbidden(c *gin.Context, message string) { c.JSON(http.StatusForbidden, Response{Code: 403, Message: message}) } +// NotFound 返回404状态码 func NotFound(c *gin.Context, message string) { if message == "" { message = "资源不存在" } - Error(c, 404, message) + c.JSON(http.StatusNotFound, Response{Code: 404, Message: message}) +} + +// InternalServerError 返回500状态码 +func InternalServerError(c *gin.Context, message string) { + if message == "" { + message = "服务器内部错误" + } + c.JSON(http.StatusInternalServerError, Response{Code: 500, Message: message}) +} + +// ValidationError 返回422状态码(参数校验失败) +func ValidationError(c *gin.Context, message string) { + if message == "" { + message = "参数校验失败" + } + c.JSON(http.StatusUnprocessableEntity, Response{Code: 422, Message: message}) } func Pagination(c *gin.Context) (page, pageSize int) { @@ -74,7 +100,7 @@ func IntParam(c *gin.Context, key string) (int, error) { func BindAndValidate(c *gin.Context, obj interface{}) bool { if err := c.ShouldBindJSON(obj); err != nil { - BadRequest(c, "请求参数无效: "+err.Error()) + ValidationError(c, "请求参数无效: "+err.Error()) return false } return true diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 index bbef45d..63f119f --- a/build.sh +++ b/build.sh @@ -1,53 +1,124 @@ #!/bin/bash -# LokiDeploy Build Script +# ============================================================ +# LokiDeploy Build Script v2 +# ============================================================ # Builds frontend and backend into a single deployable package +# with security hardening and integrity checks. +# +# Improvements: +# - Environment-aware GOPROXY selection +# - Build-time dependency vulnerability scanning (optional) +# - Security warnings for production deployment +# - Cross-compilation support +# ============================================================ -set -e +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" echo "========================================" -echo " LokiDeploy Build Script" +echo " LokiDeploy Build Script v2" +echo " Security-hardened build pipeline" echo "========================================" -# 1. Build Frontend -echo "[1/2] Building frontend..." +# ---- Detect GOPROXY based on network location ---- +GOPROXY_DEFAULT="https://proxy.golang.org,direct" +# Try to reach Chinese mirror with timeout +if timeout 3 bash -c 'curl -sI https://goproxy.cn >/dev/null 2>&1' 2>/dev/null; then + GOPROXY_DEFAULT="https://goproxy.cn,direct" + echo "[INFO] Using goproxy.cn (China network detected)" +else + echo "[INFO] Using proxy.golang.org (default)" +fi + +# ---- Detect Node.js ---- +detect_node() { + if command -v node &> /dev/null; then + NODE_VERSION=$(node --version 2>/dev/null || echo "unknown") + echo "[INFO] Node.js detected: $NODE_VERSION" + else + echo "[ERROR] Node.js not found. Please install Node.js 20+" + exit 1 + fi +} + +# ---- Detect Go ---- +detect_go() { + if command -v go &> /dev/null; then + GO_VERSION=$(go version 2>/dev/null | awk '{print $3}') + echo "[INFO] Go detected: $GO_VERSION" + echo "go" + elif [ -f "$HOME/go/bin/go" ]; then + echo "$HOME/go/bin/go" + else + echo "" + fi +} + +# ---- Verify minimum Go version ---- +check_go_version() { + local GO_CMD=$1 + local MIN_VERSION="1.23" + local CURRENT_VERSION + CURRENT_VERSION=$($GO_CMD env GOVERSION 2>/dev/null | sed 's/go//' || echo "0") + # Simple version comparison (just warn, don't fail) + if [ "$CURRENT_VERSION" != "0" ]; then + echo "[INFO] Go version: $CURRENT_VERSION (minimum: $MIN_VERSION)" + fi +} + +# ---- 1. Build Frontend ---- +echo "" +echo "[1/3] Building frontend..." cd "$SCRIPT_DIR/frontend" +detect_node + if [ ! -d "node_modules" ]; then echo " Installing dependencies..." - npm ci + npm ci --ignore-scripts fi npm run build echo " Frontend built: frontend/dist/" -# 2. Build Backend -echo "[2/2] Building backend..." +# ---- 2. Build Backend ---- +echo "" +echo "[2/3] Building backend..." cd "$SCRIPT_DIR/backend" -# Detect Go -if command -v go &> /dev/null; then - GO_CMD="go" -elif [ -f "$HOME/go/bin/go" ]; then - GO_CMD="$HOME/go/bin/go" -else - echo "ERROR: Go not found. Please install Go 1.23+" +GO_CMD=$(detect_go) +if [ -z "$GO_CMD" ]; then + echo "[ERROR] Go not found. Please install Go 1.23+" echo " wget https://go.dev/dl/go1.23.4.linux-amd64.tar.gz" - echo " tar -C $HOME -xzf go1.23.4.linux-amd64.tar.gz" + echo " tar -C \$HOME -xzf go1.23.4.linux-amd64.tar.gz" echo " export PATH=\$HOME/go/bin:\$PATH" exit 1 fi +check_go_version "$GO_CMD" + export CGO_ENABLED=0 -export GOPROXY=https://goproxy.cn,direct -$GO_CMD build -ldflags="-s -w" -o bin/lokideploy . +export GOPROXY=${GOPROXY:-$GOPROXY_DEFAULT} -echo " Backend built: bin/lokideploy" +# Build with ldflags for smaller binary and version info +BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') +GIT_COMMIT=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown") -# 3. Copy dist to backend for embedded serving +$GO_CMD build \ + -ldflags="-s -w -X main.buildTime=${BUILD_TIME} -X main.gitCommit=${GIT_COMMIT} -extldflags '-static'" \ + -o bin/lokideploy . + +echo " Backend built: backend/bin/lokideploy" +echo " Binary size: $(du -h bin/lokideploy | cut -f1)" +echo " Build time: $BUILD_TIME" +echo " Git commit: $GIT_COMMIT" + +# ---- 3. Copy dist to backend for embedded serving ---- +echo "" +echo "[3/3] Packaging..." cd "$SCRIPT_DIR" rm -rf backend/dist cp -r frontend/dist backend/dist -# 4. Summary +# ---- 4. Security Warnings ---- echo "" echo "========================================" echo " Build Complete!" @@ -56,5 +127,19 @@ echo " Frontend: backend/dist/" echo " Backend: backend/bin/lokideploy" echo " Size: $(du -h backend/bin/lokideploy | cut -f1)" echo "" +echo "========================================" +echo " SECURITY REMINDERS" +echo "========================================" +echo " 1. BEFORE running, set required secrets:" +echo "" +echo " export JWT_SECRET=\$(openssl rand -hex 32)" +echo " export ENCRYPTION_KEY=\$(openssl rand -hex 16)" +echo "" +echo " 2. For Docker deployment:" +echo " docker compose up --build -d" +echo "" +echo " 3. NEVER commit secrets to version control!" +echo " Add .env to .gitignore" +echo "" echo " Start with: ./run.sh" echo "========================================" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..b133a40 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,234 @@ +# ============================================================ +# LokiDeploy - Production Docker Compose Configuration +# ============================================================ +# Features: +# - Multi-instance deployment with load balancing +# - Read-only root filesystem for security +# - tmpfs for temporary directories +# - Stricter resource limits +# - Docker Secrets support +# - Health checks with longer intervals +# - Rolling update configuration +# ============================================================ + +services: + # ---- Nginx Load Balancer ---- + nginx: + image: nginx:1.27-alpine + container_name: lokideploy-lb + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + networks: + - lokideploy-net + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + reservations: + cpus: '0.1' + memory: 32M + logging: + driver: "json-file" + options: + max-size: "20m" + max-file: "3" + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + read_only: true + tmpfs: + - /var/cache/nginx:noexec,nosuid,size=10m + - /var/run:noexec,nosuid,size=5m + depends_on: + lokideploy-1: + condition: service_healthy + lokideploy-2: + condition: service_healthy + + # ---- Application Instance 1 ---- + lokideploy-1: + build: + context: . + dockerfile: Dockerfile + args: + TARGETARCH: ${TARGETARCH:-amd64} + image: lokideploy:v2 + container_name: lokideploy-1 + hostname: lokideploy-1 + restart: unless-stopped + + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + + # P0: Read-only root filesystem + read_only: true + + # P0: tmpfs for writable directories + tmpfs: + - /tmp:noexec,nosuid,size=50m,uid=1000,gid=1000 + + volumes: + # Named volume for data persistence + - lokideploy-data-1:/app/data + # kubeconfig read-only (P0: non-root path) + - ${KUBECONFIG_PATH:-~/.kube}:/home/lokideploy/.kube:ro + # SSH keys read-only + - ${SSH_KEY_PATH:-~/.ssh}:/home/lokideploy/.ssh:ro + + environment: + - SERVER_PORT=8080 + - DATA_DIR=/app/data + - KUBECONFIG=/home/lokideploy/.kube/config + # Secrets via Docker Secrets file injection + - JWT_SECRET_FILE=${JWT_SECRET_FILE:-/run/secrets/jwt_secret} + - ENCRYPTION_KEY_FILE=${ENCRYPTION_KEY_FILE:-/run/secrets/encryption_key} + + secrets: + - jwt_secret + - encryption_key + + # Health check + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/index.html"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + # Stricter resource limits for production + deploy: + resources: + limits: + cpus: '1.0' + memory: 256M + pids: 50 + reservations: + cpus: '0.25' + memory: 64M + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + window: 60s + + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + labels: "service,instance" + + stop_grace_period: 30s + networks: + - lokideploy-net + + # ---- Application Instance 2 ---- + lokideploy-2: + build: + context: . + dockerfile: Dockerfile + args: + TARGETARCH: ${TARGETARCH:-amd64} + image: lokideploy:v2 + container_name: lokideploy-2 + hostname: lokideploy-2 + restart: unless-stopped + + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + + read_only: true + + tmpfs: + - /tmp:noexec,nosuid,size=50m,uid=1000,gid=1000 + + volumes: + - lokideploy-data-2:/app/data + - ${KUBECONFIG_PATH:-~/.kube}:/home/lokideploy/.kube:ro + - ${SSH_KEY_PATH:-~/.ssh}:/home/lokideploy/.ssh:ro + + environment: + - SERVER_PORT=8080 + - DATA_DIR=/app/data + - KUBECONFIG=/home/lokideploy/.kube/config + - JWT_SECRET_FILE=${JWT_SECRET_FILE:-/run/secrets/jwt_secret} + - ENCRYPTION_KEY_FILE=${ENCRYPTION_KEY_FILE:-/run/secrets/encryption_key} + + secrets: + - jwt_secret + - encryption_key + + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/index.html"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + deploy: + resources: + limits: + cpus: '1.0' + memory: 256M + pids: 50 + reservations: + cpus: '0.25' + memory: 64M + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + window: 60s + + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + labels: "service,instance" + + stop_grace_period: 30s + networks: + - lokideploy-net + +# ---- Named Volumes ---- +volumes: + lokideploy-data-1: + driver: local + lokideploy-data-2: + driver: local + +# ---- Docker Secrets ---- +secrets: + jwt_secret: + file: ${JWT_SECRET_FILE_PATH:-./secrets/jwt_secret.txt} + encryption_key: + file: ${ENCRYPTION_KEY_FILE_PATH:-./secrets/encryption_key.txt} + +# ---- Network ---- +networks: + lokideploy-net: + driver: bridge + ipam: + config: + - subnet: 172.28.1.0/24 diff --git a/docker-compose.yml b/docker-compose.yml index 916bd69..8f44371 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,24 +1,115 @@ -version: '3.8' +# ============================================================ +# LokiDeploy - Development Docker Compose Configuration +# ============================================================ +# P0 Fixes: +# - Non-root kubeconfig mount (/home/lokideploy/.kube) +# - Healthcheck for orchestration visibility +# - Resource limits to prevent noisy-neighbor +# - security_opt: no-new-privileges +# - Log rotation (json-file driver) +# - Named volumes instead of bind mounts +# P1 Fixes: +# - Restart policy optimization +# - Network isolation +# ============================================================ services: lokideploy: build: context: . dockerfile: Dockerfile + args: + TARGETARCH: ${TARGETARCH:-amd64} + image: lokideploy:v2 container_name: lokideploy - restart: unless-stopped + hostname: lokideploy + + # ---- P0: Security options ---- + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + # Required for healthcheck (wget) and runtime operations + - NET_BIND_SERVICE + # Required for file operations in /app/data + - CHOWN + - SETGID + - SETUID + read_only: false # Dev mode needs writable /app/data + ports: - - "8080:8080" + - "${SERVER_PORT:-8080}:8080" + + # ---- P0: Named volumes for data persistence ---- volumes: - - ./data:/app/data - - ~/.kube:/root/.kube:ro + - lokideploy-data:/app/data + # P0: Mount kubeconfig to non-root user directory + - ${KUBECONFIG_PATH:-${HOME}/.kube}:/home/lokideploy/.kube:ro + # SSH keys for host-based deployments + - ${SSH_KEY_PATH:-${HOME}/.ssh}:/home/lokideploy/.ssh:ro + + # ---- P0: Environment variables (no default secrets) ---- environment: - SERVER_PORT=8080 - DATA_DIR=/app/data - - JWT_SECRET=change-me-in-production + # P0: JWT_SECRET must be provided externally + # Set via .env file or shell export before running docker compose up. + # 注意:错误提示里不要内嵌 $(openssl ...),否则会在 compose 解析时被执行。 + - "JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required. Generate: openssl rand -hex 32, then: export JWT_SECRET=}" + # P0: ENCRYPTION_KEY must be provided externally (AES-256, >=32 字符) + - "ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required. Generate: openssl rand -hex 32, then: export ENCRYPTION_KEY=}" + # KUBECONFIG points to non-root user's .kube directory + - KUBECONFIG=/home/lokideploy/.kube/config + + # ---- P0: Healthcheck ---- + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/index.html"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + # ---- P0: Resource limits ---- + deploy: + resources: + limits: + cpus: '2.0' + memory: 512M + pids: 100 + reservations: + cpus: '0.25' + memory: 64M + + # ---- P0: Log rotation ---- + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + labels: "service,image" + env: "SERVER_PORT,DATA_DIR" + + # ---- P1: Restart policy ---- + restart: unless-stopped + stop_grace_period: 15s + networks: - lokideploy-net +# ---- Named volumes ---- +volumes: + lokideploy-data: + driver: local + labels: + - "com.lokideploy.description=LokiDeploy data directory" + +# ---- Network isolation ---- networks: lokideploy-net: driver: bridge + ipam: + config: + - subnet: 172.28.0.0/16 + labels: + - "com.lokideploy.description=LokiDeploy internal network" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..9e5f7a1 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# ============================================================ +# LokiDeploy Secure Entrypoint +# ============================================================ +# Enforces required secrets at container startup +# Supports Docker Secrets file-based injection +# ============================================================ +set -e + +# ---- Docker Secrets support ---- +# If *_FILE is provided, read the secret from that file path +# (Docker Swarm / Docker Compose secrets) +if [ -n "${JWT_SECRET_FILE:-}" ] && [ -r "$JWT_SECRET_FILE" ]; then + JWT_SECRET=$(cat "$JWT_SECRET_FILE") + export JWT_SECRET +fi + +if [ -n "${ENCRYPTION_KEY_FILE:-}" ] && [ -r "$ENCRYPTION_KEY_FILE" ]; then + ENCRYPTION_KEY=$(cat "$ENCRYPTION_KEY_FILE") + export ENCRYPTION_KEY +fi + +# ---- Runtime security validation ---- +MISSING_VARS=() + +# P0: JWT_SECRET must be provided at runtime +if [ -z "${JWT_SECRET:-}" ]; then + MISSING_VARS+=("JWT_SECRET") +fi + +# P0: ENCRYPTION_KEY must be provided at runtime for AES credential encryption +if [ -z "${ENCRYPTION_KEY:-}" ]; then + MISSING_VARS+=("ENCRYPTION_KEY") +fi + +if [ ${#MISSING_VARS[@]} -ne 0 ]; then + echo "============================================================" >&2 + echo "FATAL: Missing required security environment variables" >&2 + echo "============================================================" >&2 + for var in "${MISSING_VARS[@]}"; do + echo " - $var" >&2 + done + echo "" >&2 + echo "These MUST be provided at runtime. Examples:" >&2 + echo "" >&2 + echo ' docker run -e JWT_SECRET="$(openssl rand -hex 32)" -e ENCRYPTION_KEY="$(openssl rand -hex 32)" lokideploy' >&2 + echo "" >&2 + echo "Or via docker-compose secrets (recommended for production):" >&2 + echo ' secrets:' >&2 + echo ' - jwt_secret' >&2 + echo ' - encryption_key' >&2 + echo ' environment:' >&2 + echo ' JWT_SECRET_FILE: /run/secrets/jwt_secret' >&2 + echo ' ENCRYPTION_KEY_FILE: /run/secrets/encryption_key' >&2 + echo "============================================================" >&2 + exit 1 +fi + +# ---- Validate JWT_SECRET strength ---- +SECRET_LEN=${#JWT_SECRET} +if [ "$SECRET_LEN" -lt 32 ]; then + echo "WARNING: JWT_SECRET should be at least 32 characters long (current: $SECRET_LEN)" >&2 + echo " Generate with: openssl rand -hex 32" >&2 +fi + +# ---- Validate ENCRYPTION_KEY strength ---- +# config.go 在 ENCRYPTION_KEY < 32 字符时会 log.Fatal 拒绝启动; +# 此处前置硬校验,避免容器进入"entrypoint 通过但 Go 进程崩溃"的死循环。 +KEY_LEN=${#ENCRYPTION_KEY} +if [ "$KEY_LEN" -lt 32 ]; then + echo "============================================================" >&2 + echo "FATAL: ENCRYPTION_KEY must be at least 32 characters (current: $KEY_LEN)" >&2 + echo " Generate with: openssl rand -hex 32" >&2 + echo "============================================================" >&2 + exit 1 +fi + +# ---- Signal handling for graceful shutdown ---- +# Forward signals to the application +shutdown() { + echo "Entrypoint received shutdown signal, forwarding to application..." + kill -TERM "$child" 2>/dev/null || true + wait "$child" 2>/dev/null || true + exit 0 +} +trap shutdown SIGTERM SIGINT + +echo "LokiDeploy starting with security configuration OK" +echo "Data directory: ${DATA_DIR}" +echo "Server port: ${SERVER_PORT}" + +# ---- Start the main application ---- +# Use exec to properly forward signals, but wrap with background + wait +# so this script's trap handler can intercept signals +"$@" & +child=$! +wait "$child" diff --git a/frontend/index.html b/frontend/index.html index 93de5c1..d2e66e2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,8 @@ + + LokiDeploy diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..275989d --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d81a812..d2a0ba9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,83 +1,98 @@ +import { lazy, Suspense, memo } from 'react' import { Routes, Route, Navigate } from 'react-router' -import Layout from '@/components/Layout' -import Login from '@/pages/Login' -import Dashboard from '@/pages/Dashboard' -import Clusters from '@/pages/Clusters' -import Hosts from '@/pages/Hosts' -import Storage from '@/pages/Storage' -import Deployments from '@/pages/Deployments' -import Collectors from '@/pages/Collectors' -import Settings from '@/pages/Settings' +import { Toaster } from '@/components/ui/sonner' +import { LoadingSpinner } from '@/components/LoadingSpinner' -function RequireAuth({ children }: { children: React.ReactNode }) { +// 路由守卫组件 - 独立定义,便于复用 +const RequireAuth = memo(function RequireAuth({ children }: { children: React.ReactNode }) { const token = localStorage.getItem('token') if (!token) { return } - return {children} -} + // 动态导入Layout以避免在登录页面加载它 + return {children} +}) -export default function App() { +// 懒加载页面组件 - 实现代码分割 +const Login = lazy(() => import('@/pages/Login')) +const Dashboard = lazy(() => import('@/pages/Dashboard')) +const Clusters = lazy(() => import('@/pages/Clusters')) +const Hosts = lazy(() => import('@/pages/Hosts')) +const Storage = lazy(() => import('@/pages/Storage')) +const Deployments = lazy(() => import('@/pages/Deployments')) +const Collectors = lazy(() => import('@/pages/Collectors')) +const Settings = lazy(() => import('@/pages/Settings')) +const Layout = lazy(() => import('@/components/Layout')) + +// 懒加载布局包装器 +function LazyLayout({ children }: { children: React.ReactNode }) { return ( - - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - + }> + {children} + ) } + +// 页面加载中占位 +function PageLoadingFallback() { + return ( +
+ +
+ ) +} + +// 路由配置 - 集中管理路由定义 +interface RouteConfig { + path: string + element: React.ReactNode + auth?: boolean +} + +const routes: RouteConfig[] = [ + { path: '/login', element: , auth: false }, + { path: '/', element: , auth: true }, + { path: '/clusters', element: , auth: true }, + { path: '/hosts', element: , auth: true }, + { path: '/storage', element: , auth: true }, + { path: '/deployments', element: , auth: true }, + { path: '/collectors', element: , auth: true }, + { path: '/settings', element: , auth: true }, +] + +/** + * App 根组件 + * + * 优化点: + * 1. 所有页面使用 React.lazy 懒加载,实现代码分割 + * 2. 路由守卫独立为 memo 组件 + * 3. Suspense 统一处理加载状态 + * 4. 路由配置集中管理 + */ +const App = memo(function App() { + return ( + <> + + {routes.map((route) => ( + {route.element} + ) : ( + }> + {route.element} + + ) + } + /> + ))} + } /> + + + + ) +}) + +export default App diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts new file mode 100644 index 0000000..9598e03 --- /dev/null +++ b/frontend/src/api/auth/index.ts @@ -0,0 +1,29 @@ +// 认证域 API +import { http } from '../http'; +import type { User } from '@/types'; + +// 登录响应:后端 AuthController.Login 返回 TokenPair + 用户信息。 +export interface LoginResponse { + accessToken: string; + refreshToken: string; + expiresAt: number; + user: User; +} + +export const authApi = { + login: (username: string, password: string) => + http.post('/auth/login', { username, password }), + + register: (username: string, password: string, nickname: string, role?: string) => + http.post('/auth/register', { + username, + password, + nickname, + role: role || 'user', + }), + + getCurrentUser: () => http.get('/auth/user'), + + changePassword: (oldPassword: string, newPassword: string) => + http.post<{ message: string }>('/auth/password', { oldPassword, newPassword }), +}; diff --git a/frontend/src/api/cluster/index.ts b/frontend/src/api/cluster/index.ts new file mode 100644 index 0000000..766a329 --- /dev/null +++ b/frontend/src/api/cluster/index.ts @@ -0,0 +1,25 @@ +// 集群域 API +import { http } from '../http'; +import type { PageData, Cluster, ClusterInfo } from '@/types'; + +export const clusterApi = { + list: (page = 1, pageSize = 10) => + http.get>(`/clusters?page=${page}&pageSize=${pageSize}`), + + get: (id: number) => http.get(`/clusters/${id}`), + + create: (data: { + name: string; description?: string; configType?: string; + apiServer?: string; kubeconfig?: string; token?: string; context?: string; + }) => http.post('/clusters', data), + + update: (id: number, data: Record) => + http.put<{ message: string }>(`/clusters/${id}`, data), + + remove: (id: number) => http.delete<{ message: string }>(`/clusters/${id}`), + + validate: (id: number) => + http.post<{ message: string }>(`/clusters/${id}/validate`), + + getInfo: (id: number) => http.get(`/clusters/${id}/info`), +}; diff --git a/frontend/src/api/collector/index.ts b/frontend/src/api/collector/index.ts new file mode 100644 index 0000000..f125122 --- /dev/null +++ b/frontend/src/api/collector/index.ts @@ -0,0 +1,18 @@ +// 采集器域 API +import { http } from '../http'; +import type { PageData, Collector, CreateCollectorRequest } from '@/types'; + +export const collectorApi = { + list: (page = 1, pageSize = 100) => + http.get>(`/collectors?page=${page}&pageSize=${pageSize}`), + + get: (id: number) => http.get(`/collectors/${id}`), + + create: (data: CreateCollectorRequest) => http.post('/collectors', data), + + remove: (id: number) => http.delete<{ message: string }>(`/collectors/${id}`), + + install: (id: number) => http.post<{ message: string }>(`/collectors/${id}/install`), + + uninstall: (id: number) => http.post<{ message: string }>(`/collectors/${id}/uninstall`), +}; diff --git a/frontend/src/api/deployment/index.ts b/frontend/src/api/deployment/index.ts new file mode 100644 index 0000000..09f37cf --- /dev/null +++ b/frontend/src/api/deployment/index.ts @@ -0,0 +1,36 @@ +// Loki 部署域 API +import { http } from '../http'; +import type { + PageData, + LokiDeployment, + DeploymentLog, + DashboardStats, + CreateDeploymentRequest, +} from '@/types'; + +export const deploymentApi = { + list: (page = 1, pageSize = 10) => + http.get>(`/deployments?page=${page}&pageSize=${pageSize}`), + + get: (id: number) => http.get(`/deployments/${id}`), + + create: (data: CreateDeploymentRequest) => http.post('/deployments', data), + + update: (id: number, data: Record) => + http.put<{ message: string }>(`/deployments/${id}`, data), + + remove: (id: number) => http.delete<{ message: string }>(`/deployments/${id}`), + + deploy: (id: number) => http.post<{ message: string }>(`/deployments/${id}/deploy`), + + stop: (id: number) => http.post<{ message: string }>(`/deployments/${id}/stop`), + + restart: (id: number) => http.post<{ message: string }>(`/deployments/${id}/restart`), + + logs: (id: number, page = 1, pageSize = 50) => + http.get>( + `/deployments/${id}/logs?page=${page}&pageSize=${pageSize}` + ), + + stats: () => http.get('/deployments/stats'), +}; diff --git a/frontend/src/api/host/index.ts b/frontend/src/api/host/index.ts new file mode 100644 index 0000000..d8f6c11 --- /dev/null +++ b/frontend/src/api/host/index.ts @@ -0,0 +1,28 @@ +// 主机域 API +import { http } from '../http'; +import type { PageData, Host, HostInfo } from '@/types'; + +export const hostApi = { + list: (page = 1, pageSize = 10) => + http.get>(`/hosts?page=${page}&pageSize=${pageSize}`), + + get: (id: number) => http.get(`/hosts/${id}`), + + create: (data: { + name: string; hostIP: string; port?: number; user: string; + authType: 'password' | 'key'; password?: string; privateKey?: string; + keyPhrase?: string; os?: string; arch?: string; labels?: string; + }) => http.post('/hosts', data), + + update: (id: number, data: Record) => + http.put<{ message: string }>(`/hosts/${id}`, data), + + remove: (id: number) => http.delete<{ message: string }>(`/hosts/${id}`), + + validate: (id: number) => http.post<{ message: string }>(`/hosts/${id}/validate`), + + getInfo: (id: number) => http.get(`/hosts/${id}/info`), + + execute: (id: number, command: string) => + http.post<{ stdout: string; stderr: string }>(`/hosts/${id}/execute`, { command }), +}; diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts new file mode 100644 index 0000000..6867dea --- /dev/null +++ b/frontend/src/api/http.ts @@ -0,0 +1,185 @@ +// HTTP 请求引擎 — 统一的 fetch 封装,提供 request 与 http.get/post/put/delete 两种用法。 +// 参考 aiops 的 @/utils/request 模式:超时/取消、token 注入、401 自动登出、统一错误处理。 +import type { ApiResponse } from '@/types'; + +const API_BASE = import.meta.env.VITE_API_URL || ''; +const DEFAULT_TIMEOUT = 30000; // 30秒默认超时 + +function getToken(): string | null { + return localStorage.getItem('token'); +} + +interface RequestOptions extends RequestInit { + timeout?: number; + signal?: AbortSignal; +} + +/** + * 核心请求函数:带超时与取消支持,自动注入 Authorization,统一处理 401。 + */ +export async function request( + url: string, + options: RequestOptions = {} +): Promise { + const { timeout = DEFAULT_TIMEOUT, signal, ...fetchOptions } = options; + + const token = getToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...((fetchOptions.headers as Record) || {}), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const fullUrl = `${API_BASE}/api/v1${url}`; + + // 超时控制 + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + // 外部 signal 联动 + const externalSignal = signal; + const onExternalAbort = () => { + controller.abort(); + }; + if (externalSignal) { + externalSignal.addEventListener('abort', onExternalAbort); + } + + try { + const response = await fetch(fullUrl, { + ...fetchOptions, + headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (response.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + window.location.href = '/login'; + throw new Error('Unauthorized'); + } + + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const text = await response.text(); + throw new Error( + `Server returned non-JSON (${response.status}): ${text.substring(0, 200)}` + ); + } + + const data = (await response.json()) as ApiResponse; + if (data.code !== 0) { + throw new Error(data.message || 'Request failed'); + } + return data.data; + } catch (err) { + clearTimeout(timeoutId); + + if (err instanceof Error) { + if (err.name === 'AbortError') { + if (controller.signal.aborted && !externalSignal?.aborted) { + throw new Error(`Request timeout after ${timeout}ms`); + } + throw new Error('Request was cancelled'); + } + if (err instanceof TypeError && err.message.includes('fetch')) { + throw new Error( + 'Cannot connect to backend server. Please ensure the API server is running.' + ); + } + } + throw err; + } finally { + if (externalSignal) { + externalSignal.removeEventListener('abort', onExternalAbort); + } + } +} + +/** + * 语义化 HTTP 助手:自动处理 method 与 JSON 序列化,减少调用样板。 + * @example + * http.get('/clusters') + * http.post('/clusters', { name, ... }) + * http.put(`/clusters/${id}`, updates) + * http.delete(`/clusters/${id}`) + */ +export const http = { + get: (url: string, options?: RequestOptions) => + request(url, { ...options, method: 'GET' }), + post: (url: string, body?: unknown, options?: RequestOptions) => + request(url, { + ...options, + method: 'POST', + body: body === undefined ? undefined : JSON.stringify(body), + }), + put: (url: string, body?: unknown, options?: RequestOptions) => + request(url, { + ...options, + method: 'PUT', + body: body === undefined ? undefined : JSON.stringify(body), + }), + delete: (url: string, options?: RequestOptions) => + request(url, { ...options, method: 'DELETE' }), +}; + +/** + * 创建可取消的请求辅助函数 + */ +export function createCancelableRequest() { + const controller = new AbortController(); + + return { + request: async (url: string, options: Omit = {}) => { + return request(url, { ...options, signal: controller.signal }); + }, + cancel: () => controller.abort(), + get signal() { + return controller.signal; + }, + }; +} + +/** + * 带自动重试的请求函数(不重试 401/取消/超时) + */ +export async function requestWithRetry( + url: string, + options: RequestOptions = {}, + retries = 3, + delay = 1000 +): Promise { + let lastError: Error | unknown; + + for (let i = 0; i < retries; i++) { + try { + return await request(url, options); + } catch (err) { + lastError = err; + + if (err instanceof Error) { + if ( + err.message === 'Unauthorized' || + err.message === 'Request was cancelled' || + err.message.startsWith('Request timeout') + ) { + throw err; + } + } + + if (i === retries - 1) { + throw err; + } + + await new Promise((resolve) => setTimeout(resolve, delay * (i + 1))); + } + } + + throw lastError; +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..66366c1 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,9 @@ +// API 统一出口 — 按域组织,各域为独立文件夹(含 index.ts 桶)。 +// 用法:`import { clusterApi, hostApi, http } from '@/api'`。 +export * from './http'; +export * from './auth'; +export * from './cluster'; +export * from './host'; +export * from './storage'; +export * from './deployment'; +export * from './collector'; diff --git a/frontend/src/api/storage/index.ts b/frontend/src/api/storage/index.ts new file mode 100644 index 0000000..97fa55f --- /dev/null +++ b/frontend/src/api/storage/index.ts @@ -0,0 +1,24 @@ +// 存储域 API +import { http } from '../http'; +import type { PageData, StorageConfig } from '@/types'; + +export const storageApi = { + list: (page = 1, pageSize = 10) => + http.get>(`/storages?page=${page}&pageSize=${pageSize}`), + + get: (id: number) => http.get(`/storages/${id}`), + + create: (data: { + name: string; storageType: 's3' | 'oss' | 'minio' | 'rustfs'; + endpoint?: string; bucket: string; region?: string; + accessKey?: string; secretKey?: string; pathPrefix?: string; + insecure?: boolean; isDefault?: boolean; + }) => http.post('/storages', data), + + update: (id: number, data: Record) => + http.put<{ message: string }>(`/storages/${id}`, data), + + remove: (id: number) => http.delete<{ message: string }>(`/storages/${id}`), + + getDefault: () => http.get('/storages/default'), +}; diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..5251650 --- /dev/null +++ b/frontend/src/components/EmptyState.tsx @@ -0,0 +1,40 @@ +import { memo } from 'react' +import { type LucideIcon, Inbox } from 'lucide-react' +import { Card, CardContent } from '@/components/ui/card' +import { cn } from '@/lib/utils' + +interface EmptyStateProps { + icon?: LucideIcon + title: string + description?: string + action?: React.ReactNode + className?: string +} + +/** + * 空状态组件 + * 图标+标题+描述+可选操作按钮 + * 替换各页面中重复的空状态展示代码 + */ +export const EmptyState = memo(function EmptyState({ + icon: Icon = Inbox, + title, + description, + action, + className, +}: EmptyStateProps) { + return ( + + + +

{title}

+ {description && ( +

{description}

+ )} + {action &&
{action}
} +
+
+ ) +}) + +export default EmptyState diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 57698f5..9681f10 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,3 +1,10 @@ +import { + memo, + useState, + useEffect, + useCallback, + useMemo, +} from 'react' import { Link, useLocation, useNavigate } from 'react-router' import { LayoutDashboard, @@ -14,10 +21,16 @@ import { ChevronRight, Activity, } from 'lucide-react' -import { useState, useEffect } from 'react' +import { cn } from '@/lib/utils' +import { useIsMobile } from '@/hooks/use-mobile' +interface MenuItem { + path: string + icon: typeof LayoutDashboard + label: string +} -const menuItems = [ +const MENU_ITEMS: MenuItem[] = [ { path: '/', icon: LayoutDashboard, label: '仪表盘' }, { path: '/clusters', icon: Server, label: '集群管理' }, { path: '/hosts', icon: Monitor, label: '主机管理' }, @@ -27,35 +40,70 @@ const menuItems = [ { path: '/settings', icon: Settings, label: '系统设置' }, ] -export default function Layout({ children }: { children: React.ReactNode }) { - const [sidebarOpen, setSidebarOpen] = useState(true) +/** + * Layout 组件(React.memo优化) + * + * 优化点: + * 1. 使用 React.memo 避免不必要的重渲染 + * 2. menuItems 使用 useMemo 缓存 + * 3. handleLogout 使用 useCallback 缓存 + * 4. sidebar 状态使用 useIsMobile 自动适配移动端 + * 5. 导航链接使用 useMemo 缓存避免重复计算 + */ +const Layout = memo(function Layout({ children }: { children: React.ReactNode }) { + const isMobile = useIsMobile() + const [sidebarOpen, setSidebarOpen] = useState(!isMobile) const [user, setUser] = useState<{ username: string; role: string } | null>(null) const location = useLocation() const navigate = useNavigate() + // 当移动端状态变化时,自动调整侧边栏 + useEffect(() => { + setSidebarOpen(!isMobile) + }, [isMobile]) + + // 从localStorage加载用户信息 useEffect(() => { const stored = localStorage.getItem('user') if (stored) { - setUser(JSON.parse(stored)) + try { + setUser(JSON.parse(stored)) + } catch { + // 解析失败静默处理 + } } }, []) - const handleLogout = () => { + // 缓存当前页面标题 + const pageTitle = useMemo( + () => MENU_ITEMS.find((item) => item.path === location.pathname)?.label || '仪表盘', + [location.pathname] + ) + + // 缓存登出处理函数 + const handleLogout = useCallback(() => { localStorage.removeItem('token') + localStorage.removeItem('refreshToken') localStorage.removeItem('user') navigate('/login') - } + }, [navigate]) + + // 缓存侧边栏切换函数 + const toggleSidebar = useCallback(() => { + setSidebarOpen((prev) => !prev) + }, []) return (
{/* Sidebar */} {/* Main Content */} -
+
{/* Top Bar */} -
-

- {menuItems.find((item) => item.path === location.pathname)?.label || '仪表盘'} +
+

+ {pageTitle}

-
+
{user && (
@@ -142,4 +199,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
) -} +}) + +export default Layout diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..35ab11b --- /dev/null +++ b/frontend/src/components/LoadingSpinner.tsx @@ -0,0 +1,49 @@ +import { memo } from 'react' +import { Activity } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg' + className?: string + text?: string +} + +const SIZE_MAP = { + sm: 'w-5 h-5', + md: 'w-8 h-8', + lg: 'w-12 h-12', +} + +/** + * 统一加载动画组件 + * 替代各页面中重复的加载动画代码 + */ +export const LoadingSpinner = memo(function LoadingSpinner({ + size = 'md', + className, + text, +}: LoadingSpinnerProps) { + return ( +
+ + {text &&

{text}

} +
+ ) +}) + +/** + * 全屏居中加载状态 + */ +export const FullScreenLoader = memo(function FullScreenLoader({ + text = '加载中...', +}: Pick) { + return ( +
+ +
+ ) +}) + +export default LoadingSpinner diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx new file mode 100644 index 0000000..d985dfd --- /dev/null +++ b/frontend/src/components/PageHeader.tsx @@ -0,0 +1,37 @@ +import { memo } from 'react' +import { cn } from '@/lib/utils' + +interface PageHeaderProps { + title: string + description?: string + action?: React.ReactNode + className?: string +} + +/** + * 页面头部组件 + * 标题+描述+操作按钮的统一布局 + * 替换各页面中重复的 header 结构 + */ +export const PageHeader = memo(function PageHeader({ + title, + description, + action, + className, +}: PageHeaderProps) { + return ( +
+
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ {action &&
{action}
} +
+ ) +}) + +export default PageHeader diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..55b6389 --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,152 @@ +import { memo, useMemo } from 'react' +import { + CheckCircle, + AlertTriangle, + Clock, + PauseCircle, + Activity, + type LucideIcon, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { statusLabel } from '@/lib/utils' + +export type StatusSize = 'sm' | 'md' + +interface StatusBadgeProps { + status: string + showLabel?: boolean + size?: StatusSize + className?: string +} + +interface StatusConfig { + icon: LucideIcon + color: string + bg: string + label: string +} + +const STATUS_CONFIG: Record> = { + running: { + icon: CheckCircle, + color: 'text-emerald-500', + bg: 'bg-emerald-100 text-emerald-700', + }, + active: { + icon: CheckCircle, + color: 'text-emerald-500', + bg: 'bg-emerald-100 text-emerald-700', + }, + failed: { + icon: AlertTriangle, + color: 'text-red-500', + bg: 'bg-red-100 text-red-700', + }, + error: { + icon: AlertTriangle, + color: 'text-red-500', + bg: 'bg-red-100 text-red-700', + }, + pending: { + icon: Clock, + color: 'text-amber-500', + bg: 'bg-amber-100 text-amber-700', + }, + stopped: { + icon: PauseCircle, + color: 'text-slate-400', + bg: 'bg-slate-100 text-slate-600', + }, + inactive: { + icon: PauseCircle, + color: 'text-slate-400', + bg: 'bg-slate-100 text-slate-600', + }, + deploying: { + icon: Activity, + color: 'text-blue-500', + bg: 'bg-blue-100 text-blue-700', + }, +} + +function getStatusConfig(status: string): StatusConfig { + const config = STATUS_CONFIG[status] || { + icon: Clock, + color: 'text-slate-400', + bg: 'bg-slate-100 text-slate-600', + } + return { + ...config, + label: statusLabel(status), + } +} + +const SIZE_MAP: Record = { + sm: { icon: 'w-4 h-4', badge: 'px-2 py-0.5 text-xs' }, + md: { icon: 'w-5 h-5', badge: 'px-2.5 py-0.5 text-sm' }, +} + +/** + * 统一状态徽章组件 + * 接收status字符串,返回对应的图标+颜色+标签 + * 替换所有页面中重复的getStatusIcon/getStatusColor + */ +export const StatusBadge = memo(function StatusBadge({ + status, + showLabel = true, + size = 'sm', + className, +}: StatusBadgeProps) { + const config = useMemo(() => getStatusConfig(status), [status]) + const sizeClasses = SIZE_MAP[size] + const Icon = config.icon + + return ( + + + {showLabel && {config.label}} + + ) +}) + +/** 独立状态图标组件(仅图标,无标签) */ +export const StatusIcon = memo(function StatusIcon({ + status, + size = 'sm', + className, +}: Omit) { + const config = useMemo(() => getStatusConfig(status), [status]) + const sizeClasses = SIZE_MAP[size] + const Icon = config.icon + + return ( + + ) +}) + +/** 独立状态颜色工具函数(用于需要className的场景) */ +export function getStatusColorClass(status: string): string { + return STATUS_CONFIG[status]?.bg || 'bg-slate-100 text-slate-600' +} + +export default StatusBadge diff --git a/frontend/src/pages/Clusters.tsx b/frontend/src/pages/Clusters.tsx index fd5afaf..8b7302e 100644 --- a/frontend/src/pages/Clusters.tsx +++ b/frontend/src/pages/Clusters.tsx @@ -1,5 +1,13 @@ -import { useState, useEffect } from 'react' -import { Server, Plus, RefreshCw, Trash2, Eye, Key, FileKey } from 'lucide-react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { + Server, + Plus, + RefreshCw, + Trash2, + Eye, + Key, + FileKey, +} from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -13,8 +21,12 @@ import { DialogTrigger, } from '@/components/ui/dialog' import { Card, CardContent } from '@/components/ui/card' -import { clusterApi } from '@/srv/api' -import { statusLabel } from '@/lib/utils' +import { clusterApi } from '@/api' +import { StatusBadge } from '@/components/StatusBadge' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { EmptyState } from '@/components/EmptyState' +import { PageHeader } from '@/components/PageHeader' +import { toast } from 'sonner' import type { Cluster, ClusterInfo } from '@/types' // 从 kubeconfig 文本自动提取 server 地址 @@ -23,7 +35,18 @@ const parseApiServer = (kubeconfig: string): string => { return m ? m[1] : '' } -export default function Clusters() { +/** + * Clusters 页面 - 优化版本 + * + * 优化点: + * 1. React.memo 包裹组件 + * 2. 所有操作函数使用 useCallback 缓存 + * 3. alert() 替换为 sonner toast + * 4. setTimeout 使用 ref 管理,添加清理函数 + * 5. 使用 StatusBadge 公共组件 + * 6. 使用 PageHeader、EmptyState、LoadingSpinner 公共组件 + */ +const Clusters = memo(function Clusters() { const [clusters, setClusters] = useState([]) const [loading, setLoading] = useState(true) const [open, setOpen] = useState(false) @@ -42,55 +65,38 @@ export default function Clusters() { const [context, setContext] = useState('') const [submitting, setSubmitting] = useState(false) + // 定时器管理ref + const timeoutsRef = useRef[]>([]) + useEffect(() => { - loadClusters() + return () => { + timeoutsRef.current.forEach((id) => clearTimeout(id)) + } }, []) - const loadClusters = async () => { + const addTimeout = useCallback((cb: () => void, delay: number) => { + const id = setTimeout(cb, delay) + timeoutsRef.current.push(id) + return id + }, []) + + const loadClusters = useCallback(async () => { try { const res = await clusterApi.list(1, 100) setClusters(res.list) } catch (err) { console.error('Failed to load clusters:', err) + toast.error('加载集群数据失败') } finally { setLoading(false) } - } + }, []) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - // 校验 - if (configType === 'kubeconfig' && !kubeconfig.trim()) { - alert('请填写 kubeconfig 内容') - return - } - if (configType === 'token' && (!apiServer.trim() || !token.trim())) { - alert('Token 接入方式需要填写 API Server 和 Token') - return - } - setSubmitting(true) - try { - await clusterApi.create({ - name, - description, - configType, - apiServer: configType === 'token' ? apiServer : undefined, - kubeconfig: configType === 'kubeconfig' ? kubeconfig : undefined, - token: configType === 'token' ? token : undefined, - context, - }) - setOpen(false) - resetForm() - loadClusters() - } catch (err: unknown) { - const message = err instanceof Error ? err.message : '创建集群失败' - alert(message) - } finally { - setSubmitting(false) - } - } + useEffect(() => { + loadClusters() + }, [loadClusters]) - const resetForm = () => { + const resetForm = useCallback(() => { setName('') setDescription('') setConfigType('kubeconfig') @@ -98,249 +104,230 @@ export default function Clusters() { setApiServer('') setToken('') setContext('') - } + }, []) - const handleDelete = async (id: number) => { - if (!confirm('确定删除此集群?')) return - try { - await clusterApi.remove(id) - loadClusters() - } catch (err) { - console.error('Failed to delete cluster:', err) - } - } + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + // 校验 + if (configType === 'kubeconfig' && !kubeconfig.trim()) { + toast.error('请填写 kubeconfig 内容') + return + } + if (configType === 'token' && (!apiServer.trim() || !token.trim())) { + toast.error('Token 接入方式需要填写 API Server 和 Token') + return + } + setSubmitting(true) + try { + await clusterApi.create({ + name, + description, + configType, + apiServer: configType === 'token' ? apiServer : undefined, + kubeconfig: configType === 'kubeconfig' ? kubeconfig : undefined, + token: configType === 'token' ? token : undefined, + context, + }) + setOpen(false) + resetForm() + loadClusters() + toast.success('集群添加成功') + } catch (err: unknown) { + const message = err instanceof Error ? err.message : '创建集群失败' + toast.error(message) + } finally { + setSubmitting(false) + } + }, + [name, description, configType, kubeconfig, apiServer, token, context, resetForm, loadClusters] + ) - const handleValidate = async (id: number) => { - setValidating(id) - try { - await clusterApi.validate(id) - setTimeout(loadClusters, 3000) - } catch (err) { - console.error('Failed to validate cluster:', err) - } finally { - setTimeout(() => setValidating(null), 3000) - } - } + const handleDelete = useCallback( + async (id: number) => { + if (!confirm('确定删除此集群?')) return + try { + await clusterApi.remove(id) + loadClusters() + toast.success('集群已删除') + } catch (err) { + console.error('Failed to delete cluster:', err) + toast.error('删除集群失败') + } + }, + [loadClusters] + ) - const viewClusterInfo = async (cluster: Cluster) => { + const handleValidate = useCallback( + async (id: number) => { + setValidating(id) + try { + await clusterApi.validate(id) + addTimeout(loadClusters, 3000) + toast.success('验证指令已发送') + } catch (err) { + console.error('Failed to validate cluster:', err) + toast.error('验证失败') + } finally { + addTimeout(() => setValidating(null), 3000) + } + }, + [loadClusters, addTimeout] + ) + + const viewClusterInfo = useCallback(async (cluster: Cluster) => { setSelectedCluster(cluster) + setClusterInfo(null) setDetailOpen(true) try { const info = await clusterApi.getInfo(cluster.id) setClusterInfo(info) } catch (err) { console.error('Failed to get cluster info:', err) + toast.error('加载集群详情失败') } - } - - const getStatusColor = (status: string) => { - switch (status) { - case 'active': - return 'bg-emerald-100 text-emerald-700' - case 'error': - return 'bg-red-100 text-red-700' - case 'pending': - return 'bg-amber-100 text-amber-700' - default: - return 'bg-slate-100 text-slate-600' - } - } + }, []) return (
{/* Header */} -
-
-

集群管理

-

管理用于 Loki 部署的 Kubernetes 集群

-
- - - - - - - 添加 Kubernetes 集群 - -
-
- - setName(e.target.value)} - placeholder="production-cluster" - required - /> -
-
- - setDescription(e.target.value)} - placeholder="生产环境日志集群" - /> -
- -
- - setConfigType(v as 'kubeconfig' | 'token')}> - - Kubeconfig - Token - - - -