Files
wg-app-link/README.md
T
iris 4e423bbfb0 Take the app half too, which was duplicated the same way
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.
2026-08-28 17:57:07 -04:00

223 lines
14 KiB
Markdown

# wg-app-link
The private link between a phone and a machine you run, extracted from the
two projects that had each written it.
`dev-updater` serves locally-built APKs to a phone. `ai-app` runs model
sessions for one. They have nothing in common above the waterline — and
underneath they are the same program: a server bound to a WireGuard
interface so it is not on the LAN, presenting a certificate from a CA the
app pins, answering only requests carrying a bearer token that was enrolled
by scanning a QR code off the terminal, and keeping its state in
owner-only files outside the repo.
That link was written twice. This is it written once.
> **Status: proposal with a working core.** Nothing has been removed from
> either project yet. The Rust modules here are real, tested and
> lint-clean; the rest of this file is the case for what should follow and
> what should not.
## The evidence
Measured, not estimated — `difflib` over the two working trees on
2026-08-28:
| file | dev-updater | ai-app | identical |
|---|---|---|---|
| `EnrollmentScanActivity.kt` | 32 lines | 32 lines | **98%** |
| `ServerConfig.kt` | 139 | 122 | **89%** |
| `auth.rs` | 250 | 230 | **83%** |
| `PinnedCert.kt` | 60 | 59 | **75%** |
| `certs.rs` | 218 | 199 | **70%** |
| RON `format` module | — | — | **byte-identical** |
The percentages understate it, because the differences are almost entirely
a product name and a type parameter:
- **`EnrollmentScanActivity.kt`** differs by its `package` line. That is all.
- **`PinnedCert.kt`** differs by `package` and one string, `"dev-updater-dev-ca"` against `"ai-app-dev-ca"`.
- **`certs.rs`** differs by the organisation name in the certificate, and by ai-app having factored the file-mode handling into a `private` module that dev-updater still has inline in two places.
- **`auth.rs`** differs by the state type the middleware is generic over (`AppState` against `SessionManager`), and by dev-updater keeping `print_enrollment` in `auth.rs` where ai-app keeps the same function in `main.rs`.
- **`wg_address` and `local_addresses`** are the same logic in both, in files that are otherwise 8% alike.
ai-app's own `Cargo.toml` already says the quiet part: RON is used there
because it is *"the same choice, and the same house rules, as the sibling
dev-updater project's config."*
## Why this is worth doing, in one observation
**The two copies have each drifted into holding an improvement the other
lacks.** Not hypothetically — as of the day this was written:
| improvement | in dev-updater | in ai-app |
|---|---|---|
| `private::` module owning every file mode | no, inline twice | yes |
| `SharedPreferences.edit { }` instead of the deprecated builder | no | yes |
| `existingTokenKey()` — reads the key without creating one, so a stored blob with no key means "not enrolled" rather than leaving a stray key behind | yes | no |
| `hasLocalNetworkPermission` — tells a denied permission apart from an unreachable server, which are identical at the socket | yes | no |
| Catppuccin theme with colour-by-consequence buttons | yes | no, ad-hoc `Color(0xFF…)` per screen |
Every one of those is a fix somebody made once, in one repo, that the other
will either never get or get by being written a third time. That is the
cost this repo removes, and it is already being paid.
## It found a bug in all three before shipping a line
`private` was lifted from ai-app because that copy was the better one. Two
sessions then read it as a module rather than as scattered helpers, and
found that `OpenOptions::mode` applies **only when a file is created** — so
opening one that already exists keeps whatever mode it had.
Both projects rewrite existing files holding private material. `certs.rs`
reissues the TLS leaf on every start, so a `leaf-key.pem` that had ever
existed world-readable would have stayed that way for the rest of its life,
with every start looking like it was setting the mode. The config's temp
file is the other: normally fresh, but one left by a crashed save is
reopened with its old mode and then renamed over the file holding the
enrolled token hashes.
It was latent in both — the modes on both machines were checked, and were
correct — but it was present three times: dev-updater, ai-app, and this
crate's first commit.
The reason nobody caught it is the argument for this repo, and it is not
about duplication. The correct reasoning was already written down, in a
comment, three functions above the code that needed it: `create_dir` sets
the mode a second time and explains exactly why. It stayed invisible for as
long as nobody had cause to read those functions as a set. Two readers
looking at one module found it in under an hour.
Fixed here (`67ac924`), in ai-app (`d0b6b66`), and in dev-updater
(`bdaade4`), which also adopted the `private` module wholesale — the first
time the sharing paid rather than merely being argued for.
## What is here
`server/` — the `wg-app-link` crate. Six modules, each extracted only after
diffing the two copies and finding nothing but a name between them.
- **`netif`** — `wg_address()`, which fails closed when the tunnel is down, and `local_addresses()` for the certificate's SANs. The product name is a parameter so the failure reads as advice rather than as a library complaining. Both split the *lookup* from the *decision*, so the failure path can be tested on a machine that has a tunnel — every machine this runs on does.
- **`enroll`** — token generation, hex-SHA-256 storage, constant-time comparison, the `<scheme>://enroll?…` URI, and the terminal QR. The URI scheme is the parameter, because it is what routes a scan back to the right app.
- **`certs`** — the CA generated once and never replaced, the leaf reissued every start. `product` names the organisation and common name and is the whole of what is per-project. Shared because being written twice is worst here: a trust anchor built two ways can be built differently two ways, and the difference surfaces as an opaque handshake failure on a phone.
- **`format`** — the two RON house rules. This was byte-identical in both projects, which made it the clearest thing in the evidence table and the easiest deletion.
- **`private`** — owner-only files and directories, taken from ai-app's version, with an `append_file` alongside `create_file` because a transcript must never be truncated by being opened.
- **`xdg`** — where each project's state lives: `config_home(product)` and `data_home(product)`, resolving the XDG variable, ignoring it unless absolute, and falling back under `$HOME`. Shared because the *reason* is shared and is not obvious from the code — the repo is a mount that resolves at different absolute paths on each side, so config inside it records paths that work on only one, and a CA private key inside it would let the untrusted side mint a leaf the pinned app trusts.
`app/` — the Android library, `com.example.wgapplink`. The same test applied
to Kotlin: the four files appeared in both apps and differed only in
comments and a product name.
- **`PinnedTls`** — trusts one CA and not the system store, so a genuine certificate for another host is refused as firmly as a self-signed one. The PEM is constructed with, not read, because each app generates its own at build time from the machine doing the build.
- **`ServerStore`** — the enrollment: preferences, the `<scheme>://enroll?…` URI, and the token sealed under an Android Keystore key. Two parameters, both per-app and both load-bearing — see the warning below.
- **`EnrollmentScanActivity`** — zxing's capture activity with the 10% framing inset and the laser decorations removed, so framing is never the user's problem.
- **`localNetworkAllowed`** — whether `ACCESS_LOCAL_NETWORK` was granted. Android 17 made it mandatory and a denial is invisible at the socket, so without asking, a blocked app and an unreachable server produce the same timeout.
**Adopting the app half takes two lines and one catalog entry.** In
`settings.gradle.kts`:
```kotlin
include(":link")
project(":link").projectDir = file("../wg-app-link/app")
```
and in the version catalog, because a subproject resolves plugin versions
from the build including it rather than from its own:
```toml
androidLibrary = { id = "com.android.library", version.ref = "agp" }
```
It also needs `androidx-core-ktx` and `zxing-embedded` in that catalog, and
`alias(libs.plugins.androidLibrary) apply false` in the root build file.
Consumed as a subproject rather than a published artifact so the two stay
locked to whatever commit the submodule points at — the same arrangement
the Rust half uses with a path dependency.
> **The Keystore alias is persisted. Carry the existing value over exactly.**
> `ServerStore(scheme, keyAlias)` takes both because both are per-app, but
> they fail differently. A wrong scheme means a scanned QR is ignored, which
> is visible immediately. A wrong `keyAlias` means the app can no longer
> unseal the token it already stored, so an enrolled phone silently reads as
> not enrolled with nothing on screen to say why. ai-app's is
> `aiapp-token-key`; dev-updater's is `dev-updater-token-key`.
**Not moved: the `PinnedCaCertificate.kt` generator.** It is ~40 lines of
Gradle in each app's build file, and all three things it varies — the
environment variable, the certificate path, and the package the constant is
emitted into — are per-app, so sharing it means parameterising the whole of
it and introducing a composite build that neither project has today. The
one bug it has had is fixed identically in both copies: a PEM constant must
start at the opening quotes, or `CertificateFactory` loses its `-----BEGIN`
preamble sniff, tries DER, and fails at runtime with an ASN.1 decode error
nowhere near the generator. Worth revisiting if it ever needs a second fix.
## What should follow, and what should not
**Should follow, in this order.** Each is already near-identical:
1. ~~The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`.~~ Done — see `app/` above. It paid as predicted: ai-app's `ServerConfig` had gained `localNetworkAllowed` and the KTX `edit` block while dev-updater's had not, so the two had drifted in both directions exactly as the evidence suggested.
2. Atomic owner-only config save. Both do temp-file-then-rename with the mode set before the rename; only the schema differs.
**Should not.** Naming these is the point of the exercise:
- **The auth middleware itself.** It is generic over each project's state type. Share the primitives (`enroll`), let each keep the six lines that wire them to its own state — the alternative is a trait that exists only to let one function be shared.
- **The HTTP clients.** dev-updater's `DownloadServer.kt` and ai-app's `Api.kt` are 14% alike and 135 against 494 lines. They have diverged because they are genuinely different programs. A shared *pinned transport* underneath them may be worth it later; a shared API is not.
- **Config schemas.** Shared house rules, separate contents.
- **Anything above the link.** Projects, builds, sessions, providers. If a third project would not want it, it does not belong here.
## Two decisions worth stating, rather than inheriting
**`WG_INTERFACE` is a constant, not a parameter.** For these two projects a
second answer would mean two ideas of what "the tunnel" is, and there is
one tunnel. That reasoning is about *these* projects rather than about the
world, so it is the first thing a third user should expect to change.
**`local_addresses` puts `10.0.2.2` in every certificate.** It is the alias
an Android emulator reaches its host by, and not an address the machine
owns — so this is a SAN for something that is not this host, present so a
debug build can reach a server running beside it. Inherited from ai-app
and correct there; written down here because promoting it makes it every
future project's default rather than one project's choice.
## A review heuristic this repo keeps proving
**When you find a rule stated somewhere, grep for its siblings.** Three
separate bugs today were the correct rule already written down, applied to
one member of a set and not the others:
- `create_dir` set its mode a second time and explained why; `create_file`
and `append_file`, three functions below, did not.
- ai-app explained one ssh failure and not its sibling.
- dev-updater's acceptance gate compared components by *declaration*, and
`BuildState::matches` — which meant the same thing — compared them by
full equality, so writing a measured field replaced a running build's
state.
None was found by a test. All three were found by a second reader looking
at code as a set, which is the thing this crate is actually buying.
## The name
`wg-app-link`: the link between an app and the machine it talks to, over
WireGuard.
Two earlier names were worse. `wg-server-app` names the two projects this
came out of rather than what it is, which stops being useful the moment a
third one uses it. `wg-link` was worse still, and not merely vague —
`wg` and `ip link` are both real network tools, so it reads as something
that manages a WireGuard interface. `app` is what rules that reading out.
The `wg-` prefix does slightly oversell WireGuard's share of the code:
only `netif` is about the tunnel, while `enroll`, `private` and the
certificate work are not. It earns its place anyway, because binding the
tunnel is the architectural premise the rest follows from — it is why
there is no LAN exposure to defend, and why a plain-HTTP bootstrap port is
safe to offer at all.
## Testing
`./run-tests.sh`. The crate is warning-clean under `cargo clippy
--all-targets` and formatted with plain `cargo fmt` at its defaults, per
the house rules.