feat(server): logging & error-visibility hardening

Make server failures debuggable without leaking secrets:
- configurable stdout log level (config.yaml logging.level + OWNCORD_LOGGING_LEVEL)
- preserve the DB cause in ErrInternal wraps; log auth-DB failures distinctly
  from bad tokens; log the previously-silent expired-session cleanup goroutine
- route HTTP handler panics through slog (was chi stderr-only, invisible to
  the admin log stream)
- stackutil: argument-free panic stacks so key/token bytes never reach the
  admin ring buffer / SSE; slog.LogValuer redaction on VoiceConfig/GitHubConfig/
  GIFConfig/Config and db.User/db.Session
- logctx: req_id/trace_id correlation on ...Context log calls

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-24 11:07:12 +02:00
co-authored by Claude Opus 4.8
parent 4b36a3339a
commit 4fc21cb372
29 changed files with 623 additions and 64 deletions
+2 -4
View File
@@ -6,7 +6,6 @@ import (
"context"
"fmt"
"log/slog"
"runtime"
"sync"
"sync/atomic"
"time"
@@ -16,6 +15,7 @@ import (
"github.com/owncord/server/permissions"
"github.com/owncord/server/plugin"
"github.com/owncord/server/service"
"github.com/owncord/server/stackutil"
"github.com/owncord/server/syncutil"
)
@@ -276,12 +276,10 @@ func (h *Hub) Run() {
}
panicCount++
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
slog.Error("hub: panic recovered",
"panic", r,
"panic_count", panicCount,
"stack", string(buf[:n]))
"stack", stackutil.Capture())
if panicCount >= 3 {
slog.Error("hub: too many panics in 60s, stopping")
+5 -6
View File
@@ -4,7 +4,8 @@ import (
"context"
"fmt"
"log/slog"
"runtime"
"github.com/owncord/server/stackutil"
)
// handlerV2Entry pairs a V2 handler with its domain-specific dependency struct.
@@ -47,15 +48,13 @@ func (r *HandlerRegistry) DispatchV2(ctx context.Context, cmd Command, info Clie
}
defer func() {
if rec := recover(); rec != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
// TODO: stack trace may contain sensitive function arguments
// (e.g. encrypted keys). Consider scrubbing or limiting frames.
// stackutil.Capture omits argument values, which for E2EE
// handlers can include encrypted key material.
slog.Error("DispatchV2 panic recovered",
"type", cmd.Type(),
"user_id", info.UserID,
"panic", rec,
"stack", string(buf[:n]),
"stack", stackutil.Capture(),
)
result = Result{Error: ClientError{Code: ErrCodeInternal, Message: "internal error"}}
ok = true
+8
View File
@@ -503,6 +503,11 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db
sess, err := database.GetSessionByTokenHash(ctx, hash)
if err != nil || sess == nil {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token"))
if err != nil {
// DB outage, not a bad token — carry the cause so the caller's log
// distinguishes it from an ordinary invalid-token rejection.
return nil, "", 0, fmt.Errorf("auth: session lookup failed: %w", err)
}
return nil, "", 0, fmt.Errorf("auth: invalid session")
}
@@ -514,6 +519,9 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db
user, err := database.GetUserByID(ctx, sess.UserID)
if err != nil || user == nil {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("user not found"))
if err != nil {
return nil, "", 0, fmt.Errorf("auth: user lookup failed: %w", err)
}
return nil, "", 0, fmt.Errorf("auth: user not found")
}
+11 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"fmt"
"log/slog"
"time"
)
@@ -226,10 +227,18 @@ func (h *Hub) sendToUserIfInVoiceChannel(voiceChannelID, targetUserID int64, msg
target, ok := h.clients[targetUserID]
if !ok {
return // target not connected — silently drop
// Undeliverable key offer can leave the peer unable to decrypt — the
// payload is dropped, but log (IDs only, never the encrypted key) so
// the failure is diagnosable rather than silent.
slog.Debug("e2ee: key offer dropped, target not connected",
"target_user_id", targetUserID, "voice_channel_id", voiceChannelID)
return
}
if target.getVoiceChID() != voiceChannelID {
return // target not in expected voice channel — silently drop
slog.Debug("e2ee: key offer dropped, target not in expected voice channel",
"target_user_id", targetUserID, "voice_channel_id", voiceChannelID,
"target_voice_channel_id", target.getVoiceChID())
return
}
target.sendMsg(msg)
}