Skip to content

Configuration Reference

Complete reference for all configuration options.

Server Configuration

[server]

Option Type Default Description
bind_address string "0.0.0.0:50051" gRPC server listen address
hosts_file_path path /etc/hosts.d/router-hosts Output hosts(5) file path
dnsmasq_conf_path path - Output dnsmasq conf-dir file of local=/address= directives (additive)
unbound_conf_path path - Output unbound conf-dir file of local-zone/local-data directives (additive)
unbound_ttl int 300 TTL (seconds) for unbound_conf_path local-data records

At least one of hosts_file_path, dnsmasq_conf_path, or unbound_conf_path must be set.

When dnsmasq_conf_path is configured, router-hosts also writes a file for consumption via dnsmasq conf-dir, emitting a directive pair per name and alias:

local=/<fqdn>/
address=/<fqdn>/<ip>

The address= line answers the matching record type (A for an IPv4 address, AAAA for an IPv6 address). The companion local= line makes dnsmasq authoritative for that exact name, so a query for the other record type returns NODATA instead of being forwarded upstream. This prevents public-AAAA leaks in split-horizon setups (GH #325).

The local= line is required because dnsmasq 2.86+ forwards non-matching record types upstream when only address= is present. Both directives are scoped per-name, not zone-wide like local=/<domain>/ (which would return NXDOMAIN for unmanaged names in the zone).

When unbound_conf_path is configured, router-hosts writes a server: clause header followed by, per managed name, one local-zone: "<fqdn>." static plus its local-data records:

server:
local-zone: "api.fzymgc.house." static
local-data: "api.fzymgc.house. 300 IN A 10.0.0.5"
local-data: "api.fzymgc.house. 300 IN AAAA fd00::5"

The server: header is required because local-zone:/local-data: are server:-clause options; it keeps the file valid whether you pull it in via a top-level include: or from inside an existing server: clause (unbound merges server: clauses). Without it, unbound rejects the file with a syntax error at the first local-zone:.

static answers the listed record types and returns NODATA for any other type at that name, so a name's missing types (e.g. AAAA, HTTPS/type-65) are never forwarded upstream. Names are emitted verbatim (trailing-dot normalized): a bare, non-FQDN alias (e.g. api) becomes local-zone: "api." static, making unbound authoritative for that entire pseudo-TLD — inventories MUST carry FQDNs. Reload is out of scope: point a host-side systemd path unit at the conf directory to reload unbound on write.

[database]

Option Type Default Description
path path XDG data dir SQLite database file path
url string - PostgreSQL connection URL (use instead of path)

[tls]

Option Type Default Description
cert_path path required Server certificate file
key_path path required Server private key file
ca_cert_path path required CA certificate for client verification

[hooks]

Hooks are arrays of hook definitions. Each hook has a name (kebab-case identifier) and command (shell command).

Option Type Default Description
on_success array of hooks [] Hooks to run after successful host updates
on_failure array of hooks [] Hooks to run after failed host updates
default_timeout duration string "30s" Timeout applied to any hook that does not set its own timeout

Each hook definition:

Field Type Description
name string Unique kebab-case identifier (used in logs/metrics)
command string Shell command to execute
timeout duration string This hook's execution timeout. Omitted or "0s" inherits [hooks] default_timeout.

Duration format and the unquoted-integer footgun:

  • Timeout values are Go duration strings — "10s", "2m", "1m30s" — and MUST be quoted.
  • An unquoted integer is decoded as NANOSECONDS by the TOML decoder, not seconds. timeout = 10 is accepted (it is a positive duration) and makes that hook time out effectively immediately — it does not raise a validation error. Always quote:
# Correct — ten seconds
timeout = "10s"

# WRONG — decodes to 10 nanoseconds, not ten seconds. Accepted at load
# time (it's a positive duration), but the hook will time out instantly.
timeout = 10
  • A negative timeout, at either default_timeout or a per-hook timeout, is rejected at server start with a configuration error. An omitted or "0s" value inherits the default rather than being rejected.
  • There is no upper bound on a hook timeout. Hooks no longer run on the write RPC path, so a slow hook cannot stall a write; the only relevant bound is the coalescing queue (depth 1), not the timeout. An arbitrary ceiling would only break legitimate slow hooks.

Example hooks:

[hooks]
default_timeout = "20s"

[[hooks.on_success]]
name = "reload-dnsmasq"
command = "systemctl reload dnsmasq"
timeout = "10s"

[[hooks.on_failure]]
name = "notify-failure"
command = "notify-send 'router-hosts update failed'"

Client Configuration

[server]

Option Type Default Description
address string required Server gRPC address

[tls]

Option Type Default Description
cert_path path required Client certificate file
key_path path required Client private key file
ca_cert_path path required CA certificate for server verification

[limits]

Option Type Default Description
max_stream_entries int 50000 Maximum entries a single collecting command (host list, host search, snapshot list, render) accumulates from a server response
max_stream_bytes int 67108864 (64 MiB) Maximum accumulated serialized size, in bytes, of the same response

Both bounds are independent and are enforced at every collecting call site: a response can cross the byte budget while its entry count stays far below max_stream_entries (comment and tag text carry no length validation, unlike hostnames and aliases), and the reverse. Crossing either bound refuses the whole response — the client never returns a truncated result, and never silently drops entries to fit.

max_stream_bytes counts serialized protobuf bytes as reported by proto.Size on each message actually received over the wire. This is a conservative operational bound on wire volume received, not an exact Go heap ceiling: slice headers, string headers, and the converted Go objects the client builds from each message all sit outside this count.

Both keys can also be set via environment variable, which overrides the config file:

Environment variable Overrides
ROUTER_HOSTS_MAX_STREAM_ENTRIES limits.max_stream_entries
ROUTER_HOSTS_MAX_STREAM_BYTES limits.max_stream_bytes

A configured value of 0 (or an absent [limits] table) resolves to the default above rather than to "unlimited." A negative value, or a non-numeric environment variable value, is rejected at load time with an error naming the offending key.

Invalid config file handling

The client refuses to start when a config file is found but unusable: the TOML does not parse, it contains an unknown key, or it fails validation. The error names the offending file path and the specific problem (parse failure, the unknown key, or the validation message).

An absent config file is not an error — running on environment variables and/or CLI flags alone remains fully supported. Only a config file that exists and is wrong is treated as an operator error.