| 1 |
|
// Package logging is the log policy of a self-hosted SourceHut instance: the |
| 2 |
|
// verbosity, the source positions, the colour decision, and — the part that is |
| 3 |
|
// not presentation — the set of attribute keys that must never reach a log |
| 4 |
|
// file. |
| 5 |
|
// |
| 6 |
|
// It exists because the rest of sr-ht-ecore already depends on the answer. |
| 7 |
|
// [sourcecraft.dev/bigbes/sr-ht-ecore/middleware.RecoverPanics] reports a |
| 8 |
|
// recovered panic through slog's *default* logger, because a middleware living |
| 9 |
|
// in another module has no constructor |
| 10 |
|
// through which the service could hand it one. A service that never calls |
| 11 |
|
// slog.SetDefault still logs those reports — through Go's plain stderr handler, |
| 12 |
|
// unlevelled, unmasked, and in a different format from every other line in the |
| 13 |
|
// same journal. Until now ecore needed that and could not say so. |
| 14 |
|
// |
| 15 |
|
// The second half is policy rather than taste. Six services of this instance |
| 16 |
|
// (compare, spec, dolt, cover, bench, tokens) each grew the same forty lines in |
| 17 |
|
// their main.go: the same level parser, the same os.Stderr.Stat colour probe, |
| 18 |
|
// and six separately maintained copies of the credential mask list. The copies |
| 19 |
|
// had already drifted — three different key sets and three different patterns, |
| 20 |
|
// with one service masking a DSN that the other five did not know was a |
| 21 |
|
// credential and another masking the private keys that the rest did not. What |
| 22 |
|
// is being redacted (the instance's unified-login cookie, tokens.sr.ht's |
| 23 |
|
// working tokens, the Authorization header they travel in) is a fact about the |
| 24 |
|
// instance, not about any one service, so it is maintained once here. |
| 25 |
|
// |
| 26 |
|
// # The handler stays with the caller |
| 27 |
|
// |
| 28 |
|
// Every service on this instance installs auxilia's scribe.TintHandler, and |
| 29 |
|
// this package deliberately does not build it. ecore is a small SourceHut- |
| 30 |
|
// specific library, auxilia is a large general one, and linking scribe into |
| 31 |
|
// every consumer of chrome, csrf and middleware in order to share a list of |
| 32 |
|
// masked keys would be the wrong trade — a service that wants a JSON handler |
| 33 |
|
// for a log shipper should not have to link a tinting one. So the policy is |
| 34 |
|
// resolved here and the handler is constructed there: |
| 35 |
|
// |
| 36 |
|
// opts := logging.Defaults(conf, "dolt.sr.ht") |
| 37 |
|
// logging.Install(scribe.NewTintHandler( |
| 38 |
|
// scribe.WithWriter(os.Stderr), |
| 39 |
|
// scribe.WithLevel(opts.Level), |
| 40 |
|
// scribe.WithSource(opts.AddSource), |
| 41 |
|
// scribe.WithTimeFormat(opts.TimeFormat), |
| 42 |
|
// scribe.WithNoColor(!opts.Color), |
| 43 |
|
// scribe.WithMaskKeys(opts.MaskKeys...), |
| 44 |
|
// scribe.WithMask(opts.MaskPattern, opts.MaskReplacement), |
| 45 |
|
// )) |
| 46 |
|
// |
| 47 |
|
// The masking is not scribe's to own either: [Options.ReplaceAttr] applies the |
| 48 |
|
// same rules through stdlib slog alone, so a service on a JSON handler gets the |
| 49 |
|
// instance's redaction without importing anything. |
| 50 |
|
// |
| 51 |
|
// logging.Install(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ |
| 52 |
|
// Level: opts.Level, |
| 53 |
|
// AddSource: opts.AddSource, |
| 54 |
|
// ReplaceAttr: opts.ReplaceAttr(), |
| 55 |
|
// })) |
| 56 |
|
// |
| 57 |
|
// [Install] belongs in main and nowhere else: slog's default logger is |
| 58 |
|
// process-global state, so a package that set it would be deciding for |
| 59 |
|
// everything else that was linked alongside it. |
| 60 |
|
package logging |
| 61 |
|
|
| 62 |
|
import ( |
| 63 |
|
"log/slog" |
| 64 |
|
"os" |
| 65 |
|
"regexp" |
| 66 |
|
"slices" |
| 67 |
|
"strings" |
| 68 |
|
"time" |
| 69 |
|
|
| 70 |
|
"github.com/vaughan0/go-ini" |
| 71 |
|
|
| 72 |
|
"sourcecraft.dev/bigbes/sr-ht-core/config" |
| 73 |
|
) |
| 74 |
|
|
| 75 |
|
const ( |
| 76 |
|
// LevelEnv is the environment variable that names the verbosity for one |
| 77 |
|
// run. It is what an operator reaches for from a shell or a systemd |
| 78 |
|
// Environment= line, and it overrides [LevelKey]. |
| 79 |
|
LevelEnv = "LOG_LEVEL" |
| 80 |
|
|
| 81 |
|
// LevelKey is the config.ini key holding the instance's persistent |
| 82 |
|
// verbosity, read from the service's own section: `log-level=info`. It is |
| 83 |
|
// the setting an operator writes down; [LevelEnv] and -d are the ones they |
| 84 |
|
// pass for a single run. |
| 85 |
|
LevelKey = "log-level" |
| 86 |
|
|
| 87 |
|
// TimeFormat is the timestamp every service prints. The sortable form |
| 88 |
|
// rather than RFC 3339: under systemd the journal stamps its own arrival |
| 89 |
|
// time beside this one, and two RFC 3339 stamps per line is a line nobody |
| 90 |
|
// reads to the end. |
| 91 |
|
TimeFormat = time.DateTime |
| 92 |
|
|
| 93 |
|
// MaskReplacement is what a masked value is printed as. |
| 94 |
|
MaskReplacement = "***" |
| 95 |
|
|
| 96 |
|
// MaskPattern matches the attribute key *path* of a value that must not be |
| 97 |
|
// logged — "token", "user.password", "req.headers.authorization". It is the |
| 98 |
|
// union of the patterns the six services had each arrived at separately, |
| 99 |
|
// and it is a pattern rather than a list because the credential that leaks |
| 100 |
|
// is the attribute somebody adds next year under a name nobody thought to |
| 101 |
|
// add to a list. |
| 102 |
|
// |
| 103 |
|
// It matches the key and never the message, so prose that happens to |
| 104 |
|
// contain the word "token" costs nothing and cannot be used to defeat it. |
| 105 |
|
// |
| 106 |
|
// Note that this masks `token_id` as well, which tokens.sr.ht's local copy |
| 107 |
|
// deliberately did not — a row id is what correlates two lines about one |
| 108 |
|
// credential. The instance-wide default errs the other way, because the |
| 109 |
|
// failure it is guarding against is a live credential in a log file and the |
| 110 |
|
// failure it causes is an id that has to be logged under a key not |
| 111 |
|
// containing "token" (`id` is the better name for it anyway). |
| 112 |
|
MaskPattern = `(?i)(secret|token|api_?key|password|pubkey|credential|dsn|authorization|cookie)` |
| 113 |
|
|
| 114 |
|
// PartialMaskPattern and PartialMaskKeep are tokens.sr.ht's correlation |
| 115 |
|
// exception and are NOT part of [Defaults]: a key that is or ends in |
| 116 |
|
// "token" or "secret" keeps its first six characters instead of being |
| 117 |
|
// replaced whole, which is enough to line two log lines up against each |
| 118 |
|
// other and useless to anybody who wants to present the credential. |
| 119 |
|
// |
| 120 |
|
// It is opt-in because it is strictly weaker than the default — six |
| 121 |
|
// characters of a live working token is still six characters of a live |
| 122 |
|
// working token, and only a service whose whole subject is credentials has |
| 123 |
|
// enough to gain from it to pay that. Such a service installs it *before* |
| 124 |
|
// the rules of [Defaults], because the first matching rule wins and the |
| 125 |
|
// blanket rule would otherwise swallow the prefix this exists to keep: |
| 126 |
|
// |
| 127 |
|
// scribe.WithMaskPartial(logging.PartialMaskPattern, logging.PartialMaskKeep), |
| 128 |
|
// scribe.WithMaskKeys(opts.MaskKeys...), |
| 129 |
|
// scribe.WithMask(opts.MaskPattern, opts.MaskReplacement), |
| 130 |
|
PartialMaskPattern = `(?i)(^|[._-])(token|secret)$` |
| 131 |
|
PartialMaskKeep = 6 |
| 132 |
|
) |
| 133 |
|
|
| 134 |
|
// maskKeys is the exact-key half of the policy: the credentials this instance's |
| 135 |
|
// services are known to handle today, named as they are named in the code. |
| 136 |
|
// |
| 137 |
|
// Both halves are installed because they fail differently — the list covers |
| 138 |
|
// what is named now and cannot be worked around by an unlucky regexp, and |
| 139 |
|
// [MaskPattern] covers what gets named later. Every entry here was in at least |
| 140 |
|
// one of the six services' lists, and no service had all of them. |
| 141 |
|
var maskKeys = []string{ |
| 142 |
|
// The unified-login session cookie and the header it arrives in, which |
| 143 |
|
// every service of this instance reads (SPEC ch. 6). |
| 144 |
|
"cookie", |
| 145 |
|
"authorization", |
| 146 |
|
|
| 147 |
|
// tokens.sr.ht working tokens and the generic credential names services |
| 148 |
|
// pass them under. |
| 149 |
|
"token", |
| 150 |
|
"api_key", |
| 151 |
|
"apikey", |
| 152 |
|
"password", |
| 153 |
|
|
| 154 |
|
// Keys out of config.ini that a startup line is likely to echo. |
| 155 |
|
"network-key", |
| 156 |
|
"private-key", |
| 157 |
|
|
| 158 |
|
// The connection string of a migration binary, which carries a password. |
| 159 |
|
"dsn", |
| 160 |
|
"data_source_name", |
| 161 |
|
} |
| 162 |
|
|
| 163 |
|
// MaskKeys returns the instance's masked attribute keys, ready to be handed to |
| 164 |
|
// a handler in one line: |
| 165 |
|
// |
| 166 |
|
// scribe.WithMaskKeys(logging.MaskKeys()...) |
| 167 |
|
// |
| 168 |
|
// It is a function returning a fresh slice rather than an exported variable |
| 169 |
|
// because a mask set that any linked package can append to or truncate is not a |
| 170 |
|
// policy. |
| 171 |
18 |
func MaskKeys() []string { |
| 172 |
18 |
return slices.Clone(maskKeys) |
| 173 |
18 |
} |
| 174 |
|
|
| 175 |
|
// Options is everything about logging that the services of one instance decide |
| 176 |
|
// identically. [Defaults] resolves it; the caller spends it on the handler of |
| 177 |
|
// its choice. |
| 178 |
|
type Options struct { |
| 179 |
|
// Level is the resolved verbosity — see [Defaults] for where it comes from. |
| 180 |
|
Level slog.Level |
| 181 |
|
|
| 182 |
|
// AddSource asks for file:line on every record. It is on by default: what |
| 183 |
|
// reaches these logs is mostly a failure nobody can reproduce, and "which |
| 184 |
|
// of the six render sites said this" is the first question about each one. |
| 185 |
|
AddSource bool |
| 186 |
|
|
| 187 |
|
// Color reports whether escape sequences are wanted, resolved from NO_COLOR |
| 188 |
|
// and from whether stderr is a terminal. Handlers usually ask the inverse |
| 189 |
|
// question, hence scribe.WithNoColor(!opts.Color). |
| 190 |
|
Color bool |
| 191 |
|
|
| 192 |
|
// TimeFormat is the timestamp layout, [TimeFormat] by default. |
| 193 |
|
TimeFormat string |
| 194 |
|
|
| 195 |
|
// MaskKeys, MaskPattern and MaskReplacement are the redaction policy, in |
| 196 |
|
// the order a handler should install them. Both matchers run against the |
| 197 |
|
// attribute's key path, never against its value or the message. |
| 198 |
|
MaskKeys []string |
| 199 |
|
MaskPattern string |
| 200 |
|
MaskReplacement string |
| 201 |
|
} |
| 202 |
|
|
| 203 |
|
// Defaults resolves the instance's logging policy, reading the service's own |
| 204 |
|
// section of config.ini for [LevelKey]. |
| 205 |
|
// |
| 206 |
|
// The verbosity has three sources, strongest first: |
| 207 |
|
// |
| 208 |
|
// - `-d` in the argument vector, which every SourceHut daemon takes as its |
| 209 |
|
// debug flag; |
| 210 |
|
// - $LOG_LEVEL, for one run; |
| 211 |
|
// - [section]log-level in config.ini, the instance's persistent setting. |
| 212 |
|
// |
| 213 |
|
// A value none of them can read — including the empty string of an unset |
| 214 |
|
// variable — falls through to the next source, and info if there is none. It is |
| 215 |
|
// operator input: a typo in a logging preference must never be the reason a |
| 216 |
|
// service will not boot. |
| 217 |
|
// |
| 218 |
|
// -d is read straight out of os.Args rather than taken as a parameter because |
| 219 |
|
// of when this is called. core-go's server.New parses the argument vector, but |
| 220 |
|
// it runs after config loading and validation, and a daemon that becomes |
| 221 |
|
// verbose only once it has finished starting is silent for exactly the window |
| 222 |
|
// an operator passes -d to watch. A service that installs its logger before |
| 223 |
|
// loading config calls Defaults(nil, "") — -d and $LOG_LEVEL still resolve, and |
| 224 |
|
// the config file has nothing to say yet. |
| 225 |
10 |
func Defaults(conf ini.File, section string) Options { |
| 226 |
10 |
return defaults(resolveLevel(conf, section, true)) |
| 227 |
10 |
} |
| 228 |
|
|
| 229 |
|
// DefaultsWithoutDebugFlag is Defaults for a binary whose -d is not the |
| 230 |
|
// daemon's. |
| 231 |
|
// |
| 232 |
|
// A migration CLI on this instance passes -d to brant, where it means |
| 233 |
|
// --dialect and takes a value: `coversrht-migrate -d postgres` would otherwise |
| 234 |
|
// arrive here as a request for debug logging, silently, because the probe sees |
| 235 |
|
// the flag and never the value. $LOG_LEVEL and the config key still resolve — |
| 236 |
|
// only the argument scan is dropped, which is the one source that cannot tell |
| 237 |
|
// the two meanings apart. |
| 238 |
2 |
func DefaultsWithoutDebugFlag(conf ini.File, section string) Options { |
| 239 |
2 |
return defaults(resolveLevel(conf, section, false)) |
| 240 |
2 |
} |
| 241 |
|
|
| 242 |
12 |
func defaults(level slog.Level) Options { |
| 243 |
12 |
return Options{ |
| 244 |
12 |
Level: level, |
| 245 |
12 |
AddSource: true, |
| 246 |
12 |
Color: ColorEnabled(os.Stderr), |
| 247 |
12 |
TimeFormat: TimeFormat, |
| 248 |
12 |
MaskKeys: MaskKeys(), |
| 249 |
12 |
MaskPattern: MaskPattern, |
| 250 |
12 |
MaskReplacement: MaskReplacement, |
| 251 |
12 |
} |
| 252 |
12 |
} |
| 253 |
|
|
| 254 |
|
// resolveLevel walks the three sources of verbosity in order of authority. |
| 255 |
|
// debugFlag is false for a binary whose -d belongs to something else. |
| 256 |
12 |
func resolveLevel(conf ini.File, section string, debugFlag bool) slog.Level { |
| 257 |
12 |
if debugFlag && DebugRequested(os.Args[1:]) { |
| 258 |
3 |
return slog.LevelDebug |
| 259 |
3 |
} |
| 260 |
9 |
if level, ok := ParseLevel(os.Getenv(LevelEnv)); ok { |
| 261 |
2 |
return level |
| 262 |
2 |
} |
| 263 |
7 |
if section != "" { |
| 264 |
3 |
if level, ok := ParseLevel(config.GetString(conf, section, LevelKey, "")); ok { |
| 265 |
2 |
return level |
| 266 |
2 |
} |
| 267 |
|
} |
| 268 |
5 |
return slog.LevelInfo |
| 269 |
|
} |
| 270 |
|
|
| 271 |
|
// ParseLevel reads a verbosity name — "debug", "info", "warn" (or "warning"), |
| 272 |
|
// "error" — case and surrounding space insensitively. The second result reports |
| 273 |
|
// whether the name was one of those, which is what lets a caller tell "the |
| 274 |
|
// operator did not say" from "the operator said something unreadable" and |
| 275 |
|
// degrade rather than refuse. |
| 276 |
25 |
func ParseLevel(s string) (slog.Level, bool) { |
| 277 |
25 |
switch strings.ToLower(strings.TrimSpace(s)) { |
| 278 |
2 |
case "debug": |
| 279 |
2 |
return slog.LevelDebug, true |
| 280 |
1 |
case "info": |
| 281 |
1 |
return slog.LevelInfo, true |
| 282 |
6 |
case "warn", "warning": |
| 283 |
6 |
return slog.LevelWarn, true |
| 284 |
2 |
case "error": |
| 285 |
2 |
return slog.LevelError, true |
| 286 |
14 |
default: |
| 287 |
14 |
return slog.LevelInfo, false |
| 288 |
|
} |
| 289 |
|
} |
| 290 |
|
|
| 291 |
|
// DebugRequested reports whether the argument vector (without argv[0]) carries |
| 292 |
|
// SourceHut's -d debug flag. |
| 293 |
|
// |
| 294 |
|
// Only the standalone token counts, which is what all six services did by hand; |
| 295 |
|
// recognising a clustered "-bd" would mean reproducing core-go's getopt here, |
| 296 |
|
// against an argument vector core-go is about to parse properly anyway. A "--" |
| 297 |
|
// ends the scan: what follows it is an operand, not a flag. |
| 298 |
18 |
func DebugRequested(args []string) bool { |
| 299 |
18 |
for _, arg := range args { |
| 300 |
16 |
if arg == "--" { |
| 301 |
1 |
return false |
| 302 |
1 |
} |
| 303 |
15 |
if arg == "-d" { |
| 304 |
6 |
return true |
| 305 |
6 |
} |
| 306 |
|
} |
| 307 |
11 |
return false |
| 308 |
|
} |
| 309 |
|
|
| 310 |
|
// ColorEnabled reports whether escape sequences should be written to f. |
| 311 |
|
// |
| 312 |
|
// NO_COLOR disables them whatever it is set to, which is what the convention at |
| 313 |
|
// no-color.org asks for: its presence is the signal and its value means |
| 314 |
|
// nothing, so NO_COLOR=0 disables colour exactly as NO_COLOR=1 does. Otherwise |
| 315 |
|
// the question is whether f is a character device — a terminal is, and the |
| 316 |
|
// pipe, file or journal socket that systemd, a container and a shell redirect |
| 317 |
|
// hand a daemon are not. One Stat answers it, which is cheaper than taking |
| 318 |
|
// golang.org/x/term as a dependency for one bit. |
| 319 |
20 |
func ColorEnabled(f *os.File) bool { |
| 320 |
20 |
if _, set := os.LookupEnv("NO_COLOR"); set { |
| 321 |
4 |
return false |
| 322 |
4 |
} |
| 323 |
16 |
info, err := f.Stat() |
| 324 |
16 |
if err != nil { |
| 325 |
1 |
return false |
| 326 |
1 |
} |
| 327 |
15 |
return info.Mode()&os.ModeCharDevice != 0 |
| 328 |
|
} |
| 329 |
|
|
| 330 |
|
// ReplaceAttr compiles the masking policy into a slog.HandlerOptions. |
| 331 |
|
// ReplaceAttr function, so that a service on a stdlib handler redacts exactly |
| 332 |
|
// what a service on scribe's tint handler redacts: |
| 333 |
|
// |
| 334 |
|
// slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ |
| 335 |
|
// Level: opts.Level, |
| 336 |
|
// AddSource: opts.AddSource, |
| 337 |
|
// ReplaceAttr: opts.ReplaceAttr(), |
| 338 |
|
// }) |
| 339 |
|
// |
| 340 |
|
// The rules are compiled once, here, rather than per record; the key matching |
| 341 |
|
// mirrors scribe's, so an attribute is redacted however deeply it is nested and |
| 342 |
|
// whatever the caller believed it was logging. Returns nil — a valid |
| 343 |
|
// ReplaceAttr meaning "no substitution" — when the policy is empty. |
| 344 |
|
// |
| 345 |
|
// An unparseable [Options.MaskPattern] panics, on this call, in main. A mask |
| 346 |
|
// rule that silently did not compile would be a redaction that silently does |
| 347 |
|
// not happen. |
| 348 |
4 |
func (o Options) ReplaceAttr() func(groups []string, a slog.Attr) slog.Attr { |
| 349 |
4 |
rules := o.maskRules() |
| 350 |
4 |
if len(rules) == 0 { |
| 351 |
1 |
return nil |
| 352 |
1 |
} |
| 353 |
|
|
| 354 |
2 |
replacement := o.MaskReplacement |
| 355 |
2 |
if replacement == "" { |
| 356 |
0 |
replacement = MaskReplacement |
| 357 |
0 |
} |
| 358 |
|
|
| 359 |
30 |
return func(groups []string, a slog.Attr) slog.Attr { |
| 360 |
30 |
// A group's own attribute carries no value to mask; its contents each |
| 361 |
30 |
// arrive here separately, with the group name in groups. |
| 362 |
30 |
if a.Value.Kind() == slog.KindGroup { |
| 363 |
0 |
return a |
| 364 |
0 |
} |
| 365 |
|
|
| 366 |
30 |
key := a.Key |
| 367 |
30 |
if len(groups) > 0 { |
| 368 |
2 |
key = strings.Join(groups, ".") + "." + a.Key |
| 369 |
2 |
} |
| 370 |
91 |
for _, rule := range rules { |
| 371 |
91 |
if rule.MatchString(key) { |
| 372 |
19 |
return slog.String(a.Key, replacement) |
| 373 |
19 |
} |
| 374 |
|
} |
| 375 |
11 |
return a |
| 376 |
|
} |
| 377 |
|
} |
| 378 |
|
|
| 379 |
|
// maskRules compiles the key list and the pattern into one ordered rule set. |
| 380 |
|
// The key patterns are built the way scribe builds them — anchored to a path |
| 381 |
|
// separator on both sides, case-insensitively — so that the two handlers cannot |
| 382 |
|
// disagree about what "cookie" matches. |
| 383 |
4 |
func (o Options) maskRules() []*regexp.Regexp { |
| 384 |
4 |
rules := make([]*regexp.Regexp, 0, len(o.MaskKeys)+1) |
| 385 |
10 |
for _, key := range o.MaskKeys { |
| 386 |
10 |
if key == "" { |
| 387 |
0 |
continue |
| 388 |
|
} |
| 389 |
10 |
rules = append(rules, regexp.MustCompile(`(?i)(^|\.|\])`+regexp.QuoteMeta(key)+`($|\.|\[)`)) |
| 390 |
|
} |
| 391 |
4 |
if o.MaskPattern != "" { |
| 392 |
3 |
rules = append(rules, regexp.MustCompile(o.MaskPattern)) |
| 393 |
3 |
} |
| 394 |
3 |
return rules |
| 395 |
|
} |
| 396 |
|
|
| 397 |
|
// Install makes h the handler of slog's default logger and returns that logger, |
| 398 |
|
// for the callers that would rather pass a logger than reach for the global. |
| 399 |
|
// |
| 400 |
|
// This is the line the rest of ecore is waiting for. |
| 401 |
|
// [sourcecraft.dev/bigbes/sr-ht-ecore/middleware.RecoverPanics] reports through |
| 402 |
|
// the default logger and takes no logger of its own, so a |
| 403 |
|
// binary that builds a handler and does not install it has its panic reports — |
| 404 |
|
// and only those — come out in Go's plain format, with none of the masking |
| 405 |
|
// below applied. slog.SetDefault also redirects the standard log package's |
| 406 |
|
// output into h, so a dependency that still writes through "log" lands in the |
| 407 |
|
// same stream. |
| 408 |
|
// |
| 409 |
|
// Call it from main, once, before anything logs. |
| 410 |
2 |
func Install(h slog.Handler) *slog.Logger { |
| 411 |
2 |
if h == nil { |
| 412 |
1 |
panic("logging: Install called with a nil handler") |
| 413 |
|
} |
| 414 |
1 |
log := slog.New(h) |
| 415 |
1 |
slog.SetDefault(log) |
| 416 |
1 |
return log |
| 417 |
|
} |