-
Notifications
You must be signed in to change notification settings - Fork 0
fix: persist generated JWT secret across restarts #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -216,6 +216,58 @@ func WriteDefaultsReference(path string, defaults File) error { | |
| return os.WriteFile(path, []byte(content), 0o644) | ||
| } | ||
|
|
||
| // PersistJWTSecret writes JWT_SECRET=<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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✨ [POSITIVE] POSITIVE: The |
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -184,3 +184,56 @@ func TestDefaultPathUsesDataDir(t *testing.T) { | |
| t.Fatalf("DefaultPath() = %q, want %q", got, want) | ||
| } | ||
| } | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✨ [POSITIVE] POSITIVE: The new tests provide excellent coverage for the JWT secret persistence logic, including scenarios for replacing commented lines, creating new files, and verifying file permissions. This ensures the reliability of the new functionality. |
||
| 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) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, "+ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✨ [POSITIVE] POSITIVE: The updated flag description and the comment for |
||
| "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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✨ [POSITIVE] POSITIVE: The logging for both successful persistence and cases where persistence fails is very helpful for debugging and operational monitoring. It clearly indicates the state of the JWT secret generation and storage. |
||
| } else { | ||
| log.WithField("path", path).Info("generated and persisted JWT secret") | ||
| } | ||
| return []byte(secret), nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
✨ [POSITIVE] POSITIVE: The
PersistJWTSecretfunction is well-implemented, handling file creation, updates, and ensuring secure file permissions (0600). Theos.Chmodcall afteros.WriteFileis a good defensive measure to guarantee the correct permissions are set, regardless of whether the file was newly created or already existed.