300 lines
8.8 KiB
Go
300 lines
8.8 KiB
Go
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
|
|
CrowdsecBinPath string // crowdsec daemon binary (for -t config test)
|
|
CrowdsecConfigDir string // /etc/crowdsec or equivalent
|
|
UIUsername string
|
|
UIPassword string // bcrypt hash after first run
|
|
UISessionSecret string
|
|
PollIntervalSec int
|
|
IPInfoToken string // ipinfo.io API token for GeoIP DB download
|
|
IPInfoDBFile string // e.g. "asn.mmdb" or "country.mmdb"
|
|
IPInfoDBPath string // absolute path where the MMDB is saved
|
|
IPInfoRefreshDays int // auto-refresh interval in days
|
|
}
|
|
|
|
// 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"),
|
|
CrowdsecBinPath: strVal(vals, "crowdsec_path", "/usr/sbin/crowdsec"),
|
|
CrowdsecConfigDir: strVal(vals, "crowdsec_config_dir", "/etc/crowdsec"),
|
|
UIUsername: strVal(vals, "ui_username", "admin"),
|
|
IPInfoToken: vals["ipinfo_token"],
|
|
IPInfoDBFile: strVal(vals, "ipinfo_db_file", "asn.mmdb"),
|
|
IPInfoDBPath: strVal(vals, "ipinfo_db_path", "/var/lib/crowdsec/data/GeoLite2-ASN.mmdb"),
|
|
IPInfoRefreshDays: intVal(vals, "ipinfo_refresh_days", 7),
|
|
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
|
|
|
|
# CrowdSec daemon binary — used to test configs before applying (crowdsec -t)
|
|
# Leave empty to disable config validation (saves will apply without testing).
|
|
crowdsec_path = /usr/sbin/crowdsec
|
|
|
|
# CrowdSec config directory — files editable via the Config Editor page
|
|
crowdsec_config_dir = /etc/crowdsec
|
|
|
|
# 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
|
|
|
|
# IPInfo.io GeoIP database auto-refresh
|
|
# Get a free token at https://ipinfo.io/signup
|
|
# Available free DB files: asn.mmdb, country.mmdb, country_asn.mmdb
|
|
ipinfo_token =
|
|
ipinfo_db_file = asn.mmdb
|
|
ipinfo_db_path = /var/lib/crowdsec/data/GeoLite2-ASN.mmdb
|
|
ipinfo_refresh_days = 7
|
|
`, 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
|
|
}
|