Files
housecarl/internal/config/config.go
T
abrown38andClaude Opus 5.5 68ba2d06c5 Sets from Jarl: sets holding the addresses of names in Jarl
A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).

hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:51:55 +01:00

494 lines
17 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"`
// Shadow loads the ruleset without letting it block, reject or
// translate anything, beside the firewall in force (docs/shadow.md).
Shadow Shadow `yaml:"shadow"`
// Names shows inside addresses' names, by reverse lookups
// (docs/configuration.md).
Names Names `yaml:"names"`
}
// Names configures the reverse lookups that put names beside inside
// addresses in the web UI and hc.
type Names struct {
Enabled bool `yaml:"enabled"`
// Resolver is a DNS server ("10.10.0.1:53"; "": the system's).
Resolver string `yaml:"resolver"`
// Cache is how long a name is kept.
Cache Duration `yaml:"cache"`
}
// Shadow is shadow mode: Housecarl's ruleset is loaded and counts, logs
// and traces what it would do, while another firewall decides.
type Shadow struct {
Enabled bool `yaml:"enabled"`
// Position is where Housecarl's filter chains run: after the other
// firewall's (it sees what that one lets through, so what it would
// drop is what switching would break) or before (it sees everything).
Position string `yaml:"position"`
}
// Shadow positions.
const (
ShadowAfter = "after"
ShadowBefore = "before"
)
// 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"`
// Refresh is how often the Jarl sets' names are read (docs/jarl.md).
Refresh Duration `yaml:"refresh"`
}
// 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)}},
Shadow: Shadow{Position: ShadowAfter},
Names: Names{Enabled: true, Cache: Duration(10 * time.Minute)},
Jarl: Jarl{Refresh: Duration(time.Minute)},
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")
}
if c.Shadow.Position != ShadowAfter && c.Shadow.Position != ShadowBefore {
return fmt.Errorf("shadow.position must be after or before")
}
if c.Names.Resolver != "" {
if _, err := netip.ParseAddrPort(c.Names.Resolver); err != nil {
return fmt.Errorf("names.resolver must be an address and port, such as 10.10.0.1:53")
}
}
if c.Names.Cache <= 0 {
return fmt.Errorf("names.cache must be a duration, such as 10m")
}
if c.Jarl.Refresh < Duration(10*time.Second) {
return fmt.Errorf("jarl.refresh must be a duration of 10s or more, such as 1m")
}
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
# Shadow mode (docs/shadow.md): Housecarl's ruleset is loaded beside the
# firewall you have now, but blocks, rejects and translates nothing. Its
# counters, log and tracer show what it would do. No change is on
# probation, and takeovers and closing connections are refused. housecarld
# reads this when it starts: restart it to switch.
shadow:
enabled: false
# after: Housecarl's filter chains run after the other firewall's, and
# see only what it lets through: what Housecarl would drop is what
# switching over would break. before: they see every packet.
position: after
`