mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(service): settings family — SettingsService over the Store seam The B3-8 settings/audit family's service: List, Patch (whitelist, boolean normalization, the require_2fa preconditions incl. the TOTP census and the unrelated-key guard, atomic apply, one audit row per changed key) and Setting (the read the hub and the backup scheduler consume; wraps db.ErrNotFound as the store reports it). db gains ApplySettings — the handler's raw upsert loop as one hand-written transactional wrapper where raw SQL belongs — and Store carries it. parseSettingsPatchBool duplicates auth.go's parseBooleanSettingValue with the admin surface's own pinned error wording; both messages are test-pinned, so the twins stay separate. Service-level characterization in settings_test.go mirrors the admin/api_test.go PATCH rows and adds the service-only contracts (ErrNotFound wrap, audit rows, multi-key apply). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * refactor(admin): settings handlers thin over SettingsService; scheduler reads via it handleGetSettings/handlePatchSettings become adapters (decode, delegate, map ErrBadRequest to 400 with the service's prefix-free message); the whitelist and every precondition now live only in the service, so admin/types.go's copy is gone. MaintainBackups reads backup_schedule and backup_retention through the service — its backup mechanics keep the handle — and the maintenance chain threads Settings from the runtime the hub stage built. NewHandler/NewAdminAPI gain the settings parameter; all 207 construction sites wired via the newTestSettingsService helper. Behavior parity pinned by the existing TestAdminAPI_*Settings* rows (all green); the only unpinned change is the PATCH 500 path collapsing its four stage-specific internal messages into one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * refactor(ws): hub settings cache reads through a SettingsReader The hub's server_name/motd cache consumes a consumer-side SettingsReader interface (service.SettingsService satisfies it; HubOptions.Settings is required and validated like DB and Limiter — the RequiredCollaborators pin gains the refusal case). hub_settings.go no longer touches db at all, so the import pin from the B3-5 finisher goes, and its allowlist row goes with it; the thinned admin settings handler's row is deleted too — two allowlist rows down, the settings family's persistence now lives only in db/ and service/. Test helpers (both ws package namespaces) default the reader over the test database; newBareHub wires it explicitly; production passes Services.Settings from StartRuntime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * docs(boundaries,b3): settings/audit family re-measure and evidence The backup pair takes its forecast boundary disposition; the family's two deleted rows and the disposition counts (28/18/15 -> 24/18/17) re-derived from the tool. Family evidence block appended to the B3-8 section; README B3 row records B3-5 complete and the family opened. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * fix(service): prefix-free ErrBadRequest wraps for the pinned admin bodies The %.0w rework was meant to ride the service commit but was left unstaged: with the plain %w wrap the PATCH error bodies carry a 'bad request: ' prefix the admin pins reject. Zero-width wrapping keeps errors.Is(ErrBadRequest) while err.Error() stays exactly the pinned message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * test(app): lifecycle hub fixtures wire the required Settings reader The two direct ws.NewHub sites in lifecycle_test predate Settings becoming required; race across internal/app is green again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * test(db): cover ApplySettings — the db coverage floor caught the gap CI's coverage floor failed db at 78.9% against 79.3%: ApplySettings was exercised only from service tests, which do not count toward db's own figure. Four db-side rows cover the apply, the empty no-op, the in-transaction failure rollback and the begin failure, using the package's full-migration opener. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * chore(coverage): raise the service floor to the branch's measured 69.2 The settings family's tested service code raised the Linux figure from the 67.8 floor to 69.2; the ratchet raises the floor in the same PR (service is not in the run-varying set). db stays at 79.3 — this PR restores its figure (79.5 with the ApplySettings tests), it did not set out to raise it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 --------- Co-authored-by: Claude <noreply@anthropic.com>
128 lines
5.4 KiB
Go
128 lines
5.4 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
|
|
"github.com/J3vb/OwnCord/Server/api"
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
"github.com/J3vb/OwnCord/Server/config"
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
"github.com/J3vb/OwnCord/Server/plugin"
|
|
"github.com/J3vb/OwnCord/Server/service"
|
|
"github.com/J3vb/OwnCord/Server/ws"
|
|
)
|
|
|
|
// StartRuntime builds the collaborators the hub and the router share,
|
|
// constructs the hub with everything it must hold before Run (B3-4:
|
|
// HubOptions replaced the pre-Run setters), and starts the dispatch
|
|
// goroutine.
|
|
//
|
|
// Before B3-3 this lived inside api.NewRouter, while main.go set the event
|
|
// persister and store after NewRouter returned — two owners of one hub, with
|
|
// nothing checking that the required collaborators were present before Run
|
|
// started. B3-3 collapsed the owners to one; B3-4 moved the pre-Run wiring
|
|
// into ws.NewHub itself, which now refuses to construct without its required
|
|
// collaborators, so an incomplete hub is a startup error here rather than a
|
|
// later panic.
|
|
//
|
|
// The limiter and the service layer are built here rather than in the router
|
|
// because the hub needs the SAME instances: the limiter persists auth
|
|
// lockouts and the service layer holds the permission cache the hub
|
|
// invalidates, so a second copy of either would silently split that state.
|
|
//
|
|
// It starts the hub, so every caller must stop it — App.Close does, through
|
|
// the "hub" close step; api's tests rely on the goleak ignore for
|
|
// ws.(*Hub).Run.func1 exactly as they did when NewRouter started it.
|
|
func StartRuntime(cfg *config.Config, database *db.DB, pluginRegistry *plugin.Registry) (api.Runtime, error) {
|
|
// Lockouts are persisted to the database so they survive restarts (M2).
|
|
limiter := auth.NewPersistentRateLimiter(database)
|
|
// Service layer — centralises business logic for REST and WS handlers.
|
|
// *db.DB satisfies service.Store directly.
|
|
svc := service.New(database, limiter)
|
|
|
|
lk, proc, voiceEnabled := buildVoice(cfg)
|
|
|
|
// WebSocket hub — WS does its own in-band auth, so no AuthMiddleware.
|
|
hub, err := ws.NewHub(ws.HubOptions{
|
|
DB: database,
|
|
Limiter: limiter,
|
|
Services: svc,
|
|
Settings: svc.Settings,
|
|
// nil pluginRegistry means plugins are disabled; the hub no-ops.
|
|
PluginRegistry: pluginRegistry,
|
|
LiveKit: lk,
|
|
LiveKitProcess: proc,
|
|
// Replay budget knobs land at construction — the dispatch loop
|
|
// reads the ring unlocked.
|
|
ReplayRingSize: cfg.EventPersistence.ReplayRingSize,
|
|
ReplayColdLimit: cfg.EventPersistence.ReplayColdLimit,
|
|
})
|
|
if err != nil {
|
|
return api.Runtime{}, fmt.Errorf("app: building hub: %w", err)
|
|
}
|
|
|
|
// The plugin event sink consumes the built hub's broadcaster, so it is
|
|
// the surviving two-phase wire (moved from api.routerPluginWiring).
|
|
if pluginRegistry != nil {
|
|
sink := pluginRegistry.Sink()
|
|
sink.SetBroadcaster(hub.BroadcastToChannel)
|
|
hub.SetPluginEventSink(sink)
|
|
}
|
|
|
|
// Start the supervised LiveKit process only once the hub holds it
|
|
// (OC-0019): the voice_join guard must be able to fail closed via
|
|
// IsRunning() == false the moment Start fails, never see a half-wired
|
|
// hub with a running process it does not know about.
|
|
if proc != nil {
|
|
if startErr := proc.Start(); startErr != nil {
|
|
slog.Error("failed to start LiveKit process", "error", startErr)
|
|
}
|
|
}
|
|
|
|
go hub.Run()
|
|
|
|
return api.Runtime{Hub: hub, Limiter: limiter, Services: svc, VoiceEnabled: voiceEnabled}, nil
|
|
}
|
|
|
|
// buildVoice creates the LiveKit client and, when OwnCord manages the
|
|
// companion process, the process manager — construction only; StartRuntime
|
|
// starts the process after the hub holds it. It reports whether voice is
|
|
// configured; the webhook, LiveKit health and signalling-proxy routes are
|
|
// still mounted by the router on exactly that condition (the `lkErr == nil`
|
|
// guard, now api.Runtime.VoiceEnabled).
|
|
func buildVoice(cfg *config.Config) (*ws.LiveKitClient, *ws.LiveKitProcess, bool) {
|
|
// Create LiveKit client if voice config is present; voice is disabled on failure.
|
|
lk, lkErr := ws.NewLiveKitClient(&cfg.Voice)
|
|
if lkErr != nil {
|
|
slog.Warn("failed to create LiveKit client, voice disabled", "error", lkErr)
|
|
return nil, nil, false
|
|
}
|
|
|
|
// Optionally build a companion LiveKit process — either from a
|
|
// configured binary or via checksum-verified auto-download (the
|
|
// download happens in the background inside Start). The hub keeps the
|
|
// process even if Start() later fails (OC-0019): its only hub consumer
|
|
// is the voice_join guard (`h.lkProcess != nil && !h.lkProcess.IsRunning()`),
|
|
// which reads a nil process as "LiveKit is externally managed, don't
|
|
// check". OwnCord being told to manage LiveKit and failing to launch it
|
|
// must fail joins closed via IsRunning() == false, not wave them
|
|
// through with no SFU running.
|
|
if cfg.Voice.LiveKitBinaryPath != "" || cfg.Voice.AutoDownloadLiveKit {
|
|
return lk, ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir), true
|
|
}
|
|
|
|
// Warn if LiveKit is externally managed and webhook may be blocked by admin CIDRs.
|
|
lkHost := ""
|
|
if u, parseErr := url.Parse(cfg.Voice.LiveKitURL); parseErr == nil {
|
|
lkHost = u.Hostname()
|
|
}
|
|
if lkHost != "" && lkHost != "localhost" && lkHost != "127.0.0.1" && lkHost != "::1" {
|
|
slog.Warn("LiveKit is externally managed but webhook endpoint is admin-IP-restricted — "+
|
|
"add the LiveKit server's IP to livekit_webhook_allowed_cidrs or webhooks will be silently dropped",
|
|
"livekit_host", lkHost)
|
|
}
|
|
return lk, nil, true
|
|
}
|