Files
felhom.eu/hub/internal/accesslog/accesslog_test.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

236 lines
8.9 KiB
Go

package accesslog
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
"time"
)
type clock struct {
mu sync.Mutex
t time.Time
}
func (c *clock) now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
func (c *clock) set(t time.Time) { c.mu.Lock(); c.t = t; c.mu.Unlock() }
func readAll(t *testing.T, dir string) string {
t.Helper()
ents, _ := os.ReadDir(dir)
var b strings.Builder
for _, e := range ents {
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
b.Write(raw)
}
return b.String()
}
func serve(h http.Handler, r *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// A request is logged: one line, in today's file, with method, path, status, the socket peer, the forwarded-for hop and
// the identity kind the handler's auth helper marked.
func TestAccessLog_ARequestIsLogged(t *testing.T) {
dir := t.TempDir()
c := &clock{t: time.Date(2026, 10, 17, 20, 35, 16, 0, time.UTC)}
l := New(Options{Dir: dir, Now: c.now})
h := l.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Mark(r.Context(), "customer")
w.WriteHeader(http.StatusAccepted)
}))
r := httptest.NewRequest(http.MethodPost, "/api/v1/report", nil)
r.RemoteAddr = "10.42.0.31:51234"
r.Header.Set("X-Forwarded-For", "192.168.0.149")
r.Header.Set("Authorization", "Bearer 0123")
if w := serve(h, r); w.Code != http.StatusAccepted {
t.Fatalf("status %d", w.Code)
}
l.Close()
raw, err := os.ReadFile(filepath.Join(dir, "access-2026-10-17.log"))
if err != nil {
t.Fatalf("today's file: %v", err)
}
want := "2026-10-17T20:35:16Z POST /api/v1/report 202 0ms remote=10.42.0.31 xff=192.168.0.149 cred=bearer auth=customer\n"
if string(raw) != want {
t.Fatalf("line =\n%q\nwant\n%q", raw, want)
}
if fi, _ := os.Stat(filepath.Join(dir, "access-2026-10-17.log")); fi.Mode().Perm() != 0o600 {
t.Fatalf("file mode %v, want 0600", fi.Mode().Perm())
}
}
// NO SECRET REACHES THE FILE: a bearer token, an operator access token, Basic credentials, a session cookie, a
// retrieval passphrase, a token in the query string, a self-bind capability in the path, a long secret-shaped path
// segment, and a secret stuffed into X-Forwarded-For. Positive control: the same line's path and method ARE there.
// RED-PROOF (2026-10-17 night): with SafePath returning r.URL.RequestURI() (the raw URL), this fails with
// `secrets written to the access log: [bind query segment]`.
func TestAccessLog_NeverWritesASecret(t *testing.T) {
secrets := map[string]string{
"bearer": "4f1c9e2b7a60d38e5b9c0a1f2e3d4c5b6a7980f1e2d3c4b5a69788f0e1d2c3b4",
"fhp": "fhp_abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrst",
"basic": "operator-password-Xy9",
"cookie": "c0ffee0123456789c0ffee0123456789c0ffee0123456789c0ffee0123456789",
"passphrase": "korte-alma-szilva-barack",
"query": "QUERYTOKEN-5d2a9f",
"bind": "BINDTOKEN-77aa88bb99cc",
"segment": "Zm9vYmFyYmF6cXV4cXV1eHF1dXhxdXV4cXV1eA",
"xff": "XFFSECRET-123",
}
dir := t.TempDir()
l := New(Options{Dir: dir})
h := l.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
reqs := []*http.Request{
httptest.NewRequest(http.MethodGet, "/api/v1/wait?gen=4&token="+secrets["query"], nil),
httptest.NewRequest(http.MethodGet, "/bind/"+secrets["bind"], nil),
httptest.NewRequest(http.MethodPost, "/bind/"+secrets["bind"]+"/resend", nil),
httptest.NewRequest(http.MethodGet, "/api/v1/assets/file/"+secrets["segment"], nil),
httptest.NewRequest(http.MethodGet, "/configuration", nil),
}
reqs[0].Header.Set("Authorization", "Bearer "+secrets["bearer"])
reqs[1].Header.Set("Authorization", "Bearer "+secrets["fhp"])
reqs[2].SetBasicAuth("operator", secrets["basic"])
reqs[3].AddCookie(&http.Cookie{Name: "__Host-hub_session", Value: secrets["cookie"]})
reqs[4].Header.Set("X-Retrieval-Password", secrets["passphrase"])
reqs[4].Header.Set("X-Forwarded-For", secrets["xff"]+", 192.168.0.149")
for _, r := range reqs {
serve(h, r)
}
l.Close()
got := readAll(t, dir)
var leaked []string
for name, v := range secrets {
if strings.Contains(got, v) {
leaked = append(leaked, name)
}
}
sort.Strings(leaked)
if len(leaked) > 0 {
t.Fatalf("secrets written to the access log: %v\n%s", leaked, got)
}
for _, want := range []string{"GET /api/v1/wait 200", "GET /bind/[redacted] 200", "POST /bind/[redacted]/resend",
"/api/v1/assets/file/[redacted]", "cred=token", "cred=basic", "cred=session-cookie", "cred=passphrase", "xff=?,192.168.0.149"} {
if !strings.Contains(got, want) {
t.Errorf("positive control missing %q in\n%s", want, got)
}
}
if n := strings.Count(got, "\n"); n != len(reqs) {
t.Errorf("%d line(s), want %d", n, len(reqs))
}
}
// Day files older than 90 days are removed — at start and at each new day — and nothing else in the directory is.
func TestAccessLog_Keeps90DaysThenDeletes(t *testing.T) {
dir := t.TempDir()
for _, n := range []string{"access-2026-07-18.log", "access-2026-07-19.log", "access-2026-10-16.log", "notes.txt", "access-2026-07-01.log.keep"} {
if err := os.WriteFile(filepath.Join(dir, n), []byte("x\n"), 0o600); err != nil {
t.Fatal(err)
}
}
c := &clock{t: time.Date(2026, 10, 17, 1, 0, 0, 0, time.UTC)}
l := New(Options{Dir: dir, Now: c.now})
h := l.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
serve(h, httptest.NewRequest(http.MethodGet, "/", nil))
// The next day: the first line of 2026-10-18 opens a new file and prunes again (2026-07-19 is now 91 days old).
// Let the writer take the first line on 10-17 before the clock moves (poll the file, never a bare sleep).
for i := 0; ; i++ {
if _, err := os.Stat(filepath.Join(dir, "access-2026-10-17.log")); err == nil {
break
}
if i == 200 {
t.Fatal("the 10-17 line was never written")
}
time.Sleep(10 * time.Millisecond)
}
c.set(time.Date(2026, 10, 18, 0, 0, 5, 0, time.UTC))
serve(h, httptest.NewRequest(http.MethodGet, "/", nil))
l.Close()
var names []string
ents, _ := os.ReadDir(dir)
for _, e := range ents {
names = append(names, e.Name())
}
sort.Strings(names)
want := []string{"access-2026-07-01.log.keep", "access-2026-10-16.log", "access-2026-10-17.log", "access-2026-10-18.log", "notes.txt"}
if strings.Join(names, " ") != strings.Join(want, " ") {
t.Fatalf("files = %v\nwant %v", names, want)
}
}
// A logging failure never fails the request: the directory cannot be created, the handler's answer is unchanged, and
// the failure is reported (once a minute at most) to the hub's own log.
func TestAccessLog_FailureNeverFailsTheRequest(t *testing.T) {
blocker := filepath.Join(t.TempDir(), "file-not-dir")
if err := os.WriteFile(blocker, nil, 0o600); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var reported []string
l := New(Options{Dir: filepath.Join(blocker, "access-log"), Logf: func(f string, a ...any) {
mu.Lock()
reported = append(reported, f)
mu.Unlock()
}})
h := l.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
_, _ = w.Write([]byte("served"))
}))
for i := 0; i < 3; i++ {
w := serve(h, httptest.NewRequest(http.MethodGet, "/x", nil))
if w.Code != http.StatusTeapot || w.Body.String() != "served" {
t.Fatalf("request %d altered by the log failure: %d %q", i, w.Code, w.Body.String())
}
}
l.Close()
mu.Lock()
defer mu.Unlock()
if len(reported) != 1 || !strings.Contains(reported[0], "request was served") {
t.Fatalf("failure reports = %q, want exactly one (rate-limited)", reported)
}
}
// The long-poll keeps working through the recorder: http.ResponseController reaches Flush through Unwrap/Flush.
func TestAccessLog_FlushPassesThrough(t *testing.T) {
l := New(Options{Dir: t.TempDir()})
defer l.Close()
var ferr error
h := l.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
ferr = http.NewResponseController(w).Flush()
}))
w := serve(h, httptest.NewRequest(http.MethodGet, "/api/v1/wait", nil))
if ferr != nil || !w.Flushed {
t.Fatalf("flush through the access log: err=%v flushed=%v", ferr, w.Flushed)
}
}
// kube-probe's own /healthz is not logged; one that came through the ingress is.
func TestAccessLog_SkipsOnlyTheKubeletProbe(t *testing.T) {
dir := t.TempDir()
l := New(Options{Dir: dir})
h := l.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
probe := httptest.NewRequest(http.MethodGet, "/healthz", nil)
probe.Header.Set("User-Agent", "kube-probe/1.31")
serve(h, probe)
viaIngress := httptest.NewRequest(http.MethodGet, "/healthz", nil)
viaIngress.Header.Set("User-Agent", "kube-probe/1.31")
viaIngress.Header.Set("X-Forwarded-For", "37.191.56.193")
serve(h, viaIngress)
l.Close()
got := readAll(t, dir)
if strings.Count(got, "/healthz") != 1 || !strings.Contains(got, "xff=37.191.56.193") {
t.Fatalf("want exactly the ingress /healthz logged, got:\n%s", got)
}
}