Files
chick 9608a7ebc0 fix(agent): 审计写入失败不再静默
审计日志是证据而不是控制手段:写失败不能让请求失败,否则磁盘写满会变成一次远程
拒绝服务——已经执行成功的指令会因为日志追加不上而开始报错,那是坏错了地方。

但它也不能消失。静默丢审计条目是那种"运维以为自己有记录、其实没有"的失败,而这份
日志的全部意义就是事后能回答"谁在什么时候要求了什么"。现在写失败会进 agent 自己的
日志,磁盘写满表现为一行反复出现,而不是一片空白。

顺带把 Close 之后的语义写清楚:Close 会丢掉 sink,此后的写入返回 nil——这是刻意的,
因为一个正在关闭的进程与在途请求赛跑时,为每一个恰好到达的请求报一次失败是噪声
而不是信息。这条差别在 agent 日志里看得见。

测试用真实失败路径(写一个已关闭的文件)而不是填满文件系统,并断言 exec.record 把
它报了出来。

Go 全包、Node 112 项、Swift 101 项通过。
2026-09-17 01:52:59 +08:00

99 lines
3.4 KiB
Go

package runtime
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Entry is one line of the audit log.
//
// Metadata only, and that is the whole design: the request and response bodies are the actual
// credentials and SMS contents, and copying them into a second file on the same box adds a place to
// lose them without adding any investigative value. What is kept is enough to answer "who asked for
// what, when, and was it allowed" from the relay request id.
type Entry struct {
At time.Time `json:"ts"`
Node string `json:"node,omitempty"`
Req string `json:"req,omitempty"`
App string `json:"app,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Status int `json:"status"`
Outcome string `json:"outcome"`
}
// Audit is an append-only JSONL writer. One line per request, flushed as it is written: an audit log
// that loses its tail on SIGKILL cannot explain the incident that killed the process.
type Audit struct {
mu sync.Mutex
sink *os.File
enc *json.Encoder
}
// OpenAudit appends to path, creating it 0600 and its parent directories if needed.
//
// The file is opened append-only and never rotated in-process. Rotation is logrotate's job: an
// agent that renames its own log is an agent that can lose an entry during the rename.
func OpenAudit(path string) (*Audit, error) {
if path == "" {
return nil, errors.New("runtime: audit path is empty")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
sink, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
return &Audit{sink: sink, enc: json.NewEncoder(sink)}, nil
}
// NullAudit returns a writer that keeps nothing. Used by tests and by `msa-agent --audit=-`, because
// the executor is simpler when it can assume a non-nil audit target.
func NullAudit() *Audit { return &Audit{} }
// Write appends one entry. A write failure is returned to the caller rather than panicking: the
// audit log is evidence, not a control, and a full disk must not become a remote denial of service.
//
// Returning nil after Close is deliberate and is the one case where a dropped entry is not an error:
// a shutdown path racing an in-flight request would otherwise log a failure for every request that
// happened to arrive at that moment, which is noise rather than information. The executor logs what it
// sees either way, so the difference is visible in the agent's log.
func (a *Audit) Write(entry Entry) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.sink == nil {
return nil
}
if entry.At.IsZero() {
entry.At = time.Now().UTC()
}
entry.At = entry.At.UTC()
if err := a.enc.Encode(entry); err != nil {
return fmt.Errorf("runtime: audit write: %w", err)
}
return a.sink.Sync()
}
// Close releases the file.
//
// A write after Close is dropped, and dropped silently is the wrong word for it: the executor reports
// every write error it sees, and this returns one, so a request racing a clean shutdown shows up as a
// warning rather than as a missing line nobody notices. The alternative — failing the request — buys
// nothing, because the command it describes has already run or been refused.
func (a *Audit) Close() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.sink == nil {
return nil
}
err := a.sink.Close()
a.sink = nil
return err
}