Android's Local Network Definition says a local network "excludes
cellular (WWAN) or VPN connections", which reads as: an app reaching its
server through WireGuard needs no ACCESS_LOCAL_NETWORK. Both projects
reach their server through WireGuard, so both had a reason to delete it.
Measured on a real device on 2026-08-28: the permission is still
required, and without it the traffic is dropped. The documented exclusion
does not hold for a tun carrying an RFC1918 destination.
The note goes here because this is the one function a person would read
before removing the permission, and because neither project can find this
out for itself -- the API 36 emulator images both are tested against do
not enforce the permission at all, so removing it passes every test
available locally and fails only on the phone.
Both projects saved by rendering, writing a temp file through `private`,
and renaming over the target. `format::write` is that, and it belongs with
the house rules rather than beside either schema: it is the two existing
modules used together, and the thing worth stating once is why they are
used together at all.
That thing is the temp file, and it is not obvious. It is not always new
-- a save killed partway leaves one behind, and reopening it keeps
whatever mode it already had, which is then renamed straight over the file
holding the enrolled token hashes. Opening through `private` sets the mode
on the way in, so the fresh and the leftover case are the same case.
There is a test that creates a 0644 leftover and asserts the config comes
out 0600, because that state cannot arise on a machine where nothing has
ever crashed mid-save -- which is every machine either project has been
developed on.
The temp name is now appended rather than substituted, so `config.ron`
yields `config.ron.tmp`. `with_extension("tmp")` would have produced
`config.tmp`, which is a name that could plausibly belong to something
else.
.gitignore covered the Rust half's target/ only, so `git add -A` swept in
about 190 files of Gradle intermediates -- caches, lint models, .class and
.dex files. Removed from tracking and ignored, in a new commit rather than
by rewriting what was already pushed.
Four Kotlin files appeared in both apps. Diffed with the product names
normalised, EnrollmentScanActivity was 31 lines each differing in six --
all of them comments -- and PinnedCert was 58 against 59 with identical
TrustManager logic. That is the same evidence that moved the Rust half.
Two parameters, both per-app, and they fail differently. A wrong URI
scheme means a scanned QR is ignored, which is visible at once. A wrong
Keystore alias means the app cannot unseal the token it already stored,
so an enrolled phone reads as not enrolled and nothing says why -- so
both existing values are recorded in the README rather than left to be
rediscovered.
The certificate alias went the other way and became a constant: it names
an entry in a KeyStore that exists only in memory for the length of one
lazy block, so nothing ever reads it back and having two of them said
nothing.
The drift was in both directions, as the evidence predicted: ai-app had
gained localNetworkAllowed -- which matters because Android 17's
ACCESS_LOCAL_NETWORK denial is invisible at the socket, and without it a
blocked app and an unreachable server produce the same timeout -- and had
moved to the KTX `edit` block. dev-updater had neither, and gets both.
No Compose, deliberately. Nothing here draws anything; the screens are
each app's own because that is where the two products actually differ.
The PinnedCaCertificate generator did not move, and the README says why:
all three things it varies are per-app, so sharing it means a composite
build neither project has. Its one historical bug is already fixed
identically in both copies.
Both servers keep config, the CA's private key and their own state
outside the repo, and both resolve the location by the same rules --
XDG variable, ignored unless absolute, falling back under $HOME, joined
with the product name. ai-app had factored it into one helper taking the
variable and the fallback; dev-updater had the same logic inline in two
functions. That is the test this crate applies: identical but for a
product name.
The reasoning is worth keeping together with the code, so the module doc
carries both halves of why it is outside the repo -- the shared mount
resolves at different absolute paths on each side, which is how every
project once read "not built" on one of them, and a CA key on a mount
the untrusted side can write would let it mint a leaf the pinned app
trusts.
`xdg_dir` takes the environment as an argument so the rules can be
tested without setting process-wide variables, which parallel tests
cannot do without racing. Three tests come across from ai-app, including
the one that matters least often and costs most: a relative setting is
ignored rather than resolved, so a server started from a different
working directory does not quietly look elsewhere for its token hashes
and enroll itself afresh.
Review from ai-app's session, acted on.
**A test that asserted nothing.** `a_missing_tunnel_explains_itself`
returned early whenever `wg0` was up -- which it is on this VM and on the
host, so it passed everywhere and never once checked the message. The same
shape of test that let the file-permissions bug survive three codebases.
`netif` now splits the lookup from the decision: `wg_address_among` and
`addresses_among` take the interfaces, so the failure is reachable by
handing in an empty list rather than by hoping the machine has no tunnel.
Four tests where there were two, including that an IPv6-only `wg0` is not
an answer.
**Two modules the docs promised and the crate did not have.** `certs`,
which is the piece where being written twice is worst -- a trust anchor
built two ways can be built differently two ways, and the difference
reaches a phone as an opaque handshake failure. And `format`, the RON
house rules, which were byte-identical in both projects and so the
clearest thing in the evidence table. `product` names the certificate and
is the whole of what is per-project; the test decodes the DER and looks
for it there rather than trusting what was passed in.
**The README title still said wg-server-app**, three commits after
everything else was renamed.
Two judgement calls promoted from silent to written down, both of which
would otherwise be inherited rather than chosen: `WG_INTERFACE` is a
constant because *these* projects have one tunnel, which is the first
thing a third user should expect to change; and `local_addresses` puts
the emulator's host alias in every certificate, a SAN for an address the
machine does not own.
And a review heuristic the day kept proving: when you find a rule stated,
grep for its siblings. Three bugs today were the correct rule already
written down and applied to one member of a set.
`wg-link` was not merely vague. `wg` and `ip link` are both real network
tools, so it reads as something that manages a WireGuard interface --
misleading rather than ambiguous. `app` rules that reading out, and says
which two ends the link is between.
The repository is still called wg-server-app until it is renamed on
gitea; the crate leads.
Three copies, one bug, and the correct reasoning already written down in
a comment three functions above the code that needed it. That is an
argument about review rather than about duplication, and it is more
persuasive than any of the similarity percentages above it.
`OpenOptions::mode` applies only when the file is created, so opening one
that already exists keeps whatever mode it had. For `append_file` that is
the normal case rather than the odd one -- every write to a transcript
after the first -- and for `create_file` it happens on every rewrite. A
file made wrong by an older version or by hand would have stayed
world-readable for as long as it was only ever appended to.
This is the same reasoning as `create_dir`'s second call, which was
already here and already commented. Missing it on the file paths was the
kind of gap that only shows when the two are read side by side, which is
an argument for this crate existing rather than against it.
Set through the open handle rather than the path, so nothing can swap
what is at that path between the open and the chmod.
Caught by ai-app's session reviewing the module I had taken from their
code, which is the review this repo is supposed to make possible.
dev-updater serves APKs to a phone and ai-app runs model sessions for
one. Above the waterline they share nothing. Underneath they are the
same program: bound to wg0 so they are not on the LAN, presenting a
certificate from a CA the app pins, answering only requests carrying a
token enrolled by scanning a QR off the terminal, keeping state in
owner-only files outside the repo.
Three modules, each extracted only after diffing the two copies and
finding nothing between them but a product name and a type parameter.
netif fails closed when the tunnel is down. enroll generates, stores and
compares the token, and prints the QR, with the URI scheme as the one
per-project part. private owns the file modes, taken from ai-app's
version because it had already factored out what dev-updater still has
inline in two places.
Nothing is removed from either project. This is a proposal with a
working core, and the README carries the measured evidence -- the
enrollment scanner activity differs by its package line and nothing
else, the RON format module is byte-identical, and the two copies have
each drifted into holding an improvement the other lacks, which is the
cost being paid today.