Files
LokiStack/backend/services/log_source.go
T
logan 7624baa564
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
feat(logs): 数据源连接方式自动探测
部署完成及启动时实测 cluster-svc(API proxy) 与 url 直连,
选择可用方式并落库,兼容平台/Loki 分别在主机或集群内的组合。
2026-10-30 01:23:45 +08:00

589 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 日志数据源管理 — CRUD、默认源、部署自动注册(Managed 标记)。
package services
import (
"context"
"encoding/json"
"fmt"
"log"
"regexp"
"sort"
"strings"
"time"
"lokistack/models"
"lokistack/pkg/crypto"
"lokistack/repository"
)
// ==================== 数据源 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
}
// UserAccessLabels 返回某用户在指定数据源上的授权 labels。
//
// 返回值语义:
// - nil → 该用户对此数据源完全无权限
// - 空 map → 有权限但不限定范围(可查全部)
// - 非空 map → 只能查匹配这些 label 的日志(如 {"namespace":"astralx"})
func UserAccessLabels(src *models.LogSource, userID uint) map[string]string {
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 nil
}
if access.Labels == nil {
return map[string]string{}
}
return access.Labels
}
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("数据源不存在")
}
// reachableEndpoint 把部署记录里的访问地址转换成「平台侧能真正连上」的地址。
//
// 主机模式创建部署时存的是容器的监听地址(0.0.0.0:3100 / [::]:3100),
// 那是容器内部视角,平台进程在别的机器/集群里,用它必然 connection refused。
// 换成该主机的真实 IP 再返回;主机信息缺失时原样返回(至少不会更差)。
func reachableEndpoint(d *models.LokiDeployment) string {
endpoint := strings.TrimSpace(d.Endpoint)
if endpoint == "" {
return endpoint
}
if !strings.Contains(endpoint, "0.0.0.0") && !strings.Contains(endpoint, "[::]") {
return endpoint
}
hostIP := ""
if d.Host != nil {
hostIP = strings.TrimSpace(d.Host.HostIP)
}
if hostIP == "" {
// 没带 Host 关联时回库查一次,避免调用方漏预加载
db := repository.GetDB()
if db != nil && d.HostID != nil {
db.RLock()
for i := range db.Hosts {
if db.Hosts[i].ID == *d.HostID {
hostIP = strings.TrimSpace(db.Hosts[i].HostIP)
break
}
}
db.RUnlock()
}
}
if hostIP == "" {
return endpoint
}
endpoint = strings.Replace(endpoint, "0.0.0.0", hostIP, 1)
endpoint = strings.Replace(endpoint, "[::]", hostIP, 1)
return endpoint
}
// CleanupManagedLogSource 删除某部署名对应的**平台托管**数据源。
//
// 为什么必须有:部署删除后它自动注册的数据源若不清掉,「日志查询」下拉里会
// 留一个指向已删实例的源,选中必然连接失败(线上残留过 host-verify-loki)。
//
// 连带处理:
// - 该源上的查询授权策略组:DeleteLogSource 内部会一并删除
// - 绑定了该源的告警规则:置为禁用(保留配置便于审计,但不再触发,
// 否则引擎每次评估都会因为连不上而写失败记录)
// - 手填的数据源(Managed=false)不动:那是用户自己建的,可能指向别的实例
func CleanupManagedLogSource(name string) {
db := repository.GetDB()
if db == nil || name == "" {
return
}
db.RLock()
var sourceID uint
for _, src := range db.LogSources {
if src.Managed && src.Name == name {
sourceID = src.ID
break
}
}
db.RUnlock()
if sourceID == 0 {
// 部署本来就没注册数据源(如部署失败),无需处理
return
}
if err := DeleteLogSource(sourceID); err != nil {
log.Printf("[deploy-delete] 清理数据源 %q(id=%d) 失败: %v", name, sourceID, err)
}
db.Lock()
defer db.Unlock()
disabled := 0
for i := range db.AlertRules {
if db.AlertRules[i].SourceID == sourceID && db.AlertRules[i].Enabled {
db.AlertRules[i].Enabled = false
disabled++
}
}
if disabled > 0 {
db.RequestSave()
log.Printf("[deploy-delete] %d 条告警规则因数据源被删而禁用", disabled)
}
}
// CleanupOrphanManagedLogSources 启动时清理孤儿托管数据源:
// 部署记录已删除、或所属集群已删除的 managed 源。
//
// 为什么需要:CleanupManagedLogSource 只在「删除部署」的代码路径里执行;
// 如果部署是在旧版本(没有清理逻辑)删掉的,或删除过程中进程退出,
// 托管源就会永久残留,在数据源列表里指向一个不存在的实例。
func CleanupOrphanManagedLogSources() {
db := repository.GetDB()
if db == nil {
return
}
db.Lock()
names := make(map[string]bool, len(db.Deployments))
for _, d := range db.Deployments {
names[d.Name] = true
}
clusters := make(map[uint]bool, len(db.Clusters))
for _, c := range db.Clusters {
clusters[c.ID] = true
}
kept := make([]models.LogSource, 0, len(db.LogSources))
removed, removedDefault := 0, false
for _, s := range db.LogSources {
orphan := false
if s.Managed {
if !names[s.Name] {
orphan = true
}
if s.ClusterID != nil && !clusters[*s.ClusterID] {
orphan = true
}
}
if orphan {
removed++
if s.IsDefault {
removedDefault = true
}
continue
}
kept = append(kept, s)
}
if removed > 0 {
if removedDefault && len(kept) > 0 {
kept[0].IsDefault = true
}
db.LogSources = kept
db.RequestSave()
log.Printf("[startup] 清理 %d 个孤儿托管数据源", removed)
}
db.Unlock()
}
// AutoDetectLogSource 探测数据源的可用连接方式,返回最终选择(cluster-svc / url)。
//
// 交付环境组合很多:平台可能在主机或集群内,Loki 也可能在主机或集群内。
// cluster-svc 走 kube-apiserver 的 service proxy,不依赖集群 DNS;url 直连
// 则需要平台能访问到该地址。这里按「代理优先、URL 兜底」实测,避免按部署
// 模式硬编码导致某种组合连不上。
func AutoDetectLogSource(ctx context.Context, src *models.LogSource) string {
if src.ClusterID != nil {
probe := *src
probe.TargetType = "cluster-svc"
if _, status, err := lokiProxyGET(ctx, &probe, "/ready", nil); err == nil && status == 200 {
src.TargetType = "cluster-svc"
return "cluster-svc"
}
}
if src.URL != "" {
probe := *src
probe.TargetType = "url"
if _, status, err := lokiProxyGET(ctx, &probe, "/ready", nil); err == nil && status == 200 {
src.TargetType = "url"
return "url"
}
}
return ""
}
// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host→url;k8s→cluster-svc)。
// 已存在同名数据源时:平台托管的自动刷新连接信息,用户手填的不覆盖。
func EnsureLogSourceForDeployment(d *models.LokiDeployment) {
if d == nil || d.Endpoint == "" {
return
}
svc := "loki"
port := 3100
target := &models.LogSource{
Name: d.Name, TargetType: "cluster-svc",
ClusterID: d.ClusterID, Namespace: d.Namespace, Service: svc, Port: port,
AuthType: "none", Managed: true,
}
if d.Mode == "host-binary" || d.Mode == "host-docker" {
target.TargetType = "url"
// 主机模式部署的 endpoint 记的是**容器监听地址**(0.0.0.0:3100),
// 直接拿来当数据源 URL 会得到 dial tcp 0.0.0.0:3100: connection refused ——
// 平台进程不在那台主机上,0.0.0.0 只在本机有意义。
// 这里换成主机真实 IP,平台才能连上。
target.URL = reachableEndpoint(d)
target.ClusterID = nil
} else {
// k8s 部署:集群内 Service 地址也记下来,作为 URL 直连候选
target.URL = d.Endpoint
}
// 实测可用性后决定连接方式:主机/集群内平台、主机/集群内 Loki 都兼容
detectCtx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
if method := AutoDetectLogSource(detectCtx, target); method != "" {
log.Printf("[log-source] %q 自动选择连接方式: %s", target.Name, method)
}
cancel()
db := repository.GetDB()
db.Lock()
for i := range db.LogSources {
if db.LogSources[i].Name != target.Name {
continue
}
if !db.LogSources[i].Managed {
db.Unlock()
return
}
db.LogSources[i].TargetType = target.TargetType
db.LogSources[i].ClusterID = target.ClusterID
db.LogSources[i].Namespace = target.Namespace
db.LogSources[i].Service = target.Service
db.LogSources[i].Port = target.Port
db.LogSources[i].URL = target.URL
db.LogSources[i].UpdatedAt = time.Now()
db.Unlock()
db.RequestSave()
return
}
db.Unlock()
_, _ = CreateLogSource(target)
}
// EnsureLogSourcesForRunningDeployments 补齐运行中部署的数据源,避免查询页默认没有数据源。
func EnsureLogSourcesForRunningDeployments() {
db := repository.GetDB()
db.RLock()
running := make([]models.LokiDeployment, 0, len(db.Deployments))
for i := range db.Deployments {
d := db.Deployments[i]
if d.Status == "running" && d.Endpoint != "" {
running = append(running, d)
}
}
db.RUnlock()
for i := range running {
EnsureLogSourceForDeployment(&running[i])
}
}