Files
jarl/internal/config/config.go
T
adamandClaude Opus 5.5 9fc23310bd Add blocking: blocklists and block/allow rules with BIND response policy zones
jarld downloads blocklists (Adblock, hosts or plain domains), checks them
daily and reloads named only when a list changed; your own block and
allow rules take precedence, and the most specific rule wins. Lists and
rules apply per view (default: every view that offers recursion). Web UI
Blocking page with name checks, jarl blocklist and jarl block, the
/blocking API, blocked counts in the statistics. Database schema 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:24:19 +00:00

637 lines
22 KiB
Go

// Package config loads jarld's configuration file.
package config
import (
"bytes"
"fmt"
"net/netip"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"go.yaml.in/yaml/v3"
)
// DefaultPath is where jarld looks for its configuration.
const DefaultPath = "/etc/jarl/jarl.yaml"
// Config is the daemon configuration.
type Config struct {
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 jarld's
// own user need no token ("" for none).
Socket string `yaml:"socket"`
Database string `yaml:"database"`
StagingDir string `yaml:"staging_dir"`
Log Log `yaml:"log"`
Session Session `yaml:"session"`
TOTP TOTP `yaml:"totp"`
BIND BIND `yaml:"bind"`
Audit Audit `yaml:"audit"`
Backup Backup `yaml:"backup"`
Stats Stats `yaml:"stats"`
ACME ACME `yaml:"acme"`
Porkbun Porkbun `yaml:"porkbun"`
Blocking Blocking `yaml:"blocking"`
}
// Blocking configures blocklist downloads (docs/blocking.md). The lists
// and rules themselves are configuration in the database.
type Blocking struct {
// Refresh is how often each list is checked for changes; a list is
// only downloaded again when it changed.
Refresh Duration `yaml:"refresh"`
MaxDownloadMB int `yaml:"max_download_mb"`
MaxEntries int `yaml:"max_entries"`
}
func (b *Blocking) check() error {
if b.Refresh < Duration(time.Hour) {
return fmt.Errorf("refresh must be at least 1h (list hosts ask not to be polled more often)")
}
if b.MaxDownloadMB < 1 || b.MaxEntries < 1 {
return fmt.Errorf("max_download_mb and max_entries must be at least 1")
}
return nil
}
// Porkbun lets Jarl manage domains' name servers, glue records and DNSSEC
// DS records at the registrar Porkbun (docs/dnssec.md#porkbun). The key
// lives only here, in a file root and jarld can read, and never in the
// database (so not in backups either).
type Porkbun struct {
APIKey string `yaml:"api_key"`
SecretAPIKey string `yaml:"secret_api_key"`
// Domains limits which domains Jarl may manage at Porkbun (default:
// every Jarl zone the key can reach).
Domains []string `yaml:"domains"`
// APIURL is the API's base URL (for tests).
APIURL string `yaml:"api_url"`
}
// Enabled reports whether a Porkbun key is configured.
func (p *Porkbun) Enabled() bool { return p.APIKey != "" }
// Allows reports whether Jarl may manage domain (no trailing dot) at
// Porkbun.
func (p *Porkbun) Allows(domain string) bool {
if !p.Enabled() {
return false
}
if len(p.Domains) == 0 {
return true
}
for _, d := range p.Domains {
if strings.EqualFold(strings.TrimSuffix(d, "."), domain) {
return true
}
}
return false
}
func (p *Porkbun) check() error {
if (p.APIKey == "") != (p.SecretAPIKey == "") {
return fmt.Errorf("set both api_key and secret_api_key (or neither)")
}
if p.APIURL != "" {
u, err := url.Parse(p.APIURL)
if err != nil || (u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHost(u.Hostname()))) {
return fmt.Errorf("api_url must be an https:// URL")
}
}
return nil
}
func isLoopbackHost(h string) bool {
if h == "localhost" {
return true
}
a, err := netip.ParseAddr(h)
return err == nil && a.IsLoopback()
}
// Backup configures 'jarld backup' run without a file name, which is what
// the jarl-backup.timer systemd timer runs (daily by default; the schedule
// is the timer's OnCalendar=). Each run writes a new backup into Dir and
// then deletes the ones Retention and KeepAtLeast no longer want.
type Backup struct {
Dir string `yaml:"dir"`
// Retention deletes backups older than this (0 keeps them all)...
Retention Duration `yaml:"retention"`
// ...but the newest KeepAtLeast are always kept, however old.
KeepAtLeast int `yaml:"keep_at_least"`
// Stats also backs up the query statistics database.
Stats bool `yaml:"stats"`
// WarnAfter makes the dashboard warn when the newest backup is older
// than this (0: never). Keep it above the timer's interval.
WarnAfter Duration `yaml:"warn_after"`
}
// ACME configures the ACME helpers.
type ACME struct {
HTTPReq HTTPReq `yaml:"httpreq"`
}
// HTTPReq configures the endpoint for lego's httpreq DNS provider
// (docs/acme.md). lego sends an API token as an HTTP Basic password, so
// by default the endpoint only answers requests that came over HTTPS.
type HTTPReq struct {
Enabled bool `yaml:"enabled"`
// RequireHTTPS refuses requests that didn't arrive over HTTPS: jarld's
// own TLS, or a trusted proxy that says so in X-Forwarded-Proto or
// Forwarded.
RequireHTTPS bool `yaml:"require_https"`
// AllowLoopbackHTTP also accepts plain HTTP from 127.0.0.1 and ::1
// (lego on the DNS server itself), unless that address is a trusted
// proxy.
AllowLoopbackHTTP bool `yaml:"allow_loopback_http"`
}
// TLS configures HTTPS.
type TLS struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
}
// Log configures logging.
type Log struct {
Level string `yaml:"level"` // debug, info, warn, error
Format string `yaml:"format"` // text, json
}
// Session configures web sessions.
type Session struct {
Lifetime Duration `yaml:"lifetime"`
IdleTimeout Duration `yaml:"idle_timeout"`
CookieSecure string `yaml:"cookie_secure"` // auto, always, never
}
// BIND says where BIND's files and tools are.
// TOTP configures two-factor login to the web UI (docs/permissions.md).
type TOTP struct {
// Require is who must use it: none (each user decides), admins or
// all (every user who logs in with a password).
Require string `yaml:"require"`
// Issuer is the name authenticator apps show for the account ("" is
// "Jarl <hostname>").
Issuer string `yaml:"issuer"`
}
// IssuerName is Issuer, or "Jarl <short hostname>".
func (t TOTP) IssuerName() string {
if t.Issuer != "" {
return t.Issuer
}
h, _ := os.Hostname()
if h, _, _ = strings.Cut(h, "."); h == "" {
return "Jarl"
}
return "Jarl " + h
}
type BIND struct {
MainConfig string `yaml:"main_config"`
ConfigDir string `yaml:"config_dir"`
ZoneDir string `yaml:"zone_dir"`
CacheDir string `yaml:"cache_dir"`
FileGroup string `yaml:"file_group"`
// NamedUser is the user named runs as. 'jarld restore' gives it the
// DNSSEC keys it puts back.
NamedUser string `yaml:"named_user"`
NamedCheckConf string `yaml:"named_checkconf"`
NamedCheckZone string `yaml:"named_checkzone"`
RNDC string `yaml:"rndc"`
RNDCArgs []string `yaml:"rndc_args"`
ReloadTimeout Duration `yaml:"reload_timeout"`
NoReload bool `yaml:"no_reload"`
}
// Audit configures the audit log.
type Audit struct {
// RetentionDays deletes older audit events (0 keeps everything).
RetentionDays int `yaml:"retention_days"`
}
// Stats configures query statistics. When enabled, Jarl makes named publish
// its counters on a local statistics channel and log every response to a
// file, and jarld aggregates both into a separate database.
type Stats struct {
Enabled bool `yaml:"enabled"`
// QueryLog enables per-query data (top names and clients, per-view and
// per-zone counts) from named's response log. Without it only named's
// own counters are collected.
QueryLog bool `yaml:"query_log"`
// Database is the statistics database, kept apart from Jarl's
// configuration database.
Database string `yaml:"database"`
// StatisticsChannel is the address named serves its counters on
// (only reachable from this host).
StatisticsChannel string `yaml:"statistics_channel"`
// LogFile is where named logs responses. named's AppArmor profile
// allows writing under /var/log/named.
LogFile string `yaml:"log_file"`
LogSize string `yaml:"log_size"`
LogVersions int `yaml:"log_versions"`
Retention StatsRetention `yaml:"retention"`
// TopN is how many names and clients are kept per hour and per day.
TopN int `yaml:"top_n"`
// AnonymizeClients stores client networks (IPv4 /24, IPv6 /48)
// instead of addresses.
AnonymizeClients bool `yaml:"anonymize_clients"`
// ExcludeClients are addresses or prefixes whose queries are not
// counted per name or client (e.g. monitoring probes).
ExcludeClients []string `yaml:"exclude_clients"`
}
// StatsRetention says how long each resolution of the statistics is kept.
type StatsRetention struct {
Minute Duration `yaml:"minute"`
Hour Duration `yaml:"hour"`
Day Duration `yaml:"day"`
}
// Duration is a time.Duration written as "15s" or "12h" in YAML. Days
// ("30d") are accepted 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
}
// MarshalYAML implements yaml.Marshaler.
func (d Duration) MarshalYAML() (any, error) { return time.Duration(d).String(), nil }
// ParseDuration parses a Go duration, also accepting a "d" suffix.
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 a Debian/Ubuntu BIND 9.20 server.
func Default() *Config {
return &Config{
Listen: []string{"127.0.0.1:8053"},
Socket: "/run/jarl/jarl.sock",
Database: "/var/lib/jarl/jarl.db",
StagingDir: "/var/lib/jarl/staging",
Log: Log{Level: "info", Format: "text"},
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
TOTP: TOTP{Require: "none"},
BIND: BIND{
MainConfig: "/etc/bind/named.conf",
ConfigDir: "/etc/bind/jarl",
ZoneDir: "/var/lib/bind/jarl",
CacheDir: "/var/cache/bind/jarl",
FileGroup: "bind",
NamedUser: "bind",
NamedCheckConf: "/usr/bin/named-checkconf",
NamedCheckZone: "/usr/bin/named-checkzone",
RNDC: "/usr/sbin/rndc",
ReloadTimeout: Duration(15 * time.Second),
},
Stats: Stats{
QueryLog: true,
Database: "/var/lib/jarl/stats.db",
StatisticsChannel: "127.0.0.1:8058",
LogFile: "/var/log/named/jarl-responses.log",
LogSize: "20m",
LogVersions: 2,
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)},
TopN: 100,
},
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
}
}
// Load reads the configuration file at path over the defaults. A missing
// file is not an error 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)
}
return c, c.check()
}
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")
}
for _, p := range c.TrustedProxies {
if _, err := ParsePrefix(p); err != nil {
return fmt.Errorf("trusted_proxies: %v", err)
}
}
for name, p := range map[string]string{"database": c.Database, "bind.config_dir": c.BIND.ConfigDir, "bind.zone_dir": c.BIND.ZoneDir, "bind.cache_dir": c.BIND.CacheDir} {
if p == "" || !filepath.IsAbs(p) {
return fmt.Errorf("%s must be an absolute path", name)
}
}
switch c.Session.CookieSecure {
case "auto", "always", "never":
default:
return fmt.Errorf("session.cookie_secure must be auto, always or never")
}
switch c.TOTP.Require {
case "none", "admins", "all":
default:
return fmt.Errorf("totp.require must be none, admins or all")
}
if len(c.TOTP.Issuer) > 64 || strings.ContainsAny(c.TOTP.Issuer, ":") {
return fmt.Errorf("totp.issuer must be at most 64 characters, without ':'")
}
if err := c.Stats.check(); err != nil {
return fmt.Errorf("stats: %w", err)
}
if err := c.Backup.check(); err != nil {
return fmt.Errorf("backup: %w", err)
}
if err := c.Porkbun.check(); err != nil {
return fmt.Errorf("porkbun: %w", err)
}
if err := c.Blocking.check(); err != nil {
return fmt.Errorf("blocking: %w", err)
}
switch c.Log.Format {
case "text", "json":
default:
return fmt.Errorf("log.format must be text or json")
}
return nil
}
func (b *Backup) check() error {
if b.Dir == "" || !filepath.IsAbs(b.Dir) {
return fmt.Errorf("dir must be an absolute path")
}
if b.Retention < 0 || (b.Retention > 0 && b.Retention < Duration(24*time.Hour)) {
return fmt.Errorf("retention must be at least 1d (or 0 to keep every backup)")
}
if b.KeepAtLeast < 1 {
return fmt.Errorf("keep_at_least must be at least 1")
}
if b.WarnAfter < 0 || (b.WarnAfter > 0 && b.WarnAfter < Duration(time.Hour)) {
return fmt.Errorf("warn_after must be at least 1h (or 0 for no warning)")
}
return nil
}
func (s *Stats) check() error {
if !s.Enabled {
return nil
}
if s.Database == "" || !filepath.IsAbs(s.Database) {
return fmt.Errorf("database must be an absolute path")
}
ap, err := netip.ParseAddrPort(s.StatisticsChannel)
if err != nil {
return fmt.Errorf("statistics_channel %q must be ADDRESS:PORT, e.g. 127.0.0.1:8058", s.StatisticsChannel)
}
if !ap.Addr().IsLoopback() {
return fmt.Errorf("statistics_channel must be a loopback address (named's counters must not be reachable from the network)")
}
if s.QueryLog {
if !filepath.IsAbs(s.LogFile) {
return fmt.Errorf("log_file must be an absolute path")
}
if strings.ContainsAny(s.LogFile, "\"\n") {
return fmt.Errorf("log_file contains invalid characters")
}
if !logSizeRe.MatchString(s.LogSize) {
return fmt.Errorf("log_size %q must be a size such as 20m or 1g", s.LogSize)
}
if s.LogVersions < 1 || s.LogVersions > 100 {
return fmt.Errorf("log_versions must be between 1 and 100")
}
}
r := s.Retention
if r.Minute < Duration(time.Hour) || r.Hour < Duration(24*time.Hour) || r.Day < Duration(24*time.Hour) {
return fmt.Errorf("retention must keep minutes for at least 1h, hours and days for at least 1d")
}
if s.TopN < 10 || s.TopN > 10000 {
return fmt.Errorf("top_n must be between 10 and 10000")
}
for _, p := range s.ExcludeClients {
if _, err := ParsePrefix(p); err != nil {
return fmt.Errorf("exclude_clients: %v", err)
}
}
return nil
}
var logSizeRe = regexp.MustCompile(`^[0-9]+[kKmMgG]?$`)
// ParsePrefix parses an address or 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 proxy prefixes.
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.
const Example = `# Jarl daemon configuration (/etc/jarl/jarl.yaml).
# Every setting is optional; the values shown are the defaults for
# Debian/Ubuntu with BIND 9.20.
# 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:8053"
# HTTPS. The certificate is reread when the file changes (or on SIGHUP),
# so renewed Let's Encrypt certificates are picked up automatically.
tls:
cert_file: ""
key_file: ""
# A local unix socket for the API and the jarl CLI. Requests on it from
# root or jarld's own user need no token and have administrator rights
# ('sudo jarl status' works with nothing configured); anyone else must send
# a token. systemd creates /run/jarl. "" turns the socket off.
socket: /run/jarl/jarl.sock
# Reverse proxies whose X-Forwarded-For / X-Forwarded-Proto headers are
# trusted (addresses or prefixes).
trusted_proxies: []
database: /var/lib/jarl/jarl.db
staging_dir: /var/lib/jarl/staging
log:
level: info # debug, info, warn or error
format: text # text or json (both go to the journal under systemd)
session:
lifetime: 12h # web sessions end after this long...
idle_timeout: 2h # ...or after this long without activity
cookie_secure: auto # auto, always or never
# Two-factor login to the web UI with an authenticator app (TOTP). Each
# user can turn it on under Account. require makes it compulsory: none,
# admins, or all (every user with a password). A user who must use it and
# hasn't set it up can only set it up after logging in. API tokens and
# the local socket are not affected. See docs/permissions.md.
totp:
require: none
issuer: "" # the name in the app; "" is "Jarl <hostname>"
bind:
# The file named loads. 'jarld adopt' makes it include Jarl's config.
main_config: /etc/bind/named.conf
# Where Jarl writes named.conf/keys.conf, zone files, and where named
# keeps files of secondary zones. named (AppArmor) may read /etc/bind
# and write /var/lib/bind and /var/cache/bind.
config_dir: /etc/bind/jarl
zone_dir: /var/lib/bind/jarl
cache_dir: /var/cache/bind/jarl
# Group of generated files; named must be able to read them.
file_group: bind
# The user named runs as ('jarld restore' gives it the DNSSEC keys back).
named_user: bind
named_checkconf: /usr/bin/named-checkconf
named_checkzone: /usr/bin/named-checkzone
rndc: /usr/sbin/rndc
# Extra rndc arguments, e.g. ["-k", "/etc/bind/rndc.key", "-s", "127.0.0.1"].
rndc_args: []
# How long to wait for named to load changed zones before rolling back.
reload_timeout: 15s
# Write files but never run rndc (no named on this host).
no_reload: false
audit:
# Delete audit events older than this many days (0 keeps them forever).
retention_days: 0
# Database backups (docs/operations.md#backups). The jarl-backup.timer
# systemd timer runs 'jarld backup' daily; change when with
# 'systemctl edit jarl-backup.timer'. Each run writes
# DIR/jarl-<UTC time>.db, checks it, then deletes backups older than
# 'retention', always keeping the newest 'keep_at_least'. Only files named
# like that are ever deleted. Each backup also holds the DNSSEC keys of
# signed zones (private keys included), so keep the directory private.
# Restore with 'jarld restore'.
backup:
dir: /var/backups/jarl # the timer's sandbox may write only here (see the docs to move it)
retention: 30d # 0 keeps every backup
keep_at_least: 7
stats: false # also back up the statistics database
warn_after: 2d # dashboard warning when the newest backup is older (0: never)
# Query statistics (docs/stats.md). Off by default. When enabled, Jarl makes
# named publish its counters on a local statistics channel and log every
# response, and jarld aggregates both into a separate database. Changing
# this section takes effect when jarld restarts (it rewrites named.conf).
stats:
enabled: false
# Per-query data (top names and clients, per view and zone). false
# collects only named's own counters, with no logging overhead.
query_log: true
database: /var/lib/jarl/stats.db
statistics_channel: 127.0.0.1:8058
log_file: /var/log/named/jarl-responses.log
log_size: 20m
log_versions: 2
retention:
minute: 7d # per-minute counters
hour: 90d # hourly counters and top lists
day: 730d # daily counters and top lists
top_n: 100 # names and clients kept per hour and per day
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37
exclude_clients: [] # e.g. [127.0.0.1] to ignore local monitoring
# An endpoint for lego's "httpreq" DNS provider (docs/acme.md), so lego and
# Traefik can publish DNS-01 challenges without the jarl CLI. Off by
# default. lego sends an API token as an HTTP Basic password, so by default
# requests are only accepted over HTTPS: jarld's own tls (above), or a
# reverse proxy in trusted_proxies that sends "X-Forwarded-Proto: https"
# (or "Forwarded: proto=https"). Nothing else in Jarl depends on this.
acme:
httpreq:
enabled: false
require_https: true
# Also accept plain HTTP from 127.0.0.1 and ::1, for lego running on
# this server. Leave false if a reverse proxy runs on this server but
# isn't in trusted_proxies: its requests would look local.
allow_loopback_http: false
# The registrar Porkbun (docs/dnssec.md#porkbun): with an API key, Jarl can
# show and set a domain's name servers, glue records and DNSSEC DS records
# there, after checking that the change won't break the domain. Off without
# a key. Create the key at porkbun.com (Account -> API Access), switch on
# "API Access" for the domain, and limit the key to that domain if you can.
# The key can move your domain elsewhere: it lives only in this file (keep
# it readable by root and jarld only: chown root:jarl, chmod 640), never in
# Jarl's database or backups.
porkbun:
api_key: "" # pk1_...
secret_api_key: "" # sk1_...
domains: [] # only these domains (default: any Jarl zone the key reaches)
# Downloading blocklists (docs/blocking.md). The lists and rules are set up
# in the web UI or with 'jarl blocklist' and 'jarl block'; this only sets
# how jarld downloads them.
blocking:
refresh: 24h # check each list for changes this often (at least 1h)
max_download_mb: 64 # refuse larger downloads
max_entries: 1000000 # refuse lists with more domains
`