Skip to content

feat(core): LAN sharing, per site and reversible (T74) - #61

Merged
haiquang9994 merged 13 commits into
masterfrom
feat/t74-lan-sharing
Aug 31, 2026
Merged

feat(core): LAN sharing, per site and reversible (T74)#61
haiquang9994 merged 13 commits into
masterfrom
feat/t74-lan-sharing

Conversation

@haiquang9994

Copy link
Copy Markdown
Collaborator

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>.
  • A second listener on the shared site's own block; the front end's bind_addr is untouched.
  • The site certificate gains the LAN address as an IP SAN, and loses it again on unshare.
  • One whole-state PrivilegedOp::FirewallApply — one prompt, and the empty plan is the revoke.
  • The daemon answers a URL; mix draws the QR from it.

Decisions worth reviewing

  • FirewallApply carries no OS mechanism, unlike ResolverApply. Nothing reads the firewall
    back, so the helper picks netsh / ufw / firewalld / nothing itself. A field it does not need
    is a field it cannot validate.
  • mixengine-elevate refuses what is provably not a web port: TCP only, nothing under 1024 but
    80 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::Unmanaged is new. 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.
  • T50's D4 is overturned: this build now issues an IP SAN. The reuse check compares it, or the
    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:

  1. The URL the feature printed did not open the site — a phone sends Host: <address>, and no site
    block matched a name shaped like one. 200 with an empty body.
  2. bind on the shared site alone left every other site on the network. Caddy's default is
    every 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

  • Caddy binds 127.0.0.1, ::1 and the interface address — and no wildcard. netstat before and
    after.
  • A phone on the same Wi-Fi opened the site over HTTP.
  • The real netsh rule was created (Private profile, TCP) and removed again; enumerating by label
    found nothing left behind.
  • The refusal listed Tailscale, two vEthernet adapters and Wi-Fi rather than choosing between them.

macOS and Linux have run none of this locally; CI is what asks them.

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.
@haiquang9994
haiquang9994 merged commit 12f58d9 into master Aug 31, 2026
10 checks passed
@haiquang9994
haiquang9994 deleted the feat/t74-lan-sharing branch August 31, 2026 04:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant