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:
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 = 10is 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_timeoutor a per-hooktimeout, 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.