feat(core): LAN sharing, per site and reversible (T74) - #61
Merged
Conversation
Per-site listener rather than a front-end rebind, the interface address rather than 0.0.0.0, a whole-state FirewallApply that elevate validates alone, and an IP SAN that makes certificate comparison typed. The manual unshare lands with the share: opening without closing is the one order these must not ship in.
Smart App Control was turned off here on 2026-08-31 and the block is measured gone: a Caddy installed by `mix package install` runs, having been refused the week before. The real netsh rule and a phone on the same Wi-Fi move into T74's own acceptance instead of waiting for T76, and D2's reading of Caddy's `bind` gets checked rather than trusted.
`NetworkInfo` answers what is up and carries an IPv4 address; `choose_interface` picks the one to share on and refuses rather than guesses where two are up, listing both because that list is what the user reads before typing `--interface`. IPv4 only, per the T74 design. One implementation for all three systems rather than a per-OS module: `getifaddrs` and `GetAdaptersAddresses` already sit behind `if-addrs`, and the filter over its answer is the same everywhere. Behind `host`, so `mixengine-elevate` does not gain an interface enumerator.
…alone (T74) `FirewallApply` carries the ports that should end up open and the label they are written under; an empty list is the revoke, so there is no `FirewallRevoke` beside it. Unlike the resolver it carries no OS mechanism: nothing reads the firewall back, so the helper picks netsh, ufw or firewalld itself, and a field it does not need is one it cannot validate. The helper cannot ask "is this a web port" — that is a database column — so it refuses what is provably not one: TCP only, nothing below 1024 but 80 and 443, and a standing deny list of the ports MixEngine's own databases and caches answer on. That is where "databases are never exposed" is actually enforced. `Unmanaged` is a new outcome rather than a dressed-up success. macOS needs no rule for a listening socket and a Linux running neither firewall has nothing to add one to; on both, "applied" would be a lie and "failed" would stop a share that already works.
Three columns on `sites` rather than a table of its own: sharing is at most one row per site, has no history worth keeping, and every reader wants it in the same read as the site. All three move together or none does — a trigger holds that, because SQLite cannot add a table-level CHECK to an existing table and rebuilding `sites` would be its third rebuild. `Sharing` makes the half-written state unrepresentable above the database, and an unparsable address is a refusal rather than a shrug: reading it as "not shared" would silently un-share a site whose listener is up and whose firewall rule is open. `shared_url` keeps the URL, the bound address and the certificate name from ever disagreeing, and leaves the port off when it is 80 — it is a string somebody reads off a terminal and types into a phone.
A shared site answers on the interface address as well as on loopback, and no other site's rendering moves — the front end's own `bind_addr` is untouched, which is what "opt-in per site" has to mean. Caddy's `bind` replaces the default rather than adding to it, so both addresses go on one directive and loopback is named explicitly: a block carrying only the LAN address would come up on the phone and go down in the browser on this machine. nginx takes a second `listen` line instead, and both blocks of an HTTPS site carry the address — a padlock that worked everywhere except the device the site was shared for would be worse than none. The nginx consequence is asserted rather than discovered: servers are grouped by listen address before `server_name` is consulted, so the LAN address has exactly one block in its group and a request from the network carrying another site's Host is answered by the shared site. No unshared site is served over the LAN, and now a refactor cannot quietly make one.
`ensure` takes the shared address, `covered` builds the one list that both the issuing and the reuse check read, and `names` stops dropping IP entries on the way back. This overturns T50's D4, which said this module issues no IP SAN and that anything but a DNS name in that extension came from somewhere it did not write. That was right while every name a site answered to was a hostname; a LAN address is the first one that is not. The rule underneath it is unchanged — report only what a browser matches the URL against — because a browser does match an IP SAN when the URL is an address. The reuse check is where this could go wrong quietly: it runs under renewal for every site, so a reader that dropped the address would reissue the same certificate for ever. Both directions are tested, and so is rcgen turning an address-shaped string into SanType::IpAddress by itself — the whole design rests on that.
`site.share` writes the row, renders the second listener and reloads, reissues the certificate with the address, and only then asks for the firewall rule. `site.unshare` reverses every step and asks for the rule first. Both orders are the same rule from opposite ends: the machine must never be more open than the configuration says it is, and a rule open on a port nothing is listening on is the one state a user cannot diagnose. One operation either way, whole-state, computed from the rows — so the queue holds one answer to "what should this machine have open?" and an unshare of the last shared site carries no ports at all. Re-sharing on the same address keeps the original start, because T76 measures an expiry against it. The interface is never guessed: two candidates and no name is a refusal listing both, which is what a person reads before typing --interface. How many interfaces a machine has is a property of the machine, so the integration test asserts the two answers that do not depend on it and the arithmetic is unit-tested beside the code.
`mix site share <site> [--interface <name>]` and `mix site unshare`. The daemon answers a URL and the terminal draws the code from it — a graphical client draws its own from the same string, which is what keeps the drawing out of the daemon. The URL is printed above the code and never replaced by it: a QR is unreadable in a transcript, in a pipe, or over a connection that mangles block characters, and the string a person can type by hand is the answer. The line under it says why the URL is http, which is the question it raises for a site that declares HTTPS. Run for real on this machine: the refusal listed Tailscale, two vEthernet adapters and Wi-Fi and declined to choose between them, which is the case D5 was written for. Sharing on Wi-Fi wrote the row, rendered the second listener, and queued one firewall operation beside the three already waiting — one prompt, as the feature spec asks. Unsharing turned that same queued operation into its removal.
The feature spec said "shows the URL plus a QR code", which was a sentence about a screen this repository no longer has: the daemon answers the URL and `mix` draws the code. It also said the mDNS name joins the certificate SANs at the same moment as the LAN IP, which is T75's half — until that lands the URL is the address. T76 keeps every automatic revocation and loses the manual one, which shipped here: a share that could not be turned off by the person who turned it on would leave a firewall rule open until the next task. Also records what the work found: the label is per home rather than per site because a whole-state plan supersedes rather than accumulates, and the "no rule left behind" test is a Windows test because ufw has no comment field to name a rule of ours with.
… not (T74) Both defects were found in the first minute against a phone, and both survived every test in the plan — because the tests asserted what the spec said and the spec was wrong in the same place. The URL `mix site share` printed did not open the site. A phone sends `Host: 192.168.50.36:8080`; no site block matched a name shaped like an address, so Caddy answered 200 with an empty body. A blank page rather than an error, which is the slowest failure there is to diagnose. A shared site now carries its address in its own address list, over both schemes — the IP SAN makes the https half valid. Worse: binding only the shared site left every other site on the network. Caddy's default is every interface, so a home's sites were already listening on 0.0.0.0 and merely matched no Host a stranger would send — harmless only while no port was open, and opening the port is exactly what this task does. Every site block now binds 127.0.0.1 and ::1, and only a shared one appends its interface address. Measured with netstat before and after: 0.0.0.0:8080 and [::]:8080 are gone, and the three declared addresses are what remain.
A real `netsh` rule named 443 beside 8080 on a machine where Caddy had bound only 8080. `front_end_tls_port` answers what the front end's settings say, and a home that has never issued a certificate still says 443 — so the plan opened a port nothing listens on. The plan now asks the same two questions `generate::served` asks before it renders a TLS listener: does the shared site declare HTTPS, and is there a usable pair on disk. Opening a dead port is not dangerous, but "web ports only" is the promise this feature is made of, and a promise that is wider than it says is one nobody can check.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turns one site — never all of them — into something a phone on the same Wi-Fi can open, and lets the
person who turned that on turn it off again leaving nothing behind.
Spec:
docs/superpowers/specs/2026-08-31-t74-lan-sharing-design.md.What it does
mix site share <site> [--interface <name>]/mix site unshare <site>.bind_addris untouched.PrivilegedOp::FirewallApply— one prompt, and the empty plan is the revoke.mixdraws the QR from it.Decisions worth reviewing
FirewallApplycarries no OS mechanism, unlikeResolverApply. Nothing reads the firewallback, so the helper picks
netsh/ufw/firewalld/ nothing itself. A field it does not needis a field it cannot validate.
mixengine-elevaterefuses what is provably not a web port: TCP only, nothing under 1024 but80 and 443, and a standing deny list of the ports MixEngine's own databases and caches answer on.
It cannot read the database, so it cannot ask the question directly.
OpOutcome::Unmanagedis new. macOS needs no rule for a listening socket and a Linux runningneither firewall has nothing to add one to; on both, "applied" would be a lie.
renewal timer reissues every site for ever.
Found by a phone, not by a test
Two defects survived every test here, because the tests asserted what the spec said and the spec was
wrong in the same place:
Host: <address>, and no siteblock matched a name shaped like one. 200 with an empty body.
bindon the shared site alone left every other site on the network. Caddy's default isevery interface; that was harmless only while no port was open, and opening the port is what this
task does. Every site block now binds loopback explicitly.
Both are written up in the spec under What the first real run changed.
Verified on this machine
127.0.0.1,::1and the interface address — and no wildcard.netstatbefore andafter.
netshrule was created (Private profile, TCP) and removed again; enumerating by labelfound nothing left behind.
macOS and Linux have run none of this locally; CI is what asks them.