Skip to content

Releases

Beta 1.2.0-beta.115

Beta 1.2.0-beta.115 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 05 Nov 01:04

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, where VERSION is the version being released, and it
    published 1.1.1-beta.113 a few seconds after the Release workflow had already deleted the betas
    it could see. SemVer puts 1.1.1-beta.113 before 1.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
    VERSION left at 1.0.0. scripts/beta-guard now decides whether a commit should publish a beta
    at all: not on a release commit (a ## [X.Y.Z] section for the current VERSION means that
    version is released, not in development), and not when VERSION is no newer than the newest
    published stable release. The Beta workflow skips the build instead of failing, and says why.
    scripts/test-beta-guard covers both cases and runs in CI. See
    docs/RELEASING.md.

Shepherd 1.1.1

Choose a tag to compare

@github-actions github-actions released this 05 Nov 00:52

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 newest dns.log_size entries 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

Choose a tag to compare

@github-actions github-actions released this 05 Nov 00:04

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_aaaa was
    reachable only by editing shepherd.toml and 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.json and wins over dns.filter_aaaa from then on, with no restart;
    dns.filter_aaaa remains 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 filtered AAAA about 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:off finds them again in the device search, set_aaaa sets them in
    bulk, and GET/PUT /api/dns/aaaa is 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.js alongside modules cached from an earlier version. The site's CDN serves .js with a year-long lifetime, and
    the cache buster on the site side can only stamp URLs written in HTML or CSS — not the ~70 modules app.js imports.
    scripts/sync-site-demo now rewrites those imports through the new cmd/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

Choose a tag to compare

@github-actions github-actions released this 03 Nov 20:23

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 commented shepherd.toml and starts on it
    in the same process. shepherd setup runs 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 is data beside 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 into shepherd.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.toml is 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 complements scripts/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 a shepherd.toml section 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 new Router.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] in shepherd.toml is 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 the SHEPHERD_SIGNING_KEY
    repository secret, so both workflows write SHA256SUMS.sig beside 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 replaces GET /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 = true now logs the same. shepherd.example.toml said enabled = true while 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...
Read more

Shepherd 0.1.0

Choose a tag to compare

@github-actions github-actions released this 02 Nov 05:18
2d051e8

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 a self-update alarm 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 against SHA256SUMS, 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 as shepherd.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 -rollback does 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 the SHEPHERD_SIGNING_KEY secret exists (scripts/sign-release, cmd/shepherd-sign); the
    public half is update.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_mb caps 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 low storage-over-budget alarm, 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/preferences read 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 a threat-match alarm 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/webpush had the sender, VAPID keys and subscription
    store for months, waiting on HTTPS; the portal has it now through [tls], so cmd/shepherd wires
    it in: a VAPID key pair generated once under the data directory, subscriptions saved beside it, and
    a Pusher on 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 optional contact. New endpoints under /api/alarms/push. A manifest.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 like 192.0.2.0/24, and local,
    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_listen now answers with a 301 (308 for anything but GET and HEAD, so the method
    and body survive) instead of a 302, and a per-host hsts switch adds a 30-day
    Strict-Transport-Security header 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 own new-device alarm, 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/watch engine 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 failed main'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 as cached, 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 Secure whenever the
    request arrived over HTTPS (direct TLS or X-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 overflow int, 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 prints id 0 on every one: a don't-fragment sender, not a loop for 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 and duplicates_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...

Read more