63701dd086
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>
229 lines
6.6 KiB
Go
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
|
|
}
|