Files
jarl/internal/config/config.go
T
abrown38andClaude Opus 5.5 bd6b8778e4 Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG
keys, zones of every type, records, named.conf options) in SQLite and
renders named.conf, keys.conf and zone files from it.

Every change goes through one changeset pipeline: whole-configuration
validation, named-checkconf -z and named-checkzone in a staging
directory, atomic install, rndc reconfig/reload, serial verification
with rndc zonestatus, and rollback if named does not load the change.
Changes apply one at a time by default or are bundled and applied
together. Applied changesets can be reverted.

- jarld: the daemon with the REST API and an embedded web UI, plus import,
  adopt, check, resync, backup and local user recovery commands
- jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh
- users with argon2id passwords and CSRF-protected sessions; hashed API
  tokens with granular grants and roles; an audit log
- systemd unit, sysusers.d/tmpfiles.d files, Makefile
- unit tests, integration tests against a real named, scripts/e2e.sh
- docs/ guides, README and CHANGELOG

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:03:13 +00:00

258 lines
7.6 KiB
Go

// Package config loads jarld's configuration file.
package config
import (
"bytes"
"fmt"
"net/netip"
"os"
"path/filepath"
"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"`
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"`
}
// 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"`
}
// 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"},
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),
},
}
}
// 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.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.Log.Format {
case "text", "json":
default:
return fmt.Errorf("log.format must be text or json")
}
return nil
}
// 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: ""
# 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
`