From 6ed5ac863c497734bb0d571303546d807962ea82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:24:28 +0200 Subject: [PATCH] fix: persist generated JWT secret across loco-server restarts A missing JWT_SECRET used to be invented on every boot, so hub sessions and dcc-bus daemons diverged after a restart. Generate the secret once and write it into loco-server.conf so the default hub image is production-safe without operator action. Co-authored-by: Cursor --- pkgs/bigfred/server/cli/config/config.go | 56 ++++++++++++++++++- pkgs/bigfred/server/cli/config/config_test.go | 53 ++++++++++++++++++ pkgs/bigfred/server/cli/root.go | 20 ++++--- 3 files changed, 120 insertions(+), 9 deletions(-) diff --git a/pkgs/bigfred/server/cli/config/config.go b/pkgs/bigfred/server/cli/config/config.go index 9c7b4bf..e5b8abb 100644 --- a/pkgs/bigfred/server/cli/config/config.go +++ b/pkgs/bigfred/server/cli/config/config.go @@ -216,6 +216,58 @@ func WriteDefaultsReference(path string, defaults File) error { return os.WriteFile(path, []byte(content), 0o644) } +// PersistJWTSecret writes JWT_SECRET= into path, replacing a +// commented or empty JWT_SECRET line and creating the file when missing. +// The file mode is 0600 because it now holds a signing key. +func PersistJWTSecret(path, secret string) error { + if strings.TrimSpace(secret) == "" { + return fmt.Errorf("persist jwt secret: empty secret") + } + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", path, err) + } + if writeErr := Write(path, DefaultFile()); writeErr != nil { + return writeErr + } + data, err = os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s after create: %w", path, err) + } + } + updated := upsertDotenvKey(string(data), "JWT_SECRET", secret) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.WriteFile(path, []byte(updated), 0o600); err != nil { + return err + } + return os.Chmod(path, 0o600) +} + +func upsertDotenvKey(text, key, value string) string { + want := key + "=" + value + lines := strings.Split(text, "\n") + for i, line := range lines { + if isDotenvKeyLine(line, key) { + lines[i] = want + return strings.Join(lines, "\n") + } + } + if text != "" && !strings.HasSuffix(text, "\n") { + return text + "\n" + want + "\n" + } + return text + want + "\n" +} + +func isDotenvKeyLine(line, key string) bool { + s := strings.TrimSpace(line) + s = strings.TrimPrefix(s, "#") + s = strings.TrimSpace(s) + return strings.HasPrefix(strings.ToUpper(s), strings.ToUpper(key)+"=") +} + func renderConfig(d File) string { cors := strings.Join(d.CorsOrigins, ",") redisPort := uint16(6379) @@ -228,7 +280,7 @@ func renderConfig(d File) string { HTTP=%s DB=%s -# JWT_SECRET= # empty = BIGFRED_JWT_SECRET env or random per-run secret +# JWT_SECRET= # empty = generate once and persist here; or BIGFRED_JWT_SECRET env CORS_ORIGIN=%s SECURE_COOKIE=false NO_SUPERVISOR=false @@ -280,7 +332,7 @@ HTTP=%s # SQLite database path (flag: --db) DB=%s -# JWT signing secret; empty uses BIGFRED_JWT_SECRET or a random per-run secret (flag: --jwt-secret) +# JWT signing secret; empty generates once and persists into loco-server.conf (flag: --jwt-secret) JWT_SECRET= # Comma-separated CORS allowed origins (flag: --cors-origin) diff --git a/pkgs/bigfred/server/cli/config/config_test.go b/pkgs/bigfred/server/cli/config/config_test.go index bd6f012..a62cf5c 100644 --- a/pkgs/bigfred/server/cli/config/config_test.go +++ b/pkgs/bigfred/server/cli/config/config_test.go @@ -184,3 +184,56 @@ func TestDefaultPathUsesDataDir(t *testing.T) { t.Fatalf("DefaultPath() = %q, want %q", got, want) } } + +func TestPersistJWTSecretReplacesCommentedLine(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "loco-server.conf") + if err := Write(path, DefaultFile()); err != nil { + t.Fatal(err) + } + if err := PersistJWTSecret(path, "abc123deadbeef"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "JWT_SECRET=abc123deadbeef\n") && !strings.Contains(text, "JWT_SECRET=abc123deadbeef") { + t.Fatalf("missing persisted secret in:\n%s", text) + } + if strings.Contains(text, "# JWT_SECRET=") { + t.Fatalf("commented JWT_SECRET should have been replaced:\n%s", text) + } + got := Parse(text) + if got.JWTSecret != "abc123deadbeef" { + t.Fatalf("Parse JWTSecret = %q", got.JWTSecret) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("perm = %o, want 0600", info.Mode().Perm()) + } +} + +func TestPersistJWTSecretCreatesFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "etc", "loco-server.conf") + if err := PersistJWTSecret(path, "generated"); err != nil { + t.Fatal(err) + } + got := Parse(mustRead(t, path)) + if got.JWTSecret != "generated" { + t.Fatalf("JWTSecret = %q", got.JWTSecret) + } +} + +func mustRead(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/pkgs/bigfred/server/cli/root.go b/pkgs/bigfred/server/cli/root.go index 552532a..dc7fef9 100644 --- a/pkgs/bigfred/server/cli/root.go +++ b/pkgs/bigfred/server/cli/root.go @@ -25,6 +25,7 @@ import ( bfotel "github.com/keskad/loco/pkgs/bigfred/otel" "github.com/keskad/loco/pkgs/bigfred/platform" "github.com/keskad/loco/pkgs/bigfred/remotepairing" + "github.com/keskad/loco/pkgs/bigfred/server/cli/config" "github.com/keskad/loco/pkgs/bigfred/server/cmd" "github.com/keskad/loco/pkgs/bigfred/server/ctl" "github.com/keskad/loco/pkgs/bigfred/server/datadir" @@ -123,8 +124,8 @@ real-time throttle commands.`, "address the HTTP server listens on (0.0.0.0 = all interfaces)") cmd.Flags().StringVar(&f.DBPath, "db", "var/db/bigfred/bigfred.sqlite3", "path to the SQLite database file") cmd.Flags().StringVar(&f.JWTSecret, "jwt-secret", "", - "hex/base64 secret used to sign session JWTs. Falls back to BIGFRED_JWT_SECRET "+ - "env var; a random per-run secret is generated when empty (sessions don't survive restarts).") + "hex/base64 secret used to sign session JWTs. Falls back to loco-server.conf JWT_SECRET, "+ + "then BIGFRED_JWT_SECRET; a random secret is generated and persisted when empty.") cmd.Flags().StringSliceVar(&f.AllowedOrigins, "cors-origin", []string{"http://localhost:5173", "http://127.0.0.1:5173"}, "CORS allowed origins (Vite dev server on :5173 by default)") @@ -827,8 +828,8 @@ func resolveOTLPEndpoint() string { } // resolveJWTSecret picks the JWT signing key in the documented -// precedence order: explicit --jwt-secret > BIGFRED_JWT_SECRET env > -// random per-run secret (development only). +// precedence order: explicit --jwt-secret > file JWT_SECRET (via merge) > +// BIGFRED_JWT_SECRET env > generate once and persist to loco-server.conf. func resolveJWTSecret(flag string, log *logrus.Logger) ([]byte, error) { if flag != "" { return []byte(flag), nil @@ -840,13 +841,18 @@ func resolveJWTSecret(flag string, log *logrus.Logger) ([]byte, error) { if _, err := rand.Read(buf); err != nil { return nil, fmt.Errorf("generate random jwt secret: %w", err) } - log.Warn("no JWT secret configured — generated a random one. Existing sessions will not survive a restart. " + - "Set --jwt-secret or BIGFRED_JWT_SECRET in production.") // Hex-encode the secret so it is ASCII-safe. It is forwarded to each // dcc-bus daemon on the command line and written verbatim into // supervisord.conf, which supervisord parses strictly as UTF-8. Raw // random bytes routinely contain non-UTF-8 sequences (e.g. 0x96) that // make `supervisorctl reread` fail, so the daemon never starts and the // data-plane proxy returns 502. The encoding is irrelevant to HMAC. - return []byte(hex.EncodeToString(buf)), nil + secret := hex.EncodeToString(buf) + path := config.DefaultPath() + if err := config.PersistJWTSecret(path, secret); err != nil { + log.WithError(err).WithField("path", path).Warn("generated JWT secret but could not persist it; sessions will not survive a restart") + } else { + log.WithField("path", path).Info("generated and persisted JWT secret") + } + return []byte(secret), nil }