Files
OwnCord/Server/admin/logstream.go
T
jevb 447a4543e7 chore: remaining server changes (code quality, go mod tidy)
Go mod tidy, minor server-side adjustments from security verification
and code quality cleanup pass.
2026-04-01 11:38:33 +02:00

434 lines
11 KiB
Go

package admin
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"runtime"
"strings"
"sync"
"time"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// ─── Ticket Store for SSE Log Stream ────────────────────────────────────────
// ticketEntry holds a single-use ticket with a creation timestamp for TTL.
type ticketEntry struct {
createdAt time.Time
tokenHash string
}
// ticketStore manages short-lived, single-use tickets for SSE authentication.
type ticketStore struct {
mu sync.Mutex
tickets map[string]ticketEntry
}
var logTickets = &ticketStore{
tickets: make(map[string]ticketEntry),
}
const ticketTTL = 30 * time.Second
// issue creates a new single-use ticket and returns its hex string.
func (ts *ticketStore) issue(tokenHash string) (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generating ticket: %w", err)
}
ticket := hex.EncodeToString(b)
ts.mu.Lock()
defer ts.mu.Unlock()
// Opportunistic cleanup of expired tickets.
now := time.Now()
for k, v := range ts.tickets {
if now.Sub(v.createdAt) > ticketTTL {
delete(ts.tickets, k)
}
}
ts.tickets[ticket] = ticketEntry{createdAt: now, tokenHash: tokenHash}
return ticket, nil
}
// redeem validates and consumes a ticket.
func (ts *ticketStore) redeem(ticket string) (ticketEntry, bool) {
ts.mu.Lock()
defer ts.mu.Unlock()
entry, ok := ts.tickets[ticket]
if !ok {
return ticketEntry{}, false
}
delete(ts.tickets, ticket) // single-use: delete immediately
if time.Since(entry.createdAt) > ticketTTL {
return ticketEntry{}, false
}
return entry, true
}
// handleLogTicket issues a short-lived, single-use ticket for the SSE log stream.
// POST /admin/api/logs/ticket — requires normal admin auth (cookie/header).
func handleLogTicket(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, ok := r.Context().Value(adminSessionKey).(*db.Session)
if !ok || sess == nil || sess.TokenHash == "" {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session")
return
}
ticket, err := logTickets.issue(sess.TokenHash)
if err != nil {
slog.Error("failed to issue log stream ticket", "err", err)
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate ticket")
return
}
writeJSON(w, http.StatusOK, map[string]string{"ticket": ticket})
}
}
// LogEntry holds a single structured log record for the ring buffer.
type LogEntry struct {
Timestamp string `json:"ts"`
Level string `json:"level"`
Message string `json:"msg"`
Source string `json:"source"`
Attrs string `json:"attrs,omitempty"`
}
// RingBuffer is a bounded, thread-safe circular buffer of log entries
// with fan-out to SSE subscriber channels.
type RingBuffer struct {
mu sync.Mutex
entries []LogEntry
capacity int
subscribers map[*chan LogEntry]struct{}
}
// NewRingBuffer creates a ring buffer with the given capacity.
func NewRingBuffer(capacity int) *RingBuffer {
return &RingBuffer{
entries: make([]LogEntry, 0, capacity),
capacity: capacity,
subscribers: make(map[*chan LogEntry]struct{}),
}
}
// Write appends an entry, drops the oldest if full, and fans out
// to all subscribers (non-blocking to avoid slow clients blocking logging).
func (rb *RingBuffer) Write(entry LogEntry) {
rb.mu.Lock()
defer rb.mu.Unlock()
if len(rb.entries) >= rb.capacity {
// Copy to a new slice to release the backing array's first slot,
// preventing unbounded growth from repeated re-slicing.
fresh := make([]LogEntry, rb.capacity-1, rb.capacity)
copy(fresh, rb.entries[1:])
rb.entries = fresh
}
rb.entries = append(rb.entries, entry)
for chp := range rb.subscribers {
select {
case *chp <- entry:
default:
// Slow subscriber — drop to avoid blocking.
}
}
}
// Snapshot returns a copy of all current entries for backfill.
func (rb *RingBuffer) Snapshot() []LogEntry {
rb.mu.Lock()
defer rb.mu.Unlock()
out := make([]LogEntry, len(rb.entries))
copy(out, rb.entries)
return out
}
// Subscribe creates a buffered channel for a new SSE client.
// Returns the channel and an unsubscribe function.
func (rb *RingBuffer) Subscribe() (<-chan LogEntry, func()) {
ch := make(chan LogEntry, 64)
chp := &ch
rb.mu.Lock()
rb.subscribers[chp] = struct{}{}
rb.mu.Unlock()
return ch, func() {
rb.mu.Lock()
delete(rb.subscribers, chp)
rb.mu.Unlock()
}
}
// multiHandler is an slog.Handler that tees records to two handlers:
// the original stdout handler and a ring buffer handler.
type multiHandler struct {
stdout slog.Handler
ring *ringHandler
}
// ringHandler converts slog.Records into LogEntries and writes them
// to the RingBuffer.
type ringHandler struct {
buf *RingBuffer
level slog.Leveler
attrs []slog.Attr
groups []string
}
// NewMultiHandler creates a handler that sends records to both stdout
// and the ring buffer. The ring buffer captures all levels from minLevel.
func NewMultiHandler(stdout slog.Handler, buf *RingBuffer, minLevel slog.Leveler) slog.Handler {
return &multiHandler{
stdout: stdout,
ring: &ringHandler{
buf: buf,
level: minLevel,
},
}
}
func (h *multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.stdout.Enabled(ctx, level) || h.ring.Enabled(level)
}
func (h *multiHandler) Handle(ctx context.Context, r slog.Record) error {
if h.stdout.Enabled(ctx, r.Level) {
_ = h.stdout.Handle(ctx, r)
}
if h.ring.Enabled(r.Level) {
h.ring.Handle(r)
}
return nil
}
func (h *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &multiHandler{
stdout: h.stdout.WithAttrs(attrs),
ring: h.ring.withAttrs(attrs),
}
}
func (h *multiHandler) WithGroup(name string) slog.Handler {
return &multiHandler{
stdout: h.stdout.WithGroup(name),
ring: h.ring.withGroup(name),
}
}
func (rh *ringHandler) Enabled(level slog.Level) bool {
return level >= rh.level.Level()
}
func (rh *ringHandler) Handle(r slog.Record) {
// Build source from file path.
source := categorizeSource(r)
// Collect attributes as a JSON object.
attrs := make(map[string]any)
// Add pre-set attrs from WithAttrs.
for _, a := range rh.attrs {
attrs[a.Key] = a.Value.Any()
}
// Add record attrs.
r.Attrs(func(a slog.Attr) bool {
key := a.Key
if len(rh.groups) > 0 {
key = strings.Join(rh.groups, ".") + "." + key
}
attrs[key] = a.Value.Any()
return true
})
var attrsJSON string
if len(attrs) > 0 {
if b, err := json.Marshal(attrs); err == nil {
attrsJSON = string(b)
}
}
rh.buf.Write(LogEntry{
Timestamp: r.Time.Format(time.RFC3339Nano),
Level: r.Level.String(),
Message: r.Message,
Source: source,
Attrs: attrsJSON,
})
}
func (rh *ringHandler) withAttrs(attrs []slog.Attr) *ringHandler {
combined := make([]slog.Attr, len(rh.attrs)+len(attrs))
copy(combined, rh.attrs)
copy(combined[len(rh.attrs):], attrs)
return &ringHandler{
buf: rh.buf,
level: rh.level,
attrs: combined,
groups: rh.groups,
}
}
func (rh *ringHandler) withGroup(name string) *ringHandler {
groups := make([]string, len(rh.groups)+1)
copy(groups, rh.groups)
groups[len(rh.groups)] = name
return &ringHandler{
buf: rh.buf,
level: rh.level,
attrs: rh.attrs,
groups: groups,
}
}
// categorizeSource extracts a human-readable source category from the log record.
func categorizeSource(r slog.Record) string {
if r.PC == 0 {
return "server"
}
// Use runtime frame to get the source file path.
frames := runtime.CallersFrames([]uintptr{r.PC})
frame, _ := frames.Next()
file := frame.File
switch {
case strings.Contains(file, "/ws/"):
return "websocket"
case strings.Contains(file, "/api/"):
return "http"
case strings.Contains(file, "/admin/"):
return "admin"
case strings.Contains(file, "/auth/"):
return "auth"
case strings.Contains(file, "/db/"):
return "database"
case strings.Contains(file, "/storage/"):
return "storage"
case strings.Contains(file, "/updater/"):
return "updater"
case strings.Contains(file, "/config/"):
return "config"
default:
return "server"
}
}
// handleLogStream serves an SSE endpoint that streams log entries in real-time.
// Auth is via query param ?ticket= — a short-lived single-use ticket obtained
// from POST /admin/api/logs/ticket (which requires normal admin auth).
func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Authenticate via single-use ticket.
ticket := r.URL.Query().Get("ticket")
entry, ok := logTickets.redeem(ticket)
if ticket == "" || !ok {
errResp, _ := json.Marshal(map[string]string{
"error": "UNAUTHORIZED",
"message": "invalid or expired ticket",
})
http.Error(w, string(errResp), http.StatusUnauthorized)
return
}
sess, err := database.GetSessionByTokenHash(entry.tokenHash)
if err != nil || sess == nil || auth.IsSessionExpired(sess.ExpiresAt) {
errResp, _ := json.Marshal(map[string]string{
"error": "UNAUTHORIZED",
"message": "invalid or expired session",
})
http.Error(w, string(errResp), http.StatusUnauthorized)
return
}
sessionStillAuthorized := func() bool {
current, currentErr := database.GetSessionByTokenHash(entry.tokenHash)
if currentErr != nil || current == nil || auth.IsSessionExpired(current.ExpiresAt) {
return false
}
user, userErr := database.GetUserByID(current.UserID)
if userErr != nil || user == nil {
return false
}
role, roleErr := database.GetRoleByID(user.RoleID)
if roleErr != nil || role == nil {
return false
}
return permissions.HasAdmin(role.Permissions)
}
if !sessionStillAuthorized() {
errResp, _ := json.Marshal(map[string]string{
"error": "FORBIDDEN",
"message": "administrator permission required",
})
http.Error(w, string(errResp), http.StatusForbidden)
return
}
// Check that we can flush (required for SSE).
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
// Set SSE headers.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
// Send backfill.
for _, entry := range ringBuf.Snapshot() {
if !sessionStillAuthorized() {
return
}
if data, err := json.Marshal(entry); err == nil {
_, _ = fmt.Fprintf(w, "data: %s\n\n", data)
}
}
flusher.Flush()
// Subscribe for new entries.
ch, unsub := ringBuf.Subscribe()
defer unsub()
// Keepalive ticker to avoid WriteTimeout (30s).
keepalive := time.NewTicker(15 * time.Second)
defer keepalive.Stop()
ctx := r.Context()
for {
select {
case entry := <-ch:
if !sessionStillAuthorized() {
return
}
if data, err := json.Marshal(entry); err == nil {
_, _ = fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
case <-keepalive.C:
if !sessionStillAuthorized() {
return
}
_, _ = fmt.Fprint(w, ": keepalive\n\n")
flusher.Flush()
case <-ctx.Done():
return
}
}
}
}