Repository navigation
Releases
Beta 1.2.0-beta.115
Automated beta of commit 4ee39fc. Not for production use.
Unreleased changes
Fixed
- The rolling beta can no longer be older than the release it follows. Cutting 1.1.1 started the
Beta workflow on the release commit as well, whereVERSIONis the version being released, and it
published1.1.1-beta.113a few seconds after the Release workflow had already deleted the betas
it could see. SemVer puts1.1.1-beta.113before1.1.1, so for as long as that prerelease was
the newest thing on the beta channel, every install on 1.1.1 was being offered an update older
than itself -- the same way the beta channel went quiet for a day after 1.0.0 shipped with
VERSIONleft at1.0.0.scripts/beta-guardnow decides whether a commit should publish a beta
at all: not on a release commit (a## [X.Y.Z]section for the currentVERSIONmeans that
version is released, not in development), and not whenVERSIONis no newer than the newest
published stable release. The Beta workflow skips the build instead of failing, and says why.
scripts/test-beta-guardcovers both cases and runs in CI. See
docs/RELEASING.md.
Shepherd 1.1.1
One fix, for the restart: Shepherd binds its DNS listeners only once startup has finished, and
repopulating the live view from the encrypted history was reading the whole store to produce the
newest few thousand entries. A household on 1.1.0 or earlier loses DNS for as long as that takes,
which is a minute on a month of history.
Fixed
- A restart no longer leaves the network without DNS for a minute. Shepherd binds its DNS
listeners only after startup finishes, and one step of startup was reading the entire encrypted
history: the live view is repopulated from the newestdns.log_sizeentries after a restart, but
the query behind it decrypted every segment in the store, sorted all of it, and only then kept the
newest few thousand. On a 77 MB history (102 segments, 30 days) that was 52 seconds before DNS
came up, with 4 GiB of memory at the peak and nothing logged while it happened, so a slow start
was indistinguishable from a hung one. A limited history query now walks the segments newest first
and stops once no older segment can hold a newer match, trimming as it goes: the same restart
binds DNS in 2 seconds and peaks at 350 MiB, with the live view populated exactly as before.
The portal's own history searches get the same saving. Startup also says when it is loading the
past, and how long that took when it was slow enough to notice. See
docs/HISTORY.md.
Shepherd 1.1.0
A setting that could only be reached by editing shepherd.toml is now in the portal, and it is a
per-device setting as well as a network-wide one: the one device on a network that cannot cope with
a withheld AAAA answer can be handed the real one on its own card, while nothing changes for the
rest.
Added
-
AAAA filtering is a portal setting, and can be set per device.
dns.filter_aaaawas
reachable only by editingshepherd.tomland restarting, which is the wrong place for it: the
device that needs it is found by watching the query log, and nobody watching a query log should
have to go and edit a file. The three positions (auto,on,off) are now on
Upstreams -> IPv6 answers, with the mode and whether outbound IPv6 is reachable shown
separately, so "automatic" says what it has decided and why. A mode saved there is kept in
<data_dir>/settings.jsonand wins overdns.filter_aaaafrom then on, with no restart;
dns.filter_aaaaremains the value for a first run.Each device also has its own switch, on its card under Devices -> IPv6 answers, because
the network-wide setting cannot fix a device that will not take an answer. A synthesized
NODATA has carried an SOA since the last release so a client can cache it (RFC 2308), but a
client that does not cache it has nothing to remember, and one that then does not fall back to
IPv4 just asks again: a solar inverter here asked for the same filteredAAAAabout forty times
a second, indefinitely, and still does. Answer in full hands that one device the real
answer and lets it fail to connect over IPv6 the way it would without Shepherd, while the rest
of the network keeps the filter; withhold is the opposite exception. Both survive a merge of
two device records, an explicit "answer in full" preferentially, since it is the setting that
keeps a device working.aaaa:offfinds them again in the device search,set_aaaasets them in
bulk, andGET/PUT /api/dns/aaaais the network-wide mode. See
docs/IPV6.md, which also corrects hard rule 3: blocking never decided by address
family, and now says so instead of claiming AAAA records are never withheld.
Fixed
- The public demo's modules are stamped with a content hash, so a returning visitor can no longer be served a fresh
app.jsalongside modules cached from an earlier version. The site's CDN serves.jswith a year-long lifetime, and
the cache buster on the site side can only stamp URLs written in HTML or CSS — not the ~70 modulesapp.jsimports.
scripts/sync-site-demonow rewrites those imports through the newcmd/stamp-demo-modules, hashing bottom-up so a
change in any module changes the URL of everything that reaches it. See
docs/PORTAL.md.
Shepherd 1.0.0
The first stable release, and with it the promise 1.0.0 is for: shepherd.toml and the portal API
will not break under you within 1.x. A key you set today keeps meaning what it means; anything that
would change that waits for 2.0.0 and is written down here first.
What made this the release rather than another 0.x: a new install no longer starts in a text editor,
releases are signed so a running Shepherd can actually apply an update, per-device bandwidth comes
from the router rather than from steering traffic, and the one feature that cannot yet be trusted —
DHCP mode — says so on every surface and is off by default.
Added
-
First-run setup in a browser. A new install no longer starts in a text editor. Start Shepherd
with no configuration file and it brings up a setup page, prints the addresses to open (with a
one-time token, since the page listens on every address so it can be reached from the machine you
are actually sitting at), asks what it has to, writes a commentedshepherd.tomland starts on it
in the same process.shepherd setupruns it again later. It listens on 8788 rather than the
portal's 8787, because it keeps serving while Shepherd starts.What it asks is deliberately short, and two of the answers it works out rather than asking for.
The data directory isdatabeside the configuration file, created and checked while the page
loads. Upstream resolvers are measured, not guessed: every candidate is asked the same
uncached question several times from this host, and the recommendation prefers an encrypted (DoT)
resolver that also blocks known-malicious names — but only while it answers every probe and stays
under 250ms from here, so a flaky or slow one loses to a plain resolver that works. The numbers
are shown, so the recommendation can be checked. The address question is still the reason the
whole thing exists: every address with its interface, all ticked, because a device asks one
specific address and a host serving only its Wi-Fi address while the router hands out the wired
one is a network with no DNS, with nothing in the configuration looking wrong. A few catalog
categories can be blocked network-wide from the page, switched on in the running policy the portal
owns rather than copied into the configuration file.The page stays with you after the file is written, for the part that needs Shepherd running:
it asks Shepherd's own listeners over UDP and TCP for a name that must resolve and one that must
be blocked (the self-test from docs/DNS-QA.md, run on demand, not a second implementation), hands
over the portal token concealed and asks you to confirm you have kept it, says what to set in your
router's DHCP settings and to give the Shepherd host a fixed address, and offers the UniFi
connection for per-device bandwidth. It does not reconfigure your router: the eero and UniFi APIs
Shepherd speaks are the read-only ones, and neither vendor publishes a supported way to set a
network's DNS server. Closing the page is the last step, and the listener stops there.Setup can also put the portal on HTTPS, which is what makes a passkey possible: WebAuthn binds
a passkey to one hostname, so Face ID or Touch ID for the portal works at that address and nowhere
else. The step is optional and skipped by leaving the hostname empty. Give it a name in a
Cloudflare zone and an API token scoped to Zone → DNS → Edit on it, and setup checks that token
against that zone before accepting it — the same read-only check the portal's Proxy tab makes — and
refuses to finish with a hostname whose token has not been checked, because a token without edit
rights is a certificate that never arrives. The token goes to<data_dir>/secrets.json(0600),
never intoshepherd.toml. Afterwards the page waits for the certificate, saying which of the slow
parts it is on, then links to the portal at its own HTTPS name, where the passkey is registered on
the Locking screen. Setup does not register it itself: that page is served over plain HTTP from a
different origin, and a passkey belongs to the hostname's own.An existing
shepherd.tomlis kept as.bak; only the keys setup asked about are written, so
every other default keeps following Shepherd's own; a held port is explained rather than refused;
and a setup listener that cannot come up at all is logged while Shepherd starts on its defaults.
It complementsscripts/shepherd-doctor(the read-only host preflight) rather than repeating it.
See docs/SETUP.md. -
ntfy is set up in the portal. Alarms → Notifications → Set up ntfy is now a real form — server,
topic, access token or username and password, with Save, "send a test notification", a switch and
"forget server" — instead of ashepherd.tomlsection to copy and a restart. Shepherd's standing
rule is that no feature requires editing the configuration file, and the walk-through broke it.
The server lives in<data_dir>/secrets.json(0600) beside the mail relay and the router
connections (internal/secrets), is write-only like every other secret there (a saved token is
never sent back; a blank field keeps the stored one, and only for the same server, so a credential
can never be pointed at somewhere it was not entered for), and reaches the running delivery Router
through the newRouter.SetNtfy, so nothing about ntfy needs a restart. Saving publishes one
notification as the check, which is also what the test button does: ntfy offers no way to ask
whether a server and token would work without sending something, and a refused server is reported
with nothing stored.[alarms.ntfy]inshepherd.tomlis still read once and imported, so an
install already using it keeps working; after that the portal owns it and a startup line says so.
See docs/ALARMS.md. -
Releases are signed. The Ed25519 release key pair exists now: the public half is built into
Shepherd (update.ReleasePublicKeyB64) and the private half is theSHEPHERD_SIGNING_KEY
repository secret, so both workflows writeSHA256SUMS.sigbeside the checksums and a running
Shepherd verifies it before applying an update. Until now every release was published unsigned and
self-update could only report that a newer version existed. A binary built before this commit has
no key built in and still refuses to apply anything; it has to be replaced by hand once.
Changed
- The lock screen leads with one way in, and tries a passkey by itself. It used to show the PIN
and token forms together, plus a passkey button where one was available, leaving the owner to pick
between things only one of which they could usually use. It now asks the server what is actually
available to this browser (GET /api/login/methods, which replacesGET /api/login/passkey) and
expands the first of passkey, PIN, token that is; the rest become a quiet line of links. The
token is never hidden, only collapsed — it is the way back in when a phone is lost or a PIN is
forgotten. Where a passkey is the way in, the ceremony now starts without being asked: once, and
only on a page that is actually being looked at, so a cancelled attempt leaves the button rather
than prompting in a loop. The new endpoint tells a browser without a trusted-device cookie that no
PIN is available, which is the truth for it and says nothing about whether anyone else has one.
Changed
- Watch mode is deprecated. Per-device bandwidth now comes from the router over its own API
(eero and UniFi), which measures both directions instead of estimating downloads, puts nothing in
the path of your traffic, changes no setting on your router, and works on mesh networks where
one-sided ARP steering cannot be made reliable at all. Watch mode stays in the tree, off by
default, for the installs already running it, and gets no new features; the portal's Watch mode
section is labelled deprecated and points at "Read from your router" above it, and starting with
watch.enabled = truenow logs the same.shepherd.example.tomlsaidenabled = truewhile the
code defaulted to false -- the example now matches the code. The reason it was given up on rather
than merely switched off is written down in docs/WATCH.md: on an eero mesh the
gateway reflects forwarded frames carrying a foreign source IP, so the first full-size segment of
every flow never arrived, reproduced with both relays and with plain kernel forwarding. Source NAT
stopped it dead (0/10 to 10/10 on the probe, zero reflected frames), so if this is ever revived it
belongs in docs/GATEWAY.md as a DHCP/NAT mode, not as ARP steering. - The Overview and Watch mode screens are gone. The Overview repeated what the Map, History and
Bandwidth screens show better, so the portal opens on the Map and what only the Overview had (software
updates, blocklist health, your own rules, the Private Relay switch) is the new System screen under
Settings. Watch mode's status, device list and kill switch are the last section of Bandwidth. - Bandwidth is mostly charts now. A live rolling chart with a bar pair per device, an Over time chart
with round-number gridlines and a hover read-out, a share-of-traffic donut with a download against
upload bar, and a trend line per busy device.
Added
- DHCP mode — ALPHA, off by default (docs/DHCP.md). Alpha means something
specific and the doc says it plainly: no real device has ever taken an address from it. The lease
exchange is tested end to end, but over loopback sockets on high ports, so the real port 67, the
broadcast a client needs before it has an address, and a household's traffic crossing one network
card twice have been reasoned about rather than measured. It is also the one thing here that can
take a whole network off t...
Shepherd 0.1.0
Added
- Packaging plan (roadmap step 24). docs/PACKAGING.md specifies easy installs
for non-technical owners: the first-run wizard's questions, every target platform with its installer
and service manager, the port 53 and signing constraints, and the order of work. Documentation only;
no installer is built yet. - Self-update with rollback.
[update](on by default) checks GitHub's releases once a day on
the stable or beta channel, raises aself-updatealarm when a newer Shepherd exists, and the
Overview's new Software card offers "Update now" where the binary can be replaced in place.
Applying downloads the release for this platform, checks it againstSHA256SUMS, verifies that
file's Ed25519 signature against the key built into the binary (refusing an unsigned release or
a build with no key), swaps the binary in with the previous one kept asshepherd.prev, and
restarts into it. An updated binary that does not stay up for two minutes across three starts is
put back automatically on the next start;shepherd -rollbackdoes the same by hand. A read-only
container cannot be updated in place and the card says so. Both CI workflows sign the checksums
when theSHEPHERD_SIGNING_KEYsecret exists (scripts/sign-release,cmd/shepherd-sign); the
public half isupdate.ReleasePublicKeyB64, empty until the maintainer generates the pair. The
card also shows the data directory's size by part. See docs/UPDATE.md. - One retention budget.
[retention] max_mbcaps the data directory as a whole: it is split
into caps for query history, bandwidth history and network-health history (each still enforcing
its own), the Software card shows use against it, and a housekeeping check every six hours raises
a lowstorage-over-budgetalarm, naming the largest part, if the directory outgrows it anyway.
0, the default, leaves each store to its own cap (docs/UPDATE.md "Retention"). - Delivery settings for alarms.
GET/PUT /api/alarms/preferencesread and write the alarm
preferences, and the Notifications tab's new Delivery card sets the severity sent at once,
whether lower alarms wait for the digest, the digest's interval or time of day, and the time zone. - Threat feeds: which device reached for a known bad name or address. Four curated feeds
(HaGeZi's Threat Intelligence Feeds mini edition, IPsum level 3, abuse.ch's Feodo Tracker and
Spamhaus DROP; URLhaus and ThreatFox went behind an abuse.ch Auth-Key the same day and were
swapped out, and abuse.ch's SSL Blacklist has been empty since its deprecation in January 2025)
are downloaded every six
hours, cached under the data directory, and matched against every recorded DNS query's name and
answer, blocked or not: a name matches itself or a parent, an address matches exactly or by
prefix. A hit raises athreat-matchalarm attributed to the device, keyed by device and
indicator so a beacon is one alarm with a rising count, with the name, the indicator, the feed and
what the engine did with the query as evidence. The Alarms screen gains a Threat feeds tab
with each feed's state, a refresh button and the recent matches.[threats]is on by default;
the cost is a few map lookups per query and a few hundred kilobytes per feed. Local AI is set
aside by decision. See docs/THREATS.md. - Web Push delivery, end to end.
internal/webpushhad the sender, VAPID keys and subscription
store for months, waiting on HTTPS; the portal has it now through[tls], socmd/shepherdwires
it in: a VAPID key pair generated once under the data directory, subscriptions saved beside it, and
aPusheron the alarm Router next to ntfy. The Alarms screen gains a Notifications tab that
says whether this browser can subscribe (HTTPS, a home-screen web app on iOS, permission),
registers the service worker and subscribes with one button, lists every subscribed browser with
rename and remove, and sends a test notification through every channel.[alarms.push]is on by
default with an optionalcontact. New endpoints under/api/alarms/push. Amanifest.webmanifest
lets iOS add the portal to the Home Screen, which Safari requires for push. Roadmap step 9 is done
apart from a real-browser trial. - Reverse proxy: per-host access lists, a permanent HTTP-to-HTTPS redirect and HSTS. Each
proxied host can name who may reach it: addresses, prefixes like192.0.2.0/24, andlocal,
which stands for every network this machine is on plus loopback and Tailscale's ranges. Anyone
else gets a 403 from Shepherd itself, logged with the client address at most once per client per
minute.redirect_listennow answers with a 301 (308 for anything but GET and HEAD, so the method
and body survive) instead of a 302, and a per-hosthstsswitch adds a 30-day
Strict-Transport-Securityheader over HTTPS, off by default. The Proxy tab has both controls and
shows "Local only", "Restricted" and "HSTS" badges. See docs/REVERSEPROXY.md. - The passive ARP monitor's findings are alarms. Every event type in docs/ARP.md now maps to
an alarm (arp-gateway-changed,arp-spoofing,arp-ip-moved,arp-duplicate-ip,
arp-many-ips,arp-flood,arp-reply-storm) or deliberately to none: a new MAC is the
registry's ownnew-devicealarm, a private-address rotation is nothing, and the monitor's
baseline period raises nothing except a gateway change or suspected spoofing. The monitor's own
severity is kept per event, the alarm is keyed by the event's stable key so repeats fold, and
it is attributed to the registry's device for the suspect MAC. Roadmap step 10 is done.
Fixed
-
Flaky watch-mode tests. Several
internal/watchengine tests advanced the fake clock once and then
waited, so a tick fired before the engine had re-armed its timer was lost and the test timed out under the
race detector (and failedmain's QA). They now keep ticking until the engine catches up, and no longer
read a retry record that the previous round left behind. Test-only; no behaviour change. -
Release notes over GitHub's size limit. The Release workflow now trims notes longer than 120,000
characters and links the full CHANGELOG.md, which the first stable release exceeded (HTTP 422). -
Flaky daemon tests on temp-directory cleanup. Tests that run the whole daemon failed with
"TempDir RemoveAll cleanup: directory not empty" when a background worker was still writing into the data
directory as the test ended, which also failed the Release and Beta gates. Their config helper now retries
the removal until the workers have stopped. Test-only. -
Flaky wandering and excluded-device e2e tests. On a slow runner the list-load polls were answered
by the upstream and cached before the list loaded, so the later query was logged ascached, not
allowed, and the tests either matched an early un-annotated entry or timed out. They now accept both
actions and wait for the entry carrying the expected reason. Test-only. -
Code-scanning hardening. The trusted-device, session and lock cookies are now
Securewhenever the
request arrived over HTTPS (direct TLS orX-Forwarded-Proto: https); plain-HTTP LAN access keeps working. The
DNS-over-HTTPS client re-checks the upstream scheme right before it sends, the IPv6 route metric is parsed with
bounds that cannot overflowint, and the portfolio-notify workflow runs with no token permissions. -
The loop brake was stopping uploads, and Watch mode is reinstated as opt-in. The three
storms that retired Watch mode earlier on 2026-11-01 were read from sampled frames that shared
one IP identification field and one TTL, which the sampler's verdict called "the same frame
circulating". They were not the same frame. macOS, iOS and Linux (for an unconnected socket) put
identification 0 on every don't-fragment packet, so a Mac uploading to iCloud over QUIC at
1,392 bytes a packet reads as one frame going round to a verdict that counts identification
fields, and the duplicate brake "catching 3% of a runaway" was the brake correctly finding almost
nothing to catch. The 05:00 trip that day was a 270 Mbit/s QUIC flow to Apple from a MacBook,
overnight; the two instrumented "runaways" were 87,516 and 347,963 distinct frames of the same
shape. Reread by the whole frame, the live digest on that NAS now printsid 0 on every one: a don't-fragment sender, not a loopfor exactly that Mac and exactly those destinations.Three changes follow. The sampler's verdict compares the whole frame -- identification, total
length and the first sixteen bytes of the transport header -- and names a flow whose
identification is 0 throughout. The loop brake no longer stops steering on mirrored counters
alone, because a relay carrying a device's upload is mirrored by definition: a trip now needs the
duplicate brake to have refused at least a quarter of what the relay saw during the mirrored
seconds, or Shepherd's own output to have come back, or the sampled frames to be the same frame
over and over, or 300,000 packets a second, which no home device sends; mirrored counters with
none of those are logged once an hour as "a device sending through the relay, not a loop". And
the userspace relay gets the same duplicate brake as the kernel one (intercept.Dedup, same
fingerprint and window), so a loop is contained under either relay andduplicates_suppressed
means one thing. What is real -- the segment handing a steered device's frame back once, tens
of microseconds to a millisecond later, at about 60 to 80% of relayed frames when traffic is
light and 2 to 3% under load -- is dropped as before, and its mechanism is still not identified.
Watch mode stays off by default; the maintainer's NAS runs it again. Written up in
docs/WATCH.md, with the earlier readings kept and corre...