No description
Find a file
jedmota 5e3a870183 Add reservation/nickname hot-reload, dashboard, last-seen tracking, and ntfy alerts to netxdhcp
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
2026-09-02 11:20:21 +01:00
cmd Add reservation/nickname hot-reload, dashboard, last-seen tracking, and ntfy alerts to netxdhcp 2026-09-02 11:20:21 +01:00
deploy Add netxdns and netxdhcp: security-conscious local network services 2026-09-01 09:34:35 +01:00
internal Add reservation/nickname hot-reload, dashboard, last-seen tracking, and ntfy alerts to netxdhcp 2026-09-02 11:20:21 +01:00
.gitignore Add reservation/nickname hot-reload, dashboard, last-seen tracking, and ntfy alerts to netxdhcp 2026-09-02 11:20:21 +01:00
config.example.yaml Add netxdns and netxdhcp: security-conscious local network services 2026-09-01 09:34:35 +01:00
dhcp.example.yaml Add reservation/nickname hot-reload, dashboard, last-seen tracking, and ntfy alerts to netxdhcp 2026-09-02 11:20:21 +01:00
go.mod Add netxdns and netxdhcp: security-conscious local network services 2026-09-01 09:34:35 +01:00
go.sum Add netxdns and netxdhcp: security-conscious local network services 2026-09-01 09:34:35 +01:00
mise.toml Add mise.toml with pinned Go version and dev tasks 2026-09-01 19:12:40 +01:00
README.md Add reservation/nickname hot-reload, dashboard, last-seen tracking, and ntfy alerts to netxdhcp 2026-09-02 11:20:21 +01:00

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_networks is 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. ANY queries — 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/REFUSED upstream 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 (setcap or systemd AmbientCapabilities), 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 FORMERR rather 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. interface is 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_macs always wins; if allowed_macs is 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. Commit re-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 Commit rejects 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.yaml is 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 full config.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. Only reservations and nicknames ever 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.json is 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.NotifyNewDevice is always invoked from its own goroutine (go s.notifier.NotifyNewDevice(...) in handleDiscover), 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 from last_seen (a MAC genuinely never seen before, not merely a device that happens to be reconnecting), which is why ntfy.enabled requires last_seen.enabled and 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_url every 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 with ntfy.username/password/token, or a self-hosted instance if that matters to you. dhcp.yaml is 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 via setcap/systemd AmbientCapabilities only.
  • Panic isolation. A malformed packet that somehow triggers a panic in a handler is recovered per-packet (recover() in handle), so one bad packet can't take the whole listener down.
  • The dashboard is off by default and network-scoped when on. dashboard.enabled defaults to false — a new unauthenticated HTTP listener has to be turned on deliberately, never appear silently after an upgrade. When enabled, dashboard.allowed_networks is required (same "refuse to run wide open" rule as allowed_networks elsewhere 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 via encoding/json, never through an HTML template, and the browser-side code renders every field with textContent, never innerHTML — 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 DHCPDECLINE is 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).