Files
housecarl/internal/config/config.go
T
abrown38andClaude Opus 5.5 34292eeebb Build Housecarl 0.1.0: the nftables firewall manager
Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:57:14 +01:00

397 lines
13 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"`
}
// 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},
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
`