# 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. Five 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 `://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. ## 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`, as an Android library module. This is where the sharing pays most, because it is where the two copies have drifted furthest apart in *both* directions — and `applyPinnedTls` is security-critical and identical, which is the same argument `certs` won on. 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.