Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions pkgs/bigfred/server/cli/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] POSITIVE: The PersistJWTSecret function is well-implemented, handling file creation, updates, and ensuring secure file permissions (0600). The os.Chmod call after os.WriteFile is a good defensive measure to guarantee the correct permissions are set, regardless of whether the file was newly created or already existed.

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] POSITIVE: The upsertDotenvKey and isDotenvKeyLine helper functions are robust, correctly handling commented lines, case-insensitivity for keys, and ensuring proper newline handling when appending.

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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions pkgs/bigfred/server/cli/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,56 @@ func TestDefaultPathUsesDataDir(t *testing.T) {
t.Fatalf("DefaultPath() = %q, want %q", got, want)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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)
}
20 changes: 13 additions & 7 deletions pkgs/bigfred/server/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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, "+

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] POSITIVE: The updated flag description and the comment for resolveJWTSecret clearly articulate the new precedence order for JWT secret resolution, which now includes persistence to loco-server.conf. This improves clarity and maintainability.

"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)")
Expand Down Expand Up @@ -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
Expand All @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
}
Loading