=== 一、两个线上 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
207 lines
7.6 KiB
Go
207 lines
7.6 KiB
Go
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},
|
||
}
|
||
}
|