jarld also listens on /run/jarl/jarl.sock (socket in jarl.yaml). Requests on it from root or jarld's own user need no token and act as an administrator; the kernel's SO_PEERCRED says who is connecting, and anyone else must send a token. The jarl CLI uses the socket when no server is configured, or with -s local, so 'sudo jarl status' works on the server with nothing set up and a lost token or password never locks root out. Changes are audited under the system user's name via "socket". The systemd unit creates /run/jarl (0750). Socket paths over 107 bytes are refused with a clear message. The docs explain the socket and what happens when a token is revoked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
431 lines
14 KiB
Go
431 lines
14 KiB
Go
// Package config loads jarld's configuration file.
|
|
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/netip"
|
|
"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"`
|
|
BIND BIND `yaml:"bind"`
|
|
Audit Audit `yaml:"audit"`
|
|
Stats Stats `yaml:"stats"`
|
|
ACME ACME `yaml:"acme"`
|
|
}
|
|
|
|
// 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.
|
|
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"`
|
|
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"},
|
|
BIND: BIND{
|
|
MainConfig: "/etc/bind/named.conf",
|
|
ConfigDir: "/etc/bind/jarl",
|
|
ZoneDir: "/var/lib/bind/jarl",
|
|
CacheDir: "/var/cache/bind/jarl",
|
|
FileGroup: "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}},
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
if err := c.Stats.check(); err != nil {
|
|
return fmt.Errorf("stats: %w", err)
|
|
}
|
|
switch c.Log.Format {
|
|
case "text", "json":
|
|
default:
|
|
return fmt.Errorf("log.format must be text or json")
|
|
}
|
|
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
|
|
|
|
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
|
|
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
|
|
|
|
# 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
|
|
`
|