- Go 100%
Extends the existing config-file watcher to also hot-reload a MAC ->
nickname directory (internal/dhcp/nickname) alongside reservations,
shown as "hostname (nickname)" everywhere hostname is displayed.
Adds a read-only web dashboard (internal/dhcp/dashboard) showing static
reservations, active leases, and previously-seen-but-inactive
("offline") devices in one live-refreshing table, backed by a new
persistent MAC -> last-visit tracker (internal/dhcp/lastseen) that
records every MAC the server hears from, independent of lease state.
Adds best-effort push notifications via ntfy (internal/dhcp/ntfy) the
first time a MAC is ever seen — never blocking DHCP request handling,
globally rate-limited against a spoofed-MAC flood, and strictly gated
on last_seen being enabled since that's how "new" is determined.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018JYwV3qdCSKjvUZxFkj8M8
|
||
|---|---|---|
| cmd | ||
| deploy | ||
| internal | ||
| .gitignore | ||
| config.example.yaml | ||
| dhcp.example.yaml | ||
| go.mod | ||
| go.sum | ||
| mise.toml | ||
| README.md | ||
netx
Small, security-conscious network services for a local network. Each is an independent binary sharing a common style: layered packages, a required client allow/deny-list, per-client rate limiting, and no implicit trust of the network.
- netxdns — caching, forwarding DNS server with static local records.
- netxdhcp — DHCPv4 server with static reservations and persisted leases.
netxdns
A caching, forwarding DNS server for a local network: it answers static
local hostnames (e.g. nas.lan) directly, caches everything else, and
forwards the rest to upstream recursive resolvers (e.g. 1.1.1.1).
Architecture
cmd/netxdns/ entrypoint: config load, wiring, signal handling
internal/config/ YAML config loading + validation
internal/acl/ client allow-list (CIDR-based)
internal/ratelimit/ per-client-IP token-bucket rate limiting
internal/records/ static local DNS records (nas.lan -> 192.168.1.10)
internal/cache/ bounded, TTL-aware response cache
internal/resolver/ local records -> cache -> upstream forwarding
internal/server/ dns.Handler: ACL, rate limit, validation, size caps
Each package has a single responsibility and depends only on the ones
below it in that list; server is the only place that talks to
net/dns.ResponseWriter directly, so all client-facing security
controls live in one file (internal/server/server.go).
Running
go build -o netxdns ./cmd/netxdns
cp config.example.yaml config.yaml
# edit config.yaml: set allowed_networks to your actual LAN CIDR(s)
sudo setcap 'cap_net_bind_service=+ep' ./netxdns # allow binding :53 without root
./netxdns -config config.yaml
For always-on deployment, see deploy/netxdns.service — it runs the
server as a dedicated unprivileged user with only CAP_NET_BIND_SERVICE,
plus standard systemd sandboxing (ProtectSystem, NoNewPrivileges,
etc.). Prefer this over running as root.
Security posture
This server is meant to sit on a home/office LAN, not face the public internet. The design assumes that and defends in depth anyway:
- Closed by default.
allowed_networksis a required allow-list; the server refuses to start if it's empty (config.Validate), so you can't accidentally ship an open resolver. - No open-resolver reflection. Disallowed clients get no UDP reply
at all (silent drop), not even an error — an attacker spoofing a
victim's source IP gets nothing to reflect. TCP clients get a
REFUSED, since TCP already requires a real handshake with that source address. - Amplification bounded. UDP responses are capped at 1232 bytes
(the modern DNS Flag Day recommendation) regardless of what a client's
EDNS0 buffer size claims, with the truncation bit set so real clients
transparently retry over TCP.
ANYqueries — the classic small-query /huge-reply amplification lever — are refused outright (NOTIMP). - Per-client rate limiting. A token bucket per source IP blunts floods; idle buckets are swept so the map can't be grown unbounded by a spoofed-source flood.
- Bounded cache. The response cache has a max entry count (LRU
eviction) and clamps every RR's TTL into
[min_ttl, max_ttl], so a malicious or misconfigured upstream can't pin bad data forever or balloon memory use.SERVFAIL/REFUSEDupstream responses are never cached, so a transient upstream outage self-heals instead of being pinned for the cache lifetime. - Least privilege at runtime. The binary itself never calls
setuid; privilege reduction is handled by the OS (setcapor systemdAmbientCapabilities), which is the only safe way to do it in a multi-threaded Go program. - Input validation. Only single-question, standard-opcode queries
are resolved; anything else gets
FORMERRrather than being passed through to the resolver/upstream.
What this does not do
- No DNSSEC validation. Upstream responses are trusted as-is. If you need DNSSEC validation, put a validating resolver (e.g. Unbound) in the upstream chain, or treat this as a future enhancement.
- No DoH/DoT to upstream. Upstream queries go out over plain UDP/53.
Your network operator/ISP can see query names in transit. Swapping in
DoT is a contained change to
internal/resolver.NewUpstream. - Not hardened for direct internet exposure. Firewall port 53 to
your LAN regardless of
allowed_networks— the allow-list is defense in depth, not a substitute for network-level access control.
Configuration
See config.example.yaml for all fields and defaults. Key ones:
| Field | Purpose |
|---|---|
allowed_networks |
CIDRs permitted to query the server. Required. |
records |
Static local A/AAAA/CNAME/TXT records. |
domain |
Suffix for bare record names (nas -> nas.lan). |
upstream.servers |
Recursive resolvers to forward to. |
cache.* |
Cache size and TTL clamping. |
rate_limit.* |
Per-client query rate limiting. |
netxdhcp
A DHCPv4 server for a local network: it hands out addresses from a bounded pool, honors static MAC reservations, and persists leases to disk so a restart doesn't hand out conflicting addresses to clients that already believe they hold a lease.
Architecture
cmd/netxdhcp/ entrypoint: config load, wiring, expiry sweep, signals
internal/dhcp/config/ YAML config loading + validation
internal/dhcp/macfilter/ client MAC allow/deny-list
internal/ratelimit/ per-client (MAC) token-bucket rate limiting (shared with netxdns)
internal/dhcp/leasestore/ atomic on-disk persistence of dynamic leases
internal/dhcp/leasepool/ address allocation: reservations, dynamic pool, quarantine
internal/dhcp/nickname/ operator-configured MAC -> nickname directory
internal/dhcp/lastseen/ persistent MAC -> last-visit tracking, independent of active leases
internal/dhcp/ntfy/ best-effort push notification when a new MAC is first seen
internal/dhcp/server/ DHCPv4 protocol state machine (DORA, DECLINE, RELEASE, INFORM)
internal/dhcp/dashboard/ optional read-only HTTP status page (static + active + offline devices)
internal/acl/ client allow-list, reused from netxdns
internal/dhcp/server is the only place that touches the wire protocol
(via github.com/insomniacslk/dhcp) or the network directly, so — as
with netxdns — all client-facing security controls (MAC filtering, rate
limiting) live in one file. leasepool is independent of the config
package (it takes plain net.IP/*net.IPNet values via Options), so
allocation logic is unit-testable without any YAML in the loop.
internal/dhcp/dashboard depends only on leasepool.Pool.Leases(),
internal/dhcp/nickname, internal/dhcp/lastseen, internal/acl, and
internal/ratelimit — it has no privileged access of its own and
cannot mutate any lease state, since it exposes no write endpoints at
all. nickname and lastseen are both deliberately decoupled from
leasepool's allocation model — purely cosmetic/observability
directories joined against a MAC at render/log time in server and
dashboard, never folded into leasepool.Lease/Reservation itself.
Running
go build -o netxdhcp ./cmd/netxdhcp
cp dhcp.example.yaml dhcp.yaml
# edit dhcp.yaml: set interface, server_ip, subnet, and range_start/range_end
# for YOUR network, and make sure no other DHCP server (e.g. your router)
# is handing out overlapping addresses.
sudo setcap 'cap_net_bind_service,cap_net_raw+ep' ./netxdhcp
./netxdhcp -config dhcp.yaml
cap_net_raw is needed because the server binds its socket to a single
named interface (SO_BINDTODEVICE), not just a port — see below.
For always-on deployment, see deploy/netxdhcp.service, which runs as
a dedicated unprivileged user with only CAP_NET_BIND_SERVICE and
CAP_NET_RAW, plus systemd sandboxing.
Security posture
DHCP has no authentication in the protocol itself — any device on the broadcast domain can ask for a lease, and any device can claim to be the DHCP server. This implementation can't fix that (only switch-level DHCP snooping/port security can), but it minimizes what a misbehaving or hostile device on the LAN can do to this server:
- Scoped to one interface, on purpose.
interfaceis a required config field with no default — the server refuses to start unless told exactly which NIC to bind (SO_BINDTODEVICE), so it can never accidentally answer on an interface you didn't intend (e.g. a VPN or guest-network tap on the same host). - MAC allow/deny list.
denied_macsalways wins; ifallowed_macsis non-empty, only listed MACs are served at all (default-deny), otherwise every non-denied MAC is served (default-allow, the common home-network case). Disallowed clients get no reply — same silent-drop reasoning as netxdns's ACL. - No double-allocation, even under concurrency. Each incoming packet
is handled on its own goroutine (the underlying library's design), but
every allocation decision funnels through one mutex-protected
leasepool.Pool.Commitre-validates that an address is still free at commit time, not just at offer time — closing the race where two clients' DISCOVER/REQUEST exchanges interleave, and rejecting a REQUEST for an address that was never actually offered (a client can't simply ask for someone else's already-leased IP and get it). - Reservations always win, and only for their MAC. A statically
reserved address is excluded from the dynamic pool entirely at
startup, so it can never be handed to a different device by accident,
and
Commitrejects a reserved MAC's request for any address other than its reservation. - Address-conflict handling. A client-reported
DHCPDECLINE(it ARP-probed the offered address and found it already in use — typically a rogue static IP on the LAN) quarantines that address for an hour rather than immediately re-offering it, and drops whatever lease bookkeeping we had for it, since that bookkeeping is now known to be wrong. - Per-client rate limiting. Reuses the same token-bucket limiter as netxdns, keyed by MAC address, to blunt a flood of DISCOVERs (a classic DHCP-starvation attack pattern: request every address in the pool before legitimate clients can).
- Crash-safe lease persistence. Leases are written to disk with the same atomic temp-file-then-rename pattern as everywhere else in this repo, so a crash mid-write can't corrupt the lease database. On restart, persisted leases are re-validated against the current config (still in range, not now excluded, not now reserved to someone else) rather than trusted blindly — so shrinking the pool or adding a new reservation between restarts can't resurrect a stale conflicting lease.
- Reservation/nickname hot-reload is fail-safe, not fail-open.
dhcp.yamlis polled for changes (reservation_watch_interval) so static MAC -> IP bindings and nicknames can be updated without a restart. Every reload re-runs the same fullconfig.Validate()used at startup; if the edited file is invalid, the reload is rejected, the previous reservations/nicknames remain in effect, and the failure is logged — a bad hand-edit can never crash or degrade the running server. Onlyreservationsandnicknamesever hot-reload; every other field (interface, subnet, range, rate limiting,last_seen.*, etc.) still requires a restart, since those affect socket binding, pool math against already-committed leases, or server ID semantics in ways too risky to swap live — the watcher logs a warning naming any such fields it sees change, rather than silently ignoring them. - Nicknames are operator-authored, not attacker-controlled. Unlike
hostname (DHCP option 12, entirely client-supplied), a device's
nickname comes only from
dhcp.yaml, edited by whoever administers the server. It's still rendered through the same textContent-only path as hostname on the dashboard and JSON-encoded (never templated) at the API layer — purely for defense in depth and consistency with how every other display field in this repo is handled, not because it's expected to be hostile. - Last-seen history is a more sensitive file than active leases.
dhcp-lastseen.jsonis a durable, non-self-pruning record of every MAC ever seen on the network, with timestamps — unlike the active- lease file, entries here are never removed as devices come and go. It's opt-out-able (last_seen.enabled: false), local-only (never leaves the host), and written with the same atomic, 0600-permissioned temp-file-then-rename pattern as every other persisted file in this repo. - New-device notifications never block or fail DHCP request
handling.
ntfy.NotifyNewDeviceis always invoked from its own goroutine (go s.notifier.NotifyNewDevice(...)inhandleDiscover), with its own bounded timeout (ntfy.timeout); a slow or unreachable ntfy server can never delay an OFFER, and every failure (network error, non-2xx response) is logged and swallowed, never surfaced to the DHCP protocol path. A global rate limit (ntfy.min_interval, not per-MAC) protects against a flood: an attacker can trivially make many spoofed MACs each look "new," and without a global limit that would translate directly into a flood of push notifications to the operator's phone. "New" is determined strictly fromlast_seen(a MAC genuinely never seen before, not merely a device that happens to be reconnecting), which is whyntfy.enabledrequireslast_seen.enabledand refuses to start otherwise. - Treat the ntfy topic (and credentials) as secrets. Enabling ntfy
makes an outbound HTTP call — carrying a new device's MAC, IP, and
hostname — to
ntfy.server_urlevery time a new device joins. Anyone who knows a public ntfy.sh topic name can publish to or subscribe to it, so use a long random topic, a protected topic withntfy.username/password/token, or a self-hosted instance if that matters to you.dhcp.yamlis already gitignored in this repo for exactly this kind of reason. - Least privilege at runtime. As with netxdns, no in-process
setuid; privilege reduction is viasetcap/systemdAmbientCapabilitiesonly. - Panic isolation. A malformed packet that somehow triggers a panic
in a handler is recovered per-packet (
recover()inhandle), so one bad packet can't take the whole listener down. - The dashboard is off by default and network-scoped when on.
dashboard.enableddefaults tofalse— a new unauthenticated HTTP listener has to be turned on deliberately, never appear silently after an upgrade. When enabled,dashboard.allowed_networksis required (same "refuse to run wide open" rule asallowed_networkselsewhere in this repo) and access control is purely network-level: there is no login, so anyone the ACL admits can see every device's MAC, IP, and hostname. It is strictly read-only — no endpoint can create, modify, or delete a lease or reservation — and it reuses the same per-client rate limiter as the DHCP protocol itself. A device's hostname (fully attacker-controlled, since it's just a DHCP option the client sends) is served through a JSON endpoint viaencoding/json, never through an HTML template, and the browser-side code renders every field withtextContent, neverinnerHTML— so a hostname crafted to look like markup or a script tag is always displayed as inert text, never executed.
What this does not do
- No rogue-DHCP-server detection. This server will happily coexist (badly — clients will race two servers) with another DHCP server on the same segment. Preventing that is a switch/network-level control (DHCP snooping), out of scope for an end-host process.
- No DHCP relay (giaddr) special-casing. Replies are always sent to the observed packet source, which is correct for the common flat-LAN case (and happens to work for simple relay setups too, since the relay's address is the observed source) but hasn't been tested against a real relay agent.
- No ARP/ping probing before OFFER. The server trusts its own lease
bookkeeping when deciding an address is free; it does not
independently verify via ARP that nothing else on the LAN is already
using it (RFC 2131's optional server-side conflict detection). Client-
side detection via
DHCPDECLINEis handled (see above), which covers the common case. - Not hardened for direct internet exposure. DHCP is inherently a local-broadcast-domain protocol; this is not a concern in practice as long as the interface it's bound to is actually your LAN.
Configuration
See dhcp.example.yaml for all fields and defaults. Key ones:
| Field | Purpose |
|---|---|
interface |
NIC to bind to. Required, no default. |
server_ip |
This host's own address on the subnet (DHCP server identifier). |
subnet / range_start / range_end |
Pool bounds. Must all agree. |
reservations |
Static MAC -> IP bindings, excluded from the dynamic pool. |
nicknames |
Optional MAC -> nickname bindings, shown as hostname (nickname) everywhere hostname appears. Hot-reloads on the same interval as reservations. |
reservation_watch_interval |
How often to check for reservation/nickname changes on disk; <= 0 disables hot-reload. |
allowed_macs / denied_macs |
Optional client MAC filtering. |
rate_limit.* |
Per-MAC request rate limiting. |
dashboard.enabled |
Turn on the read-only web dashboard. Off by default. |
dashboard.listen_addr |
Address for the dashboard HTTP listener (default loopback-only). |
dashboard.allowed_networks |
CIDRs permitted to view the dashboard. Required if dashboard.enabled is true. |
last_seen.enabled |
Persist MAC -> last-visit tracking, surfaced in the dashboard as "offline" devices. On by default. |
last_seen.file / last_seen.flush_interval |
Where last-seen records are persisted, and how often. Does not hot-reload. |
ntfy.enabled |
Push a notification the first time a new MAC is ever seen. Off by default; requires last_seen.enabled. |
ntfy.server_url / ntfy.topic |
Where to POST notifications. Required if ntfy.enabled is true. Treat as secrets. |
ntfy.username/password / ntfy.token |
Optional credentials for a protected ntfy topic. |
ntfy.priority |
ntfy priority: min|low|default|high|urgent. |
ntfy.min_interval |
Minimum spacing between notifications; bounds a flood from spoofed MACs. |
lease_file |
Where dynamic leases are persisted. |
Development
Tool version and common commands are in mise.toml (https://mise.jdx.dev).
With mise installed:
mise install # installs the pinned Go toolchain
mise run check # fmt check + vet + test
mise run build # builds bin/netxdns and bin/netxdhcp
mise tasks # list all tasks (run:*, tidy, fmt:fix, clean, ...)
Without mise, the same checks are:
go test ./...
go vet ./...
gofmt -l .
netxdns tests cover ACL enforcement, cache TTL clamping/eviction/expiry,
local record resolution (including CNAME chasing), upstream fallback,
and end-to-end ServeDNS behavior (ACL drop, ANY refusal, truncation).
netxdhcp tests cover the full DORA state machine and DECLINE/RELEASE
handling end-to-end over real loopback UDP sockets (bypassing the
privileged interface-bind step, which needs CAP_NET_RAW), plus pool
allocation edge cases (exhaustion, sticky renewal, reservation
precedence, race-safe commit, quarantine, and restart persistence
including config-drift handling), live reservation/nickname hot-reload
behavior (new/changed/removed reservations and nicknames, conflicting
dynamic-lease eviction, and rejection of an invalid reload without
disturbing the running pool or nickname directory), the nickname
package's atomic directory swap, the lastseen package's persisted,
dirty-flag-batched flush, the DHCP server's inclusion of a configured
nickname in its "lease granted" log line and its recording of a
last-seen visit for every allowed message type, and the dashboard
(ACL enforcement, rate limiting, GET-only enforcement, the unified
static/dynamic/offline device view including exclusion of expired-
but-not-yet-swept leases, hostname content preserved verbatim through
the JSON API rather than mangled or escaped, and nickname combining
into the hostname (nickname) format for both dynamic leases and
static reservations, including the no-nickname and no-hostname edge
cases). ntfy is tested against a real httptest.Server standing in
for ntfy (request path/headers/body/auth, rate limiting, and that a
server error never panics or propagates), and internal/dhcp/server
covers the notification trigger itself end-to-end over real loopback
UDP: a genuinely new MAC's first DISCOVER fires exactly one
notification, an already-known MAC's DISCOVER fires none, and — the
specific regression this is designed to catch — a second DISCOVER
from the same new MAC does not re-notify (the subtlety being that
DISCOVER and REQUEST are handled as two independent packets through
the same per-packet dispatcher, so "is this MAC new" has to be
captured once, before the tracker records it as seen, not
re-evaluated on every packet).