jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.
- Certificates have an owner user; another user can't take the name.
An existing one is obtained again only when a name is added or
removed. A new certificates grant (names=) says which names a user
may have; it is never part of "*". Users may own
certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
delete theirs; keys only over HTTPS, the socket or loopback; every
download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
857 lines
31 KiB
Go
857 lines
31 KiB
Go
// Package config loads jarld's configuration file.
|
|
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net"
|
|
"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"`
|
|
Metrics Metrics `yaml:"metrics"`
|
|
// Certificates configures TLS certificates Jarl manages
|
|
// (docs/certificates.md); the ACME account is under acme.
|
|
Certificates Certificates `yaml:"certificates"`
|
|
}
|
|
|
|
// Certificates configures the certificates Jarl manages.
|
|
type Certificates struct {
|
|
// Dir is where certificates are written for named (DNS over TLS and
|
|
// HTTPS): one directory per certificate, readable by bind.file_group.
|
|
// "" is <bind.config_dir>/tls.
|
|
Dir string `yaml:"dir"`
|
|
// MaxPerUser is how many certificates a user may own through jarl
|
|
// cert get (certificates for other hosts), unless the user has a
|
|
// limit of their own (jarl user update --cert-limit). Default 10.
|
|
MaxPerUser int `yaml:"max_per_user"`
|
|
}
|
|
|
|
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
|
// A scrape is allowed from an address in Allow, or with Token as a bearer
|
|
// token; root and jarld's own user may also scrape on the local socket.
|
|
type Metrics struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
// Listen are extra addresses that serve only /metrics, over plain
|
|
// HTTP. /metrics is always served on the main listeners too.
|
|
Listen []string `yaml:"listen"`
|
|
// Allow are the addresses or prefixes that may scrape without a token.
|
|
Allow []string `yaml:"allow"`
|
|
// Token, when set, lets any address that sends it as
|
|
// "Authorization: Bearer TOKEN" scrape.
|
|
Token string `yaml:"token"`
|
|
}
|
|
|
|
func (m *Metrics) check() error {
|
|
if !m.Enabled {
|
|
return nil
|
|
}
|
|
for _, a := range m.Listen {
|
|
if _, port, err := net.SplitHostPort(a); err != nil || port == "" {
|
|
return fmt.Errorf("listen %q must be ADDRESS:PORT, e.g. 0.0.0.0:9450", a)
|
|
}
|
|
}
|
|
for _, p := range m.Allow {
|
|
if _, err := ParsePrefix(p); err != nil {
|
|
return fmt.Errorf("allow: %v", err)
|
|
}
|
|
}
|
|
if m.Token != "" && (len(m.Token) < 16 || strings.ContainsAny(m.Token, " \t\r\n")) {
|
|
return fmt.Errorf("token must be at least 16 characters, without spaces (e.g. from 'openssl rand -hex 24')")
|
|
}
|
|
if len(m.Allow) == 0 && m.Token == "" {
|
|
return fmt.Errorf("set allow, token or both, or nobody can scrape")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AllowPrefixes returns the parsed allow list.
|
|
func (m *Metrics) AllowPrefixes() []netip.Prefix {
|
|
var out []netip.Prefix
|
|
for _, p := range m.Allow {
|
|
if pf, err := ParsePrefix(p); err == nil {
|
|
out = append(out, pf.Masked())
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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 and the account Jarl uses to obtain
|
|
// certificates itself.
|
|
type ACME struct {
|
|
HTTPReq HTTPReq `yaml:"httpreq"`
|
|
Account ACMEAccount `yaml:"account"`
|
|
// Resolvers are the recursive resolvers used to see a domain as the
|
|
// CA does: its zone, its public name servers and any CNAME on
|
|
// _acme-challenge. Public resolvers by default, because this server's
|
|
// own answers may come from an internal view.
|
|
Resolvers []string `yaml:"resolvers"`
|
|
// CABundle is a PEM file of extra CA certificates to trust when
|
|
// talking to the ACME server (a private CA such as step-ca).
|
|
CABundle string `yaml:"ca_bundle"`
|
|
// CheckPropagation waits until every name server of the zone serves
|
|
// a challenge before telling the CA to look.
|
|
CheckPropagation bool `yaml:"check_propagation"`
|
|
}
|
|
|
|
// ACMEAccount is the ACME account certificates are obtained with.
|
|
type ACMEAccount struct {
|
|
// Directory is letsencrypt, letsencrypt-staging or an ACME directory URL.
|
|
Directory string `yaml:"directory"`
|
|
Email string `yaml:"email"`
|
|
// AgreeTOS says you accept the CA's terms of service. Jarl doesn't
|
|
// register an account without it.
|
|
AgreeTOS bool `yaml:"agree_tos"`
|
|
// EABKeyID and EABHMAC are External Account Binding credentials, for
|
|
// CAs that need them (ZeroSSL, Google).
|
|
EABKeyID string `yaml:"eab_kid"`
|
|
EABHMAC string `yaml:"eab_hmac"`
|
|
}
|
|
|
|
// Directory URLs for the names acme.account.directory accepts.
|
|
const (
|
|
LetsEncrypt = "https://acme-v02.api.letsencrypt.org/directory"
|
|
LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
)
|
|
|
|
// DirectoryURL is the account's ACME directory URL.
|
|
func (a ACMEAccount) DirectoryURL() string {
|
|
switch a.Directory {
|
|
case "", "letsencrypt":
|
|
return LetsEncrypt
|
|
case "letsencrypt-staging":
|
|
return LetsEncryptStaging
|
|
}
|
|
return a.Directory
|
|
}
|
|
|
|
func (a *ACME) check() error {
|
|
if d := a.Account.Directory; d != "" && d != "letsencrypt" && d != "letsencrypt-staging" {
|
|
u, err := url.Parse(d)
|
|
if err != nil || u.Scheme != "https" || u.Host == "" {
|
|
return fmt.Errorf("account.directory must be letsencrypt, letsencrypt-staging or an https:// URL")
|
|
}
|
|
}
|
|
if a.Account.Email != "" && !strings.Contains(a.Account.Email, "@") {
|
|
return fmt.Errorf("account.email %q isn't an email address", a.Account.Email)
|
|
}
|
|
if (a.Account.EABKeyID == "") != (a.Account.EABHMAC == "") {
|
|
return fmt.Errorf("account: set both eab_kid and eab_hmac, or neither")
|
|
}
|
|
for _, r := range a.Resolvers {
|
|
if _, _, err := net.SplitHostPort(r); err != nil {
|
|
return fmt.Errorf("resolvers: %q must be address:port, e.g. 1.1.1.1:53 or [2606:4700:4700::1111]:53", r)
|
|
}
|
|
}
|
|
if a.CABundle != "" && !filepath.IsAbs(a.CABundle) {
|
|
return fmt.Errorf("ca_bundle must be an absolute path")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CertDir is certificates.dir, or <bind.config_dir>/tls.
|
|
func (c *Config) CertDir() string {
|
|
if c.Certificates.Dir != "" {
|
|
return c.Certificates.Dir
|
|
}
|
|
return filepath.Join(c.BIND.ConfigDir, "tls")
|
|
}
|
|
|
|
// 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"`
|
|
// RateLimitLog sends rate limiting's log lines (named's query-errors
|
|
// category) to the response log too, so the Exposure page and the
|
|
// statistics can show what was, or would be, limited.
|
|
RateLimitLog bool `yaml:"rate_limit_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"`
|
|
// Queries is how long the query log keeps every answer, for exploring
|
|
// (docs/stats.md#exploring); 0 keeps none.
|
|
Queries Duration `yaml:"queries"`
|
|
}
|
|
|
|
// 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,
|
|
RateLimitLog: 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), Queries: Duration(7 * 24 * time.Hour)},
|
|
TopN: 100,
|
|
},
|
|
Certificates: Certificates{MaxPerUser: 10},
|
|
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
|
Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true},
|
|
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
|
|
Metrics: Metrics{Allow: []string{"127.0.0.1", "::1"}},
|
|
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)
|
|
}
|
|
if err := c.Metrics.check(); err != nil {
|
|
return fmt.Errorf("metrics: %w", err)
|
|
}
|
|
if err := c.ACME.check(); err != nil {
|
|
return fmt.Errorf("acme: %w", err)
|
|
}
|
|
if d := c.Certificates.Dir; d != "" && !filepath.IsAbs(d) {
|
|
return fmt.Errorf("certificates.dir must be an absolute path")
|
|
}
|
|
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 r.Queries < 0 || (r.Queries > 0 && r.Queries < Duration(time.Hour)) {
|
|
return fmt.Errorf("retention.queries must be at least 1h, or 0 for no query log")
|
|
}
|
|
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 from a certificate file you renew yourself. The certificate is
|
|
# reread when the file changes (or on SIGHUP). Easier: let Jarl obtain one
|
|
# ('jarl cert create ... --use web', docs/certificates.md), which then
|
|
# takes over from this.
|
|
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
|
|
# With a rate-limit set, also log what rate limiting limits (or would,
|
|
# in log-only mode): named's query-errors category, which Jarl then adds
|
|
# to its log channel (and keeps in syslog). The Exposure page shows it.
|
|
rate_limit_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
|
|
queries: 7d # every answer, to explore (who asked what); 0: none
|
|
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 account Jarl obtains certificates with (docs/certificates.md).
|
|
# Nothing is registered until you create a certificate. Read the CA's
|
|
# terms (https://letsencrypt.org/repository/) and set agree_tos: true.
|
|
# Try letsencrypt-staging first: its certificates aren't trusted, but
|
|
# its rate limits are generous.
|
|
account:
|
|
directory: letsencrypt # letsencrypt, letsencrypt-staging or a directory URL
|
|
email: "" # optional
|
|
agree_tos: false
|
|
eab_kid: "" # External Account Binding, only for CAs that need it
|
|
eab_hmac: ""
|
|
# Resolvers to see your domains as the CA does (their public name
|
|
# servers, a CNAME on _acme-challenge). Not this server: it may answer
|
|
# from an internal view.
|
|
resolvers: ["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"]
|
|
ca_bundle: "" # extra CA certificates for a private ACME server
|
|
# Wait until every name server of the zone serves a challenge before
|
|
# telling the CA to look (they are asked directly, on port 53).
|
|
check_propagation: true
|
|
|
|
# Where certificates with the "dns" use are written for named, one
|
|
# directory per certificate (fullchain.pem, privkey.pem), readable by
|
|
# bind.file_group. Default: <bind.config_dir>/tls.
|
|
certificates:
|
|
dir: ""
|
|
|
|
# Porkbun, as registrar and as DNS host. 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
|
|
# (docs/dnssec.md#porkbun);
|
|
# - publish the DNS-01 challenges of certificates Jarl obtains while
|
|
# Porkbun still serves the domain's DNS (docs/certificates.md): it adds
|
|
# and removes TXT records at _acme-challenge names only.
|
|
# 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
|
|
|
|
# Prometheus metrics at /metrics (docs/metrics.md): named's counters and
|
|
# Jarl's own (blocking, zones, views), plus health gauges to alert on
|
|
# (named up, validation problems, blocklist errors, backup age, DNSSEC
|
|
# steps, token expiry). Off by default. /metrics is served on the
|
|
# addresses in 'listen' at the top of this file; a scrape is allowed from
|
|
# an address in 'allow' below, or with the token. Query counters need
|
|
# stats.enabled.
|
|
metrics:
|
|
enabled: false
|
|
# Extra addresses that serve only /metrics, over plain HTTP, e.g.
|
|
# ["0.0.0.0:9450"] for a Prometheus server elsewhere on the network.
|
|
listen: []
|
|
# Addresses or prefixes that may scrape without a token.
|
|
allow: ["127.0.0.1", "::1"]
|
|
# A bearer token (at least 16 characters) that lets any address scrape:
|
|
# in Prometheus, 'authorization: { credentials: TOKEN }'. Keep this file
|
|
# readable by root and jarld only when you set it.
|
|
token: ""
|
|
`
|