internal/stats samples every minute what the kernel already counts: the
interfaces' counters (per zone too), Housecarl's table (hits per rule,
built-in and policy; drops by chain; quotas; per-device sets), the
connection tracking table, and housecarld's and the host's memory, CPU
and load. Logged drops feed top lists of sources and what they tried.
Aggregates go to a database of their own (stats.db; per minute 7 days,
per hour 90 days, per day 2 years), with the store and queries from
Jarl's.
Traffic per inside device is a setting (stats_per_device): counting sets
stats_up/down_v4/v6 and rules at the top of the forward chain, IPv6 per
/64, carried across reloads. Set names starting with meter_, conn_,
quota_ or stats_ are reserved (set_name_reserved).
GET /stats/{status,summary,timeline,top,hits,heatmap}, hc stats, a
Statistics page with Jarl's charts, two charts on the dashboard, and a
conntrack_full warning. e2e: 188 checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
431 lines
14 KiB
Go
431 lines
14 KiB
Go
// 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
|
|
`
|