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>
258 lines
7.6 KiB
Go
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
|
|
`
|