Three improvements bundled because they all ship as zddc-server v0.0.2: * /.admin/ debug dashboard with /whoami, /config, /logs sub-routes. Authorization via a top-level `admins:` glob list in <ZDDC_ROOT>/.zddc (root-only — subdir entries deliberately ignored to prevent privilege escalation via subtree write access). Non-admin requests get 404 so the page is invisible. Recent logs surface via a 500-entry slog ring buffer teed off the existing TextHandler. Lets operators debug without kubectl exec. * Default ZDDC_EMAIL_HEADER changes from `X-Email` to `X-Auth-Request-Email` — the oauth2-proxy / nginx auth-request convention that the TND helm chart already sets explicitly. Operators who set the env var explicitly are unaffected; deployments relying on the previous default need to set ZDDC_EMAIL_HEADER=X-Email or update their proxy. * dispatch() rejects any URL whose segments contain a dot prefix other than the recognized virtual prefixes (.admin, cfg.IndexPath / .archive). Matches the existing listing-pipeline filter so hidden subtrees on the served PVC (e.g. /srv/.devshell — used by the in-cluster dev-shell for persistent home-dir state) become unreachable via direct HTTP fetch, not just hidden in listings. Refreshes the X-Email reference in website/index.html accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
4.3 KiB
Go
169 lines
4.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// LogEntry is one captured slog record, in a shape suitable for JSON.
|
|
type LogEntry struct {
|
|
Time time.Time `json:"time"`
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
Attrs map[string]any `json:"attrs,omitempty"`
|
|
}
|
|
|
|
// LogRing is a fixed-size circular buffer of recent slog records, populated
|
|
// by RingHandler. Snapshot returns a copy in chronological order (oldest →
|
|
// newest); the buffer is never blocking.
|
|
type LogRing struct {
|
|
mu sync.Mutex
|
|
entries []LogEntry
|
|
size int
|
|
next int // index of the slot the next record will be written into
|
|
count int // number of entries currently held (≤ size)
|
|
}
|
|
|
|
// NewLogRing creates a ring with capacity n. Panics on n ≤ 0 — that's a
|
|
// programming error, not a runtime condition.
|
|
func NewLogRing(n int) *LogRing {
|
|
if n <= 0 {
|
|
panic("logring: capacity must be > 0")
|
|
}
|
|
return &LogRing{
|
|
entries: make([]LogEntry, n),
|
|
size: n,
|
|
}
|
|
}
|
|
|
|
func (r *LogRing) push(e LogEntry) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.entries[r.next] = e
|
|
r.next = (r.next + 1) % r.size
|
|
if r.count < r.size {
|
|
r.count++
|
|
}
|
|
}
|
|
|
|
// Snapshot returns the current contents in chronological order.
|
|
func (r *LogRing) Snapshot() []LogEntry {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]LogEntry, r.count)
|
|
if r.count < r.size {
|
|
copy(out, r.entries[:r.count])
|
|
return out
|
|
}
|
|
// Buffer full and wrapping: oldest is at r.next, newest at r.next-1.
|
|
copy(out, r.entries[r.next:])
|
|
copy(out[r.size-r.next:], r.entries[:r.next])
|
|
return out
|
|
}
|
|
|
|
// RingHandler is a slog.Handler that pushes records into a LogRing.
|
|
type RingHandler struct {
|
|
ring *LogRing
|
|
level slog.Leveler
|
|
attrs []slog.Attr
|
|
group string
|
|
}
|
|
|
|
// NewRingHandler returns a handler that records into ring at level and above.
|
|
func NewRingHandler(ring *LogRing, level slog.Leveler) *RingHandler {
|
|
return &RingHandler{ring: ring, level: level}
|
|
}
|
|
|
|
func (h *RingHandler) Enabled(_ context.Context, l slog.Level) bool {
|
|
min := slog.LevelInfo
|
|
if h.level != nil {
|
|
min = h.level.Level()
|
|
}
|
|
return l >= min
|
|
}
|
|
|
|
func (h *RingHandler) Handle(_ context.Context, r slog.Record) error {
|
|
attrs := make(map[string]any, r.NumAttrs()+len(h.attrs))
|
|
for _, a := range h.attrs {
|
|
attrs[a.Key] = a.Value.Any()
|
|
}
|
|
r.Attrs(func(a slog.Attr) bool {
|
|
attrs[a.Key] = a.Value.Any()
|
|
return true
|
|
})
|
|
if len(attrs) == 0 {
|
|
attrs = nil
|
|
}
|
|
h.ring.push(LogEntry{
|
|
Time: r.Time,
|
|
Level: r.Level.String(),
|
|
Message: r.Message,
|
|
Attrs: attrs,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (h *RingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs))
|
|
merged = append(merged, h.attrs...)
|
|
merged = append(merged, attrs...)
|
|
return &RingHandler{ring: h.ring, level: h.level, attrs: merged, group: h.group}
|
|
}
|
|
|
|
func (h *RingHandler) WithGroup(name string) slog.Handler {
|
|
// We don't render groups specially — just track the deepest one for
|
|
// debugging context. Most zddc-server logging is flat.
|
|
return &RingHandler{ring: h.ring, level: h.level, attrs: h.attrs, group: name}
|
|
}
|
|
|
|
// MultiHandler fans out each Handle call to every wrapped handler. Used to
|
|
// tee slog output to both stderr (the existing TextHandler) and the in-memory
|
|
// ring buffer that backs the admin /.admin/logs endpoint.
|
|
type MultiHandler struct {
|
|
handlers []slog.Handler
|
|
}
|
|
|
|
// NewMultiHandler returns a handler that broadcasts to all of hs.
|
|
func NewMultiHandler(hs ...slog.Handler) *MultiHandler {
|
|
return &MultiHandler{handlers: hs}
|
|
}
|
|
|
|
func (m *MultiHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
|
for _, h := range m.handlers {
|
|
if h.Enabled(ctx, l) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *MultiHandler) Handle(ctx context.Context, r slog.Record) error {
|
|
var firstErr error
|
|
for _, h := range m.handlers {
|
|
if !h.Enabled(ctx, r.Level) {
|
|
continue
|
|
}
|
|
if err := h.Handle(ctx, r.Clone()); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func (m *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
out := make([]slog.Handler, len(m.handlers))
|
|
for i, h := range m.handlers {
|
|
out[i] = h.WithAttrs(attrs)
|
|
}
|
|
return &MultiHandler{handlers: out}
|
|
}
|
|
|
|
func (m *MultiHandler) WithGroup(name string) slog.Handler {
|
|
out := make([]slog.Handler, len(m.handlers))
|
|
for i, h := range m.handlers {
|
|
out[i] = h.WithGroup(name)
|
|
}
|
|
return &MultiHandler{handlers: out}
|
|
}
|