Files
felhom.eu/hub/internal/accesslog/accesslog.go
T
admin 7d64038f02
gates / gates (push) Successful in 6m45s
Hub v0.158.0 code (built, NOT deployed): agent-only vouch, R-895 snapshot alarms, the hub's own 90-day access log (R-1140 A); installer --rotate-recovery-only (R-1141); 02/03/05/09 + break-glass
Hub: the R-120 golden guard judges only a CHANGED golden selection; offsite_snapshots_vanished /
_unexplained (operator-only, hub-origin) on the 07:10 listing; internal/accesslog writes path-only
lines to /data/access-log, 90 days. Installer: --rotate-recovery-only runs step 4b alone (rides
the next installer tag). 03: the orphan check runs after a failed backup (agent 6545ccc). 02: the
allow-list is empty, appdata_folders (controller v0.329.0). All red-proofed.
2026-10-17 23:31:34 +02:00

397 lines
11 KiB
Go
Raw 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 accesslog is the hub's OWN access log (R-1140, operator decision A 2026-10-17 22:26: „a separate log for the
// hub only"; `05` §16.4). One line per request the hub serves, in a file per UTC day on the hub's own volume
// (`<data_dir>/access-log/access-YYYY-MM-DD.log`), kept 90 days and then deleted.
//
// WHY: R-954 asked „did anyone use the old hub key before 2026-10-12?" and could not be answered — the ingress
// controller's container log covers about half a day (k3s rotates it at 10 MiB × 5), and the hub logged no requests.
//
// WHAT A LINE HOLDS — and what it never holds. Example:
//
// 2026-10-17T20:35:16Z POST /api/v1/report 200 12ms remote=10.42.0.31 xff=192.168.0.149 cred=bearer auth=customer
//
// The PATH only: the query string is dropped entirely (it can carry a token), and a path segment that is a capability
// (`/bind/<token>`) or any segment of 32+ characters is written as `[redacted]` (TestAccessLog_NeverWritesASecret).
// remote is the socket peer (behind ingress-nginx: the ingress pod); xff is the X-Forwarded-For header as the hub
// received it, each hop kept only if it parses as an IP address (anything else is written as `?`). Neither is trusted
// for anything; both are recorded so the reader can judge. cred is the KIND of credential presented (bearer, token,
// basic, session-cookie, passphrase), read from header names and prefixes — never a value. auth is the identity the
// hub's own auth helpers ESTABLISHED (customer, host, global, session, basic, token), set through Mark; `-` when none
// did (a public route, a refusal, or a handler's own check).
//
// A LOGGING FAILURE NEVER FAILS OR DELAYS A REQUEST. The middleware hands the line to a buffered channel without
// blocking; a single writer goroutine does every file operation. A full buffer drops the line and counts it; an
// unwritable directory is reported (rate-limited) and the request is served as before.
//
// kube-probe's own /healthz requests (no X-Forwarded-For, User-Agent kube-probe/…) are not logged: 11,520 lines a day
// that say nothing, and they would be 85 % of the file. A /healthz that came through the ingress is logged.
package accesslog
import (
"context"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// DefaultKeep is how long a day's file is kept (operator decision A on R-1140: 90 days).
const DefaultKeep = 90 * 24 * time.Hour
const bufferLines = 2048
var fileRe = regexp.MustCompile(`^access-(\d{4}-\d{2}-\d{2})\.log$`)
// Log is the hub's access log. Zero values are not usable; build it with New.
type Log struct {
dir string
keep time.Duration
now func() time.Time
logf func(format string, a ...any)
ch chan string
done chan struct{}
// writer-goroutine state (touched only by run)
f *os.File
day string
lastPrune string
lastErrAt time.Time
mu sync.Mutex
dropped int64
closed bool
}
// Options configure New. Now and Logf are seams for tests; nil → time.Now / discard. Logf receives the access log's
// own operational lines (a rate-limited write failure, a prune) — never a request line.
type Options struct {
Dir string
Keep time.Duration
Now func() time.Time
Logf func(format string, a ...any)
}
// New starts the writer and prunes once. The directory is created on first write, never at construction, so a
// missing volume costs a logged error and nothing else.
func New(o Options) *Log {
l := &Log{dir: o.Dir, keep: o.Keep, now: o.Now, logf: o.Logf,
ch: make(chan string, bufferLines), done: make(chan struct{})}
if l.keep <= 0 {
l.keep = DefaultKeep
}
if l.now == nil {
l.now = time.Now
}
if l.logf == nil {
l.logf = func(string, ...any) {}
}
go l.run()
return l
}
// Close drains the buffer, closes the file and stops the writer. Safe to call twice.
func (l *Log) Close() {
l.mu.Lock()
if l.closed {
l.mu.Unlock()
return
}
l.closed = true
close(l.ch)
l.mu.Unlock()
<-l.done
}
// Dropped is how many lines a full buffer (or a closed log) refused.
func (l *Log) Dropped() int64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.dropped
}
func (l *Log) enqueue(line string) {
l.mu.Lock()
defer l.mu.Unlock()
if l.closed {
l.dropped++
return
}
select {
case l.ch <- line:
default:
l.dropped++
}
}
func (l *Log) run() {
defer close(l.done)
l.prune()
for line := range l.ch {
l.write(line)
}
if l.f != nil {
_ = l.f.Close()
}
}
func (l *Log) fail(what string, err error) {
// One report a minute at most: a full or missing volume must not flood the hub's own log.
if now := l.now(); now.Sub(l.lastErrAt) >= time.Minute {
l.lastErrAt = now
l.logf("[WARN] access log: %s: %v (the request was served; this line is lost)", what, err)
}
}
func (l *Log) write(line string) {
day := l.now().UTC().Format("2006-01-02")
if l.f == nil || day != l.day {
if l.f != nil {
_ = l.f.Close()
l.f = nil
}
if err := os.MkdirAll(l.dir, 0o700); err != nil {
l.fail("create "+l.dir, err)
return
}
f, err := os.OpenFile(filepath.Join(l.dir, "access-"+day+".log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
l.fail("open the day's file", err)
return
}
l.f, l.day = f, day
if day != l.lastPrune {
l.prune()
}
}
if _, err := l.f.WriteString(line); err != nil {
l.fail("write", err)
}
}
// prune deletes day files older than keep. Only names of the exact form access-YYYY-MM-DD.log are ever removed.
func (l *Log) prune() {
now := l.now().UTC()
l.lastPrune = now.Format("2006-01-02")
cutoff := now.Add(-l.keep).Format("2006-01-02")
ents, err := os.ReadDir(l.dir)
if err != nil {
if !os.IsNotExist(err) {
l.fail("read "+l.dir, err)
}
return
}
var removed []string
for _, e := range ents {
m := fileRe.FindStringSubmatch(e.Name())
if m == nil || e.IsDir() {
continue
}
if _, perr := time.Parse("2006-01-02", m[1]); perr != nil {
continue
}
if m[1] < cutoff {
if err := os.Remove(filepath.Join(l.dir, e.Name())); err != nil {
l.fail("remove "+e.Name(), err)
continue
}
removed = append(removed, e.Name())
}
}
if len(removed) > 0 {
sort.Strings(removed)
l.logf("[INFO] access log: removed %d day file(s) older than %d days: %s", len(removed), int(l.keep.Hours()/24), strings.Join(removed, ", "))
}
}
// ── identity marks ──────────────────────────────────────────────────────────────────────────────────
type ctxKey struct{}
type entry struct {
mu sync.Mutex
auth string
}
// Mark records the identity kind an auth helper established for this request ("customer", "host", "global",
// "session", "basic", "token"). A KIND only — never a name, a key or a token. No-op outside the middleware.
func Mark(ctx context.Context, kind string) {
if e, ok := ctx.Value(ctxKey{}).(*entry); ok {
e.mu.Lock()
e.auth = kind
e.mu.Unlock()
}
}
// ── the middleware ──────────────────────────────────────────────────────────────────────────────────
type recorder struct {
http.ResponseWriter
status int
}
func (r *recorder) WriteHeader(code int) {
if r.status == 0 {
r.status = code
}
r.ResponseWriter.WriteHeader(code)
}
func (r *recorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
return r.ResponseWriter.Write(b)
}
// Flush and Unwrap keep http.ResponseController (the /api/v1/wait long-poll's Flush and SetWriteDeadline) working.
func (r *recorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
// Middleware logs every request next serves. nil l → next unchanged.
func (l *Log) Middleware(next http.Handler) http.Handler {
if l == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" && r.Header.Get("X-Forwarded-For") == "" && strings.HasPrefix(r.UserAgent(), "kube-probe/") {
next.ServeHTTP(w, r)
return
}
start := l.now()
e := &entry{}
rec := &recorder{ResponseWriter: w}
finished := false
defer func() {
status := fmt.Sprint(rec.status)
if !finished {
status = "panic"
} else if rec.status == 0 {
status = "200"
}
e.mu.Lock()
auth := e.auth
e.mu.Unlock()
l.enqueue(FormatLine(start, r, status, l.now().Sub(start), auth))
}()
next.ServeHTTP(rec, r.WithContext(context.WithValue(r.Context(), ctxKey{}, e)))
finished = true
})
}
// FormatLine renders one line. Exported for the size measurement and the tests; it reads only the method, the PATH,
// the socket peer, X-Forwarded-For (IPs only) and the NAMES/prefixes of the credential headers.
func FormatLine(start time.Time, r *http.Request, status string, dur time.Duration, auth string) string {
if auth == "" {
auth = "-"
}
remote := r.RemoteAddr
if h, _, err := net.SplitHostPort(remote); err == nil {
remote = h
}
return fmt.Sprintf("%s %s %s %s %dms remote=%s xff=%s cred=%s auth=%s\n",
start.UTC().Format("2006-01-02T15:04:05Z"), safeToken(r.Method, 10), SafePath(r), status,
dur.Milliseconds(), safeIP(remote), safeXFF(r.Header.Get("X-Forwarded-For")), credKind(r), auth)
}
// SafePath is the request path with no query string, escaped (no space or newline can reach the file), with capability
// segments redacted, at most 256 bytes.
func SafePath(r *http.Request) string {
p := r.URL.EscapedPath()
if p == "" {
p = "/"
}
segs := strings.Split(p, "/")
for i, s := range segs {
switch {
case i > 0 && segs[i-1] == "bind" && i == 2: // /bind/<token>[/resend] — the self-bind capability
segs[i] = "[redacted]"
case len(s) >= 32:
segs[i] = "[redacted]"
}
}
p = strings.Join(segs, "/")
if len(p) > 256 {
p = p[:256] + "…"
}
return p
}
func safeToken(s string, max int) string {
var b strings.Builder
for _, c := range s {
if c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' {
b.WriteRune(c)
}
}
if b.Len() == 0 || b.Len() > max {
return "?"
}
return b.String()
}
func safeIP(s string) string {
if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil {
return ip.String()
}
if s == "" {
return "-"
}
return "?"
}
func safeXFF(h string) string {
if strings.TrimSpace(h) == "" {
return "-"
}
var hops []string
for i, part := range strings.Split(h, ",") {
if i == 8 {
hops = append(hops, "…")
break
}
hops = append(hops, safeIP(part))
}
return strings.Join(hops, ",")
}
// credKind names the credential presented — never its value.
func credKind(r *http.Request) string {
var kinds []string
if a := r.Header.Get("Authorization"); a != "" {
switch {
case strings.HasPrefix(a, "Bearer fhp_"):
kinds = append(kinds, "token")
case strings.HasPrefix(a, "Bearer "):
kinds = append(kinds, "bearer")
case strings.HasPrefix(a, "Basic "):
kinds = append(kinds, "basic")
default:
kinds = append(kinds, "other")
}
}
for _, c := range r.Cookies() {
if strings.HasSuffix(c.Name, "hub_session") {
kinds = append(kinds, "session-cookie")
break
}
}
if r.Header.Get("X-Retrieval-Password") != "" {
kinds = append(kinds, "passphrase")
}
if len(kinds) == 0 {
return "-"
}
return strings.Join(kinds, "+")
}