package models import ( "fmt" "regexp" "strings" ) // ScrapeJob 主机 Promtail 的一个采集项(对应配置里的 scrape_configs 一项)。 // // 这套结构是「结构化编辑器(B 档)」的落点:用户按字段填,平台负责渲染成 // 合法的 promtail YAML(见 services.PromtailConfigForHost)。 // // 设计取舍: // - 不暴露任意 pipeline 语句,只暴露能覆盖 90% 场景的几个开关 // (多行合并 / JSON 解析 / 自定义时间戳 / 命中丢弃 / 限流); // - System=true 的采集项由平台内置:安装时探测目标机 syslog 格式 // (ISO8601 vs 传统 syslog),自动选择正则与 timestamp 格式 —— 这是 // Bug4 的根治点,用户不需要懂正则。 type ScrapeJob struct { Name string `json:"name"` Paths []string `json:"paths"` Labels map[string]string `json:"labels,omitempty"` // Encoding 仅支持 utf-8 / ascii;留空 = utf-8 Encoding string `json:"encoding,omitempty"` // System 内置系统日志采集项:自动加 syslog 正则 + 时间戳解析,Paths 留空时用默认系统日志路径 System bool `json:"system,omitempty"` // Multiline 多行合并(Java 堆栈等) Multiline *JobMultiline `json:"multiline,omitempty"` // JSON 是否用 json 阶段解析(解析后字段可用于 drop/labels) JSON bool `json:"json,omitempty"` // Timestamp 自定义时间戳解析;留空且非 System 时使用采集时间 Timestamp *JobTimestamp `json:"timestamp,omitempty"` // DropIfMatch 命中这些正则的日志行直接丢弃(如健康检查日志) DropIfMatch []string `json:"dropIfMatch,omitempty"` // RateLimit 限流;留空用平台默认(rate=2000 burst=4000 drop) RateLimit *JobRateLimit `json:"rateLimit,omitempty"` } type JobMultiline struct { // FirstLine 行首正则,匹配到即认为是新一条日志的开始 FirstLine string `json:"firstline"` // MaxLines 单条合并的上限行数,默认 128,上限 1000 MaxLines int `json:"maxLines,omitempty"` } type JobTimestamp struct { Source string `json:"source"` // Format 是 Go 时间布局(RFC3339 / RFC3339Nano / 2006-01-02 15:04:05 …) Format string `json:"format"` Location string `json:"location,omitempty"` } type JobRateLimit struct { Rate int `json:"rate"` Burst int `json:"burst"` } // DefaultSystemPaths 系统日志默认采集路径(不存在时 promtail 会跳过,不报错) var DefaultSystemPaths = []string{"/var/log/messages", "/var/log/secure", "/var/log/cron", "/var/log/maillog", "/var/log/syslog", "/var/log/dmesg"} const ( MaxScrapeJobs = 20 MaxJobLabels = 20 MaxJobPaths = 20 maxJobNameLen = 64 DefaultMultilineMax = 128 MaxMultilineLines = 1000 ) var jobNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) var labelNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) // forbiddenPathPrefixes 绝对禁止采集的路径。 // // 采集就是把文件内容推到 Loki —— 把 /etc/shadow、私钥目录写进采集项, // 等于把主机凭据搬到日志平台里(权限要求更低的系统),必须拦死。 var forbiddenPathPrefixes = []string{ "/etc/shadow", "/etc/gshadow", "/etc/sudoers", "/etc/ssh/", "/root/.ssh/", "/proc/", "/sys/", "/dev/", } var forbiddenPathSubstrings = []string{ "id_rsa", "id_ed25519", "id_ecdsa", ".pem", "private_key", "keystore", } // ValidateScrapeJobs 校验采集项集合;返回的错误信息直接展示给用户,要指名到具体字段。 func ValidateScrapeJobs(jobs []ScrapeJob) error { if len(jobs) > MaxScrapeJobs { return fmt.Errorf("采集项过多(%d 个),最多 %d 个", len(jobs), MaxScrapeJobs) } seen := map[string]bool{} for i := range jobs { j := &jobs[i] j.Name = strings.TrimSpace(j.Name) if j.Name == "" { return fmt.Errorf("第 %d 个采集项缺少名称(job_name)", i+1) } if len(j.Name) > maxJobNameLen || !jobNameRe.MatchString(j.Name) { return fmt.Errorf("采集项名称「%s」不合法:只能包含字母、数字、点、下划线、中划线,且不超过 %d 字符", j.Name, maxJobNameLen) } if seen[j.Name] { return fmt.Errorf("采集项名称「%s」重复", j.Name) } seen[j.Name] = true paths := j.Paths if j.System && len(paths) == 0 { paths = DefaultSystemPaths } if len(paths) == 0 { return fmt.Errorf("采集项「%s」至少需要填写一个日志路径", j.Name) } if len(paths) > MaxJobPaths { return fmt.Errorf("采集项「%s」路径过多(%d 个),最多 %d 个", j.Name, len(paths), MaxJobPaths) } for _, p := range paths { if err := validateLogPath(p); err != nil { return fmt.Errorf("采集项「%s」的路径不合法:%w", j.Name, err) } } if len(j.Labels) > MaxJobLabels { return fmt.Errorf("采集项「%s」标签过多(%d 个),最多 %d 个(标签基数过高会拖垮 Loki)", j.Name, len(j.Labels), MaxJobLabels) } for k, v := range j.Labels { if !labelNameRe.MatchString(k) { return fmt.Errorf("采集项「%s」的标签名「%s」不合法:只能用字母、数字、下划线,且不能以数字开头", j.Name, k) } switch k { case "job", "host", "__path__": return fmt.Errorf("采集项「%s」的标签名「%s」是平台保留字段,不能自定义", j.Name, k) } if len(v) > 200 || strings.ContainsAny(v, "\n\r") { return fmt.Errorf("采集项「%s」的标签「%s」取值过长或包含换行", j.Name, k) } } if j.Multiline != nil { if strings.TrimSpace(j.Multiline.FirstLine) == "" { return fmt.Errorf("采集项「%s」启用了多行合并,但没填「首行正则」", j.Name) } if _, err := regexp.Compile(j.Multiline.FirstLine); err != nil { return fmt.Errorf("采集项「%s」的首行正则无法编译:%v", j.Name, err) } if j.Multiline.MaxLines < 0 || j.Multiline.MaxLines > MaxMultilineLines { return fmt.Errorf("采集项「%s」的多行最大行数需在 0~%d 之间", j.Name, MaxMultilineLines) } } if j.Timestamp != nil && j.Timestamp.Format != "" { if strings.TrimSpace(j.Timestamp.Source) == "" { return fmt.Errorf("采集项「%s」配置了时间戳格式,但没填字段名(source)", j.Name) } } for _, exp := range j.DropIfMatch { if strings.TrimSpace(exp) == "" { continue } if _, err := regexp.Compile(exp); err != nil { return fmt.Errorf("采集项「%s」的丢弃正则「%s」无法编译:%v", j.Name, exp, err) } } if j.RateLimit != nil { if j.RateLimit.Rate < 0 || j.RateLimit.Burst < 0 { return fmt.Errorf("采集项「%s」的限流参数不能为负", j.Name) } } } return nil } func validateLogPath(p string) error { p = strings.TrimSpace(p) if p == "" { return fmt.Errorf("路径为空") } if strings.ContainsAny(p, "\n\r") { return fmt.Errorf("路径包含换行") } if !strings.HasPrefix(p, "/") { return fmt.Errorf("「%s」必须是绝对路径(以 / 开头)", p) } low := strings.ToLower(p) for _, prefix := range forbiddenPathPrefixes { if strings.HasPrefix(low, prefix) { return fmt.Errorf("「%s」属于禁止采集的敏感路径(凭据/内核接口)", p) } } for _, sub := range forbiddenPathSubstrings { if strings.Contains(low, sub) { return fmt.Errorf("「%s」看起来是密钥/凭据文件,禁止采集", p) } } return nil } // DefaultScrapeJobs 未配置采集项时的默认值(保持旧行为:应用日志 + 系统日志) func DefaultScrapeJobs(logPath string) []ScrapeJob { if strings.TrimSpace(logPath) == "" { logPath = "/var/log/*.log" } return []ScrapeJob{ {Name: "app", Paths: []string{logPath}}, {Name: "system", System: true}, } }