wg-app-link: the WireGuard-and-pinned-TLS half both apps needed
The Rust crate and the Android half of one arrangement: a server that binds the tunnel interface and nothing else, certificates it generates and keeps outside any shared checkout, enrolment that carries a token and the CA to a phone, and a client that trusts exactly that certificate and no other. Extracted because ai-app and dev-updater had written all of it twice and the two copies had already drifted -- one of them carried a bug the other did not. History before this point was squashed away; it was a running record of that extraction and of a personal machine's addresses, and neither is worth keeping in a public repository.
This commit is contained in:
commit
f95bc77f7b
17 files changed
+2552
No files matched your search
@@ -0,0 +1,222 @@
|
||||
# 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, plus `write`, which renders a value and replaces a file with it atomically and owner-only. 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.~~ Done — `format::write`, since it is the RON house rules and the owner-only rules used together. The subtlety it now states once is that the temp file is *not always new*: a save killed partway leaves one behind, and reopening it keeps whatever mode it had, which is then renamed over the file holding the enrolled token hashes. There is a test for exactly that, because it cannot happen on a machine where nothing has ever crashed mid-save.
|
||||
|
||||
**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.
|
||||
Reference in new issue
Block a user