// Package config loads housecarld's configuration file. It only says how // the daemon runs (where it listens, its files, logging); the firewall's // configuration itself is in the database. package config import ( "bytes" "fmt" "net" "net/netip" "net/url" "os" "path/filepath" "strings" "time" "go.yaml.in/yaml/v3" ) // DefaultPath is where housecarld looks for its configuration. const DefaultPath = "/etc/housecarl/housecarl.yaml" // Config is the daemon configuration. type Config struct { // Listen are the web UI's and API's addresses. Listen []string `yaml:"listen"` TLS TLS `yaml:"tls"` TrustedProxies []string `yaml:"trusted_proxies"` // Socket is a local unix socket for the API, where root and // housecarld's own user need no token ("" for none). Socket string `yaml:"socket"` Database string `yaml:"database"` Log Log `yaml:"log"` Session Session `yaml:"session"` Backup Backup `yaml:"backup"` // Jarl is the Jarl server Housecarl uses for its web UI's // certificate (docs/certificates.md). Jarl Jarl `yaml:"jarl"` // Ruleset is where the confirmed ruleset is saved for the boot unit // (housecarl-ruleset.service loads it before the network comes up). Ruleset string `yaml:"ruleset"` // Stats keeps traffic, hits and drops over time (docs/stats.md). Stats Stats `yaml:"stats"` } // Stats configures statistics: a database of their own, aggregates // only, pruned by age. type Stats struct { Enabled bool `yaml:"enabled"` Database string `yaml:"database"` Retention StatsRetention `yaml:"retention"` // TopN is how many entries the top lists keep per hour and day. TopN int `yaml:"top_n"` } // StatsRetention is how long each resolution is kept. type StatsRetention struct { Minute Duration `yaml:"minute"` Hour Duration `yaml:"hour"` Day Duration `yaml:"day"` } // TLS is the web UI's certificate: from files, or obtained through ACME // with Jarl publishing the DNS-01 challenge (docs/certificates.md). type TLS struct { CertFile string `yaml:"cert_file"` KeyFile string `yaml:"key_file"` ACME ACME `yaml:"acme"` } // ACME obtains the web UI's certificate (docs/certificates.md). type ACME struct { // Domain is the certificate's name ("" turns ACME off). Domain string `yaml:"domain"` Email string `yaml:"email"` // Directory is letsencrypt, letsencrypt-staging (the default, for a // first certificate browsers don't trust) or an ACME directory URL. Directory string `yaml:"directory"` // AgreeTOS says you accept the CA's terms of service; no account is // registered without it. AgreeTOS bool `yaml:"agree_tos"` // Resolvers are public recursive resolvers, to see the challenge as // the CA would. Resolvers []string `yaml:"resolvers"` // CheckPropagation also asks each of the zone's public name servers // directly before the CA is told to look. CheckPropagation bool `yaml:"check_propagation"` // CABundle is a PEM file of extra CAs to trust for the ACME server // (a private CA such as step-ca, or Pebble in tests). CABundle string `yaml:"ca_bundle"` } // Directory URLs for the names tls.acme.directory accepts. const ( LetsEncrypt = "https://acme-v02.api.letsencrypt.org/directory" LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory" ) // DirectoryURL is the ACME directory's URL. func (a ACME) DirectoryURL() string { switch a.Directory { case "", "letsencrypt-staging": return LetsEncryptStaging case "letsencrypt": return LetsEncrypt } return a.Directory } // Staging reports whether the directory is Let's Encrypt's staging one. func (a ACME) Staging() bool { return a.DirectoryURL() == LetsEncryptStaging } // Jarl is a Jarl server and an API token for it. type Jarl struct { // Server is Jarl's URL ("" when Housecarl doesn't use Jarl). Server string `yaml:"server"` // Token, or TokenFile holding it (better: the file can be readable by // the housecarl user alone). Token string `yaml:"token"` TokenFile string `yaml:"token_file"` // CACert is a PEM file with a CA to trust for Jarl's certificate. CACert string `yaml:"ca_cert"` Insecure bool `yaml:"insecure"` } // Enabled reports whether a Jarl server is configured. func (j Jarl) Enabled() bool { return j.Server != "" } // ReadToken returns the token, from the setting or the file. func (j Jarl) ReadToken() (string, error) { if j.TokenFile == "" { return j.Token, nil } b, err := os.ReadFile(j.TokenFile) if err != nil { return "", fmt.Errorf("jarl.token_file: %w", err) } return strings.TrimSpace(string(b)), nil } // Log configures logging to stderr (the journal under systemd). type Log struct { Level string `yaml:"level"` // debug, info, warn, error Format string `yaml:"format"` // text or json } // Session configures web UI logins. type Session struct { Lifetime Duration `yaml:"lifetime"` IdleTimeout Duration `yaml:"idle_timeout"` // CookieSecure is auto (secure over HTTPS), always or never. CookieSecure string `yaml:"cookie_secure"` } // Backup configures 'housecarld backup'. type Backup struct { Dir string `yaml:"dir"` Retention Duration `yaml:"retention"` // KeepAtLeast backups are kept however old they are. KeepAtLeast int `yaml:"keep_at_least"` } // Duration is a time.Duration written as "15s" or "12h"; days ("30d") // work too. type Duration time.Duration // UnmarshalYAML implements yaml.Unmarshaler. func (d *Duration) UnmarshalYAML(n *yaml.Node) error { var s string if err := n.Decode(&s); err != nil { return err } v, err := ParseDuration(s) if err != nil { return err } *d = Duration(v) return nil } // ParseDuration parses a Go duration, also accepting days ("30d"). func ParseDuration(s string) (time.Duration, error) { s = strings.TrimSpace(s) if n, ok := strings.CutSuffix(s, "d"); ok { var days int if _, err := fmt.Sscanf(n, "%d", &days); err != nil || fmt.Sprint(days) != n { return 0, fmt.Errorf("invalid duration %q", s) } return time.Duration(days) * 24 * time.Hour, nil } return time.ParseDuration(s) } // Default returns the defaults for Debian/Ubuntu. func Default() *Config { return &Config{ Listen: []string{"127.0.0.1:8070"}, TrustedProxies: []string{}, Ruleset: "/var/lib/housecarl/ruleset.nft", Socket: "/run/housecarl/housecarl.sock", Database: "/var/lib/housecarl/housecarl.db", Log: Log{Level: "info", Format: "text"}, Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"}, Backup: Backup{Dir: "/var/backups/housecarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7}, Stats: Stats{Enabled: true, Database: "/var/lib/housecarl/stats.db", TopN: 100, Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)}}, TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}}, } } // Load reads the file at path over the defaults. A missing file is fine // when allowMissing is set. func Load(path string, allowMissing bool) (*Config, error) { c := Default() b, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) && allowMissing { return c, c.check() } return nil, err } dec := yaml.NewDecoder(bytes.NewReader(b)) dec.KnownFields(true) if err := dec.Decode(c); err != nil && err.Error() != "EOF" { return nil, fmt.Errorf("%s: %w", path, err) } if err := c.check(); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } return c, nil } func (c *Config) check() error { if len(c.Listen) == 0 { return fmt.Errorf("listen: at least one address is needed") } if c.Socket != "" && !filepath.IsAbs(c.Socket) { return fmt.Errorf("socket must be an absolute path (or \"\" for none)") } if len(c.Socket) > 107 { return fmt.Errorf("socket: the path is %d bytes long; unix sockets allow at most 107", len(c.Socket)) } if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") { return fmt.Errorf("tls: set both cert_file and key_file") } if err := c.checkACME(); err != nil { return err } for _, p := range c.TrustedProxies { if _, err := ParsePrefix(p); err != nil { return fmt.Errorf("trusted_proxies: %v", err) } } if c.Ruleset != "" && !filepath.IsAbs(c.Ruleset) { return fmt.Errorf("ruleset must be an absolute path (or \"\" for none)") } if c.Database == "" || !filepath.IsAbs(c.Database) { return fmt.Errorf("database must be an absolute path") } if c.Backup.Dir != "" && !filepath.IsAbs(c.Backup.Dir) { return fmt.Errorf("backup.dir must be an absolute path") } switch c.Session.CookieSecure { case "auto", "always", "never": default: return fmt.Errorf("session.cookie_secure must be auto, always or never") } switch c.Log.Format { case "text", "json": default: return fmt.Errorf("log.format must be text or json") } switch c.Log.Level { case "debug", "info", "warn", "error": default: return fmt.Errorf("log.level must be debug, info, warn or error") } return nil } func (c *Config) checkACME() error { j := c.Jarl if j.Server != "" { if u, err := url.Parse(j.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return fmt.Errorf("jarl.server %q: give Jarl's URL, e.g. https://dns.example.com:8053", j.Server) } if j.Token == "" && j.TokenFile == "" { return fmt.Errorf("jarl: set token or token_file (a Jarl API token, docs/jarl.md)") } if j.Token != "" && j.TokenFile != "" { return fmt.Errorf("jarl: set token or token_file, not both") } } a := c.TLS.ACME if a.Domain == "" { return nil } if c.TLS.CertFile != "" { return fmt.Errorf("tls: cert_file and acme can't both be set; choose one") } if !j.Enabled() { return fmt.Errorf("tls.acme needs Jarl to publish the challenge: set jarl.server and its token (docs/certificates.md)") } d := strings.TrimSuffix(strings.ToLower(a.Domain), ".") if strings.HasPrefix(d, "*.") || strings.Count(d, ".") < 1 || strings.ContainsAny(d, " /:") { return fmt.Errorf("tls.acme.domain %q: give the full name the web UI is reached by, e.g. firewall.example.com", a.Domain) } if u := a.DirectoryURL(); !strings.HasPrefix(u, "https://") { return fmt.Errorf("tls.acme.directory %q: use letsencrypt, letsencrypt-staging or an https:// ACME directory URL", a.Directory) } for _, r := range a.Resolvers { if _, _, err := net.SplitHostPort(r); err != nil { return fmt.Errorf("tls.acme.resolvers: %q must be HOST:PORT, e.g. 1.1.1.1:53", r) } } return nil } // ParsePrefix parses an address or a prefix. func ParsePrefix(s string) (netip.Prefix, error) { if strings.Contains(s, "/") { return netip.ParsePrefix(s) } a, err := netip.ParseAddr(s) if err != nil { return netip.Prefix{}, err } return netip.PrefixFrom(a, a.BitLen()), nil } // Proxies returns the trusted proxies. func (c *Config) Proxies() []netip.Prefix { var out []netip.Prefix for _, p := range c.TrustedProxies { if pf, err := ParsePrefix(p); err == nil { out = append(out, pf.Masked()) } } return out } // Example is a commented example configuration with the defaults. const Example = `# Housecarl daemon configuration (/etc/housecarl/housecarl.yaml). # Every setting is optional; the values shown are the defaults. The # firewall's configuration (zones, sets, rules, NAT) is not here: it is in # the database, changed with hc, the API or the web UI. # Addresses the web UI and API listen on. Keep this off the internet: # bind to localhost or a management network, and use TLS (below) or a # TLS-terminating reverse proxy. listen: - "127.0.0.1:8070" # HTTPS for the web UI and API: a certificate from files you renew yourself # (reread when they change)... # tls: # cert_file: /etc/housecarl/tls/fullchain.pem # key_file: /etc/housecarl/tls/privkey.pem # # ...or one Housecarl obtains and renews itself from Let's Encrypt, with Jarl # publishing the DNS-01 challenge (needs jarl: below; docs/certificates.md). # Start with the staging directory: its certificate isn't trusted by # browsers, but it proves everything works without Let's Encrypt's rate # limits. Then set directory: letsencrypt. tls: acme: domain: "" # e.g. firewall.example.com; "" is off email: "" directory: letsencrypt-staging # letsencrypt, or an ACME directory URL agree_tos: false # you accept the CA's terms of service # Public resolvers, to see the challenge as the CA does. resolvers: ["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"] # Also ask the zone's public name servers directly. check_propagation: true ca_bundle: "" # extra CA for a private ACME server # Jarl, the BIND manager, publishes the certificate's DNS-01 challenge # (docs/certificates.md). Give Housecarl a token that may only write its # challenge record; keep it in a file only the housecarl user can read. # jarl: # server: https://dns.example.com:8053 # token_file: /etc/housecarl/jarl.token # ca_cert: "" # a CA to trust for Jarl's certificate # insecure: false # The confirmed ruleset, saved for housecarl-ruleset.service, which loads # it at boot before the network comes up (and before housecarld starts). ruleset: /var/lib/housecarl/ruleset.nft # Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are # believed. trusted_proxies: [] # A local socket for the API: root and housecarld's own user use it without # a token ('sudo hc status' just works), and changes made through it need # no confirmation. "" turns it off. socket: /run/housecarl/housecarl.sock database: /var/lib/housecarl/housecarl.db log: level: info # debug, info, warn or error format: text # text or json session: lifetime: 12h idle_timeout: 2h cookie_secure: auto # auto (secure over HTTPS), always or never # 'housecarld backup' (the daily housecarl-backup.timer) writes copies of the # database here and deletes those older than retention, keeping at least # keep_at_least. backup: dir: /var/backups/housecarl retention: 30d keep_at_least: 7 # Statistics (docs/stats.md): traffic per interface and zone, hits and # drops, blocked sources, connections, quotas, kept over time in a database # of their own (aggregates only). Deleting it is always safe. Traffic per # device is a firewall setting: hc settings set --stats-per-device on. stats: enabled: true database: /var/lib/housecarl/stats.db retention: minute: 7d # per-minute figures hour: 90d # hourly figures and top lists day: 730d # daily figures and top lists top_n: 100 # entries kept per top list, per hour and day `