refactor: 前后端目录结构模块化 + v2 优化移植与缺陷修复

前端:
- 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 <noreply@anthropic.com>
This commit is contained in:
Logan.LiandClaude committed 2026-06-30 10:46:26 +08:00
1 parent fb8d10231a
commit 327e282356
93 files changed
+8433 -3089

No files matched your search

+39 -12
View File
@@ -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
}
+31 -24
View File
@@ -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
+2 -11
View File
@@ -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
}
+2 -13
View File
@@ -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
}
+6 -21
View File
@@ -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,
})
}
+4 -16
View File
@@ -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)
+2 -14
View File
@@ -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
}
+25
View File
@@ -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"`
}
+12
View File
@@ -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"`
}
+14
View File
@@ -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"`
}
+19
View File
@@ -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"`
}
+16
View File
@@ -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"`
}
+15
View File
@@ -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"`
}
+43 -9
View File
@@ -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")
}
+9 -1
View File
@@ -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)
+28 -3
View File
@@ -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
}
-115
View File
@@ -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
}
+41
View File
@@ -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
}
+87 -4
View File
@@ -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:<base64(AES-GCM(ciphertext))>
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(),
}
}
+23 -11
View File
@@ -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
}
}
+173
View File
@@ -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:")
}
+78
View File
@@ -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")
}
}
+15
View File
@@ -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())
}
+138
View File
@@ -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)
}
+66
View File
@@ -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)")
}
}
+15
View File
@@ -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())
}
+281
View File
@@ -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
}
+85
View File
@@ -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)
}
}
+32
View File
@@ -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 }
+35 -1
View File
@@ -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"})
+5 -4
View File
@@ -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",
+19 -16
View File
@@ -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
}
}
+16 -12
View File
@@ -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
}
}
+11 -10
View File
@@ -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)
+88 -41
View File
@@ -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
+163 -9
View File
@@ -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(卸载)
+154 -27
View File
@@ -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
}
+29 -17
View File
@@ -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)
+37 -8
View File
@@ -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+)`)
File diff suppressed because it is too large. Load diff
+194 -5
View File
@@ -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)
}
+23 -14
View File
@@ -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 {
-79
View File
@@ -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
}
+36 -4
View File
@@ -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("解密操作失败")
)
-62
View File
@@ -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"
}
+30 -4
View File
@@ -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