Files
opsx c983d0e3c1 feat(collectors): 可编辑采集配置(B 档结构化编辑器)+ 修 host 标签 / syslog 时间戳
=== 一、两个线上 bug(172.19.16.8 真机取证)===

1) host 标签是字面量 ${HOSTNAME}
   Promtail 不做环境变量展开(除非 -config.expand-env + systemd Environment),
   实测 21,825 条日志的 host 全是 "${HOSTNAME}",无法按主机过滤。
   → 安装时 `hostname -s` 探测真实主机名并渲染进配置,结果存 Collector.ResolvedHostname,
     让「预览配置」与线上一致。

2) 系统日志时间戳解析失效
   TencentOS 4 的 /var/log/messages 是 ISO8601(2026-07-17T15:38:24.923784+08:00),
   旧正则按传统 syslog(Oct 28 18:11:06)写死 → timestamp 阶段匹配不上,用的是采集时间,
   回填历史日志时间全错。
   → 安装时探测目标机格式:iso8601 → RFC3339Nano;rfc3164 → Jan _2 15:04:05 + Local;
     系统日志路径同时探测(Ubuntu 没有 messages,写死会一条都采不到)。

   实测:写一条 10 分钟前的 ISO8601 探针行,Loki 里 loki_ts 与该行自身时间偏差 **0 秒**;
   另写一条 2 小时前的被 Loki 以 entry too far behind 拒绝 —— 恰好反证时间戳是按事件时间
   解析后推送的(旧实现会用采集时间,反而被接收)。

=== 二、B 档:结构化多 Job 编辑器 ===

后端
- models/scrape_job.go:ScrapeJob + ValidateScrapeJobs
  校验 job 名格式、绝对路径、**敏感路径拦截**(/etc/shadow、/etc/sudoers、/root/.ssh、
  id_rsa、*.pem…)、正则可编译、job/host/__path__ 保留标签名、数量上限。
- services/promtail_host_config.go:多 job 渲染;多路径展开成多个 static_configs;
  pipeline 顺序硬约束 cri → multiline → json → timestamp → drop → limit;
  encoding 写在 scrape_config 级(放进 static_configs 里 promtail 直接起不来)。
- services/host_profile.go:探测 distro / init / hostname / 时区 / 架构 / journald
  与真实存在的系统日志路径。
- CollectorService:renderConfig 实时渲染、PreviewConfig(只渲染不落库)、
  Update 支持 scrapeJobs 并校验、安装结果(hostname/syslog 格式/配置快照)落库。
- 新增 POST /api/v1/collectors/:id/preview → {config, warnings, hostname, syslogFormat}。
- installHost:配置先写 .new + `promtail -check-syntax` 通过才替换,重启后轮询
  systemctl is-active,失败把 journalctl 尾部带回前端。

前端
- 采集项结构化编辑器:名称/路径/标签常驻,多行合并 / JSON / 自定义时间戳 / 命中丢弃 /
  限流折叠在「高级」里;「预览 YAML」弹窗在保存前看到将要下发的配置。
- 本地即时校验 + 旧数据(只有 logPath)自动迁移成 app + system 两个采集项。

=== 三、验证(host-verify-promtail @172.19.16.8)===
- 预览:host=pre-cex-ops-jenkins(无 ${HOSTNAME}),2 job / 3 条系统日志路径 /
  ISO8601 正则 / 多行 + drop + 限流 全部按填写渲染。
- 部署:promtail active、NRestarts=0、-check-syntax 通过,主机侧配置与预览逐行一致。
- Loki:新数据 host 全为 pre-cex-ops-jenkins;job=system 415 条/5min、job=app 40 条/5min。
- 负向 7/7 返回指名到字段的 400:/etc/shadow、/root/.ssh/id_rsa、相对路径、坏正则、
  job 名带空格、保留标签名、空路径。

镜像:lghost/lokistack:latest + v0.1 = sha256:26172f51187d
2026-10-28 18:41:39 +00:00

207 lines
7.6 KiB
Go
Raw Permalink 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.
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},
}
}