Files
Alphaeus Mote 63701dd086 feat: real restore, portable secret key, multi-arch image, real CSRF
Addresses the gaps identified in the last audit.

Restore (was a stub returning "not yet implemented"). Every repository shares
one connection pool, so the database cannot be swapped underneath a live
server. Restore is therefore two-phase: RestoreBackup validates the file and
stages it beside the database; db.New applies it before the pool is opened,
which is the only safe moment. The database being replaced is preserved as
<db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot
replay the old journal over the restored file. Validation is strict — SQLite
integrity_check plus a schema probe — because applying an unrelated file
would destroy the install. GET/DELETE /api/v1/backups/restore inspect and
cancel a staged restore. The CLI does both phases at once, since it runs
standalone; `orchestrad backup` was also a stub and now works.

Secret key. With nothing configured the key is generated once and persisted
to <data>/secret.key, so restarts reuse it and moving the stack to another
server is a matter of copying the data directory. Upgrades are handled: if a
database already exists the install was silently running on the legacy
built-in default, so that value is adopted and written out rather than
replaced — generating a fresh key there would make every stored credential
undecryptable. The file is owner-only (ACL-restricted on Windows).

Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the
architectures the release binaries already covered. The Dockerfile
cross-compiles via TARGETARCH rather than emulating, so arm64 costs little.

CSRF: the middleware previously checked only that a header was *present* and
was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens
are now nonce + HMAC-SHA256 signed with the application secret, validated
properly, and the middleware is mounted on /api/v1. Bearer and API-key
requests are not CSRF-reachable and pass through untouched, so this is
transparent to the SPA and to API clients.

Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not
the source of truth — <data>/tls holds the key, so portability is unaffected
and a non-exportable server key is the better posture), the PFX password is
written to server.pfx.password beside the bundle so an operator importing it
by hand does not have to hunt for a password they never chose, and the
"renewed" log line now reflects whether a leaf was actually issued instead of
guessing from its age.

Verified live: backup -> stage -> restart applies and preserves the previous
database; secret key generated, adopted, and read back across restarts with
the credential check confirming decryptability; CSRF endpoint issues real
signed tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:57:15 -04:00

229 lines
6.6 KiB
Go

// Package config handles application configuration loading from environment and files
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// Config holds all application configuration
type Config struct {
DataPath string
SecretKey []byte
// SecretKeySource records where SecretKey came from, so startup can report
// it (and warn when the legacy built-in default was adopted).
SecretKeySource string
Server ServerConfig
Database DatabaseConfig
Logging LoggingConfig
OIDC OIDCConfig
CORS CORSConfig
Maintenance MaintenanceConfig
}
// MaintenanceConfig controls background history pruning and database compaction
// so the database does not grow without bound.
type MaintenanceConfig struct {
RunRetentionDays int // rule_runs (+ their actions) older than this are deleted
AuditRetentionDays int // audit_events older than this are deleted
IntervalHours int // how often maintenance runs
Vacuum bool // run VACUUM after pruning to reclaim space
}
// ServerConfig holds HTTP server settings
type ServerConfig struct {
Host string
Port int
TrustedProxies []string
}
// DatabaseConfig holds database connection settings
type DatabaseConfig struct {
Path string
MaxOpenConns int
MaxIdleConns int
WALMode bool
ForeignKeys bool
MigrationsPath string
}
// LoggingConfig holds logging settings
type LoggingConfig struct {
Level string
FilePath string
MaxSizeMB int
MaxBackups int
MaxAgeDays int
Compress bool
EnableConsole bool
}
// OIDCConfig holds OIDC provider settings
type OIDCConfig struct {
Enabled bool
ProviderURL string
ClientID string
ClientSecret string
RedirectURL string
}
// CORSConfig holds CORS settings
type CORSConfig struct {
AllowedOrigins []string
AllowCredentials bool
}
// Load loads configuration from environment variables and optional config file
func Load() (*Config, error) {
dataPath := getEnv("ORCHESTRAD_DATA_PATH", "./data")
// Ensure data directory exists
if err := os.MkdirAll(dataPath, 0755); err != nil {
return nil, fmt.Errorf("creating data directory: %w", err)
}
dbPath := filepath.Join(dataPath, "db", "orchestrad.db")
// The master key for credential encryption. When nothing supplies one it is
// generated and persisted inside the data directory, so restarts keep the
// same key and moving the stack to another server is a matter of copying
// the data directory.
secretKey, secretKeySource, err := resolveSecretKey(dataPath, dbPath)
if err != nil {
return nil, fmt.Errorf("loading secret key: %w", err)
}
cfg := &Config{
DataPath: dataPath,
SecretKey: secretKey,
SecretKeySource: secretKeySource,
Server: ServerConfig{
// Listen address/port precedence: environment variable, then the
// value the Windows installer recorded in the registry, then the
// built-in default. (On non-Windows registrySetting returns "".)
Host: getEnv("ORCHESTRAD_HOST", firstNonEmpty(registrySetting("ListenAddress"), "0.0.0.0")),
Port: getEnvInt("ORCHESTRAD_PORT", atoiOr(registrySetting("ListenPort"), 18090)),
// Trust reverse proxies in local/private ranges by default so
// X-Forwarded-* headers (client IP, scheme, host) are honored out
// of the box behind an edge proxy. Override with an explicit CIDR
// list, the keywords "local"/"private"/"all"/"none", or "" to disable.
TrustedProxies: splitCSV(getEnv("ORCHESTRAD_TRUSTED_PROXIES", "local")),
},
Database: DatabaseConfig{
Path: dbPath,
MaxOpenConns: getEnvInt("ORCHESTRAD_DB_MAX_OPEN_CONNS", 25),
MaxIdleConns: getEnvInt("ORCHESTRAD_DB_MAX_IDLE_CONNS", 5),
WALMode: true,
ForeignKeys: true,
MigrationsPath: getEnv("ORCHESTRAD_MIGRATIONS_PATH", "migrations"),
},
Logging: LoggingConfig{
Level: getEnv("ORCHESTRAD_LOG_LEVEL", "info"),
FilePath: filepath.Join(dataPath, "logs", "orchestrad.log"),
MaxSizeMB: getEnvInt("ORCHESTRAD_LOG_MAX_SIZE_MB", 5),
MaxBackups: getEnvInt("ORCHESTRAD_LOG_MAX_BACKUPS", 3),
MaxAgeDays: getEnvInt("ORCHESTRAD_LOG_MAX_AGE_DAYS", 30),
Compress: true,
EnableConsole: true,
},
CORS: CORSConfig{
AllowedOrigins: corsOrigins(),
AllowCredentials: true,
},
Maintenance: MaintenanceConfig{
RunRetentionDays: getEnvInt("ORCHESTRAD_RUN_RETENTION_DAYS", 90),
AuditRetentionDays: getEnvInt("ORCHESTRAD_AUDIT_RETENTION_DAYS", 180),
IntervalHours: getEnvInt("ORCHESTRAD_MAINTENANCE_INTERVAL_HOURS", 24),
Vacuum: getEnvBool("ORCHESTRAD_MAINTENANCE_VACUUM", true),
},
}
return cfg, nil
}
func corsOrigins() []string {
if raw := os.Getenv("ORCHESTRAD_ALLOWED_ORIGINS"); raw != "" {
return splitCSV(raw)
}
return []string{"http://localhost:3000", "http://127.0.0.1:3000"}
}
// getEnvOrFile reads a value from key if set, else from the file at keyFile if
// that env points to a readable file. Returns (nil, nil) when neither is set.
// Trailing newlines and carriage returns are stripped from file contents so
// secrets written by `echo` or common secret mounts work without surprises.
func getEnvOrFile(key, keyFile string) ([]byte, error) {
if v := os.Getenv(key); v != "" {
return []byte(v), nil
}
path := strings.TrimSpace(os.Getenv(keyFile))
if path == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s=%s: %w", keyFile, path, err)
}
return []byte(strings.TrimRight(strings.TrimRight(string(data), "\n"), "\r")), nil
}
func splitCSV(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
func getEnvInt(key string, defaultVal int) int {
if val := os.Getenv(key); val != "" {
if i, err := strconv.Atoi(val); err == nil {
return i
}
}
return defaultVal
}
func getEnvBool(key string, defaultVal bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "":
return defaultVal
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// firstNonEmpty returns v if it is non-empty, otherwise def.
func firstNonEmpty(v, def string) string {
if v != "" {
return v
}
return def
}
// atoiOr parses v as an int, returning def when v is empty or unparseable.
func atoiOr(v string, def int) int {
if i, err := strconv.Atoi(v); err == nil {
return i
}
return def
}