// 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 `