package config import ( "bufio" "crypto/rand" "encoding/hex" "fmt" "log" "os" "strconv" "strings" "golang.org/x/crypto/bcrypt" ) const defaultConfigFile = "app_config.conf" // ConfigFile returns the active config file path. // Override with CONFIG_FILE env var (path only, no secrets in env). func ConfigFile() string { if v := os.Getenv("CONFIG_FILE"); v != "" { return v } return defaultConfigFile } // Config holds all runtime settings loaded from app_config.conf. type Config struct { Port string CrowdSecAPIURL string CrowdSecAPILogin string CrowdSecAPIPassword string // sent as-is to LAPI — never hashed CscliPath string UIUsername string UIPassword string // bcrypt hash after first run UISessionSecret string PollIntervalSec int } // FirstRunError is returned when the config file was just created and needs editing. type FirstRunError struct{ Path string } func (e *FirstRunError) Error() string { return fmt.Sprintf( "%s created — fill in crowdsec_api_login and crowdsec_api_password, then restart", e.Path, ) } // HashPassword generates a bcrypt hash suitable for use as ui_password in the config. func HashPassword(plaintext string) (string, error) { h, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost) if err != nil { return "", err } return string(h), nil } // VerifyUIPassword checks plaintext against the stored ui_password (bcrypt or plaintext fallback). func (c *Config) VerifyUIPassword(plaintext string) bool { if isBcryptHash(c.UIPassword) { return bcrypt.CompareHashAndPassword([]byte(c.UIPassword), []byte(plaintext)) == nil } // Fallback: constant-time plaintext compare (should not happen after first startup) a := []byte(plaintext) b := []byte(c.UIPassword) if len(a) != len(b) { return false } var diff byte for i := range a { diff |= a[i] ^ b[i] } return diff == 0 } // Load reads app_config.conf (or CONFIG_FILE path), creating it on first run. // If ui_password is plaintext, it is hashed with bcrypt and written back to the file. func Load() (*Config, error) { path := ConfigFile() if _, err := os.Stat(path); os.IsNotExist(err) { if err := writeDefault(path); err != nil { return nil, fmt.Errorf("create %s: %w", path, err) } return nil, &FirstRunError{Path: path} } vals, err := parseFile(path) if err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } c := &Config{ Port: strVal(vals, "port", ":8080"), CrowdSecAPIURL: strVal(vals, "crowdsec_api_url", "http://localhost:8080"), CrowdSecAPILogin: vals["crowdsec_api_login"], CrowdSecAPIPassword: vals["crowdsec_api_password"], CscliPath: strVal(vals, "cscli_path", "/usr/local/bin/cscli"), UIUsername: strVal(vals, "ui_username", "admin"), UIPassword: strVal(vals, "ui_password", "changeme"), UISessionSecret: vals["ui_session_secret"], PollIntervalSec: intVal(vals, "poll_interval_sec", 15), } if c.CrowdSecAPILogin == "" { return nil, fmt.Errorf("crowdsec_api_login is required in %s", path) } if c.CrowdSecAPIPassword == "" { return nil, fmt.Errorf("crowdsec_api_password is required in %s", path) } if len(c.UISessionSecret) < 32 { return nil, fmt.Errorf("ui_session_secret must be at least 32 characters in %s", path) } // Auto-hash ui_password if stored as plaintext. if c.UIPassword != "" && !isBcryptHash(c.UIPassword) { hash, err := bcrypt.GenerateFromPassword([]byte(c.UIPassword), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("hash ui_password: %w", err) } c.UIPassword = string(hash) if err := updateConfigValue(path, "ui_password", c.UIPassword); err != nil { log.Printf("[WARN] could not save hashed ui_password to %s: %v", path, err) } else { log.Printf("ui_password hashed and saved to %s", path) } } return c, nil } // CscliAvailable returns true if the cscli binary exists at the configured path. func (c *Config) CscliAvailable() bool { _, err := os.Stat(c.CscliPath) return err == nil } func isBcryptHash(s string) bool { return strings.HasPrefix(s, "$2a$") || strings.HasPrefix(s, "$2b$") || strings.HasPrefix(s, "$2y$") } // updateConfigValue rewrites a single key's value in the config file, preserving all other lines. func updateConfigValue(path, key, value string) error { data, err := os.ReadFile(path) if err != nil { return err } lines := strings.Split(string(data), "\n") updated := false for i, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } idx := strings.IndexByte(trimmed, '=') if idx < 0 { continue } if strings.TrimSpace(trimmed[:idx]) == key { lines[i] = key + " = " + value updated = true break } } if !updated { return fmt.Errorf("key %q not found in %s", key, path) } return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0600) } // writeDefault creates the config file with a random session secret and usage notes. func writeDefault(path string) error { secret, err := randomHex(32) if err != nil { return err } content := fmt.Sprintf(`# CrowdSec Dashy — Configuration # Auto-generated on first run. Edit required fields and restart. # Lines starting with # are comments. # --------------------------------------------------------------- # Network — address:port the web UI listens on port = :8080 # CrowdSec Local API crowdsec_api_url = http://localhost:8080 crowdsec_api_login = crowdsec_api_password = # Path to cscli binary (required for bouncers, machines, hub, metrics) # In Docker: bind-mount the binary into the container at this path. # Leave empty or point to a missing path to disable CLI features gracefully. cscli_path = /usr/local/bin/cscli # Web UI — HTTP Basic Auth # ui_password is auto-hashed with bcrypt on first startup. # To pre-hash a password: crowdsec-dashy -pwhash "your-password" ui_username = admin ui_password = changeme # Session signing secret — auto-generated, keep private ui_session_secret = %s # Dashboard live-poll interval (seconds) poll_interval_sec = 15 `, secret) return os.WriteFile(path, []byte(content), 0600) } // parseFile reads key = value pairs, ignoring blank lines and # comments. func parseFile(path string) (map[string]string, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() vals := make(map[string]string) scanner := bufio.NewScanner(f) lineNum := 0 for scanner.Scan() { lineNum++ line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } idx := strings.IndexByte(line, '=') if idx < 0 { return nil, fmt.Errorf("line %d: expected key = value", lineNum) } key := strings.TrimSpace(line[:idx]) val := strings.TrimSpace(line[idx+1:]) if key == "" { return nil, fmt.Errorf("line %d: empty key", lineNum) } vals[key] = val } return vals, scanner.Err() } func strVal(m map[string]string, key, def string) string { if v, ok := m[key]; ok && v != "" { return v } return def } func intVal(m map[string]string, key string, def int) int { v, ok := m[key] if !ok || v == "" { return def } n, err := strconv.Atoi(v) if err != nil || n <= 0 { return def } return n } func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("generate random bytes: %w", err) } return hex.EncodeToString(b), nil }