Files
OwnCord/Server/admin/setup_wizard_test.go
T
J3vbandClaude Fable 5 6a26f2a839 fix(server): drain fully before the self-update/restore restart handoff (#1380)
* feat(server): supervisor detection and server.restart_mode config key

RunningUnderSupervisor detects systemd (INVOCATION_ID) and, best-effort,
NSSM (NSSM_SERVICE_NAME — 2.24 does not set it, so NSSM deployments set
the mode explicitly). server.restart_mode (auto|spawn|supervised, default
auto, env OWNCORD_SERVER_RESTART_MODE) selects how a self-restart hands
off after the server drains: exit for the supervisor to relaunch, or
spawn the replacement directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp

* fix(server): make the self-restart handoff drain fully before starting the successor

The update/restore/wizard restart previously spawned the replacement
while the old server was still serving, then SIGTERMed itself and
hard-exited after 10s. That design failed in every documented deployment
mode: under the shipped systemd unit the spawned child (same cgroup) was
killed when the old main process exited and Restart=on-failure never
relaunched a clean exit; on Windows the self-SIGTERM is unsupported and
silently dropped, so graceful shutdown never ran — hub.GracefulStop (the
only caller of LiveKitProcess.Stop) was skipped, orphaning livekit-server
on TCP 7880/UDP 50000-60000 and dropping queued event/audit batches; and
NSSM's relaunch raced the self-spawned replacement for the database lock.

Admin handlers now perform only the on-disk swap and request a restart
through an injected hook (admin.SetRestartHandoff). The main package's
restart coordinator cancels the parent of run()'s signal.NotifyContext —
the exact drain a SIGTERM triggers, on every platform — and after run()
has fully torn down (listeners closed, hub and LiveKit stopped, queues
flushed, DB closed and its lock released) main() performs the handoff:
spawn the replacement in spawn mode, or exit 0 for the supervisor in
supervised mode. A 90s backstop force-exits a wedged teardown; the
DB-lock and bind retries demote to safety nets.

A three-state guard (idle/busy/restart-pending) serializes update apply,
backup restore, and setup-wizard restarts against each other: concurrent
applies no longer race the same staged .new file or broadcast a spurious
update_aborted, and conflicting requests get 409 UPDATE_IN_PROGRESS /
RESTART_PENDING. The swap being free of process side effects also makes
the apply success path unit-testable for the first time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp

* fix(server): errno-based bind-conflict detection, ACME bind retry, LiveKit Pdeathsig

isAddrInUse now unwraps to the platform errno (EADDRINUSE; WSAEADDRINUSE
10048 on Windows) with the English strings kept only as fallback — the
string-only match never fired on localized Windows, silently disabling
the bind retry. The retry loop is extracted into serveWithBindRetry and
now also covers the ACME :80 challenge server, which previously gave up
on first conflict and stayed dead (breaking HTTP-01 renewals) until the
next restart. The .old-binary boot cleanup retries briefly for the
window where a spawn-mode predecessor has not fully exited. The
companion livekit-server gets Pdeathsig SIGKILL on Linux so a parent
killed without teardown (kill -9, OOM, backstop exit) cannot orphan it
with the voice ports held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp

* docs(deploy): Restart=always unit and per-supervisor restart-mode guidance

Restart=always is what lets the deliberate clean exit after a
self-update/restore relaunch under systemd (systemctl stop is never
auto-restarted; failure exits behave as before). Deployment docs gain
the required NSSM AppEnvironmentExtra line, the Task Scheduler and
Docker restart-policy notes, and the new drain-then-handoff update flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 08:25:40 +02:00

434 lines
14 KiB
Go

package admin_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/owncord/server/admin"
"github.com/owncord/server/config"
"github.com/owncord/server/db"
)
// wizardRunningCfg mimics the config a fresh server boots with: file defaults
// plus the runtime-generated LiveKit credentials.
func wizardRunningCfg() *config.Config {
return &config.Config{
Server: config.ServerConfig{Port: 8443, Name: "OwnCord Server"},
TLS: config.TLSConfig{Mode: "self_signed"},
Upload: config.UploadConfig{MaxSizeMB: 100},
Voice: config.VoiceConfig{
LiveKitAPIKey: "key-generated123",
LiveKitAPISecret: "generated-secret-0123456789abcdef",
Quality: "medium",
},
}
}
// wizardHandler builds the admin API with wizard options and a restart stub
// that signals restarted (buffered) instead of restarting the process. A
// wizard run that triggers a restart leaves the process-global
// restart-serialization guard in restart-pending, so it is reset after every
// wizard test.
func wizardHandler(t *testing.T, database *db.DB, cfgPath string, restarted chan string) http.Handler {
t.Helper()
t.Cleanup(admin.ResetRestartState)
return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database),
admin.SetupOptions{
ConfigPath: cfgPath,
RunningCfg: wizardRunningCfg(),
Restart: func(reason string) { restarted <- reason },
})
}
func getSetting(t *testing.T, database *db.DB, key string) string {
t.Helper()
v, err := database.GetSetting(context.Background(), key)
if err != nil {
t.Fatalf("GetSetting(%q): %v", key, err)
}
return v
}
func TestSetupWizard_FullFlow(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
"username": "owner",
"password": "SecurePass123!",
"wizard": map[string]any{
"server_name": "My Cool Server",
"motd": "Welcome friends!",
"registration_open": true,
"port": 9000,
"tls_mode": "off",
"upload_max_size_mb": 250,
"voice_quality": "high",
"voice_auto_download": true,
},
})
if rr.Code != http.StatusCreated {
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
}
var resp struct {
Token string `json:"token"`
InviteCode string `json:"invite_code"`
RestartRequired bool `json:"restart_required"`
RestartURL string `json:"restart_url"`
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Token == "" || resp.InviteCode == "" {
t.Error("token/invite_code missing — account creation should be unchanged")
}
if len(resp.Warnings) != 0 {
t.Errorf("warnings = %v, want none", resp.Warnings)
}
if !resp.RestartRequired {
t.Fatal("restart_required = false, want true (port and tls changed)")
}
// httptest requests carry Host "example.com"; tls off → http scheme.
if resp.RestartURL != "http://example.com:9000/admin" {
t.Errorf("restart_url = %q, want %q", resp.RestartURL, "http://example.com:9000/admin")
}
select {
case reason := <-restarted:
if reason != "setup_wizard" {
t.Errorf("restart reason = %q, want setup_wizard", reason)
}
case <-time.After(5 * time.Second):
t.Fatal("restart hook was never invoked")
}
// DB settings the app reads live.
if got := getSetting(t, database, "server_name"); got != "My Cool Server" {
t.Errorf("server_name = %q", got)
}
if got := getSetting(t, database, "motd"); got != "Welcome friends!" {
t.Errorf("motd = %q", got)
}
if got := getSetting(t, database, "registration_open"); got != "1" {
t.Errorf("registration_open = %q, want 1", got)
}
if got := getSetting(t, database, "max_upload_bytes"); got != "262144000" {
t.Errorf("max_upload_bytes = %q, want 262144000 (250 MB)", got)
}
if got := getSetting(t, database, "voice_quality"); got != "high" {
t.Errorf("voice_quality = %q, want high", got)
}
// config.yaml written with the wizard values + persisted voice creds.
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("loading wizard-written config: %v", err)
}
if cfg.Server.Port != 9000 {
t.Errorf("config port = %d, want 9000", cfg.Server.Port)
}
if cfg.Server.Name != "My Cool Server" {
t.Errorf("config server name = %q", cfg.Server.Name)
}
if cfg.TLS.Mode != "off" {
t.Errorf("config tls mode = %q, want off", cfg.TLS.Mode)
}
if cfg.Upload.MaxSizeMB != 250 {
t.Errorf("config upload max = %d, want 250", cfg.Upload.MaxSizeMB)
}
if cfg.Voice.Quality != "high" {
t.Errorf("config voice quality = %q, want high", cfg.Voice.Quality)
}
if cfg.Voice.LiveKitAPIKey != "key-generated123" {
t.Errorf("LiveKit key = %q — the running credentials were not persisted", cfg.Voice.LiveKitAPIKey)
}
if cfg.Voice.LiveKitAPISecret != "generated-secret-0123456789abcdef" {
t.Errorf("LiveKit secret = %q — the running credentials were not persisted", cfg.Voice.LiveKitAPISecret)
}
if !cfg.Voice.AutoDownloadLiveKit {
t.Error("config voice.auto_download_livekit = false, want true from wizard toggle")
}
}
func TestSetupWizard_NoRestartWhenValuesMatchRunning(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
// Same port/tls/upload/voice as the running config; only live-read
// values (name, motd) change.
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
"username": "owner",
"password": "SecurePass123!",
"wizard": map[string]any{
"server_name": "Renamed Server",
"motd": "hi",
"port": 8443,
"tls_mode": "self_signed",
"upload_max_size_mb": 100,
"voice_quality": "medium",
},
})
if rr.Code != http.StatusCreated {
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
}
var resp struct {
RestartRequired bool `json:"restart_required"`
RestartURL string `json:"restart_url"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.RestartRequired {
t.Error("restart_required = true, want false (no startup-only value changed)")
}
if resp.RestartURL != "" {
t.Errorf("restart_url = %q, want empty", resp.RestartURL)
}
select {
case <-restarted:
t.Error("restart hook invoked though nothing needed a restart")
case <-time.After(100 * time.Millisecond):
}
// Config is still written (server.name changed on disk).
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("loading wizard-written config: %v", err)
}
if cfg.Server.Name != "Renamed Server" {
t.Errorf("config server name = %q, want Renamed Server", cfg.Server.Name)
}
}
func TestSetupWizard_InvalidValuesRejectBeforeAccountCreation(t *testing.T) {
cases := map[string]map[string]any{
"port too low": {"port": 0},
"port too high": {"port": 70000},
"bad tls mode": {"tls_mode": "quantum"},
"acme without domain": {"tls_mode": "acme"},
"bad domain chars": {"tls_mode": "acme", "tls_domain": "not a domain!"},
"single-label domain": {"tls_mode": "acme", "tls_domain": "localhost"},
"upload zero": {"upload_max_size_mb": 0},
"upload too large": {"upload_max_size_mb": 20000},
"bad voice quality": {"voice_quality": "ultra"},
"empty server name": {"server_name": " "},
"tag-only server name": {"server_name": "<b></b>"},
}
for name, wizard := range cases {
t.Run(name, func(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
"username": "owner",
"password": "SecurePass123!",
"wizard": wizard,
})
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rr.Code, rr.Body.String())
}
count, err := database.UserCount(context.Background())
if err != nil {
t.Fatalf("UserCount: %v", err)
}
if count != 0 {
t.Errorf("user count = %d, want 0 — invalid wizard payload must reject before account creation", count)
}
if _, err := os.Stat(cfgPath); !os.IsNotExist(err) {
t.Error("config file written despite rejected payload")
}
})
}
}
func TestSetupWizard_ConfigWriteFailureWarnsButCreatesAccount(t *testing.T) {
database := openAdminTestDB(t)
// Point at a directory that does not exist so the atomic write fails.
cfgPath := filepath.Join(t.TempDir(), "missing-dir", "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
"username": "owner",
"password": "SecurePass123!",
"wizard": map[string]any{"port": 9000},
})
if rr.Code != http.StatusCreated {
t.Fatalf("POST /setup = %d, want 201 despite config failure; body=%s", rr.Code, rr.Body.String())
}
var resp struct {
Token string `json:"token"`
RestartRequired bool `json:"restart_required"`
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Token == "" {
t.Error("token missing — the account must still be created")
}
if len(resp.Warnings) == 0 {
t.Error("warnings empty, want a config-write warning")
}
if resp.RestartRequired {
t.Error("restart_required = true, but the config was never written — restarting would change nothing")
}
select {
case <-restarted:
t.Error("restart hook invoked after a failed config write")
case <-time.After(100 * time.Millisecond):
}
count, err := database.UserCount(context.Background())
if err != nil {
t.Fatalf("UserCount: %v", err)
}
if count != 1 {
t.Errorf("user count = %d, want 1", count)
}
}
func TestSetupWizard_LegacyPayloadUnchangedBehaviour(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
"username": "owner",
"password": "SecurePass123!",
})
if rr.Code != http.StatusCreated {
t.Fatalf("legacy POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
}
var resp struct {
RestartRequired bool `json:"restart_required"`
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.RestartRequired || len(resp.Warnings) != 0 {
t.Error("legacy payload must not trigger restarts or warnings")
}
if _, err := os.Stat(cfgPath); !os.IsNotExist(err) {
t.Error("legacy payload must not write config.yaml")
}
select {
case <-restarted:
t.Error("legacy payload must not restart the server")
case <-time.After(100 * time.Millisecond):
}
}
func TestSetupStatus_DefaultsOnlyPreSetupAndSecretFree(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
rr := doRequest(t, handler, "GET", "/setup/status", "", nil)
if rr.Code != http.StatusOK {
t.Fatalf("GET /setup/status = %d, want 200", rr.Code)
}
var resp struct {
NeedsSetup bool `json:"needs_setup"`
Defaults *struct {
ServerName string `json:"server_name"`
Motd string `json:"motd"`
Port int `json:"port"`
TLSMode string `json:"tls_mode"`
UploadMaxSizeMB int `json:"upload_max_size_mb"`
VoiceQuality string `json:"voice_quality"`
} `json:"defaults"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !resp.NeedsSetup || resp.Defaults == nil {
t.Fatalf("pre-setup status should carry defaults; body=%s", rr.Body.String())
}
// server_name/motd come from the seeded settings table, the rest from the
// running config.
if resp.Defaults.ServerName != "Test Server" {
t.Errorf("defaults.server_name = %q, want Test Server (DB value)", resp.Defaults.ServerName)
}
if resp.Defaults.Motd != "Hello" {
t.Errorf("defaults.motd = %q, want Hello (DB value)", resp.Defaults.Motd)
}
if resp.Defaults.Port != 8443 || resp.Defaults.TLSMode != "self_signed" ||
resp.Defaults.UploadMaxSizeMB != 100 || resp.Defaults.VoiceQuality != "medium" {
t.Errorf("config-derived defaults wrong: %+v", resp.Defaults)
}
// Never leak credentials through the unauthenticated status endpoint.
lower := strings.ToLower(rr.Body.String())
for _, needle := range []string{"livekit", "secret", "api_key", "token", "cidr"} {
if strings.Contains(lower, needle) {
t.Errorf("status response leaks %q: %s", needle, rr.Body.String())
}
}
// After setup completes, defaults disappear along with needs_setup.
rr2 := doRequest(t, handler, "POST", "/setup", "", map[string]string{
"username": "owner", "password": "SecurePass123!",
})
if rr2.Code != http.StatusCreated {
t.Fatalf("setup = %d, want 201", rr2.Code)
}
rr3 := doRequest(t, handler, "GET", "/setup/status", "", nil)
if !strings.Contains(rr3.Body.String(), `"needs_setup":false`) {
t.Errorf("post-setup status = %s, want needs_setup false", rr3.Body.String())
}
if strings.Contains(rr3.Body.String(), "defaults") {
t.Errorf("post-setup status still exposes defaults: %s", rr3.Body.String())
}
}
func TestSetupWizard_ForeignOriginBlocked(t *testing.T) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan string, 1)
handler := wizardHandler(t, database, cfgPath, restarted)
body := map[string]any{
"username": "owner",
"password": "SecurePass123!",
"wizard": map[string]any{"port": 9000},
}
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
req := httptest.NewRequest("POST", "/setup", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("wizard POST from foreign origin = %d, want 403", rr.Code)
}
if _, err := os.Stat(cfgPath); !os.IsNotExist(err) {
t.Error("config file written from a cross-origin request")
}
}