feat: opsx-loki 全面升级 — 前端重构/用户与LDAP/部署向导/日志查询

基于 WEB-UI-Development-Guide 标准的整体升级,并更名 LokiDeploy → opsx-loki。

前端(Gin+React+shadcn/ui):
- 按 02/07 章重构:页面域化拆分(无超 400 行文件)、PageContainer/ListQuery、
  反馈分级(成功静默/失败 toast/危险确认)、401 单飞刷新+队列重放
- 暗色模式(next-themes)+ 语义 Token(--primary 品牌色/success/warning/info)
- 部署向导(状态推导四步:环境→存储→Loki→采集,内嵌表单闭环)
- 日志查询页:LogQL 标签构建器/拖拽直方图/日志上下文/Live Tail/查询历史
- 按用户+标签策略的查询授权(服务端强制合并策略)

后端(Go+Gin+JSON DB):
- 简单用户管理(仅 admin,无 RBAC)+ TokenVersion 会话吊销 + 登录 IP 限流
- LDAP 接入:配置(AES 加密存储/hasPassword 脱敏)/测试连接/批量导入/登录
- 存储配置测试连接(SigV4 零依赖)+ 桶不存在自动创建
- Loki 三模式部署修复:SSH 密钥认证、3.x 配置模板、ruler 对象存储嵌套块、
  凭证解密、孤儿 deploying 状态恢复、Grafana 部署(可选默认关)+面板导入
- DaemonSet 采集纯 YAML(去 helm 依赖)+ DNS 自愈回退 ClusterIP + 集群名标签
- 日志查询代理:cluster-svc 经 kube-apiserver proxy/url 直连 + cursor 翻页
- 统一 log 命名空间;部署就绪探活;Grafana 访问地址自动记录

修复:run.sh 增量编译检测、Sanitized 脱敏补全、LokiDeployment Update 白名单、
健康检查 CreatedAt 零值等 10+ 存量 bug。

数据兼容:DB 沿用旧 lokideploy.json(新名优先);localStorage 一次性迁移;
HKDF 加密前缀保留(已加密凭证不受影响)。
This commit is contained in:
logan committed 2026-10-24 13:20:12 +08:00
1 parent 327e282356
commit f9c6c1d826
204 files changed
+14107 -9103

No files matched your search

+14 -1
View File
@@ -46,7 +46,7 @@ func GetConfig() *Config {
instance = &Config{
ServerPort: getEnv("SERVER_PORT", "8080"),
DatabasePath: filepath.Join(dataDir, "lokideploy.json"),
DatabasePath: databasePath(dataDir),
JWTSecret: jwtSecret,
EncryptionKey: encKey,
DataDir: dataDir,
@@ -67,3 +67,16 @@ func getEnv(key, defaultVal string) string {
}
return defaultVal
}
// databasePath 新名优先;旧 lokideploy.json 存在且新文件不存在时沿用旧文件(数据平滑迁移)
func databasePath(dataDir string) string {
newPath := filepath.Join(dataDir, "opsxloki.json")
if _, err := os.Stat(newPath); err == nil {
return newPath
}
oldPath := filepath.Join(dataDir, "lokideploy.json")
if _, err := os.Stat(oldPath); err == nil {
return oldPath
}
return newPath
}
+22 -22
View File
@@ -1,11 +1,11 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/pkg/jwt"
"lokideploy/services"
"lokideploy/utils"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/pkg/jwt"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
@@ -41,11 +41,27 @@ func (ctrl *AuthController) RefreshToken(c *gin.Context) {
if !utils.BindAndValidate(c, &req) {
return
}
tokenPair, err := jwt.RefreshAccessToken(req.RefreshToken)
claims, err := jwt.ParseRefreshToken(req.RefreshToken)
if err != nil {
utils.Unauthorized(c, "invalid or expired refresh token")
return
}
// 对照数据库校验用户存在性与会话版本:改密/重置密码后旧 refresh token 立即失效
user, err := ctrl.service.GetUserByID(claims.UserID)
if err != nil {
utils.Unauthorized(c, "user no longer exists")
return
}
if claims.TokenVersion != user.TokenVersion {
utils.Unauthorized(c, "session revoked, please login again")
return
}
// 以用户当前 role 与版本签发,角色变更在刷新时即时生效
tokenPair, err := jwt.GenerateTokenPair(user.ID, user.Username, user.Role, user.TokenVersion)
if err != nil {
utils.Error(c, 500, err.Error())
return
}
utils.Success(c, gin.H{
"accessToken": tokenPair.AccessToken,
"refreshToken": tokenPair.RefreshToken,
@@ -53,22 +69,6 @@ func (ctrl *AuthController) RefreshToken(c *gin.Context) {
})
}
func (ctrl *AuthController) Register(c *gin.Context) {
var req dto.RegisterRequest
if !utils.BindAndValidate(c, &req) {
return
}
if req.Role == "" {
req.Role = "user"
}
user, err := ctrl.service.Register(req.Username, req.Password, req.Nickname, req.Role)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, models.SanitizedJSON(user))
}
func (ctrl *AuthController) GetUser(c *gin.Context) {
claims, ok := jwt.GetUserFromContext(c)
if !ok {
+4 -4
View File
@@ -1,10 +1,10 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
+4 -4
View File
@@ -1,10 +1,10 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
+36 -4
View File
@@ -1,10 +1,12 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
"context"
"time"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
@@ -37,6 +39,8 @@ func (ctrl *DeploymentController) Create(c *gin.Context) {
ResourcesCPU: req.ResourcesCPU,
ResourcesMem: req.ResourcesMem,
StorageSize: req.StorageSize,
GrafanaEnabled: req.GrafanaEnabled,
GrafanaVersion: req.GrafanaVersion,
})
if err != nil {
utils.Error(c, 400, err.Error())
@@ -59,6 +63,34 @@ func (ctrl *DeploymentController) Get(c *gin.Context) {
utils.Success(c, models.SanitizedJSON(deployment))
}
// Ready 部署就绪探测(经自动注册的数据源访问 Loki /ready)
func (ctrl *DeploymentController) Ready(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
d, err := ctrl.service.Get(uint(id))
if err != nil {
utils.NotFound(c, "部署不存在")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
ready, msg := services.DeploymentReady(ctx, d.Name)
utils.Success(c, gin.H{"ready": ready, "message": msg})
}
// Preflight 向导预检:聚合存储/集群/主机/Loki/采集器状态
func (ctrl *DeploymentController) Preflight(c *gin.Context) {
utils.Success(c, ctrl.service.Preflight())
}
// GetVersions 返回支持的 Loki 版本列表(前端部署表单版本下拉数据源)
func (ctrl *DeploymentController) GetVersions(c *gin.Context) {
utils.Success(c, gin.H{"versions": services.LokiVersionList})
}
func (ctrl *DeploymentController) List(c *gin.Context) {
page, pageSize := utils.Pagination(c)
deployments, total, err := ctrl.service.List(page, pageSize)
+5 -11
View File
@@ -1,11 +1,10 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/pkg/crypto"
"lokideploy/services"
"lokideploy/utils"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
@@ -111,12 +110,7 @@ func (ctrl *HostController) GetInfo(c *gin.Context) {
return
}
password := host.Password
if len(password) > 4 && password[:4] == "ENC:" {
password, _ = crypto.Decrypt(password[4:])
}
client, err := services.NewSSHClient(host.HostIP, host.Port, host.User, password)
client, err := services.NewSSHClientWithAuth(services.HostSSHAuth(host))
if err != nil {
utils.Error(c, 500, err.Error())
return
+81
View File
@@ -0,0 +1,81 @@
package controllers
import (
"opsxloki/dto"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
// LdapController LDAP 配置与用户导入(配置读写仅 admin)
type LdapController struct{}
func NewLdapController() *LdapController {
return &LdapController{}
}
// GetConfig LDAP 配置(密码剔除,附 hasPassword/configured)
func (ctrl *LdapController) GetConfig(c *gin.Context) {
utils.Success(c, services.GetLDAPPublicConfig())
}
// UpdateConfig 保存配置(bindPassword 空则保留旧值)
func (ctrl *LdapController) UpdateConfig(c *gin.Context) {
var req dto.LdapConfigRequest
if !utils.BindAndValidate(c, &req) {
return
}
cfg := &services.LDAPConfig{
Enabled: req.Enabled, Host: req.Host, Port: req.Port, UseSSL: req.UseSSL,
BindDN: req.BindDN, BindPassword: req.BindPassword, BaseDN: req.BaseDN,
UserFilter: req.UserFilter, UsernameAttr: req.UsernameAttr,
NameAttr: req.NameAttr, EmailAttr: req.EmailAttr,
}
if err := services.SaveLDAPConfig(cfg); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, services.GetLDAPPublicConfig())
}
// TestConnection 用表单参数测试(未保存也可测;密码空回退已存;可选用户级验证)
func (ctrl *LdapController) TestConnection(c *gin.Context) {
var req dto.LdapConfigRequest
if !utils.BindAndValidate(c, &req) {
return
}
cfg := &services.LDAPConfig{
Enabled: req.Enabled, Host: req.Host, Port: req.Port, UseSSL: req.UseSSL,
BindDN: req.BindDN, BindPassword: req.BindPassword, BaseDN: req.BaseDN,
UserFilter: req.UserFilter, UsernameAttr: req.UsernameAttr,
NameAttr: req.NameAttr, EmailAttr: req.EmailAttr,
}
if cfg.Port == 0 {
cfg.Port = 389
}
result := services.TestLDAPConnection(cfg, req.TestUsername, req.TestPassword)
utils.Success(c, result)
}
// ListUsers 导入预览:按 ouDN(空用 baseDN)搜索用户
func (ctrl *LdapController) ListUsers(c *gin.Context) {
var req dto.LdapUsersRequest
if !utils.BindAndValidate(c, &req) {
return
}
cfg := services.GetLDAPConfig()
if !cfg.Enabled || cfg.Host == "" {
utils.Error(c, 400, "LDAP 未启用或未配置")
return
}
if req.BindPassword != "" {
cfg.BindPassword = req.BindPassword
}
users, err := services.SearchLDAPUsersForImport(cfg, req.OUDN)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, users)
}
+59
View File
@@ -0,0 +1,59 @@
package controllers
import (
"strconv"
"opsxloki/models"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
func (ctrl *LogsController) ListPolicyGroups(c *gin.Context) {
utils.Success(c, models.SanitizedJSON(services.ListLogPolicyGroups()))
}
func (ctrl *LogsController) CreatePolicyGroup(c *gin.Context) {
var group models.LogPolicyGroup
if !utils.BindAndValidate(c, &group) {
return
}
created, err := services.CreateLogPolicyGroup(group)
if err != nil {
utils.BadRequest(c, err.Error())
return
}
utils.Success(c, models.SanitizedJSON(created))
}
func (ctrl *LogsController) UpdatePolicyGroup(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "无效的策略组 ID")
return
}
var group models.LogPolicyGroup
if !utils.BindAndValidate(c, &group) {
return
}
updated, err := services.UpdateLogPolicyGroup(uint(id), group)
if err != nil {
utils.BadRequest(c, err.Error())
return
}
utils.Success(c, models.SanitizedJSON(updated))
}
func (ctrl *LogsController) DeletePolicyGroup(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "无效的策略组 ID")
return
}
if err := services.DeleteLogPolicyGroup(uint(id)); err != nil {
utils.BadRequest(c, err.Error())
return
}
utils.Success(c, gin.H{"message": "deleted"})
}
+280
View File
@@ -0,0 +1,280 @@
package controllers
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
func (ctrl *LogsController) Context(c *gin.Context) {
var req dto.LogContextRequest
if !utils.BindAndValidate(c, &req) {
return
}
src := services.GetLogSource(req.SourceID)
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
ts, err := strconv.ParseInt(req.TimestampNs, 10, 64)
if err != nil || ts < 1 {
utils.BadRequest(c, "时间戳无效")
return
}
parts := make([]string, 0, len(req.Labels))
for key, value := range req.Labels {
if !services.ValidLogLabelName(key) {
utils.BadRequest(c, "标签名无效")
return
}
encoded, _ := json.Marshal(value)
parts = append(parts, key+"="+string(encoded))
}
query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", "{"+strings.Join(parts, ",")+"}")
if err != nil {
utils.Forbidden(c, err.Error())
return
}
span := int64(5 * time.Minute)
before, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts-span, ts-1, 1, "backward", "")
if err != nil {
utils.Error(c, 502, err.Error())
return
}
after, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts+1, ts+span, 1, "forward", "")
if err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, gin.H{"before": before.Entries, "after": after.Entries})
}
// LogsController 日志查询代理 + 数据源管理
type LogsController struct{}
func NewLogsController() *LogsController {
return &LogsController{}
}
// ==================== 数据源 ====================
func (ctrl *LogsController) ListSources(c *gin.Context) {
list := services.ListVisibleLogSources(c.GetUint("userId"), c.GetString("role") == "admin")
utils.Success(c, utils.PageData{List: list, Total: int64(len(list)), Page: 1, PageSize: 100})
}
func (ctrl *LogsController) CreateSource(c *gin.Context) {
var req dto.LogSourceRequest
if !utils.BindAndValidate(c, &req) {
return
}
if req.Name == "" || req.TargetType == "" {
utils.BadRequest(c, "名称与类型必填")
return
}
if err := services.ValidateSourceAccess(req.Access); err != nil {
utils.BadRequest(c, err.Error())
return
}
src := &models.LogSource{
Name: req.Name, TargetType: req.TargetType,
Namespace: req.Namespace, Service: req.Service, Port: req.Port,
URL: req.URL, AuthType: req.AuthType, BasicUser: req.BasicUser,
BasicPass: req.BasicPass, BearerToken: req.BearerToken, Headers: req.Headers, SkipTLSVerify: req.SkipTLSVerify, IsDefault: req.IsDefault, Access: req.Access,
}
if req.TargetType == "cluster-svc" {
if req.ClusterID > 0 {
id := req.ClusterID
src.ClusterID = &id
} else {
utils.BadRequest(c, "cluster-svc 类型必须选择集群")
return
}
}
created, err := services.CreateLogSource(src)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, created.Sanitized())
}
func (ctrl *LogsController) UpdateSource(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var updates map[string]interface{}
if !utils.BindAndValidate(c, &updates) {
return
}
if err := services.UpdateLogSource(uint(id), updates); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "updated"})
}
func (ctrl *LogsController) DeleteSource(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := services.DeleteLogSource(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "deleted"})
}
// TestSource 数据源连通性测试(按 id 或表单参数)
func (ctrl *LogsController) TestSource(c *gin.Context) {
var req dto.LogSourceRequest
if !utils.BindAndValidate(c, &req) {
return
}
var src *models.LogSource
if req.ID > 0 {
src = services.GetLogSource(req.ID)
if src == nil {
utils.NotFound(c, "数据源不存在")
return
}
} else {
src = &models.LogSource{
TargetType: req.TargetType, Namespace: req.Namespace, Service: req.Service,
Port: req.Port, URL: req.URL, AuthType: req.AuthType,
BasicUser: req.BasicUser, BasicPass: req.BasicPass, BearerToken: req.BearerToken, Headers: req.Headers, SkipTLSVerify: req.SkipTLSVerify,
}
if req.TargetType == "cluster-svc" && req.ClusterID > 0 {
id := req.ClusterID
src.ClusterID = &id
}
}
utils.Success(c, services.TestLogSource(c.Request.Context(), src))
}
// ==================== 查询代理 ====================
func (ctrl *LogsController) Query(c *gin.Context) {
var req dto.LogQueryRequest
if !utils.BindAndValidate(c, &req) {
return
}
src := services.GetLogSource(req.SourceID)
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", req.Query)
if err != nil {
utils.Forbidden(c, err.Error())
return
}
result, err := services.QueryLokiLogs(c.Request.Context(), src, query,
req.Start, req.End, req.Limit, req.Direction, req.Cursor)
if err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, result)
}
func (ctrl *LogsController) Labels(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64)
src := services.GetLogSource(uint(id))
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
if _, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", "{}"); err != nil {
utils.Forbidden(c, err.Error())
return
}
start, _ := strconv.ParseInt(c.Query("start"), 10, 64)
end, _ := strconv.ParseInt(c.Query("end"), 10, 64)
labels, err := services.LokiLabels(c.Request.Context(), src, start, end)
if err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, labels)
}
func (ctrl *LogsController) LabelValues(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64)
src := services.GetLogSource(uint(id))
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
start, _ := strconv.ParseInt(c.Query("start"), 10, 64)
end, _ := strconv.ParseInt(c.Query("end"), 10, 64)
requestedQuery := c.Query("query")
if strings.TrimSpace(requestedQuery) == "" {
requestedQuery = "{}"
}
query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", requestedQuery)
if err != nil {
utils.Forbidden(c, err.Error())
return
}
values, err := services.LokiLabelValues(c.Request.Context(), src,
c.Param("name"), query, start, end)
if err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, values)
}
// Health Loki /ready 探活(查询页状态点)
func (ctrl *LogsController) Health(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64)
src := services.GetLogSource(uint(id))
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
if _, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", "{}"); err != nil {
utils.Forbidden(c, err.Error())
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
utils.Success(c, gin.H{"ready": services.LokiReady(ctx, src)})
}
func (ctrl *LogsController) Histogram(c *gin.Context) {
var req dto.LogQueryRequest
if !utils.BindAndValidate(c, &req) {
return
}
src := services.GetLogSource(req.SourceID)
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", req.Query)
if err != nil {
utils.Forbidden(c, err.Error())
return
}
buckets, err := services.LokiHistogram(c.Request.Context(), src, query, req.Start, req.End)
if err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, buckets)
}
+23 -4
View File
@@ -1,10 +1,10 @@
package controllers
import (
"lokideploy/dto"
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
@@ -96,3 +96,22 @@ func (ctrl *StorageController) GetDefault(c *gin.Context) {
}
utils.Success(c, models.SanitizedJSON(storage))
}
// TestConnection 测试对象存储连接(凭证 + 桶存在性,可选自动创建;不落库)
func (ctrl *StorageController) TestConnection(c *gin.Context) {
var req dto.StorageTestRequest
if !utils.BindAndValidate(c, &req) {
return
}
result := services.TestStorageConnection(services.StorageTestParams{
StorageType: req.StorageType,
Endpoint: req.Endpoint,
Bucket: req.Bucket,
Region: req.Region,
AccessKey: req.AccessKey,
SecretKey: req.SecretKey,
Insecure: req.Insecure,
CreateIfMissing: req.CreateIfMissing,
})
utils.Success(c, result)
}
+110
View File
@@ -0,0 +1,110 @@
package controllers
import (
"strconv"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/pkg/jwt"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
// UserController 用户管理(全部端点仅 admin 可用,无 RBAC)
type UserController struct {
service *services.AuthService
}
func NewUserController() *UserController {
return &UserController{service: services.NewAuthService()}
}
// List 分页返回用户列表(Sanitized 输出,密码哈希不出库)
func (ctrl *UserController) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
users, total := ctrl.service.ListUsers(page, pageSize)
list := make([]map[string]interface{}, len(users))
for i := range users {
list[i] = users[i].Sanitized()
}
utils.Success(c, utils.PageData{List: list, Total: total, Page: page, PageSize: pageSize})
}
// Create 创建用户(原 /auth/register 逻辑收编至此)
func (ctrl *UserController) Create(c *gin.Context) {
var req dto.UserCreateRequest
if !utils.BindAndValidate(c, &req) {
return
}
role := req.Role
if role == "" {
role = "user"
}
user, err := ctrl.service.Register(req.Username, req.Password, req.Nickname, role)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, models.SanitizedJSON(user))
}
// Delete 删除用户(守卫:不能删自己 / 最后一个 admin)
func (ctrl *UserController) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "无效的用户 ID")
return
}
claims, ok := jwt.GetUserFromContext(c)
if !ok {
utils.Unauthorized(c, "")
return
}
if err := ctrl.service.DeleteUser(uint(id), claims.UserID); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "user deleted"})
}
// ResetPassword 管理员重置密码(递增 TokenVersion,目标用户旧会话全部失效)
func (ctrl *UserController) ResetPassword(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "无效的用户 ID")
return
}
var req dto.ResetPasswordRequest
if !utils.BindAndValidate(c, &req) {
return
}
if err := ctrl.service.ResetPassword(uint(id), req.NewPassword); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "password reset"})
}
// BatchImportLDAP 从 LDAP 批量导入用户(冲突策略见 services.BatchImportLDAPUsers)
func (ctrl *UserController) BatchImportLDAP(c *gin.Context) {
var req dto.LdapImportRequest
if !utils.BindAndValidate(c, &req) {
return
}
users := make([]services.LDAPImportUser, 0, len(req.Users))
for _, u := range req.Users {
users = append(users, services.LDAPImportUser{DN: u.DN, Username: u.Username, Nickname: u.Nickname, Email: u.Email})
}
result := services.BatchImportLDAPUsers(users)
utils.Success(c, result)
}
+2
View File
@@ -16,4 +16,6 @@ type CreateDeploymentRequest struct {
ResourcesCPU string `json:"resourcesCPU"`
ResourcesMem string `json:"resourcesMem"`
StorageSize string `json:"storageSize"`
GrafanaEnabled bool `json:"grafanaEnabled"`
GrafanaVersion string `json:"grafanaVersion"`
}
+34
View File
@@ -0,0 +1,34 @@
package dto
// LDAP 域
type LdapConfigRequest struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
UseSSL bool `json:"useSSL"`
BindDN string `json:"bindDN"`
BindPassword string `json:"bindPassword"`
BaseDN string `json:"baseDN"`
UserFilter string `json:"userFilter"`
UsernameAttr string `json:"usernameAttr"`
NameAttr string `json:"nameAttr"`
EmailAttr string `json:"emailAttr"`
TestUsername string `json:"testUsername"`
TestPassword string `json:"testPassword"`
}
type LdapUsersRequest struct {
OUDN string `json:"ouDN"`
BindPassword string `json:"bindPassword"`
}
type LdapImportRequest struct {
Users []LdapImportUser `json:"users" binding:"required,min=1"`
}
type LdapImportUser struct {
DN string `json:"dn"`
Username string `json:"username" binding:"required"`
Nickname string `json:"nickname"`
Email string `json:"email"`
}
+39
View File
@@ -0,0 +1,39 @@
package dto
import "opsxloki/models"
// 日志查询域
type LogSourceRequest struct {
ID uint `json:"id"`
Name string `json:"name"`
TargetType string `json:"targetType" binding:"omitempty,oneof=cluster-svc url"`
ClusterID uint `json:"clusterID"`
Namespace string `json:"namespace"`
Service string `json:"service"`
Port int `json:"port"`
URL string `json:"url"`
AuthType string `json:"authType"`
BasicUser string `json:"basicUser"`
BasicPass string `json:"basicPass"`
BearerToken string `json:"bearerToken"`
Headers map[string]string `json:"headers"`
SkipTLSVerify bool `json:"skipTLSVerify"`
IsDefault bool `json:"isDefault"`
Access []models.LogSourceAccess `json:"access"`
}
type LogQueryRequest struct {
SourceID uint `json:"sourceId" binding:"required"`
Query string `json:"query" binding:"required"`
Start int64 `json:"start"` // epoch 纳秒
End int64 `json:"end"`
Limit int `json:"limit"`
Direction string `json:"direction"`
Cursor string `json:"cursor"`
}
type LogContextRequest struct {
SourceID uint `json:"sourceId" binding:"required"`
TimestampNs string `json:"timestampNs" binding:"required"`
Labels map[string]string `json:"labels" binding:"required"`
}
+12
View File
@@ -13,3 +13,15 @@ type CreateStorageRequest struct {
Insecure bool `json:"insecure"`
IsDefault bool `json:"isDefault"`
}
// StorageTestRequest 测试连接(不落库;createIfMissing 时桶不存在则自动创建)
type StorageTestRequest struct {
StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs"`
Endpoint string `json:"endpoint" binding:"required"`
Bucket string `json:"bucket" binding:"required"`
Region string `json:"region"`
AccessKey string `json:"accessKey" binding:"required"`
SecretKey string `json:"secretKey" binding:"required"`
Insecure bool `json:"insecure"`
CreateIfMissing bool `json:"createIfMissing"`
}
+13
View File
@@ -0,0 +1,13 @@
package dto
// 用户管理域(admin 专用)
type UserCreateRequest struct {
Username string `json:"username" binding:"required,min=2,max=32"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname"`
Role string `json:"role" binding:"omitempty,oneof=admin user"`
}
type ResetPasswordRequest struct {
NewPassword string `json:"newPassword" binding:"required,min=6"`
}
+4 -1
View File
@@ -1,4 +1,4 @@
module lokideploy
module opsxloki
go 1.24.0
@@ -7,12 +7,14 @@ toolchain go1.24.3
require (
github.com/gin-contrib/cors v1.7.3
github.com/gin-gonic/gin v1.10.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.47.0
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.12.6 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
@@ -20,6 +22,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
+22
View File
@@ -1,3 +1,7 @@
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk=
github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -20,6 +24,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -37,6 +45,20 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+9 -5
View File
@@ -9,10 +9,10 @@ import (
"syscall"
"time"
"lokideploy/config"
"lokideploy/repository"
"lokideploy/routers"
"lokideploy/seed"
"opsxloki/config"
"opsxloki/repository"
"opsxloki/routers"
"opsxloki/seed"
)
func main() {
@@ -26,6 +26,10 @@ func main() {
log.Println("Seeding default data...")
seed.Seed()
// 孤儿部署状态恢复:进程重启会杀掉进行中的部署 goroutine,
// 残留的 "deploying" 状态将永远卡住(UI 无停止入口),启动时统一标记为失败。
repository.GetDB().RecoverOrphanDeployments()
r := routers.SetupRouter()
srv := &http.Server{
Addr: ":" + cfg.ServerPort,
@@ -41,7 +45,7 @@ func main() {
defer stop()
go func() {
log.Printf("LokiDeploy starting on port %s", cfg.ServerPort)
log.Printf("opsx-loki 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) {
+31 -5
View File
@@ -3,8 +3,10 @@ package middleware
import (
"strings"
"lokideploy/pkg/jwt"
"lokideploy/utils"
"opsxloki/models"
"opsxloki/pkg/jwt"
"opsxloki/repository"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
@@ -39,10 +41,34 @@ func JWTAuth() gin.HandlerFunc {
return
}
// 会话版本校验:改密/重置密码后 TokenVersion 递增,旧 token 立即失效;
// 同时以数据库中的最新 role 为准(角色变更即时生效,不依赖旧 claims)。
db := repository.GetDB()
db.RLock()
var current *models.User
for i := range db.Users {
if db.Users[i].ID == claims.UserID {
u := db.Users[i]
current = &u
break
}
}
db.RUnlock()
if current == nil {
utils.Unauthorized(c, "user no longer exists")
c.Abort()
return
}
if claims.TokenVersion != current.TokenVersion {
utils.Unauthorized(c, "session revoked, please login again")
c.Abort()
return
}
c.Set("user", claims)
c.Set("userId", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("userId", current.ID)
c.Set("username", current.Username)
c.Set("role", current.Role)
c.Next()
}
}
+84
View File
@@ -0,0 +1,84 @@
package middleware
import (
"net/http"
"sync"
"time"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
// ipLimiter 内存滑动窗口 IP 限流(借鉴 opsx-web-lite rate_limit.go 的简化版)。
// 用于登录防爆破:超限返回 429。单实例部署下内存实现即可,无需外部存储。
type ipLimiter struct {
mu sync.Mutex
window time.Duration
max int
hits map[string][]time.Time
lastSweep time.Time
}
func newIPLimiter(max int, window time.Duration) *ipLimiter {
return &ipLimiter{
window: window,
max: max,
hits: make(map[string][]time.Time),
}
}
// Allow 记录一次命中并判断是否放行
func (l *ipLimiter) Allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
// 惰性清理过期记录,防止 map 无限增长
if now.Sub(l.lastSweep) > time.Minute {
for k, v := range l.hits {
kept := v[:0]
for _, t := range v {
if now.Sub(t) < l.window {
kept = append(kept, t)
}
}
if len(kept) == 0 {
delete(l.hits, k)
} else {
l.hits[k] = kept
}
}
l.lastSweep = now
}
history := l.hits[ip]
kept := history[:0]
for _, t := range history {
if now.Sub(t) < l.window {
kept = append(kept, t)
}
}
if len(kept) >= l.max {
l.hits[ip] = kept
return false
}
l.hits[ip] = append(kept, now)
return true
}
// LoginRateLimit 登录接口 IP 限流:max 次 / window 时长
func LoginRateLimit(max int, window time.Duration) gin.HandlerFunc {
limiter := newIPLimiter(max, window)
return func(c *gin.Context) {
if !limiter.Allow(c.ClientIP()) {
c.JSON(http.StatusTooManyRequests, utils.Response{
Code: http.StatusTooManyRequests,
Message: "登录尝试过于频繁,请稍后再试",
})
c.Abort()
return
}
c.Next()
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package models
import (
"time"
"lokideploy/pkg/crypto"
"opsxloki/pkg/crypto"
)
type Cluster struct {
+8
View File
@@ -26,6 +26,10 @@ type LokiDeployment struct {
ResourcesCPU string `json:"resourcesCPU"`
ResourcesMem string `json:"resourcesMem"`
StorageSize string `json:"storageSize"`
GrafanaEnabled bool `json:"grafanaEnabled"`
GrafanaVersion string `json:"grafanaVersion"`
GrafanaPort int `json:"grafanaPort"`
GrafanaEndpoint string `json:"grafanaEndpoint"`
Status string `json:"status"`
StatusMessage string `json:"statusMessage"`
HelmValues string `json:"helmValues"`
@@ -62,6 +66,10 @@ func (d *LokiDeployment) Sanitized() map[string]interface{} {
"resourcesCPU": d.ResourcesCPU,
"resourcesMem": d.ResourcesMem,
"storageSize": d.StorageSize,
"grafanaEnabled": d.GrafanaEnabled,
"grafanaVersion": d.GrafanaVersion,
"grafanaPort": d.GrafanaPort,
"grafanaEndpoint": d.GrafanaEndpoint,
"status": d.Status,
"statusMessage": d.StatusMessage,
"helmValues": d.HelmValues,
+30 -28
View File
@@ -3,7 +3,7 @@ package models
import (
"time"
"lokideploy/pkg/crypto"
"opsxloki/pkg/crypto"
)
// SSHAuthConfig SSH认证配置(定义在models包避免循环导入)
@@ -19,37 +19,39 @@ type SSHAuthConfig struct {
// Host 主机模型,敏感字段(Password/PrivateKey/KeyPhrase)加密存储
// 加密格式: ENC:<base64(AES-GCM(ciphertext))>
type Host struct {
ID uint `json:"id"`
Name string `json:"name"`
HostIP string `json:"hostIP"`
Port int `json:"port"`
User string `json:"user"`
AuthType string `json:"authType"`
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"`
Labels string `json:"labels"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint `json:"id"`
Name string `json:"name"`
HostIP string `json:"hostIP"`
Port int `json:"port"`
User string `json:"user"`
AuthType string `json:"authType"`
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"`
StatusMessage string `json:"statusMessage,omitempty"`
Labels string `json:"labels"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (h *Host) Sanitized() map[string]interface{} {
return map[string]interface{}{
"id": h.ID,
"name": h.Name,
"hostIP": h.HostIP,
"port": h.Port,
"user": h.User,
"authType": h.AuthType,
"os": h.OS,
"arch": h.Arch,
"status": h.Status,
"labels": h.Labels,
"createdAt": h.CreatedAt,
"updatedAt": h.UpdatedAt,
"id": h.ID,
"name": h.Name,
"hostIP": h.HostIP,
"port": h.Port,
"user": h.User,
"authType": h.AuthType,
"os": h.OS,
"arch": h.Arch,
"status": h.Status,
"statusMessage": h.StatusMessage,
"labels": h.Labels,
"createdAt": h.CreatedAt,
"updatedAt": h.UpdatedAt,
}
}
+22
View File
@@ -0,0 +1,22 @@
package models
import "time"
// LogPolicyGroup grants one exact stream-label scope on one source to multiple users.
type LogPolicyGroup struct {
ID uint `json:"id"`
Name string `json:"name"`
SourceID uint `json:"sourceId"`
Labels map[string]string `json:"labels"`
UserIDs []uint `json:"userIds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (g *LogPolicyGroup) Sanitized() map[string]interface{} {
return map[string]interface{}{
"id": g.ID, "name": g.Name, "sourceId": g.SourceID,
"labels": g.Labels, "userIds": g.UserIDs,
"createdAt": g.CreatedAt, "updatedAt": g.UpdatedAt,
}
}
+56
View File
@@ -0,0 +1,56 @@
package models
import "time"
// LogSource 日志查询数据源。
// cluster-svc: 经目标集群 kube-apiserver 的 service proxy 访问(无需直通 ClusterIP/DNS),
//
// 覆盖「本平台部署的 Loki」与「接入集群中已有的外部 Loki」两类场景;
//
// url: 直连地址(host-binary 部署或网络可达的 Loki)。
type LogSource struct {
ID uint `json:"id"`
Name string `json:"name"`
TargetType string `json:"targetType"` // cluster-svc | url
ClusterID *uint `json:"clusterID,omitempty"`
Namespace string `json:"namespace"`
Service string `json:"service"`
Port int `json:"port"`
URL string `json:"url"`
AuthType string `json:"authType"` // none | basic | bearer
BasicUser string `json:"basicUser"`
BasicPass string `json:"basicPass"` // ENC: 加密存储,不外发
BearerToken string `json:"bearerToken"`
Headers map[string]string `json:"headers,omitempty"`
SkipTLSVerify bool `json:"skipTLSVerify"`
IsDefault bool `json:"isDefault"`
Access []LogSourceAccess `json:"access,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (s *LogSource) Sanitized() map[string]interface{} {
return map[string]interface{}{
"id": s.ID, "name": s.Name, "targetType": s.TargetType,
"clusterID": s.ClusterID, "namespace": s.Namespace, "service": s.Service, "port": s.Port,
"url": s.URL, "authType": s.AuthType, "basicUser": s.BasicUser,
"hasBasicPass": s.BasicPass != "", "isDefault": s.IsDefault,
"hasBearerToken": s.BearerToken != "", "headerNames": headerNames(s.Headers), "skipTLSVerify": s.SkipTLSVerify,
"access": s.Access,
"createdAt": s.CreatedAt, "updatedAt": s.UpdatedAt,
}
}
func headerNames(headers map[string]string) []string {
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
return names
}
// LogSourceAccess grants one user access to a source, optionally restricted to exact stream labels.
type LogSourceAccess struct {
UserID uint `json:"userId"`
Labels map[string]string `json:"labels,omitempty"`
}
+6
View File
@@ -80,6 +80,12 @@ func SanitizedJSON(v any) any {
result[i] = item.Sanitized()
}
return result
case []LogPolicyGroup:
result := make([]map[string]interface{}, len(val))
for i := range val {
result[i] = val[i].Sanitized()
}
return result
case []Sanitizer:
result := make([]map[string]interface{}, len(val))
for i, item := range val {
+12 -7
View File
@@ -2,14 +2,18 @@ package models
import "time"
// User TokenVersion 用于服务端会话吊销:改密/管理员重置密码时递增,
// 旧 token(access 与 refresh)随版本不匹配立即失效(借鉴 opsx-web-lite)。
type User struct {
ID uint `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
Role string `json:"role"`
AuthSource string `json:"authSource"` // local | ldap
TokenVersion int `json:"tokenVersion"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (u *User) Sanitized() map[string]interface{} {
@@ -18,6 +22,7 @@ func (u *User) Sanitized() map[string]interface{} {
"username": u.Username,
"nickname": u.Nickname,
"role": u.Role,
"authSource": u.AuthSource,
"createdAt": u.CreatedAt,
"updatedAt": u.UpdatedAt,
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"io"
"strings"
"lokideploy/config"
"opsxloki/config"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/hkdf"
+1 -1
View File
@@ -10,6 +10,6 @@ import (
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.Setenv("DATA_DIR", os.TempDir()+"/opsxloki-crypto-pkg-test")
os.Exit(m.Run())
}
+19 -16
View File
@@ -8,7 +8,7 @@ import (
"strconv"
"time"
"lokideploy/config"
"opsxloki/config"
"github.com/gin-gonic/gin"
jwtv5 "github.com/golang-jwt/jwt/v5"
@@ -26,6 +26,8 @@ type Claims struct {
Username string `json:"username"`
Role string `json:"role"`
TokenType string `json:"tokenType"` // access | refresh
// TokenVersion 签发时的用户会话版本;改密/重置密码后递增,旧 token 立即失效
TokenVersion int `json:"ver,omitempty"`
jwtv5.RegisteredClaims
}
@@ -36,16 +38,17 @@ type TokenPair struct {
ExpiresAt time.Time `json:"expiresAt"`
}
func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) {
func GenerateTokenPair(userID uint, username, role string, tokenVersion int) (*TokenPair, error) {
cfg := config.GetConfig()
now := time.Now()
// Access Token
accessClaims := Claims{
UserID: userID,
Username: username,
Role: role,
TokenType: "access",
UserID: userID,
Username: username,
Role: role,
TokenType: "access",
TokenVersion: tokenVersion,
RegisteredClaims: jwtv5.RegisteredClaims{
ExpiresAt: jwtv5.NewNumericDate(now.Add(accessTokenExpiry)),
IssuedAt: jwtv5.NewNumericDate(now),
@@ -61,10 +64,11 @@ func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) {
// Refresh Token
refreshClaims := Claims{
UserID: userID,
Username: username,
Role: role,
TokenType: "refresh",
UserID: userID,
Username: username,
Role: role,
TokenType: "refresh",
TokenVersion: tokenVersion,
RegisteredClaims: jwtv5.RegisteredClaims{
ExpiresAt: jwtv5.NewNumericDate(now.Add(refreshTokenExpiry)),
IssuedAt: jwtv5.NewNumericDate(now),
@@ -85,19 +89,18 @@ func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) {
}, nil
}
// RefreshAccessToken 使用refresh token换取新的access token
func RefreshAccessToken(refreshTokenString string) (*TokenPair, error) {
// ParseRefreshToken 解析并校验 refresh token,返回 claims。
// 用户存在性与 TokenVersion 校验由调用方(controller)对照数据库完成,
// 通过后用用户当前 role/版本调 GenerateTokenPair 签发新 token 对。
func ParseRefreshToken(refreshTokenString string) (*Claims, 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)
return claims, nil
}
func ParseToken(tokenString string) (*Claims, error) {
+12 -9
View File
@@ -8,7 +8,7 @@ import (
// TestTokenPairRoundTrip 覆盖签发 + 解析,并校验 access token 类型。
func TestTokenPairRoundTrip(t *testing.T) {
pair, err := GenerateTokenPair(42, "alice", "admin")
pair, err := GenerateTokenPair(42, "alice", "admin", 3)
if err != nil {
t.Fatalf("GenerateTokenPair: %v", err)
}
@@ -25,22 +25,25 @@ func TestTokenPairRoundTrip(t *testing.T) {
if claims.TokenType != "access" {
t.Fatalf("access token should have tokenType=access, got %q", claims.TokenType)
}
if claims.TokenVersion != 3 {
t.Fatalf("access token should carry tokenVersion=3, got %d", claims.TokenVersion)
}
}
// TestRefreshRequiresRefreshToken 覆盖 token 类型强制:
// 用 access token 刷新必须被拒;用 refresh token 刷新应成功。
// 用 access token 刷新必须被拒;refresh token 应解析成功并携带版本。
func TestRefreshRequiresRefreshToken(t *testing.T) {
pair, _ := GenerateTokenPair(1, "bob", "user")
pair, _ := GenerateTokenPair(1, "bob", "user", 0)
if _, err := RefreshAccessToken(pair.AccessToken); err == nil {
t.Fatal("RefreshAccessToken must reject access tokens")
if _, err := ParseRefreshToken(pair.AccessToken); err == nil {
t.Fatal("ParseRefreshToken must reject access tokens")
}
newPair, err := RefreshAccessToken(pair.RefreshToken)
claims, err := ParseRefreshToken(pair.RefreshToken)
if err != nil {
t.Fatalf("RefreshAccessToken(refresh): %v", err)
t.Fatalf("ParseRefreshToken(refresh): %v", err)
}
if newPair.AccessToken == "" {
t.Fatal("new access token must not be empty")
if claims.UserID != 1 || claims.TokenType != "refresh" {
t.Fatalf("unexpected refresh claims: %+v", claims)
}
}
+123 -30
View File
@@ -8,8 +8,8 @@ import (
"sync"
"time"
"lokideploy/config"
"lokideploy/models"
"opsxloki/config"
"opsxloki/models"
)
// saveRequest 表示一个保存请求,通过channel序列化
@@ -20,17 +20,19 @@ type saveRequest struct {
// 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
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"`
LogSources []models.LogSource `json:"logSources"`
LogPolicyGroups []models.LogPolicyGroup `json:"logPolicyGroups"`
sequences map[string]uint
// 防抖保存机制
saveTimer *time.Timer
@@ -51,23 +53,26 @@ func InitDB() error {
_ = 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{},
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{},
LogSources: []models.LogSource{},
LogPolicyGroups: []models.LogPolicyGroup{},
sequences: map[string]uint{
"users": 0,
"clusters": 0,
"hosts": 0,
"storages": 0,
"deployments": 0,
"collectors": 0,
"logs": 0,
"users": 0,
"clusters": 0,
"hosts": 0,
"storages": 0,
"deployments": 0,
"collectors": 0,
"logs": 0,
"logPolicyGroups": 0,
},
saveChan: make(chan saveRequest, 10),
stopChan: make(chan struct{}),
@@ -111,17 +116,81 @@ func InitDB() error {
d.sequences["logs"] = l.ID
}
}
for _, group := range d.LogPolicyGroups {
if group.ID > d.sequences["logPolicyGroups"] {
d.sequences["logPolicyGroups"] = group.ID
}
}
}
}
migratedLogAccess := migrateLogAccessToGroups(d)
// 启动序列化保存goroutine
d.wg.Add(1)
go d.saveWorker()
db = d
if migratedLogAccess {
d.RequestSave()
}
return nil
}
// Legacy per-user grants are grouped by identical source and label scope.
func migrateLogAccessToGroups(d *JsonDB) bool {
changed := false
for i := range d.LogSources {
source := &d.LogSources[i]
if len(source.Access) == 0 {
continue
}
byLabels := map[string]*models.LogPolicyGroup{}
for _, rule := range source.Access {
validUser := false
for _, user := range d.Users {
if user.ID == rule.UserID && user.Role != "admin" {
validUser = true
break
}
}
if !validUser {
continue
}
alreadyGrouped := false
for _, group := range d.LogPolicyGroups {
if group.SourceID != source.ID {
continue
}
for _, id := range group.UserIDs {
if id == rule.UserID {
alreadyGrouped = true
break
}
}
}
if alreadyGrouped {
continue
}
keyBytes, _ := json.Marshal(rule.Labels)
key := string(keyBytes)
group := byLabels[key]
if group == nil {
d.sequences["logPolicyGroups"]++
id := d.sequences["logPolicyGroups"]
group = &models.LogPolicyGroup{ID: id, Name: fmt.Sprintf("旧授权 · %s · %d", source.Name, id), SourceID: source.ID, Labels: rule.Labels, CreatedAt: time.Now(), UpdatedAt: time.Now()}
byLabels[key] = group
}
group.UserIDs = append(group.UserIDs, rule.UserID)
}
for _, group := range byLabels {
d.LogPolicyGroups = append(d.LogPolicyGroups, *group)
}
source.Access = nil
changed = true
}
return changed
}
// Shutdown 优雅关闭数据库,确保所有数据已落盘。
// 顺序很关键:先停防抖定时器(不再产生新请求)→ 关闭 worker 并等其退出
// (worker 可能正在处理已入队的请求)→ 此时已无并发,做最后一次 doSave 把
@@ -181,7 +250,7 @@ func (d *JsonDB) doSave() error {
// 原子写入:先写入临时文件,再重命名
dir := filepath.Dir(d.dataFile)
tmpFile := filepath.Join(dir, fmt.Sprintf(".lokideploy-%d.tmp", time.Now().UnixNano()))
tmpFile := filepath.Join(dir, fmt.Sprintf(".opsxloki-%d.tmp", time.Now().UnixNano()))
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
return err
@@ -275,7 +344,31 @@ func (d *JsonDB) CreateUser(user *models.User) error {
user.ID = d.sequences["users"] + 1
d.sequences["users"] = user.ID
}
if user.CreatedAt.IsZero() {
now := time.Now()
user.CreatedAt = now
user.UpdatedAt = now
}
d.Users = append(d.Users, *user)
d.RequestSave()
return nil
}
// RecoverOrphanDeployments 启动时把残留的 deploying 状态部署标记为失败:
// 部署 goroutine 随进程消亡,不恢复会永久卡在「部署中」(UI 无停止入口)。
func (d *JsonDB) RecoverOrphanDeployments() {
d.Lock()
defer d.Unlock()
recovered := 0
for i := range d.Deployments {
if d.Deployments[i].Status == "deploying" {
d.Deployments[i].Status = "failed"
d.Deployments[i].StatusMessage = "服务重启导致部署中断,请重新部署"
d.Deployments[i].UpdatedAt = time.Now()
recovered++
}
}
if recovered > 0 {
d.RequestSave()
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"sync"
"testing"
"lokideploy/models"
"opsxloki/models"
)
// TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试:
+26
View File
@@ -0,0 +1,26 @@
package repository
import (
"opsxloki/models"
"testing"
)
func TestMigrateLogAccessToGroups(t *testing.T) {
d := &JsonDB{
Users: []models.User{{ID: 1, Role: "user"}, {ID: 2, Role: "user"}},
LogSources: []models.LogSource{{ID: 9, Name: "loki", Access: []models.LogSourceAccess{
{UserID: 1, Labels: map[string]string{"namespace": "log"}},
{UserID: 2, Labels: map[string]string{"namespace": "log"}},
}}},
sequences: map[string]uint{"logPolicyGroups": 0},
}
if !migrateLogAccessToGroups(d) {
t.Fatal("expected migration")
}
if len(d.LogSources[0].Access) != 0 {
t.Fatal("legacy grants remain")
}
if len(d.LogPolicyGroups) != 1 || len(d.LogPolicyGroups[0].UserIDs) != 2 {
t.Fatalf("expected one shared group, got %#v", d.LogPolicyGroups)
}
}
+2 -2
View File
@@ -4,13 +4,13 @@ import (
"os"
"testing"
"lokideploy/config"
"opsxloki/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")
dir, err := os.MkdirTemp("", "opsxloki-repository-test")
if err != nil {
panic(err)
}
+50 -5
View File
@@ -7,8 +7,8 @@ import (
"strings"
"time"
"lokideploy/controllers"
"lokideploy/middleware"
"opsxloki/controllers"
"opsxloki/middleware"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
@@ -31,6 +31,9 @@ func SetupRouter() *gin.Engine {
storageCtrl := controllers.NewStorageController()
deploymentCtrl := controllers.NewDeploymentController()
collectorCtrl := controllers.NewCollectorController()
userCtrl := controllers.NewUserController()
ldapCtrl := controllers.NewLdapController()
logsCtrl := controllers.NewLogsController()
// API v1
api := r.Group("/api/v1")
@@ -51,8 +54,8 @@ func SetupRouter() *gin.Engine {
c.String(http.StatusOK, "# LokiDeploy Metrics\n")
})
// Public
api.POST("/auth/login", authCtrl.Login)
// Public(登录接口挂 IP 限流防爆破:20 次/分钟)
api.POST("/auth/login", middleware.LoginRateLimit(20, time.Minute), authCtrl.Login)
api.POST("/auth/refresh", authCtrl.RefreshToken)
// Auth required
@@ -62,7 +65,45 @@ func SetupRouter() *gin.Engine {
// Auth routes
authorized.GET("/auth/user", authCtrl.GetUser)
authorized.POST("/auth/password", authCtrl.ChangePassword)
authorized.POST("/auth/register", middleware.AdminAuth(), authCtrl.Register)
// 日志查询:数据源管理 + Loki 代理查询
logs := authorized.Group("/logs")
{
logs.GET("/sources", logsCtrl.ListSources)
logs.POST("/sources", middleware.AdminAuth(), logsCtrl.CreateSource)
logs.PUT("/sources/:id", middleware.AdminAuth(), logsCtrl.UpdateSource)
logs.DELETE("/sources/:id", middleware.AdminAuth(), logsCtrl.DeleteSource)
logs.POST("/sources/test", middleware.AdminAuth(), logsCtrl.TestSource)
logs.GET("/policy-groups", middleware.AdminAuth(), logsCtrl.ListPolicyGroups)
logs.POST("/policy-groups", middleware.AdminAuth(), logsCtrl.CreatePolicyGroup)
logs.PUT("/policy-groups/:id", middleware.AdminAuth(), logsCtrl.UpdatePolicyGroup)
logs.DELETE("/policy-groups/:id", middleware.AdminAuth(), logsCtrl.DeletePolicyGroup)
logs.POST("/query", logsCtrl.Query)
logs.POST("/context", logsCtrl.Context)
logs.GET("/labels", logsCtrl.Labels)
logs.GET("/label/:name/values", logsCtrl.LabelValues)
logs.POST("/histogram", logsCtrl.Histogram)
logs.GET("/health", logsCtrl.Health)
}
// LDAP 配置(admin 读写;查询供用户页判断导入按钮显隐,登录用户即可读)
authorized.GET("/settings/ldap", middleware.AdminAuth(), ldapCtrl.GetConfig)
authorized.PUT("/settings/ldap", middleware.AdminAuth(), ldapCtrl.UpdateConfig)
authorized.POST("/settings/ldap/test", middleware.AdminAuth(), ldapCtrl.TestConnection)
authorized.POST("/settings/ldap/users", middleware.AdminAuth(), ldapCtrl.ListUsers)
// Users(admin 专用)
users := authorized.Group("/users")
users.Use(middleware.AdminAuth())
{
users.GET("", userCtrl.List)
users.POST("", userCtrl.Create)
users.DELETE("/:id", userCtrl.Delete)
users.POST("/:id/reset-password", userCtrl.ResetPassword)
users.POST("/batch-import-ldap", userCtrl.BatchImportLDAP)
}
// 普通用户仅可访问日志查询及自己的账号接口;管理资源统一在服务端保护。
authorized.Use(middleware.AdminAuth())
// Clusters
authorized.GET("/clusters", clusterCtrl.List)
@@ -87,6 +128,7 @@ func SetupRouter() *gin.Engine {
authorized.GET("/storages", storageCtrl.List)
authorized.GET("/storages/:id", storageCtrl.Get)
authorized.POST("/storages", storageCtrl.Create)
authorized.POST("/storages/test", storageCtrl.TestConnection)
authorized.PUT("/storages/:id", storageCtrl.Update)
authorized.DELETE("/storages/:id", storageCtrl.Delete)
authorized.GET("/storages/default", storageCtrl.GetDefault)
@@ -102,6 +144,9 @@ func SetupRouter() *gin.Engine {
authorized.POST("/deployments/:id/restart", deploymentCtrl.Restart)
authorized.GET("/deployments/:id/logs", deploymentCtrl.GetLogs)
authorized.GET("/deployments/stats", deploymentCtrl.GetStats)
authorized.GET("/deployments/preflight", deploymentCtrl.Preflight)
authorized.GET("/deployments/:id/ready", deploymentCtrl.Ready)
authorized.GET("/deployments/versions", deploymentCtrl.GetVersions)
// Collectors
authorized.GET("/collectors", collectorCtrl.List)
+3 -3
View File
@@ -1,9 +1,9 @@
package seed
import (
"lokideploy/models"
"lokideploy/pkg/crypto"
"lokideploy/repository"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
)
func Seed() {
+186 -11
View File
@@ -1,11 +1,12 @@
package services
import (
"lokideploy/models"
"lokideploy/pkg/crypto"
"lokideploy/pkg/jwt"
"lokideploy/repository"
"lokideploy/utils"
"fmt"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/pkg/jwt"
"opsxloki/repository"
"opsxloki/utils"
)
type AuthService struct{}
@@ -17,23 +18,89 @@ func NewAuthService() *AuthService {
func (s *AuthService) Login(username, password string) (*models.User, *jwt.TokenPair, error) {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
var local *models.User
for i := range db.Users {
if db.Users[i].Username == username {
if !crypto.VerifyPassword(password, db.Users[i].Password) {
return nil, nil, utils.ErrInvalidCredentials
}
tokenPair, err := jwt.GenerateTokenPair(db.Users[i].ID, db.Users[i].Username, db.Users[i].Role)
u := db.Users[i]
local = &u
break
}
}
db.RUnlock()
// 本地用户密码校验(普通本地用户唯一路径;LDAP 用户不使用本地密码)
if local != nil && local.AuthSource != "ldap" {
if !crypto.VerifyPassword(password, local.Password) {
return nil, nil, utils.ErrInvalidCredentials
}
return s.issueTokenPair(local)
}
// LDAP 用户(本地已导入或首次登录自动创建);enabled=false 或验证失败返回统一错误
if ldapCfg := GetLDAPConfig(); ldapCfg.Enabled && ldapCfg.Host != "" {
if entry, err := verifyLDAPPassword(ldapCfg, username, password); err == nil {
user, err := s.syncLDAPUser(entry)
if err != nil {
return nil, nil, err
}
return &db.Users[i], tokenPair, nil
return s.issueTokenPair(user)
}
}
return nil, nil, utils.ErrInvalidCredentials
}
func (s *AuthService) issueTokenPair(u *models.User) (*models.User, *jwt.TokenPair, error) {
pair, err := jwt.GenerateTokenPair(u.ID, u.Username, u.Role, u.TokenVersion)
if err != nil {
return nil, nil, err
}
return u, pair, nil
}
// syncLDAPUser LDAP 验证通过后同步本地记录:不存在则创建(user 角色,密码占位不可本地登录),
// 存在则同步昵称/邮箱
func (s *AuthService) syncLDAPUser(entry *ldapUserEntry) (*models.User, error) {
db := repository.GetDB()
// 已存在 → 同步昵称并标记 LDAP 来源
db.Lock()
for i := range db.Users {
if db.Users[i].Username == entry.Username {
db.Users[i].Nickname = entry.DisplayName
db.Users[i].AuthSource = "ldap"
db.RequestSave()
u := db.Users[i]
db.Unlock()
return &u, nil
}
}
db.Unlock()
// 不存在 → 创建(CreateUser 自管锁与 ID 分配)
hash, _ := crypto.HashPassword("ldap-user-" + entry.Username + "-no-local-login")
user := &models.User{
Username: entry.Username,
Password: hash,
Nickname: entry.DisplayName,
Role: "user",
AuthSource: "ldap",
}
if err := db.CreateUser(user); err != nil {
return nil, err
}
return user, nil
}
func (s *AuthService) Register(username, password, nickname, role string) (*models.User, error) {
db := repository.GetDB()
db.RLock()
for i := range db.Users {
if db.Users[i].Username == username {
db.RUnlock()
return nil, utils.ErrUsernameExists
}
}
db.RUnlock()
hash, err := crypto.HashPassword(password)
if err != nil {
return nil, err
@@ -70,6 +137,9 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
defer db.Unlock()
for i := range db.Users {
if db.Users[i].ID == userID {
if db.Users[i].AuthSource == "ldap" {
return fmt.Errorf("LDAP 用户请在 LDAP 目录中修改密码")
}
if !crypto.VerifyPassword(oldPassword, db.Users[i].Password) {
return utils.ErrInvalidCredentials
}
@@ -78,6 +148,111 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
return err
}
db.Users[i].Password = hash
db.Users[i].TokenVersion++
db.RequestSave()
return nil
}
}
return utils.ErrNotFound
}
// ListUsers 分页返回用户列表(按 ID 升序),pageSize 上限 100
func (s *AuthService) ListUsers(page, pageSize int) ([]models.User, int64) {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
total := int64(len(db.Users))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
start := (page - 1) * pageSize
if start >= int(total) {
return []models.User{}, total
}
end := start + pageSize
if end > int(total) {
end = int(total)
}
users := make([]models.User, end-start)
copy(users, db.Users[start:end])
return users, total
}
// DeleteUser 删除用户;不能删除自己,不能删除最后一个管理员
func (s *AuthService) DeleteUser(id, requesterID uint) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
idx := -1
adminCount := 0
for i := range db.Users {
if db.Users[i].ID == id {
idx = i
}
if db.Users[i].Role == "admin" {
adminCount++
}
}
if idx == -1 {
return utils.ErrNotFound
}
if id == requesterID {
return utils.ErrCannotDeleteSelf
}
if db.Users[idx].Role == "admin" && adminCount <= 1 {
return utils.ErrLastAdmin
}
db.Users = append(db.Users[:idx], db.Users[idx+1:]...)
for i := range db.LogSources {
kept := db.LogSources[i].Access[:0]
for _, rule := range db.LogSources[i].Access {
if rule.UserID != id {
kept = append(kept, rule)
}
}
db.LogSources[i].Access = kept
}
groups := db.LogPolicyGroups[:0]
for _, group := range db.LogPolicyGroups {
members := group.UserIDs[:0]
for _, member := range group.UserIDs {
if member != id {
members = append(members, member)
}
}
group.UserIDs = members
if len(members) > 0 {
groups = append(groups, group)
}
}
db.LogPolicyGroups = groups
db.RequestSave()
return nil
}
// ResetPassword 管理员重置用户密码,并递增 TokenVersion 吊销其全部旧会话
func (s *AuthService) ResetPassword(userID uint, newPassword string) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Users {
if db.Users[i].ID == userID {
if db.Users[i].AuthSource == "ldap" {
return fmt.Errorf("LDAP 用户密码由目录服务管理,不能在此重置")
}
hash, err := crypto.HashPassword(newPassword)
if err != nil {
return err
}
db.Users[i].Password = hash
db.Users[i].TokenVersion++
db.RequestSave()
return nil
}
+4 -4
View File
@@ -7,10 +7,10 @@ import (
"path/filepath"
"time"
"lokideploy/config"
"lokideploy/models"
"lokideploy/repository"
"lokideploy/utils"
"opsxloki/config"
"opsxloki/models"
"opsxloki/repository"
"opsxloki/utils"
)
type ClusterService struct{}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"os/exec"
"strings"
"lokideploy/models"
"opsxloki/models"
)
type ClusterInfo struct {
+9 -5
View File
@@ -5,9 +5,9 @@ import (
"fmt"
"time"
"lokideploy/models"
"lokideploy/repository"
"lokideploy/utils"
"opsxloki/models"
"opsxloki/repository"
"opsxloki/utils"
)
type CollectorService struct{}
@@ -66,11 +66,15 @@ func (s *CollectorService) generateConfigSnapshot(c *models.Collector) string {
if c.LokiDeployment != nil {
endpoint = c.LokiDeployment.Endpoint
}
clusterName := "host"
if c.Cluster != nil && c.Cluster.Name != "" {
clusterName = c.Cluster.Name
}
switch c.Type {
case "host-promtail":
return PromtailConfig(endpoint, c.LogPath)
return PromtailConfig(endpoint, c.LogPath, "host", c.Labels)
case "k8s-daemonset":
return PromtailHelmValues(endpoint)
return PromtailHelmValues(endpoint, clusterName, c.Labels)
case "k8s-fluentbit-sidecar":
host, port := parseLokiURL(endpoint)
app := c.TargetDeployment
+100 -30
View File
@@ -7,7 +7,7 @@ import (
"os/exec"
"strings"
"lokideploy/models"
"opsxloki/models"
)
// CollectorDeployer 采集器部署器,按 type 分发到三种安装/卸载流程
@@ -97,11 +97,7 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er
if host == nil {
return fmt.Errorf("主机采集器缺少目标主机")
}
password, err := DecryptCred(host.Password)
if err != nil {
return err
}
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
client, err := NewSSHClientWithAuth(HostSSHAuth(host))
if err != nil {
return err
}
@@ -145,7 +141,7 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er
if logPath == "" {
logPath = "/var/log/*.log"
}
conf := PromtailConfig(endpoint, logPath)
conf := PromtailConfig(endpoint, logPath, "host", d.collector.Labels)
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)
@@ -191,8 +187,7 @@ func (d *CollectorDeployer) uninstallHost(ctx context.Context) error {
if host == nil {
return nil
}
password, _ := DecryptCred(host.Password)
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
client, err := NewSSHClientWithAuth(HostSSHAuth(host))
if err != nil {
return err
}
@@ -209,7 +204,7 @@ func (d *CollectorDeployer) uninstallHost(ctx context.Context) error {
func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint string) error {
cluster := d.collector.Cluster
if cluster == nil {
return fmt.Errorf("DaemonSet 采集器缺少目标集群")
return fmt.Errorf("集群采集器缺少目标集群")
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
@@ -217,37 +212,106 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin
}
defer cleanup()
// 默认部署到关联 Loki 的命名空间(统一 log 命名空间策略)
ns := d.collector.Namespace
if ns == "" && d.loki != nil {
ns = d.loki.Namespace
}
if ns == "" {
ns = "logging"
ns = "log"
}
values := PromtailHelmValues(endpoint)
valuesFile, valuesCleanup, err := writeTempFile("promtail-values-*.yaml", values, 0600)
clusterName := "unknown"
if cluster.Name != "" {
clusterName = cluster.Name
}
// 幂等:先清掉同名残留(如历史失败安装留下的无注解资源)
del := exec.CommandContext(ctx, "kubectl", "delete",
"daemonset/promtail", "configmap/promtail", "serviceaccount/promtail",
"-n", ns, "--ignore-not-found=true", "--wait=false")
del.Env = env
del.CombinedOutput()
// 纯 YAML 安装(SA/RBAC/ConfigMap/DaemonSet),不依赖后端安装 helm
manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels)
if err := d.applyYAML(ctx, env, manifest, "promtail"); err != nil {
return err
}
d.log("info", fmt.Sprintf("等待 DaemonSet promtail 就绪(命名空间 %s)...", ns), "")
waitCmd := exec.CommandContext(ctx, "kubectl", "rollout", "status", "daemonset/promtail",
"-n", ns, "--timeout=5m")
waitCmd.Env = env
waitOut, _ := waitCmd.CombinedOutput()
d.log("info", string(waitOut), "")
// DNS 自愈:CoreDNS 异常的集群上服务名不可解析,回退 Loki ClusterIP 重建配置
svcURL := endpoint
if strings.Contains(endpoint, ".svc") {
probe := exec.CommandContext(ctx, "kubectl", "exec", "-n", ns,
"daemonset/promtail", "--", "wget", "-q", "-O", "/dev/null", "-T", "5", endpoint+"/ready")
probe.Env = env
if pout, perr := probe.CombinedOutput(); perr != nil {
d.log("warn", fmt.Sprintf("promtail 无法解析 Loki 服务名(%s),回退 ClusterIP...", strings.TrimSpace(string(pout))), "")
ip := d.lokiClusterIP(ctx, env, endpoint)
if ip != "" {
// 只替换 host 部分,保留 scheme/port/path: http://loki.ns.svc:3100/... → http://IP:3100/...
afterScheme := strings.SplitN(endpoint, "//", 2)
if len(afterScheme) == 2 {
rest := afterScheme[1]
host := rest
if i := strings.IndexAny(rest, ":/"); i >= 0 {
host = rest[:i]
}
fallback := afterScheme[0] + "//" + strings.Replace(rest, host, ip, 1)
if err := d.applyYAML(ctx, env, PromtailDaemonsetYAML(ns, fallback, clusterName, d.collector.Labels), "promtail"); err == nil {
rs := exec.CommandContext(ctx, "kubectl", "rollout", "restart", "daemonset/promtail", "-n", ns)
rs.Env = env
rs.CombinedOutput()
d.log("info", fmt.Sprintf("promtail 推送地址已回退为 ClusterIP: %s", fallback), "")
svcURL = fallback
}
}
}
}
}
d.collector.Config = svcURL
d.log("success", fmt.Sprintf("Promtail DaemonSet 已部署到 %s(推送至 %s)", ns, svcURL), "")
return nil
}
// applyYAML kubectl apply 一个 manifest(临时文件)
func (d *CollectorDeployer) applyYAML(ctx context.Context, env []string, manifest, label string) error {
f, cleanup, err := writeTempFile("collector-*.yaml", manifest, 0600)
if err != nil {
return err
}
defer valuesCleanup()
d.log("info", fmt.Sprintf("helm install promtail(命名空间 %s)", ns), "")
// 添加 grafana repo(幂等)
repo := exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts")
repo.Env = env
repo.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, "--namespace", ns, "--create-namespace", "--wait", "--timeout", "5m")
defer cleanup()
cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", f)
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("helm 安装 Promtail 失败: %w\n%s", err, string(out))
d.log("error", fmt.Sprintf("kubectl apply %s failed: %v", label, err), string(out))
return fmt.Errorf("kubectl apply %s failed: %w\n%s", label, err, string(out))
}
return nil
}
// lokiClusterIP 从 endpoint 的命名空间取 loki Service 的 ClusterIP(不经 DNS)
func (d *CollectorDeployer) lokiClusterIP(ctx context.Context, env []string, endpoint string) string {
ns := "log"
if d.loki != nil && d.loki.Namespace != "" {
ns = d.loki.Namespace
}
cmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "loki", "-n", ns,
"-o", "jsonpath={.spec.clusterIP}")
cmd.Env = env
out, _ := cmd.CombinedOutput()
return strings.TrimSpace(string(out))
}
func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error {
cluster := d.collector.Cluster
if cluster == nil {
@@ -259,10 +323,16 @@ func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error {
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" {
ns = "logging"
if ns == "" && d.loki != nil {
ns = d.loki.Namespace
}
cmd := exec.CommandContext(ctx, "helm", "uninstall", "promtail", "--namespace", ns)
if ns == "" {
ns = "log"
}
cmd := exec.CommandContext(ctx, "kubectl", "delete",
"daemonset/promtail", "configmap/promtail", "serviceaccount/promtail",
"clusterrole/opsxloki-promtail", "clusterrolebinding/opsxloki-promtail",
"-n", ns, "--ignore-not-found=true")
cmd.Env = env
cmd.CombinedOutput()
return nil
+182 -5
View File
@@ -1,14 +1,35 @@
package services
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// PromtailConfig 主机 Promtail 配置(推送至 lokiEndpoint,采集 logPath)
// 生产级配置:持久化位置文件、批处理、重试、CRI日志格式解析
func PromtailConfig(lokiEndpoint, logPath string) string {
return fmt.Sprintf(`server:
// promtailExternalLabels 组并 cluster 标识与用户自定义标签(JSON)为 external_labels YAML 行
func promtailExternalLabels(cluster string, labelsJSON string) string {
lines := []string{fmt.Sprintf(" cluster: %q", cluster)}
if labelsJSON != "" {
var m map[string]string
if err := json.Unmarshal([]byte(labelsJSON), &m); err == nil {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
lines = append(lines, fmt.Sprintf(" %s: %q", k, m[k]))
}
}
}
return strings.Join(lines, "\n")
}
func PromtailConfig(lokiEndpoint, logPath, cluster, labelsJSON string) string {
conf := fmt.Sprintf(`server:
http_listen_port: 9080
grpc_listen_port: 0
log_level: info
@@ -29,6 +50,7 @@ clients:
max_retries: 10
external_labels:
job: host-logs
__EXT_LABELS__
limits_config:
readline_rate_enabled: false
@@ -57,12 +79,13 @@ scrape_configs:
burst: 10000
drop: true
`, lokiEndpoint, logPath)
return strings.Replace(conf, "__EXT_LABELS__", promtailExternalLabels(cluster, labelsJSON), 1)
}
// PromtailHelmValues DaemonSet 模式 Promtail Helm values(生产级)
// 包含:cri解析、kubernetes标签、批处理配置、资源限制
func PromtailHelmValues(lokiEndpoint string) string {
return fmt.Sprintf(`config:
func PromtailHelmValues(lokiEndpoint, cluster, labelsJSON string) string {
values := fmt.Sprintf(`config:
clients:
- url: %s/loki/api/v1/push
batchwait: 1s
@@ -73,7 +96,7 @@ func PromtailHelmValues(lokiEndpoint string) string {
max_period: 5m
max_retries: 10
external_labels:
cluster: ${CLUSTER_NAME:-unknown}
__EXT_LABELS__
positions:
filename: /run/promtail/positions.yaml
scrape_configs:
@@ -164,6 +187,160 @@ extraVolumeMounts:
- name: promtail-run
mountPath: /run/promtail
`, lokiEndpoint)
return strings.Replace(values, "__EXT_LABELS__", promtailExternalLabels(cluster, labelsJSON), 1)
}
// PromtailDaemonsetYAML DaemonSet 模式纯 YAML 安装(SA/RBAC/ConfigMap/DaemonSet),
// 取代 helm 安装 —— 后端无需安装 helm 二进制。
// relabel 体系与主流 helm chart 对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。
func PromtailDaemonsetYAML(ns, lokiURL, clusterName, labelsJSON string) string {
extLabels := promtailExternalLabels(clusterName, labelsJSON)
return fmt.Sprintf(`apiVersion: v1
kind: ServiceAccount
metadata:
name: promtail
namespace: %s
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: opsxloki-promtail
rules:
- apiGroups: [""]
resources: ["pods", "nodes"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: opsxloki-promtail
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: opsxloki-promtail
subjects:
- kind: ServiceAccount
name: promtail
namespace: %s
---
apiVersion: v1
kind: ConfigMap
metadata:
name: promtail
namespace: %s
data:
promtail.yaml: |
server:
http_listen_port: 3101
grpc_listen_port: 0
positions:
filename: /run/promtail/positions.yaml
clients:
- url: %s/loki/api/v1/push
batchwait: 1s
batchsize: 1048576
backoff_config:
min_period: 500ms
max_period: 5m
max_retries: 10
external_labels:
job: promtail
%s
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_container_name]
target_label: container
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: node_name
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name, __meta_kubernetes_pod_label_app]
regex: ^;*([^;]+)(;.*)?$
target_label: app
- separator: /
source_labels: [__meta_kubernetes_namespace, __meta_kubernetes_pod_label_app_kubernetes_io_name]
target_label: job
- source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name]
separator: /
target_label: __path__
replacement: /var/log/pods/*$1/*.log
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: promtail
namespace: %s
labels:
app: promtail
spec:
selector:
matchLabels:
app: promtail
template:
metadata:
labels:
app: promtail
spec:
serviceAccountName: promtail
tolerations:
- effect: NoSchedule
operator: Exists
containers:
- name: promtail
image: grafana/promtail:3.4.0
imagePullPolicy: IfNotPresent
args: ["-config.file=/etc/promtail/promtail.yaml"]
env:
- name: HOSTNAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
ports:
- name: http-metrics
containerPort: 3101
readinessProbe:
httpGet:
path: /ready
port: http-metrics
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
volumeMounts:
- name: config
mountPath: /etc/promtail
- name: run
mountPath: /run/promtail
- name: pods
mountPath: /var/log/pods
readOnly: true
- name: containers
mountPath: /var/lib/docker/containers
readOnly: true
volumes:
- name: config
configMap:
name: promtail
- name: run
hostPath:
path: /run/promtail
- name: pods
hostPath:
path: /var/log/pods
- name: containers
hostPath:
path: /var/lib/docker/containers
`, ns, ns, ns, lokiURL, extLabels, ns)
}
// FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort)
+201 -3
View File
@@ -1,6 +1,7 @@
package services
import (
"os/exec"
"context"
"fmt"
"strconv"
@@ -8,9 +9,9 @@ import (
"sync"
"time"
"lokideploy/models"
"lokideploy/repository"
"lokideploy/utils"
"opsxloki/models"
"opsxloki/repository"
"opsxloki/utils"
)
// LokiVersionList 支持的 Loki 版本列表(按推荐程度排序)
@@ -50,6 +51,8 @@ type CreateDeploymentRequest struct {
ResourcesCPU string `json:"resourcesCPU"`
ResourcesMem string `json:"resourcesMem"`
StorageSize string `json:"storageSize"`
GrafanaEnabled bool `json:"grafanaEnabled"`
GrafanaVersion string `json:"grafanaVersion"`
}
func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDeployment, error) {
@@ -77,6 +80,8 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe
ResourcesCPU: req.ResourcesCPU,
ResourcesMem: req.ResourcesMem,
StorageSize: req.StorageSize,
GrafanaEnabled: req.GrafanaEnabled,
GrafanaVersion: req.GrafanaVersion,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -106,6 +111,13 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe
for i := range db.Storages {
if db.Storages[i].ID == d.StorageConfigID {
st := db.Storages[i]
// 模型内为 ENC: 密文,生成 Loki 配置前解密(否则密文进 ConfigMap 且无法通过对象存储认证)
if ak, err := DecryptCred(st.AccessKey); err == nil && ak != "" {
st.AccessKey = ak
}
if sk, err := DecryptCred(st.SecretKey); err == nil && sk != "" {
st.SecretKey = sk
}
d.StorageConfig = &st
break
}
@@ -171,6 +183,57 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro
if v, ok := updates["endpoint"].(string); ok {
db.Deployments[i].Endpoint = v
}
// 业务字段(来自 PUT /deployments/:id;JSON 数字解码为 float64)
if v, ok := updates["name"].(string); ok && v != "" {
db.Deployments[i].Name = v
}
if v, ok := updates["description"].(string); ok {
db.Deployments[i].Description = v
}
if v, ok := updates["version"].(string); ok && v != "" {
db.Deployments[i].Version = v
}
if v, ok := updates["namespace"].(string); ok && v != "" {
db.Deployments[i].Namespace = v
}
if v, ok := updates["resourcesCPU"].(string); ok {
db.Deployments[i].ResourcesCPU = v
}
if v, ok := updates["resourcesMem"].(string); ok {
db.Deployments[i].ResourcesMem = v
}
if v, ok := updates["storageSize"].(string); ok && v != "" {
db.Deployments[i].StorageSize = v
}
if v, ok := updates["authEnabled"].(bool); ok {
db.Deployments[i].AuthEnabled = v
}
if v, ok := updates["grafanaEnabled"].(bool); ok {
db.Deployments[i].GrafanaEnabled = v
}
if v, ok := updates["grafanaVersion"].(string); ok && v != "" {
db.Deployments[i].GrafanaVersion = v
}
if v, ok := updates["grafanaEndpoint"].(string); ok {
db.Deployments[i].GrafanaEndpoint = v
}
if v, ok := updates["storageConfigID"].(float64); ok && v > 0 {
db.Deployments[i].StorageConfigID = uint(v)
}
if v, ok := updates["clusterID"].(float64); ok && v > 0 {
id := uint(v)
db.Deployments[i].ClusterID = &id
}
if v, ok := updates["hostID"].(float64); ok && v > 0 {
id := uint(v)
db.Deployments[i].HostID = &id
}
if v, ok := updates["retentionDays"].(float64); ok && v > 0 {
db.Deployments[i].RetentionDays = int(v)
}
if v, ok := updates["replicas"].(float64); ok && v > 0 {
db.Deployments[i].Replicas = int(v)
}
db.Deployments[i].UpdatedAt = time.Now()
db.RequestSave()
return nil
@@ -260,6 +323,11 @@ func (s *DeploymentService) StartDeploy(id uint) error {
"statusMessage": "Deployment completed successfully",
"endpoint": s.getEndpoint(deployment),
})
// 部署成功自动注册日志查询数据源 + 记录 Grafana 访问地址
if fresh, gerr := s.Get(id); gerr == nil {
EnsureLogSourceForDeployment(fresh)
go s.recordGrafanaEndpoint(fresh)
}
s.AddLog(id, "success", "Deployment completed successfully", "")
}
}()
@@ -398,6 +466,13 @@ func (s *DeploymentService) loadRelations(d *models.LokiDeployment) {
for i := range db.Storages {
if db.Storages[i].ID == d.StorageConfigID {
st := db.Storages[i]
// 模型内为 ENC: 密文,生成 Loki 配置前解密(否则密文进 ConfigMap 且无法通过对象存储认证)
if ak, err := DecryptCred(st.AccessKey); err == nil && ak != "" {
st.AccessKey = ak
}
if sk, err := DecryptCred(st.SecretKey); err == nil && sk != "" {
st.SecretKey = sk
}
d.StorageConfig = &st
break
}
@@ -472,3 +547,126 @@ func GetRecommendedVersions() []string {
copy(result, LokiVersionList)
return result
}
// Preflight 向导预检:聚合各资源真实状态,前端据此推导步骤完成态(状态推导式向导)
func (s *DeploymentService) Preflight() map[string]interface{} {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
result := map[string]interface{}{
"hasStorage": len(db.Storages) > 0,
"hasActiveCluster": false,
"clusterNames": []string{},
"hasActiveHost": false,
"hostNames": []string{},
"runningLoki": nil,
"activeCollectors": 0,
"grafanaReady": false,
}
var defaultStorage string
for i := range db.Storages {
if db.Storages[i].IsDefault {
defaultStorage = db.Storages[i].Name
break
}
}
if defaultStorage == "" && len(db.Storages) > 0 {
defaultStorage = db.Storages[0].Name
}
result["defaultStorage"] = defaultStorage
clusters := []string{}
for i := range db.Clusters {
clusters = append(clusters, db.Clusters[i].Name)
if db.Clusters[i].Status == "active" || db.Clusters[i].Status == "running" {
result["hasActiveCluster"] = true
}
}
result["clusterNames"] = clusters
hosts := []string{}
for i := range db.Hosts {
hosts = append(hosts, db.Hosts[i].Name)
if db.Hosts[i].Status == "active" {
result["hasActiveHost"] = true
}
}
result["hostNames"] = hosts
for i := range db.Deployments {
d := &db.Deployments[i]
if d.Status == "running" {
loki := map[string]interface{}{
"id": d.ID,
"name": d.Name,
"mode": d.Mode,
"endpoint": d.Endpoint,
"namespace": d.Namespace,
"grafana": d.GrafanaEnabled,
"grafanaEndpoint": d.GrafanaEndpoint,
}
result["runningLoki"] = loki
if d.GrafanaEnabled {
result["grafanaReady"] = true
}
break
}
}
for i := range db.Collectors {
if db.Collectors[i].Status == "active" {
result["activeCollectors"] = result["activeCollectors"].(int) + 1
}
}
return result
}
// recordGrafanaEndpoint 记录 Grafana 可点击访问地址(k8s: 首节点 IP + NodePort;host: 主机 IP:3000),
// 供部署卡片/向导直接展示,用户不用翻日志找端口。
func (s *DeploymentService) recordGrafanaEndpoint(d *models.LokiDeployment) {
if d == nil || !d.GrafanaEnabled {
return
}
var endpoint string
switch d.Mode {
case "k8s-helm", "k8s-yaml":
if d.Cluster == nil {
s.AddLog(d.ID, "warn", "Grafana 地址记录跳过:未关联集群", "")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
env, cleanup, err := kubeconfigEnv(d.Cluster)
if err != nil {
return
}
defer cleanup()
ns := d.Namespace
if ns == "" {
ns = "log"
}
ipCmd := exec.CommandContext(ctx, "kubectl", "get", "nodes",
"-o", "jsonpath={.items[0].status.addresses[0].address}")
ipCmd.Env = env
ipOut, _ := ipCmd.CombinedOutput()
portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns,
"-o", "jsonpath={.spec.ports[0].nodePort}")
portCmd.Env = env
portOut, _ := portCmd.CombinedOutput()
ip := strings.TrimSpace(string(ipOut))
port := strings.TrimSpace(string(portOut))
s.AddLog(d.ID, "info", fmt.Sprintf("Grafana 地址探测: node=%s port=%s", ip, port), "")
if ip != "" && port != "" {
endpoint = fmt.Sprintf("http://%s:%s", ip, port)
}
case "host-binary":
if d.Host != nil {
endpoint = fmt.Sprintf("http://%s:3000", d.Host.HostIP)
}
}
if endpoint != "" {
_ = s.Update(d.ID, map[string]interface{}{"grafanaEndpoint": endpoint})
}
}
+438
View File
@@ -0,0 +1,438 @@
package services
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"time"
"opsxloki/models"
)
// GrafanaDeployer 与 Loki 同环境部署 Grafana 并自动配置 Loki 数据源。
// k8s 模式:manifest(Deployment + NodePort Service + 数据源 ConfigMap),与 Loki 同 namespace;
// host-binary 模式:tarball + systemd,与 Loki 同机。
type GrafanaDeployer struct {
deployment *models.LokiDeployment
log func(level, message, detail string)
}
const defaultGrafanaVersion = "11.3.0"
func (d *GrafanaDeployer) version() string {
if d.deployment.GrafanaVersion != "" {
return d.deployment.GrafanaVersion
}
return defaultGrafanaVersion
}
// lokiPushURL 数据源指向的 Loki 地址(k8s 走集群内服务名,主机走本机)
func (d *GrafanaDeployer) lokiPushURL() string {
if d.deployment.Mode == "host-binary" {
return "http://127.0.0.1:3100"
}
ns := d.deployment.Namespace
if ns == "" {
ns = "log"
}
return fmt.Sprintf("http://loki.%s.svc.cluster.local:3100", ns)
}
// Deploy 部署入口:按模式分发
func (d *GrafanaDeployer) Deploy(ctx context.Context) error {
d.log("info", fmt.Sprintf("Deploying Grafana %s (mode: %s)...", d.version(), d.deployment.Mode), "")
switch d.deployment.Mode {
case "k8s-helm", "k8s-yaml":
return d.deployK8s(ctx)
case "host-binary":
return d.deployHost(ctx)
default:
return fmt.Errorf("unsupported mode for grafana: %s", d.deployment.Mode)
}
}
// ==================== K8s manifest 部署 ====================
// grafanaManifest 生成 Grafana 资源;lokiURL 显式传入(默认服务名,DNS 异常的集群可回退 ClusterIP)。
// 数据卷用 emptyDir:数据源/配置由 ConfigMap 供给,重启即恢复;避免无默认
// StorageClass 的集群上 PVC Pending 阻塞(UI 内自建的面板需重新导入,见已知限制)。
func (d *GrafanaDeployer) grafanaManifest(ns, lokiURL string) string {
version := d.version()
datasources := fmt.Sprintf(`apiVersion: 1
datasources:
- name: Loki
type: loki
uid: loki-ds
access: proxy
url: %s
isDefault: true
editable: false`, lokiURL)
return fmt.Sprintf(`apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-datasources
namespace: %s
data:
datasources.yaml: |
%s
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana
namespace: %s
labels:
app: grafana
spec:
replicas: 1
selector:
matchLabels:
app: grafana
template:
metadata:
labels:
app: grafana
spec:
containers:
- name: grafana
image: grafana/grafana:%s
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3000
env:
- name: GF_SECURITY_ADMIN_USER
value: admin
- name: GF_SECURITY_ADMIN_PASSWORD
value: admin
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: datasources
mountPath: /etc/grafana/provisioning/datasources
volumes:
- name: datasources
configMap:
name: grafana-datasources
---
apiVersion: v1
kind: Service
metadata:
name: grafana
namespace: %s
labels:
app: grafana
spec:
# NodePort 便于集群外直接访问(端口由集群自动分配,实际端口见部署日志)
type: NodePort
ports:
- name: http
port: 3000
targetPort: 3000
selector:
app: grafana
`, ns, indentBlock(datasources, " "), ns, version, ns)
}
func (d *GrafanaDeployer) deployK8s(ctx context.Context) error {
if d.deployment.Cluster == nil {
return fmt.Errorf("please select target cluster")
}
env, cleanup, err := kubeconfigEnv(d.deployment.Cluster)
if err != nil {
return err
}
defer cleanup()
ns := d.deployment.Namespace
if ns == "" {
ns = "log"
}
lokiURL := d.lokiPushURL()
manifestFile, manifestCleanup, err := writeTempFile("grafana-*.yaml", d.grafanaManifest(ns, lokiURL), 0600)
if err != nil {
return err
}
defer manifestCleanup()
d.log("info", "Applying Grafana manifest (Deployment + NodePort Service + datasource ConfigMap)...", "")
cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestFile)
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
d.log("error", fmt.Sprintf("kubectl apply grafana failed: %v", err), string(out))
return fmt.Errorf("kubectl apply grafana failed: %w\n%s", err, string(out))
}
waitCmd := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/grafana",
"-n", ns, "--timeout=5m")
waitCmd.Env = env
waitOut, _ := waitCmd.CombinedOutput()
d.log("info", string(waitOut), "")
// DNS 自愈:部分集群 CoreDNS 对 Service 记录异常(如 watch 失效),
// 经 grafana 容器探测 Loki 服务名,不通则回退 ClusterIP 并热更新数据源。
dnsProbe := exec.CommandContext(ctx, "kubectl", "exec", "-n", ns, "deploy/grafana", "--",
"wget", "-q", "-O", "/dev/null", "-T", "5", lokiURL+"/ready")
dnsProbe.Env = env
if probeOut, probeErr := dnsProbe.CombinedOutput(); probeErr != nil {
d.log("warn", fmt.Sprintf("Grafana 无法解析 Loki 服务名(%s),回退 ClusterIP…", strings.TrimSpace(string(probeOut))), "")
ipCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "loki", "-n", ns,
"-o", "jsonpath={.spec.clusterIP}")
ipCmd.Env = env
ipOut, ipErr := ipCmd.CombinedOutput()
clusterIP := strings.TrimSpace(string(ipOut))
if ipErr != nil || clusterIP == "" {
d.log("error", "获取 Loki ClusterIP 失败,数据源保持服务名形式", "")
} else {
fallbackURL := fmt.Sprintf("http://%s:3100", clusterIP)
fbFile, fbCleanup, ferr := writeTempFile("grafana-fb-*.yaml", d.grafanaManifest(ns, fallbackURL), 0600)
if ferr == nil {
fbApply := exec.CommandContext(ctx, "kubectl", "apply", "-f", fbFile)
fbApply.Env = env
fbApply.CombinedOutput()
fbCleanup()
restart := exec.CommandContext(ctx, "kubectl", "rollout", "restart", "deployment/grafana", "-n", ns)
restart.Env = env
restart.CombinedOutput()
wait2 := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/grafana",
"-n", ns, "--timeout=5m")
wait2.Env = env
w2, _ := wait2.CombinedOutput()
d.log("info", string(w2), "")
d.log("info", fmt.Sprintf("数据源已回退为 ClusterIP: %s", fallbackURL), "")
}
}
}
// 回读实际分配的 NodePort(固定端口易与集群已有服务冲突)
portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns,
"-o", "jsonpath={.spec.ports[0].nodePort}")
portCmd.Env = env
portOut, _ := portCmd.CombinedOutput()
nodePort := strings.TrimSpace(string(portOut))
access := fmt.Sprintf("http://<节点IP>:%s", nodePort)
if nodePort == "" {
access = "kubectl get svc grafana -n " + ns + " 查看访问端口"
}
d.log("success", fmt.Sprintf("Grafana deployed. 访问: %s (NodePort, 账号 admin/admin), 数据源已指向 %s", access, d.lokiPushURL()), "")
// 自动导入 Loki 日志面板(打开即可看图;密码被用户修改过则跳过并提示)
if err := d.importDashboard(ctx, env, ns); err != nil {
d.log("warn", fmt.Sprintf("面板自动导入跳过: %v", err), "")
} else {
d.log("info", "已导入「Loki 日志」面板(Explore 可用,默认账号 admin/admin)", "")
}
return nil
}
// importDashboard 通过 Grafana API 导入预置的 Loki 日志面板
func (d *GrafanaDeployer) importDashboard(ctx context.Context, env []string, ns string) error {
payload := grafanaDashboardPayload()
postDashboard := func(baseURL string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimSuffix(baseURL, "/")+"/api/dashboards/db", strings.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:admin")))
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}
switch d.deployment.Mode {
case "k8s-helm", "k8s-yaml":
// 经 NodePort 直连(kube service proxy 的 POST 需额外 RBAC,直连最稳)
ipCmd := exec.CommandContext(ctx, "kubectl", "get", "nodes",
"-o", "jsonpath={.items[0].status.addresses[0].address}")
ipCmd.Env = env
ipOut, _ := ipCmd.CombinedOutput()
portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns,
"-o", "jsonpath={.spec.ports[0].nodePort}")
portCmd.Env = env
portOut, _ := portCmd.CombinedOutput()
ip, port := strings.TrimSpace(string(ipOut)), strings.TrimSpace(string(portOut))
if ip == "" || port == "" {
return fmt.Errorf("获取 NodePort 失败")
}
return postDashboard(fmt.Sprintf("http://%s:%s", ip, port))
case "host-binary":
if d.deployment.Host == nil {
return fmt.Errorf("no host")
}
return postDashboard(fmt.Sprintf("http://%s:3000", d.deployment.Host.HostIP))
}
return fmt.Errorf("unsupported mode")
}
// ==================== 主机 tarball 部署 ====================
func (d *GrafanaDeployer) deployHost(ctx context.Context) error {
host := d.deployment.Host
if host == nil {
return fmt.Errorf("please select target host")
}
client, err := NewSSHClientWithAuth(HostSSHAuth(host))
if err != nil {
return err
}
defer client.Close()
arch, _, _ := client.Execute(ctx, "uname -m")
grafanaArch := "amd64"
if strings.Contains(arch, "aarch64") || strings.Contains(arch, "arm64") {
grafanaArch = "arm64"
}
version := d.version()
d.log("info", fmt.Sprintf("Downloading Grafana %s (%s)...", version, grafanaArch), "")
url := fmt.Sprintf("https://dl.grafana.com/oss/release/grafana-%s.linux-%s.tar.gz", version, grafanaArch)
dl := fmt.Sprintf("curl -fsSL -o /tmp/grafana.tar.gz %s && tar -xzf /tmp/grafana.tar.gz -C /opt/ && rm -rf /opt/grafana && ln -sfn /opt/grafana-v%s /opt/grafana && rm -f /tmp/grafana.tar.gz", url, version)
if out, _, err := client.Execute(ctx, dl); err != nil {
return fmt.Errorf("download grafana failed: %w\n%s", err, out)
}
// 数据源 provisioning(指向同机 Loki)
dsYAML := fmt.Sprintf(`apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: %s
isDefault: true
`, d.lokiPushURL())
client.Execute(ctx, "mkdir -p /etc/grafana/provisioning/datasources /var/lib/grafana /var/log/grafana")
writeCmd := fmt.Sprintf("cat > /etc/grafana/provisioning/datasources/loki.yaml <<'EOF'\n%s\nEOF", dsYAML)
if out, _, err := client.Execute(ctx, writeCmd); err != nil {
return fmt.Errorf("write datasource config failed: %w\n%s", err, out)
}
unit := `[Unit]
Description=Grafana
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/grafana
ExecStart=/opt/grafana/bin/grafana server --homepath /opt/grafana --config /opt/grafana/conf/defaults.ini
Restart=always
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target`
writeUnit := fmt.Sprintf("cat > /etc/systemd/system/grafana.service <<'EOF'\n%s\nEOF", unit)
if out, _, err := client.Execute(ctx, writeUnit); err != nil {
return fmt.Errorf("write systemd unit failed: %w\n%s", err, out)
}
if out, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable grafana && systemctl restart grafana"); err != nil {
return fmt.Errorf("start grafana failed: %w\n%s", err, out)
}
// 健康检查(最多 10 次)
for i := 0; i < 10; i++ {
out, _, _ := client.Execute(ctx, "curl -fsSL http://localhost:3000/api/health")
if strings.Contains(out, "\"version\"") {
d.log("success", fmt.Sprintf("Grafana running on %s:3000 (admin/admin), 数据源已指向本机 Loki", host.HostIP), "")
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
d.log("warn", "Grafana 服务已启动但健康检查未通过,请稍后确认 http://<主机IP>:3000", "")
return nil
}
// CleanupGrafana 卸载 Grafana(停止部署时同步调用)
func CleanupGrafana(ctx context.Context, deployment *models.LokiDeployment) {
if deployment == nil {
return
}
switch deployment.Mode {
case "k8s-helm", "k8s-yaml":
if deployment.Cluster == nil {
return
}
env, cleanup, err := kubeconfigEnv(deployment.Cluster)
if err != nil {
return
}
defer cleanup()
ns := deployment.Namespace
if ns == "" {
ns = "log"
}
cmd := exec.CommandContext(ctx, "kubectl", "delete",
"deployment/grafana", "service/grafana", "configmap/grafana-datasources",
"-n", ns, "--ignore-not-found=true")
cmd.Env = env
cmd.CombinedOutput()
case "host-binary":
if deployment.Host == nil {
return
}
client, err := NewSSHClientWithAuth(HostSSHAuth(deployment.Host))
if err != nil {
return
}
defer client.Close()
client.Execute(ctx, "systemctl stop grafana 2>/dev/null; systemctl disable grafana 2>/dev/null; rm -f /etc/systemd/system/grafana.service; systemctl daemon-reload")
}
}
// grafanaDashboardPayload 预置「Loki 日志」面板:日志量(按命名空间)/错误日志/最近日志
func grafanaDashboardPayload() string {
return `{"dashboard":{
"uid":"loki-logs","title":"Loki 日志","tags":["lokideploy"],
"timezone":"browser","schemaVersion":39,"refresh":"30s","version":1,
"time":{"from":"now-1h","to":"now"},
"panels":[
{"id":1,"type":"timeseries","title":"日志量(按命名空间)",
"datasource":{"type":"loki","uid":"loki-ds"},
"gridPos":{"h":8,"w":12,"x":0,"y":0},
"fieldConfig":{"defaults":{"unit":"short"},"overrides":[]},
"targets":[{"refId":"A","expr":"sum by (namespace) (count_over_time({namespace=\".+\"}[5m]))","queryType":"range"}]},
{"id":2,"type":"timeseries","title":"错误日志",
"datasource":{"type":"loki","uid":"loki-ds"},
"gridPos":{"h":8,"w":12,"x":12,"y":0},
"fieldConfig":{"defaults":{"unit":"short","color":{"mode":"palette-classic"}},"overrides":[]},
"targets":[{"refId":"A","expr":"sum(count_over_time({namespace=\".+\"} |~ \"(?i)error\" [5m]))","queryType":"range"}]},
{"id":3,"type":"logs","title":"最近日志",
"datasource":{"type":"loki","uid":"loki-ds"},
"gridPos":{"h":10,"w":24,"x":0,"y":8},
"targets":[{"refId":"A","expr":"{namespace=\".+\"}","queryType":"range"}]}
]
},"overwrite":true}`
}
+10 -18
View File
@@ -3,13 +3,12 @@ package services
import (
"context"
"fmt"
"strings"
"time"
"lokideploy/models"
"lokideploy/pkg/crypto"
"lokideploy/repository"
"lokideploy/utils"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
"opsxloki/utils"
)
type HostService struct{}
@@ -112,6 +111,9 @@ func (s *HostService) Update(id uint, updates map[string]interface{}) error {
if v, ok := updates["status"].(string); ok {
db.Hosts[i].Status = v
}
if v, ok := updates["statusMessage"].(string); ok {
db.Hosts[i].StatusMessage = v
}
if v, ok := updates["os"].(string); ok {
db.Hosts[i].OS = v
}
@@ -148,14 +150,9 @@ func (s *HostService) Validate(id uint) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
password := host.Password
if strings.HasPrefix(password, "ENC:") {
password, _ = crypto.Decrypt(password[4:])
}
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
client, err := NewSSHClientWithAuth(HostSSHAuth(host))
if err != nil {
s.Update(id, map[string]interface{}{"status": "error"})
s.Update(id, map[string]interface{}{"status": "error", "statusMessage": err.Error()})
return
}
defer client.Close()
@@ -175,12 +172,7 @@ func (s *HostService) ExecuteCommand(id uint, command string) (string, string, e
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
password := host.Password
if strings.HasPrefix(password, "ENC:") {
password, _ = crypto.Decrypt(password[4:])
}
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
client, err := NewSSHClientWithAuth(HostSSHAuth(host))
if err != nil {
return "", "", err
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"os"
"regexp"
"lokideploy/models"
"opsxloki/models"
)
// resolveKubeconfig 取集群最终的 kubeconfig 文本(优先内存字段,其次磁盘文件)
+330
View File
@@ -0,0 +1,330 @@
// Package services — LDAP 接入:配置存储(SystemSetting 表 JSON)、连接测试、
// 用户搜索预览、批量导入与登录验证共用同一套连接/搜索代码。
// 设计参照 opsx-web-lite(has_password 脱敏/空密码保留旧值/批量导入冲突策略)。
package services
import (
"encoding/json"
"fmt"
"net"
"strings"
"time"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
"opsxloki/utils"
ldap "github.com/go-ldap/ldap/v3"
)
const ldapSettingKey = "ldap_settings"
// netDialer LDAP 拨号器(5s 超时,参照 aiops 的超时处理,避免连接挂起)
func netDialer() *net.Dialer {
return &net.Dialer{Timeout: 5 * time.Second}
}
// LDAPConfig LDAP 配置(存储形态;BindPassword 以 ENC: 前缀 AES-GCM 加密落库)
type LDAPConfig struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
UseSSL bool `json:"useSSL"`
BindDN string `json:"bindDN"`
BindPassword string `json:"bindPassword"` // 存储为 ENC:密文;对外输出时剔除
BaseDN string `json:"baseDN"`
UserFilter string `json:"userFilter"`
UsernameAttr string `json:"usernameAttr"`
NameAttr string `json:"nameAttr"`
EmailAttr string `json:"emailAttr"`
}
// LDAPPublicConfig 对外输出形态(密码剔除,附 hasPassword)
type LDAPPublicConfig struct {
LDAPConfig
BindPassword string `json:"bindPassword"` // 恒为空
HasPassword bool `json:"hasPassword"`
Configured bool `json:"configured"` // host+bindDN+baseDN 齐备(控制用户页导入按钮显隐)
}
func defaultLDAPConfig() *LDAPConfig {
return &LDAPConfig{
Port: 389,
UserFilter: "(objectClass=inetOrgPerson)",
UsernameAttr: "uid",
NameAttr: "displayName",
EmailAttr: "mail",
}
}
// GetLDAPConfig 读取配置(解密密码);未配置时返回默认值
func GetLDAPConfig() *LDAPConfig {
db := repository.GetDB()
db.RLock()
var raw string
for i := range db.Settings {
if db.Settings[i].Key == ldapSettingKey {
raw = db.Settings[i].Value
break
}
}
db.RUnlock()
cfg := defaultLDAPConfig()
if raw != "" {
_ = json.Unmarshal([]byte(raw), cfg)
}
if cfg.Port == 0 {
cfg.Port = 389
}
if cfg.BindPassword != "" {
if plain, err := DecryptCred(cfg.BindPassword); err == nil {
cfg.BindPassword = plain
}
}
return cfg
}
func GetLDAPPublicConfig() *LDAPPublicConfig {
cfg := GetLDAPConfig()
hasPwd := cfg.BindPassword != ""
return &LDAPPublicConfig{
LDAPConfig: LDAPConfig{
Enabled: cfg.Enabled,
Host: cfg.Host,
Port: cfg.Port,
UseSSL: cfg.UseSSL,
BindDN: cfg.BindDN,
BaseDN: cfg.BaseDN,
UserFilter: cfg.UserFilter,
UsernameAttr: cfg.UsernameAttr,
NameAttr: cfg.NameAttr,
EmailAttr: cfg.EmailAttr,
},
HasPassword: hasPwd,
Configured: cfg.Host != "" && cfg.BindDN != "" && cfg.BaseDN != "",
}
}
// SaveLDAPConfig 保存;bindPassword 为空且已有存量 → 保留旧密文
func SaveLDAPConfig(in *LDAPConfig) error {
old := GetLDAPConfig()
if in.Port == 0 {
in.Port = 389
}
if in.UserFilter == "" {
in.UserFilter = old.UserFilter
}
if in.UsernameAttr == "" {
in.UsernameAttr = "uid"
}
if in.NameAttr == "" {
in.NameAttr = "displayName"
}
if in.EmailAttr == "" {
in.EmailAttr = "mail"
}
if in.BindPassword == "" {
in.BindPassword = old.BindPassword // 已是明文(GetLDAPConfig 解密过)
} else if enc, err := crypto.EncryptField(in.BindPassword); err == nil {
in.BindPassword = enc
}
data, _ := json.Marshal(in)
db := repository.GetDB()
// NextID/CreateUser 均自拿锁,禁止在持有 db.Lock() 时调用(JsonDB 锁不可重入)
newID := db.NextID("settings")
db.Lock()
defer db.Unlock()
for i := range db.Settings {
if db.Settings[i].Key == ldapSettingKey {
db.Settings[i].Value = string(data)
db.RequestSave()
return nil
}
}
db.Settings = append(db.Settings, models.SystemSetting{
ID: newID, Key: ldapSettingKey, Value: string(data),
})
db.RequestSave()
return nil
}
// ==================== 连接与搜索 ====================
// dialLDAP 建立连接(5s 拨号超时,防参照项目无超时挂起的问题)
func dialLDAP(cfg *LDAPConfig) (*ldap.Conn, error) {
scheme := "ldap"
if cfg.UseSSL {
scheme = "ldaps"
}
url := fmt.Sprintf("%s://%s:%d", scheme, cfg.Host, cfg.Port)
conn, err := ldap.DialURL(url, ldap.DialWithDialer(netDialer()))
if err != nil {
return nil, fmt.Errorf("无法连接 LDAP 服务器 %s: %w", url, err)
}
conn.SetTimeout(10 * time.Second)
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
conn.Close()
return nil, fmt.Errorf("管理员绑定失败(检查 Bind DN / 密码): %w", err)
}
return conn, nil
}
// ldapUserEntry LDAP 用户条目(导入预览与登录共用)
type ldapUserEntry struct {
DN string
Username string
DisplayName string
Email string
}
// searchLDAPUsers 按 ouDN(空用 baseDN)WholeSubtree 搜索用户,sizeLimit 上限
func searchLDAPUsers(cfg *LDAPConfig, ouDN string, sizeLimit int) ([]ldapUserEntry, error) {
if ouDN == "" {
ouDN = cfg.BaseDN
}
conn, err := dialLDAP(cfg)
if err != nil {
return nil, err
}
defer conn.Close()
filter := cfg.UserFilter
if !strings.HasPrefix(filter, "(") {
filter = "(" + filter + ")"
}
req := ldap.NewSearchRequest(ouDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
sizeLimit, 0, false, filter,
[]string{"dn", cfg.UsernameAttr, cfg.NameAttr, "cn", cfg.EmailAttr}, nil)
res, err := conn.Search(req)
if err != nil {
return nil, fmt.Errorf("搜索失败(BaseDN/过滤器可能有误): %w", err)
}
users := make([]ldapUserEntry, 0, len(res.Entries))
seen := map[string]bool{}
for _, e := range res.Entries {
u := ldapUserEntry{
DN: e.DN,
Username: e.GetAttributeValue(cfg.UsernameAttr),
DisplayName: e.GetAttributeValue(cfg.NameAttr),
Email: e.GetAttributeValue(cfg.EmailAttr),
}
if u.DisplayName == "" {
u.DisplayName = e.GetAttributeValue("cn")
}
if u.Username == "" || seen[u.DN] {
continue // 无用户名属性的条目(如 ou 本身)跳过
}
seen[u.DN] = true
users = append(users, u)
}
return users, nil
}
// searchLDAPUserByUsername 登录用:按用户名搜索单个条目
func searchLDAPUserByUsername(cfg *LDAPConfig, username string) (*ldapUserEntry, error) {
conn, err := dialLDAP(cfg)
if err != nil {
return nil, err
}
defer conn.Close()
filter := fmt.Sprintf("(&%s(%s=%s))", normalizeFilter(cfg.UserFilter),
cfg.UsernameAttr, ldap.EscapeFilter(username))
req := ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
2, 0, false, filter,
[]string{"dn", cfg.UsernameAttr, cfg.NameAttr, "cn", cfg.EmailAttr}, nil)
res, err := conn.Search(req)
if err != nil {
return nil, err
}
if len(res.Entries) == 0 {
return nil, utils.ErrInvalidCredentials
}
if len(res.Entries) > 1 {
return nil, fmt.Errorf("LDAP 中存在多个匹配 %s 的用户", username)
}
e := res.Entries[0]
return &ldapUserEntry{
DN: e.DN,
Username: e.GetAttributeValue(cfg.UsernameAttr),
DisplayName: e.GetAttributeValue(cfg.NameAttr),
Email: e.GetAttributeValue(cfg.EmailAttr),
}, nil
}
// verifyLDAPPassword 用户级验证:bind 到用户 DN 校验密码
func verifyLDAPPassword(cfg *LDAPConfig, username, password string) (*ldapUserEntry, error) {
entry, err := searchLDAPUserByUsername(cfg, username)
if err != nil {
return nil, err
}
scheme := "ldap"
if cfg.UseSSL {
scheme = "ldaps"
}
conn, err := ldap.DialURL(fmt.Sprintf("%s://%s:%d", scheme, cfg.Host, cfg.Port),
ldap.DialWithDialer(netDialer()))
if err != nil {
return nil, err
}
defer conn.Close()
if err := conn.Bind(entry.DN, password); err != nil {
return nil, utils.ErrInvalidCredentials
}
return entry, nil
}
func normalizeFilter(f string) string {
if !strings.HasPrefix(f, "(") {
return "(" + f + ")"
}
return f
}
// TestLDAPConnection 测试连接:管理 bind + baseDN 校验 + 可选用户级验证
func TestLDAPConnection(cfg *LDAPConfig, testUsername, testPassword string) map[string]interface{} {
if cfg.Host == "" || cfg.BindDN == "" || cfg.BaseDN == "" {
return map[string]interface{}{"success": false, "message": "请先填写服务器地址 / Bind DN / Base DN"}
}
// 表单密码为空回退已存密码
if cfg.BindPassword == "" {
cfg.BindPassword = GetLDAPConfig().BindPassword
}
conn, err := dialLDAP(cfg)
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error()}
}
defer conn.Close()
// baseDN 可读性校验
req := ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases,
1, 0, false, "(objectClass=*)", []string{"dn"}, nil)
if _, err := conn.Search(req); err != nil {
return map[string]interface{}{"success": false, "message": fmt.Sprintf("服务器连通,但 Base DN 搜索失败: %v", err)}
}
result := map[string]interface{}{
"success": true,
"message": fmt.Sprintf("连接成功:服务器可达,管理员绑定与 Base DN 校验通过 (%s)", cfg.BaseDN),
}
if testUsername != "" {
if testPassword == "" {
return map[string]interface{}{"success": false, "message": "请填写测试账号的密码"}
}
entry, err := verifyLDAPPassword(cfg, testUsername, testPassword)
if err != nil {
return map[string]interface{}{"success": false, "message": fmt.Sprintf("服务器连接正常,但用户「%s」验证失败: %v", testUsername, err)}
}
result["message"] = fmt.Sprintf("连接成功,用户「%s」验证通过 (DN: %s)", testUsername, entry.DN)
result["userInfo"] = map[string]string{
"dn": entry.DN, "username": entry.Username,
"displayName": entry.DisplayName, "email": entry.Email,
}
}
return result
}
+108
View File
@@ -0,0 +1,108 @@
package services
import (
"time"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
)
// LDAPImportUser 批量导入请求中的用户条目(数据来自预览接口)
type LDAPImportUser struct {
DN string `json:"dn"`
Username string `json:"username" binding:"required"`
Nickname string `json:"nickname"`
Email string `json:"email"`
}
// LDAPImportResult 导入结果(对齐 opsx-web-lite 契约)
type LDAPImportResult struct {
SuccessCount int `json:"successCount"`
SkippedCount int `json:"skippedCount"`
UpdatedCount int `json:"updatedCount"`
FailedCount int `json:"failedCount"`
FailedUsers []map[string]interface{} `json:"failedUsers"`
}
// BatchImportLDAPUsers 批量导入 LDAP 用户:
// 已存在且 LDAP 用户 → 跳过;本地同名 → 更新昵称并转 LDAP;否则新建(user 角色,密码占位)。
func BatchImportLDAPUsers(users []LDAPImportUser) *LDAPImportResult {
result := &LDAPImportResult{FailedUsers: []map[string]interface{}{}}
db := repository.GetDB()
for _, in := range users {
if in.Username == "" {
continue
}
// 查找本地同名用户(读锁短持有;NextID/CreateUser 不可在持锁时调用)
db.RLock()
idx := -1
for i := range db.Users {
if db.Users[i].Username == in.Username {
idx = i
break
}
}
db.RUnlock()
if idx >= 0 {
db.Lock()
if db.Users[idx].AuthSource == "ldap" {
db.Unlock()
result.SkippedCount++
continue
}
db.Users[idx].AuthSource = "ldap" // 本地同名 → 转 LDAP 并同步昵称
if in.Nickname != "" {
db.Users[idx].Nickname = in.Nickname
}
db.Users[idx].UpdatedAt = time.Now()
db.RequestSave()
db.Unlock()
result.UpdatedCount++
continue
}
hash, err := crypto.HashPassword("ldap-user-" + in.Username + "-no-local-login")
if err != nil {
result.FailedCount++
result.FailedUsers = append(result.FailedUsers, map[string]interface{}{
"username": in.Username, "error": err.Error(),
})
continue
}
u := &models.User{
Username: in.Username,
Password: hash,
Nickname: in.Nickname,
Role: "user",
AuthSource: "ldap",
}
if err := db.CreateUser(u); err != nil {
result.FailedCount++
result.FailedUsers = append(result.FailedUsers, map[string]interface{}{
"username": in.Username, "error": err.Error(),
})
continue
}
result.SuccessCount++
}
return result
}
// SearchLDAPUsersForImport 预览入口(服务层薄封装,便于 controller 调用)
func SearchLDAPUsersForImport(cfg *LDAPConfig, ouDN string) ([]map[string]interface{}, error) {
users, err := searchLDAPUsers(cfg, ouDN, 500)
if err != nil {
return nil, err
}
out := make([]map[string]interface{}, 0, len(users))
for _, u := range users {
out = append(out, map[string]interface{}{
"dn": u.DN, "username": u.Username,
"displayName": u.DisplayName, "email": u.Email,
})
}
return out, nil
}
+173
View File
@@ -0,0 +1,173 @@
package services
import (
"fmt"
"strings"
"time"
"opsxloki/models"
"opsxloki/repository"
)
func ListLogPolicyGroups() []models.LogPolicyGroup {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
groups := make([]models.LogPolicyGroup, len(db.LogPolicyGroups))
for i, group := range db.LogPolicyGroups {
groups[i] = group
groups[i].UserIDs = append([]uint(nil), group.UserIDs...)
groups[i].Labels = make(map[string]string, len(group.Labels))
for key, value := range group.Labels { groups[i].Labels[key] = value }
}
return groups
}
func validatePolicyGroup(db *repository.JsonDB, group models.LogPolicyGroup, excludeID uint) error {
group.Name = strings.TrimSpace(group.Name)
if group.Name == "" {
return fmt.Errorf("策略组名称必填")
}
if group.SourceID == 0 {
return fmt.Errorf("请选择数据源")
}
foundSource := false
for _, source := range db.LogSources {
if source.ID == group.SourceID {
foundSource = true
break
}
}
if !foundSource {
return fmt.Errorf("数据源不存在")
}
if len(group.UserIDs) == 0 {
return fmt.Errorf("请选择至少一个用户")
}
for key := range group.Labels {
if !ValidLogLabelName(key) {
return fmt.Errorf("无效标签名: %s", key)
}
}
seen := map[uint]bool{}
for _, id := range group.UserIDs {
if id == 0 || seen[id] {
return fmt.Errorf("用户 ID 无效或重复")
}
seen[id] = true
found := false
for _, user := range db.Users {
if user.ID == id && user.Role != "admin" {
found = true
break
}
}
if !found {
return fmt.Errorf("用户 %d 不存在或不是普通用户", id)
}
}
for _, other := range db.LogPolicyGroups {
if other.ID == excludeID || other.SourceID != group.SourceID {
continue
}
if other.Name == group.Name {
return fmt.Errorf("此数据源已有同名策略组")
}
for _, id := range other.UserIDs {
if seen[id] {
return fmt.Errorf("用户 %d 已属于此数据源的另一个策略组", id)
}
}
}
return nil
}
// Group membership replaces legacy per-user grants for the same source.
func removeDirectGrants(db *repository.JsonDB, sourceID uint, userIDs []uint) {
members := map[uint]bool{}
for _, id := range userIDs {
members[id] = true
}
for i := range db.LogSources {
if db.LogSources[i].ID != sourceID {
continue
}
kept := db.LogSources[i].Access[:0]
for _, rule := range db.LogSources[i].Access {
if !members[rule.UserID] {
kept = append(kept, rule)
}
}
db.LogSources[i].Access = kept
return
}
}
func CreateLogPolicyGroup(group models.LogPolicyGroup) (*models.LogPolicyGroup, error) {
db := repository.GetDB()
id := db.NextID("logPolicyGroups")
db.Lock()
defer db.Unlock()
if err := validatePolicyGroup(db, group, 0); err != nil {
return nil, err
}
group.ID = id
group.Name = strings.TrimSpace(group.Name)
group.CreatedAt = time.Now()
group.UpdatedAt = group.CreatedAt
removeDirectGrants(db, group.SourceID, group.UserIDs)
db.LogPolicyGroups = append(db.LogPolicyGroups, group)
db.RequestSave()
return &group, nil
}
func UpdateLogPolicyGroup(id uint, group models.LogPolicyGroup) (*models.LogPolicyGroup, error) {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.LogPolicyGroups {
if db.LogPolicyGroups[i].ID != id {
continue
}
if err := validatePolicyGroup(db, group, id); err != nil {
return nil, err
}
group.ID = id
group.Name = strings.TrimSpace(group.Name)
group.CreatedAt = db.LogPolicyGroups[i].CreatedAt
group.UpdatedAt = time.Now()
removeDirectGrants(db, group.SourceID, group.UserIDs)
db.LogPolicyGroups[i] = group
db.RequestSave()
return &group, nil
}
return nil, fmt.Errorf("策略组不存在")
}
func DeleteLogPolicyGroup(id uint) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.LogPolicyGroups {
if db.LogPolicyGroups[i].ID == id {
db.LogPolicyGroups = append(db.LogPolicyGroups[:i], db.LogPolicyGroups[i+1:]...)
db.RequestSave()
return nil
}
}
return fmt.Errorf("策略组不存在")
}
func policyGroupAccess(groups []models.LogPolicyGroup, sourceID, userID uint) *models.LogSourceAccess {
for _, group := range groups {
if group.SourceID != sourceID {
continue
}
for _, id := range group.UserIDs {
if id == userID {
return &models.LogSourceAccess{UserID: userID, Labels: group.Labels}
}
}
}
return nil
}
+780
View File
@@ -0,0 +1,780 @@
package services
// 日志查询代理 — 后端代表前端访问 Loki HTTP API。
// cluster-svc 型经 kube-apiserver service proxy(不依赖 ClusterIP 直通与集群 DNS);
// url 型直连。契约参照 aiops 日志中心(query/labels/values/histogram + cursor 翻页)。
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
"time"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
)
const maxLogQueryLimit = 1000
// ==================== 数据源 CRUD ====================
func GetLogSource(id uint) *models.LogSource {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.LogSources {
if db.LogSources[i].ID == id {
s := db.LogSources[i]
return &s
}
}
return nil
}
func ListLogSources() []map[string]interface{} {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
out := make([]map[string]interface{}, 0, len(db.LogSources))
for i := range db.LogSources {
out = append(out, db.LogSources[i].Sanitized())
}
return out
}
func ListVisibleLogSources(userID uint, admin bool) []map[string]interface{} {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
out := make([]map[string]interface{}, 0)
for i := range db.LogSources {
s := &db.LogSources[i]
if admin || policyGroupAccess(db.LogPolicyGroups, s.ID, userID) != nil || sourceAccess(s, userID) != nil {
item := s.Sanitized()
if !admin {
item = map[string]interface{}{"id": s.ID, "name": s.Name, "isDefault": s.IsDefault}
}
out = append(out, item)
}
}
return out
}
func sourceAccess(src *models.LogSource, userID uint) *models.LogSourceAccess {
for i := range src.Access {
if src.Access[i].UserID == userID {
return &src.Access[i]
}
}
return nil
}
var labelNamePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
func ValidLogLabelName(name string) bool { return labelNamePattern.MatchString(name) }
// AuthorizedLogQuery constrains the Loki stream selector server-side. Non-admins must be explicitly granted access.
func AuthorizedLogQuery(src *models.LogSource, userID uint, admin bool, query string) (string, error) {
if admin {
return query, nil
}
db := repository.GetDB()
var access *models.LogSourceAccess
if db != nil {
db.RLock()
access = policyGroupAccess(db.LogPolicyGroups, src.ID, userID)
db.RUnlock()
}
if access == nil {
access = sourceAccess(src, userID)
}
if access == nil {
return "", fmt.Errorf("无权查询此数据源")
}
q := strings.TrimSpace(query)
if !strings.HasPrefix(q, "{") {
return "", fmt.Errorf("普通用户查询必须以日志流选择器开头")
}
close := strings.IndexByte(q, '}')
if close < 0 || strings.Contains(q[close+1:], "{") {
return "", fmt.Errorf("查询只能包含一个日志流选择器")
}
if len(access.Labels) == 0 {
return q, nil
}
keys := make([]string, 0, len(access.Labels))
for k := range access.Labels {
if !labelNamePattern.MatchString(k) {
return "", fmt.Errorf("访问策略包含无效标签名")
}
keys = append(keys, k)
}
sort.Strings(keys)
matchers := make([]string, 0, len(keys))
for _, k := range keys {
v, _ := json.Marshal(access.Labels[k])
matchers = append(matchers, k+"="+string(v))
}
inner := strings.TrimSpace(q[1:close])
if inner != "" {
inner += ","
}
return "{" + inner + strings.Join(matchers, ",") + "}" + q[close+1:], nil
}
func CreateLogSource(s *models.LogSource) (*models.LogSource, error) {
if err := encryptSourceCredentials(s); err != nil {
return nil, err
}
db := repository.GetDB()
newID := db.NextID("logSources")
db.Lock()
defer db.Unlock()
s.ID = newID
if s.IsDefault {
for i := range db.LogSources {
db.LogSources[i].IsDefault = false
}
}
// 首个数据源自动设默认
if len(db.LogSources) == 0 {
s.IsDefault = true
}
db.LogSources = append(db.LogSources, *s)
db.RequestSave()
return s, nil
}
func encryptSourceCredentials(s *models.LogSource) error {
if s.BasicPass != "" {
v, err := crypto.EncryptField(s.BasicPass)
if err != nil {
return err
}
s.BasicPass = v
}
if s.BearerToken != "" {
v, err := crypto.EncryptField(s.BearerToken)
if err != nil {
return err
}
s.BearerToken = v
}
for name, value := range s.Headers {
if !validSourceHeader(name) {
return fmt.Errorf("自定义 Header 名称无效: %s", name)
}
v, err := crypto.EncryptField(value)
if err != nil {
return err
}
s.Headers[name] = v
}
return nil
}
func validSourceHeader(name string) bool {
if name == "" || strings.EqualFold(name, "Host") || strings.EqualFold(name, "Authorization") {
return false
}
for _, c := range name {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
return false
}
}
return true
}
func UpdateLogSource(id uint, patch map[string]interface{}) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.LogSources {
if db.LogSources[i].ID != id {
continue
}
if v, ok := patch["name"].(string); ok && v != "" {
db.LogSources[i].Name = v
}
if v, ok := patch["namespace"].(string); ok {
db.LogSources[i].Namespace = v
}
if v, ok := patch["service"].(string); ok {
db.LogSources[i].Service = v
}
if v, ok := patch["port"].(float64); ok {
db.LogSources[i].Port = int(v)
}
if v, ok := patch["url"].(string); ok {
db.LogSources[i].URL = v
}
if v, ok := patch["authType"].(string); ok {
db.LogSources[i].AuthType = v
}
if v, ok := patch["basicUser"].(string); ok {
db.LogSources[i].BasicUser = v
}
if v, ok := patch["bearerToken"].(string); ok {
if v != "" {
enc, err := crypto.EncryptField(v)
if err != nil {
return err
}
db.LogSources[i].BearerToken = enc
}
}
if v, ok := patch["skipTLSVerify"].(bool); ok {
db.LogSources[i].SkipTLSVerify = v
}
if raw, ok := patch["headers"]; ok {
data, err := json.Marshal(raw)
if err != nil {
return err
}
var headers map[string]string
if err := json.Unmarshal(data, &headers); err != nil {
return err
}
for name, value := range headers {
if !validSourceHeader(name) {
return fmt.Errorf("自定义 Header 名称无效: %s", name)
}
if value == "" {
if old, ok := db.LogSources[i].Headers[name]; ok {
headers[name] = old
}
continue
}
enc, err := crypto.EncryptField(value)
if err != nil {
return err
}
headers[name] = enc
}
db.LogSources[i].Headers = headers
}
if v, ok := patch["basicPass"].(string); ok && v != "" {
if enc, err := crypto.EncryptField(v); err == nil {
db.LogSources[i].BasicPass = enc
}
}
if v, ok := patch["isDefault"].(bool); ok && v {
for j := range db.LogSources {
db.LogSources[j].IsDefault = db.LogSources[j].ID == id
}
}
if raw, ok := patch["access"]; ok {
data, err := json.Marshal(raw)
if err != nil {
return err
}
var access []models.LogSourceAccess
if err := json.Unmarshal(data, &access); err != nil {
return err
}
if err := ValidateSourceAccess(access); err != nil {
return err
}
db.LogSources[i].Access = access
}
db.LogSources[i].UpdatedAt = time.Now()
db.RequestSave()
return nil
}
return fmt.Errorf("数据源不存在")
}
func ValidateSourceAccess(access []models.LogSourceAccess) error {
seen := map[uint]bool{}
for _, rule := range access {
if rule.UserID == 0 || seen[rule.UserID] {
return fmt.Errorf("访问策略的用户 ID 无效或重复")
}
seen[rule.UserID] = true
for k := range rule.Labels {
if !labelNamePattern.MatchString(k) {
return fmt.Errorf("访问策略包含无效标签名: %s", k)
}
}
}
return nil
}
func DeleteLogSource(id uint) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.LogSources {
if db.LogSources[i].ID == id {
db.LogSources = append(db.LogSources[:i], db.LogSources[i+1:]...)
groups := db.LogPolicyGroups[:0]
for _, group := range db.LogPolicyGroups {
if group.SourceID != id {
groups = append(groups, group)
}
}
db.LogPolicyGroups = groups
db.RequestSave()
return nil
}
}
return fmt.Errorf("数据源不存在")
}
// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host-binary→url;k8s→cluster-svc)
func EnsureLogSourceForDeployment(d *models.LokiDeployment) {
if d == nil || d.Endpoint == "" {
return
}
db := repository.GetDB()
db.RLock()
for i := range db.LogSources {
if db.LogSources[i].Name == d.Name {
db.RUnlock()
return
}
}
db.RUnlock()
svc := "loki"
port := 3100
src := &models.LogSource{
Name: d.Name, TargetType: "cluster-svc",
ClusterID: d.ClusterID, Namespace: d.Namespace, Service: svc, Port: port,
AuthType: "none",
}
if d.Mode == "host-binary" {
src.TargetType = "url"
src.URL = d.Endpoint
src.ClusterID = nil
}
_, _ = CreateLogSource(src)
}
// ==================== Loki HTTP 访问 ====================
// lokiProxyGET 对数据源执行 GET;cluster-svc 走 kubectl --raw service proxy。
func lokiProxyGET(ctx context.Context, src *models.LogSource, path string, query url.Values) ([]byte, int, error) {
// cluster-svc:kubectl get --raw /api/v1/namespaces/{ns}/services/{svc}:{port}/proxy{path}?q
if src.TargetType == "cluster-svc" {
db := repository.GetDB()
db.RLock()
var cluster *models.Cluster
for i := range db.Clusters {
if src.ClusterID != nil && db.Clusters[i].ID == *src.ClusterID {
c := db.Clusters[i]
cluster = &c
break
}
}
db.RUnlock()
if cluster == nil {
return nil, 500, fmt.Errorf("数据源关联的集群不存在")
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
return nil, 500, err
}
defer cleanup()
proxyPath := fmt.Sprintf("/api/v1/namespaces/%s/services/%s:%d/proxy%s",
src.Namespace, src.Service, src.Port, path)
full := proxyPath
if qs := query.Encode(); qs != "" {
full += "?" + qs
}
cmd := exec.CommandContext(ctx, "kubectl", "get", "--raw", full)
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return nil, 502, fmt.Errorf("kubectl 代理查询失败: %v\n%s", err, truncateStr(string(out), 300))
}
return out, 200, nil
}
// url 直连
if src.URL == "" {
return nil, 500, fmt.Errorf("数据源未配置访问地址")
}
full := strings.TrimSuffix(src.URL, "/") + path
if qs := query.Encode(); qs != "" {
full += "?" + qs
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return nil, 500, err
}
applyLokiAuth(req, src)
client := &http.Client{Timeout: 60 * time.Second, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: src.SkipTLSVerify},
}}
resp, err := client.Do(req)
if err != nil {
return nil, 502, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, 502, err
}
if resp.StatusCode >= 400 {
return body, resp.StatusCode, fmt.Errorf("Loki 返回 HTTP %d: %s", resp.StatusCode, truncateStr(string(body), 300))
}
return body, resp.StatusCode, nil
}
func applyLokiAuth(req *http.Request, src *models.LogSource) {
switch src.AuthType {
case "basic":
if pass, err := DecryptCred(src.BasicPass); err == nil {
req.Header.Set("Authorization", "Basic "+
base64.StdEncoding.EncodeToString([]byte(src.BasicUser+":"+pass)))
}
case "bearer":
if token, err := DecryptCred(src.BearerToken); err == nil {
req.Header.Set("Authorization", "Bearer "+token)
}
}
for name, encrypted := range src.Headers {
if validSourceHeader(name) {
if value, err := DecryptCred(encrypted); err == nil {
req.Header.Set(name, value)
}
}
}
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// ==================== 查询实现(契约对齐 aiops) ====================
type LogEntryRow struct {
Timestamp string `json:"timestamp"`
TimestampNs string `json:"timestampNs"`
Level string `json:"level"`
Message string `json:"message"`
Labels map[string]string `json:"labels"`
}
type LogQueryResult struct {
Entries []LogEntryRow `json:"entries"`
HasMore bool `json:"hasMore"`
NextCursor string `json:"nextCursor"`
}
// QueryLokiLogs 日志查询:flatten streams → 统一行;cursor(最后一条纳秒时间戳)向后翻页
func QueryLokiLogs(ctx context.Context, src *models.LogSource, query string, start, end int64, limit int, direction, cursor string) (*LogQueryResult, error) {
if limit <= 0 {
limit = 200
}
if limit > maxLogQueryLimit {
limit = maxLogQueryLimit
}
if direction == "" {
direction = "backward"
}
if end <= 0 {
end = time.Now().UnixNano()
}
if start <= 0 {
start = end - int64(time.Hour) // 默认最近 1 小时
}
if cursor != "" {
if c, err := strconv.ParseInt(cursor, 10, 64); err == nil {
if direction == "forward" {
start = c + 1
} else {
end = c - 1
}
}
}
q := url.Values{}
q.Set("query", query)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
q.Set("limit", strconv.Itoa(limit))
q.Set("direction", direction)
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query_range", q)
if err != nil {
return nil, err
}
var resp struct {
Status string `json:"status"`
Data struct {
ResultType string `json:"resultType"`
Result []struct {
Stream map[string]string `json:"stream"`
Values [][2]string `json:"values"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("解析 Loki 响应失败: %w", err)
}
entries := make([]LogEntryRow, 0, limit)
var lastTs string
for _, stream := range resp.Data.Result {
for _, v := range stream.Values {
entries = append(entries, LogEntryRow{
Timestamp: formatLokiTimestamp(v[0]),
TimestampNs: v[0],
Level: detectLevel(stream.Stream, v[1]),
Message: v[1],
Labels: stream.Stream,
})
}
}
sort.SliceStable(entries, func(i, j int) bool {
a, _ := strconv.ParseInt(entries[i].TimestampNs, 10, 64)
b, _ := strconv.ParseInt(entries[j].TimestampNs, 10, 64)
if direction == "forward" {
return a < b
}
return a > b
})
if len(entries) > limit {
entries = entries[:limit]
}
if len(entries) > 0 {
lastTs = entries[len(entries)-1].TimestampNs
}
nextCursor := ""
if len(entries) >= limit && lastTs != "" {
if ts, err := strconv.ParseInt(strings.Split(lastTs, ".")[0], 10, 64); err == nil {
nextCursor = strconv.FormatInt(ts, 10)
}
}
return &LogQueryResult{Entries: entries, HasMore: nextCursor != "", NextCursor: nextCursor}, nil
}
// formatLokiTimestamp Loki 时间串(纳秒字符串/科学计数法)→ RFC3339
func formatLokiTimestamp(ts string) string {
if i := strings.IndexAny(ts, ".eE"); i > 0 {
ts = ts[:i]
}
n, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return ts
}
// 自适应纳秒/微秒/毫秒/秒
switch {
case n > 1e18:
return time.Unix(0, n).Format(time.RFC3339)
case n > 1e15:
return time.Unix(0, n*1e3).Format(time.RFC3339)
case n > 1e12:
return time.Unix(0, n*1e6).Format(time.RFC3339)
case n > 1e9:
return time.Unix(0, n*1e9).Format(time.RFC3339)
default:
return time.Unix(n, 0).Format(time.RFC3339)
}
}
// detectLevel 优先取 level 标签;否则按行内容推断
func detectLevel(labels map[string]string, line string) string {
if lv, ok := labels["level"]; ok && lv != "" {
return strings.ToLower(lv)
}
l := strings.ToLower(line)
for _, p := range []string{"error", "err", "fatal", "panic"} {
if strings.Contains(l, p) {
return "error"
}
}
if strings.Contains(l, "warn") {
return "warn"
}
return "info"
}
// LokiLabels 标签名列表
func LokiLabels(ctx context.Context, src *models.LogSource, start, end int64) ([]string, error) {
q := url.Values{}
if end > 0 {
q.Set("end", strconv.FormatInt(end, 10))
}
if start > 0 {
q.Set("start", strconv.FormatInt(start, 10))
}
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/labels", q)
if err != nil {
return nil, err
}
var resp struct {
Data []string `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
return resp.Data, nil
}
// LokiLabelValues 标签值列表(query 只取 {...} selector 部分)
func LokiLabelValues(ctx context.Context, src *models.LogSource, name, selector string, start, end int64) ([]string, error) {
q := url.Values{}
if s := extractStreamSelector(selector); s != "" && s != "{}" {
q.Set("query", s)
}
if end > 0 {
q.Set("end", strconv.FormatInt(end, 10))
}
if start > 0 {
q.Set("start", strconv.FormatInt(start, 10))
}
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/label/"+url.PathEscape(name)+"/values", q)
if err != nil {
return nil, err
}
var resp struct {
Data []string `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
return resp.Data, nil
}
// extractStreamSelector 从 LogQL 中提取 {...} 部分(labels API 仅接受 stream selector)
func extractStreamSelector(query string) string {
i := strings.Index(query, "{")
if i < 0 {
return ""
}
j := strings.Index(query[i:], "}")
if j < 0 {
return ""
}
return query[i : i+j+1]
}
// LokiHistogram 日志量直方图:服务端包 sum(count_over_time(q[step]))
func LokiHistogram(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]map[string]interface{}, error) {
if end <= 0 {
end = time.Now().UnixNano()
}
if start <= 0 {
start = end - int64(time.Hour)
}
span := end - start
step := pickHistogramStep(span)
wrapped := fmt.Sprintf("sum(count_over_time(%s[%s]))", query, step)
q := url.Values{}
q.Set("query", wrapped)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
q.Set("step", step)
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query_range", q)
if err != nil {
return nil, err
}
var resp struct {
Data struct {
Result []struct {
Values [][2]interface{} `json:"values"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
buckets := []map[string]interface{}{}
if len(resp.Data.Result) > 0 {
for _, v := range resp.Data.Result[0].Values {
// metric API 的 ts 为秒级浮点数(如 1.79e+09),query 日志的才是纳秒字符串
tsf, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[0]), 64)
cnt, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[1]), 64)
buckets = append(buckets, map[string]interface{}{
"timestamp": time.Unix(int64(tsf), 0).Format(time.RFC3339),
"count": int64(cnt),
})
}
}
return buckets, nil
}
// pickHistogramStep 按时间跨度自适应步长(aiops 同款)
func pickHistogramStep(spanNano int64) string {
span := time.Duration(spanNano)
switch {
case span <= 15*time.Minute:
return "30s"
case span <= time.Hour:
return "1m"
case span <= 6*time.Hour:
return "5m"
case span <= 24*time.Hour:
return "15m"
case span <= 7*24*time.Hour:
return "1h"
default:
return "6h"
}
}
// DeploymentReady 部署级就绪探测:按部署名匹配自动注册的数据源,经其访问 Loki /ready
func DeploymentReady(ctx context.Context, deploymentName string) (bool, string) {
if deploymentName == "" {
return false, ""
}
db := repository.GetDB()
db.RLock()
var src *models.LogSource
for i := range db.LogSources {
if db.LogSources[i].Name == deploymentName {
s := db.LogSources[i]
src = &s
break
}
}
db.RUnlock()
if src == nil {
return false, "未注册数据源"
}
if LokiReady(ctx, src) {
return true, ""
}
return false, "Loki 未就绪"
}
// LokiReady Loki /ready 探活(部署卡片/查询页状态点)
func LokiReady(ctx context.Context, src *models.LogSource) bool {
body, code, err := lokiProxyGET(ctx, src, "/ready", nil)
if err != nil || code != 200 {
return false
}
return strings.Contains(strings.ToLower(string(body)), "ready")
}
// TestLogSource 数据源连通性(/loki/api/v1/labels 探活)
func TestLogSource(ctx context.Context, src *models.LogSource) map[string]interface{} {
labels, err := LokiLabels(ctx, src, 0, 0)
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error()}
}
return map[string]interface{}{
"success": true,
"message": fmt.Sprintf("连接成功,发现 %d 个标签", len(labels)),
"labels": labels,
}
}
+42
View File
@@ -0,0 +1,42 @@
package services
import (
"opsxloki/models"
"strings"
"testing"
)
func TestAuthorizedLogQuery(t *testing.T) {
src := &models.LogSource{Access: []models.LogSourceAccess{{UserID: 7, Labels: map[string]string{"namespace": "prod", "pod": "loki-0"}}}}
if _, err := AuthorizedLogQuery(src, 8, false, `{app="api"}`); err == nil {
t.Fatal("ungranted user queried source")
}
q, err := AuthorizedLogQuery(src, 7, false, `{app="api"} |= "error"`)
if err != nil {
t.Fatal(err)
}
for _, part := range []string{`app="api"`, `namespace="prod"`, `pod="loki-0"`, `|= "error"`} {
if !strings.Contains(q, part) {
t.Fatalf("missing %q in %q", part, q)
}
}
if _, err := AuthorizedLogQuery(src, 7, false, `sum(count_over_time({app="api"}[1m]))`); err == nil {
t.Fatal("metric query bypassed stream restriction")
}
if _, err := AuthorizedLogQuery(src, 7, false, `{app="api"} or {app="other"}`); err == nil {
t.Fatal("second selector bypassed stream restriction")
}
}
func TestPolicyGroupAccess(t *testing.T) {
groups := []models.LogPolicyGroup{{SourceID: 2, Labels: map[string]string{"namespace": "log", "app": "api"}, UserIDs: []uint{7, 8}}}
if got := policyGroupAccess(groups, 2, 8); got == nil || got.Labels["app"] != "api" {
t.Fatal("group member should inherit exact label scope")
}
if got := policyGroupAccess(groups, 3, 8); got != nil {
t.Fatal("group must not grant access to another source")
}
if got := policyGroupAccess(groups, 2, 9); got != nil {
t.Fatal("nonmember gained access")
}
}
+93 -38
View File
@@ -10,8 +10,8 @@ import (
"strings"
"text/template"
"lokideploy/models"
"lokideploy/pkg/crypto"
"opsxloki/models"
"opsxloki/pkg/crypto"
)
// LokiDeployer 负责 Loki 的三种部署模式:k8s-helm / k8s-yaml / host-binary
@@ -34,16 +34,30 @@ func (d *LokiDeployer) Deploy(ctx context.Context) error {
d.log("warn", err.Error(), "")
}
var err error
switch d.deployment.Mode {
case "k8s-helm":
return d.deployHelm(ctx)
err = d.deployHelm(ctx)
case "k8s-yaml":
return d.deployYaml(ctx)
err = d.deployYaml(ctx)
case "host-binary":
return d.deployBinary(ctx)
err = d.deployBinary(ctx)
default:
return fmt.Errorf("unsupported deployment mode: %s", d.deployment.Mode)
}
if err != nil {
return err
}
// Loki 就绪后,按需同环境部署 Grafana(数据源自动指向本 Loki)
if d.deployment.GrafanaEnabled {
g := &GrafanaDeployer{deployment: d.deployment, log: d.log}
if gerr := g.Deploy(ctx); gerr != nil {
d.log("error", fmt.Sprintf("Grafana deployment failed: %v", gerr), "")
return fmt.Errorf("grafana deployment failed: %w", gerr)
}
}
return nil
}
// ==================== Helm 部署 (grafana/loki v6+) ====================
@@ -169,12 +183,7 @@ func (d *LokiDeployer) deployBinary(ctx context.Context) error {
return fmt.Errorf("please select target host")
}
host := d.deployment.Host
password := host.Password
if strings.HasPrefix(password, "ENC:") {
password, _ = DecryptCred(password)
}
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
client, err := NewSSHClientWithAuth(HostSSHAuth(host))
if err != nil {
return err
}
@@ -696,7 +705,6 @@ common:
filesystem:
chunks_directory: /var/lib/loki/chunks
rules_directory: /var/lib/loki/rules
admin_api_directory: /var/lib/loki/admin
replication_factor: {{ .ReplicationFactor }}
ring:
instance_addr: {{ .RingInstanceAddr }}
@@ -730,20 +738,11 @@ ingester:
chunk_target_size: 1048576
chunk_encoding: snappy
max_chunk_age: 2h
max_transfer_retries: 0
autoforget_unhealthy: true
querier:
max_concurrent: 10
query_ingesters_within: 2h
engine:
timeout: 3m
max_look_back_period: 30s
query_frontend:
max_outstanding_per_tenant: 4096
compress_responses: true
scheduler_address: ""
query_scheduler:
max_outstanding_requests_per_tenant: 4096
@@ -786,6 +785,9 @@ schema_config:
ruler:
storage:
type: {{ .RulerStoreType }}
{{- if .RulerStorageBlock }}
{{ .RulerStorageBlock }}
{{- end }}
{{- if eq .RulerStoreType "local" }}
local:
directory: /var/lib/loki/rules
@@ -802,12 +804,7 @@ frontend:
max_outstanding_per_tenant: 4096
compress_responses: true
frontend_worker:
parallelism: 10
table_manager:
retention_deletes_enabled: true
retention_period: {{ .RetentionPeriod }}h
frontend_worker: {}
chunk_store_config:
chunk_cache_config:
@@ -831,6 +828,7 @@ type lokiConfigData struct {
RingInstanceAddr string // 单副本 127.0.0.1;多副本 0.0.0.0(须可被对端寻址)
RingKVStore string // 单副本 inmemory;多副本 memberlist
MemberlistBlock string // 多副本时非空,注入 memberlist 顶级配置
RulerStorageBlock string // 对象存储时非空,注入 ruler.storage.s3/gcs/azure 嵌套配置
}
func (d *LokiDeployer) generateLokiConfig() string {
@@ -870,7 +868,7 @@ func (d *LokiDeployer) generateLokiConfig() string {
ringStore = "memberlist"
ns := d.deployment.Namespace
if ns == "" {
ns = "loki"
ns = "log"
}
memberlistBlock = fmt.Sprintf(`memberlist:
join_members:
@@ -892,6 +890,7 @@ func (d *LokiDeployer) generateLokiConfig() string {
RingInstanceAddr: ringAddr,
RingKVStore: ringStore,
MemberlistBlock: memberlistBlock,
RulerStorageBlock: d.buildRulerStorageBlock(),
}
tmpl, err := template.New("loki-config").Parse(lokiConfigTemplate)
@@ -962,6 +961,63 @@ func (d *LokiDeployer) buildStorageConfigBlock() string {
}
}
// buildRulerStorageBlock 生成 ruler.storage 下与存储类型同名的嵌套配置
// (Loki 3.x 的 ruler 存储客户端不回退读取 storage_config,缺少此块会以空 bucket 启动失败)
func (d *LokiDeployer) buildRulerStorageBlock() string {
if d.deployment.StorageConfig == nil {
return ""
}
sc := d.deployment.StorageConfig
switch sc.StorageType {
case "s3":
endpoint := sc.Endpoint
if endpoint == "" {
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region)
}
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` s3:
s3: s3://%s/%s
endpoint: %s
region: %s
access_key_id: %s
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, endpoint, sc.Region, sc.AccessKey, sc.SecretKey, insecure)
case "oss":
return fmt.Sprintf(` s3:
s3: s3://%s/%s
endpoint: %s
access_key_id: %s
secret_access_key: %s
s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, sc.Endpoint, sc.AccessKey, sc.SecretKey)
case "minio":
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` s3:
s3: s3://%s/%s
endpoint: %s
access_key_id: %s
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, sc.Endpoint, sc.AccessKey, sc.SecretKey, insecure)
case "gcs":
return fmt.Sprintf(` gcs:
bucket_name: %s`, sc.Bucket)
case "azure":
return fmt.Sprintf(` azure:
container_name: %s
account_name: %s
account_key: %s`, sc.Bucket, sc.AccessKey, sc.SecretKey)
default:
return ""
}
}
// generateLokiConfigFallback 当模板解析失败时的降级配置
func (d *LokiDeployer) generateLokiConfigFallback(retentionHours int) string {
return fmt.Sprintf(`auth_enabled: %v
@@ -996,8 +1052,6 @@ ingester:
max_chunk_age: 2h
querier:
max_concurrent: 10
query_frontend:
max_outstanding_per_tenant: 4096
compactor:
working_directory: /var/lib/loki/compactor
retention_enabled: true
@@ -1033,7 +1087,7 @@ func (d *LokiDeployer) effectiveNamespace() string {
if d.deployment.Namespace != "" {
return d.deployment.Namespace
}
return "loki"
return "log"
}
func (d *LokiDeployer) effectiveReleaseName() string {
@@ -1075,6 +1129,11 @@ func (d *LokiDeployer) log(level, message, detail string) {
// ==================== 清理 / 卸载 ====================
func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) {
// 同环境部署的 Grafana 一并清理
if deployment.GrafanaEnabled {
CleanupGrafana(ctx, deployment)
}
if deployment.Mode == "k8s-helm" && deployment.Cluster != nil {
kubeconfig, err := resolveKubeconfig(deployment.Cluster)
if err != nil {
@@ -1089,7 +1148,7 @@ func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) {
env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile))
ns := deployment.Namespace
if ns == "" {
ns = "loki"
ns = "log"
}
name := deployment.Name
if name == "" {
@@ -1121,7 +1180,7 @@ func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) {
env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile))
ns := deployment.Namespace
if ns == "" {
ns = "loki"
ns = "log"
}
// 删除所有相关资源
@@ -1140,11 +1199,7 @@ func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) {
if deployment.Mode == "host-binary" && deployment.Host != nil {
host := deployment.Host
password := host.Password
if strings.HasPrefix(password, "ENC:") {
password, _ = DecryptCred(password)
}
client, _ := NewSSHClient(host.HostIP, host.Port, host.User, password)
client, _ := NewSSHClientWithAuth(HostSSHAuth(host))
if client != nil {
defer client.Close()
client.Execute(ctx, "systemctl stop loki 2>/dev/null || true")
+16 -3
View File
@@ -14,9 +14,9 @@ import (
"strings"
"time"
"lokideploy/config"
"lokideploy/models"
"lokideploy/pkg/crypto"
"opsxloki/config"
"opsxloki/models"
"opsxloki/pkg/crypto"
"golang.org/x/crypto/ssh"
)
@@ -160,6 +160,19 @@ func buildAuthMethods(auth *models.SSHAuthConfig) []ssh.AuthMethod {
return methods
}
// HostSSHAuth 从 Host 模型构建完整 SSH 认证配置(密钥优先、密码后备)。
// 凭证保持 ENC: 原样传入,由 buildAuthMethods 统一解密。
func HostSSHAuth(host *models.Host) *models.SSHAuthConfig {
return &models.SSHAuthConfig{
Host: host.HostIP,
Port: host.Port,
User: host.User,
Password: host.Password,
PrivateKey: host.PrivateKey,
KeyPhrase: host.KeyPhrase,
}
}
// NewSSHClient 创建SSH客户端(支持密钥+密码认证,TOFU主机密钥验证)
func NewSSHClient(host string, port int, user, password string) (*SSHClient, error) {
auth := &SSHAuthConfig{
+4 -4
View File
@@ -4,10 +4,10 @@ import (
"fmt"
"time"
"lokideploy/models"
"lokideploy/pkg/crypto"
"lokideploy/repository"
"lokideploy/utils"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
"opsxloki/utils"
)
type StorageService struct{}
+216
View File
@@ -0,0 +1,216 @@
package services
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// StorageTestResult 存储连接测试结果(逐级:可达 → 认证 → 桶)
type StorageTestResult struct {
Reachable bool `json:"reachable"`
Authenticated bool `json:"authenticated"`
BucketExists bool `json:"bucketExists"`
BucketCreated bool `json:"bucketCreated"`
Message string `json:"message"`
}
// StorageTestParams 测试参数(与创建存储配置一致,额外支持自动建桶)
type StorageTestParams struct {
StorageType string
Endpoint string
Bucket string
Region string
AccessKey string
SecretKey string
Insecure bool
CreateIfMissing bool
}
// ==================== S3 SigV4(零依赖实现,兼容 MinIO/RustFS/S3/OSS S3 兼容端点) ====================
func hmacSHA256(key []byte, data string) []byte {
h := hmac.New(sha256.New, key)
h.Write([]byte(data))
return h.Sum(nil)
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// s3Request 发起一次 SigV4 签名的 S3 请求(路径风格),path 为 "/" 或 "/<bucket>"
func s3Request(method, endpoint, region, accessKey, secretKey, path string, insecure bool) (*http.Response, error) {
// endpoint 解析:带 scheme 用之;裸主机名默认 https,insecure 时 http
base := strings.TrimSpace(endpoint)
scheme := ""
if strings.HasPrefix(base, "http://") {
scheme, base = "http", strings.TrimPrefix(base, "http://")
} else if strings.HasPrefix(base, "https://") {
scheme, base = "https", strings.TrimPrefix(base, "https://")
} else {
if insecure {
scheme = "http"
} else {
scheme = "https"
}
}
base = strings.TrimSuffix(base, "/")
if region == "" {
region = "us-east-1"
}
now := time.Now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
payloadHash := sha256Hex("") // 空 body
// 规范请求(SigV4: host/x-amz-content-sha256/x-amz-date 参与签名)
canonicalURI := path
if canonicalURI == "" {
canonicalURI = "/"
}
canonicalRequest := strings.Join([]string{
method,
canonicalURI,
"", // query
"host:" + base + "\n" +
"x-amz-content-sha256:" + payloadHash + "\n" +
"x-amz-date:" + amzDate + "\n",
"host;x-amz-content-sha256;x-amz-date",
payloadHash,
}, "\n")
scope := dateStamp + "/" + region + "/s3/aws4_request"
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256",
amzDate,
scope,
sha256Hex(canonicalRequest),
}, "\n")
kDate := hmacSHA256([]byte("AWS4"+secretKey), dateStamp)
kRegion := hmacSHA256(kDate, region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
req, err := http.NewRequest(method, scheme+"://"+base+canonicalURI, nil)
if err != nil {
return nil, err
}
req.Host = base
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", payloadHash)
req.Header.Set("Authorization", fmt.Sprintf(
"AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=%s",
accessKey, scope, signature))
client := &http.Client{Timeout: 10 * time.Second}
return client.Do(req)
}
// s3ErrBrief 从 S3 错误响应 XML 中提取 Code/Message,避免把整段 XML 暴露给用户
func s3ErrBrief(body []byte) string {
var e struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
}
if err := xml.Unmarshal(body, &e); err == nil && (e.Code != "" || e.Message != "") {
if e.Code != "" && e.Message != "" {
return e.Code + ": " + e.Message
}
return e.Code + e.Message
}
brief := strings.TrimSpace(string(body))
if len(brief) > 120 {
brief = brief[:120] + "..."
}
return brief
}
// ==================== 测试连接 ====================
// TestStorageConnection 逐级测试对象存储:可达性 → 凭证 → 桶存在性(可选自动创建)。
// OSS 走其 S3 兼容端点;不覆盖 OSS 原生协议的独有能力。
func TestStorageConnection(p StorageTestParams) StorageTestResult {
// ① ListBuckets:同时验证 网络可达 + AK/SK 有效
resp, err := s3Request("GET", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/", p.Insecure)
if err != nil {
return StorageTestResult{Message: fmt.Sprintf("无法连接对象存储:%v", err)}
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return StorageTestResult{
Reachable: true,
Message: fmt.Sprintf("连接成功但认证失败(HTTP %d),请检查 Access Key / Secret Key。%s", resp.StatusCode, s3ErrBrief(body)),
}
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return StorageTestResult{
Reachable: true,
Message: fmt.Sprintf("对象存储返回异常(HTTP %d):%s", resp.StatusCode, s3ErrBrief(body)),
}
}
// 解析 ListBuckets,顺带确认目标桶是否在列表中
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var listResp struct {
Buckets struct {
Bucket []struct {
Name string `xml:"Name"`
} `xml:"Bucket"`
} `xml:"Buckets"`
}
_ = xml.Unmarshal(body, &listResp)
found := false
for _, b := range listResp.Buckets.Bucket {
if b.Name == p.Bucket {
found = true
break
}
}
result := StorageTestResult{Reachable: true, Authenticated: true, BucketExists: found}
if found {
result.Message = "连接成功,凭证有效,桶已存在"
return result
}
// ② 桶不存在:按需创建
if !p.CreateIfMissing {
result.Message = fmt.Sprintf("连接成功,凭证有效,但桶「%s」不存在;勾选「不存在时自动创建」可一键创建", p.Bucket)
return result
}
putResp, err := s3Request("PUT", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/"+url.PathEscape(p.Bucket), p.Insecure)
if err != nil {
result.Message = fmt.Sprintf("创建桶失败:%v", err)
return result
}
defer putResp.Body.Close()
// 200=创建成功;409 BucketAlreadyOwnedByYou 视为已存在
if putResp.StatusCode == http.StatusOK || putResp.StatusCode == http.StatusConflict {
result.BucketExists = true
result.BucketCreated = true
result.Message = fmt.Sprintf("连接成功,桶「%s」不存在,已自动创建 ✓", p.Bucket)
return result
}
putBody, _ := io.ReadAll(io.LimitReader(putResp.Body, 512))
result.Message = fmt.Sprintf("凭证有效,但创建桶失败(HTTP %d):%s", putResp.StatusCode, s3ErrBrief(putBody))
return result
}
+3
View File
@@ -12,6 +12,9 @@ var (
// 资源相关错误
ErrNotFound = errors.New("资源不存在")
ErrUsernameExists = errors.New("用户名已存在")
ErrCannotDeleteSelf = errors.New("不能删除当前登录的账号")
ErrLastAdmin = errors.New("不能删除最后一个管理员账号")
ErrAlreadyExists = errors.New("资源已存在")
ErrUnauthorized = errors.New("未登录或登录已过期")
ErrForbidden = errors.New("没有操作权限")