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.
103 lines
6.0 KiB
Markdown
103 lines
6.0 KiB
Markdown
# wg-server-app
|
|
|
|
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.
|
|
|
|
## What is here
|
|
|
|
`server/` — the `wg-link` crate. Three 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.
|
|
- **`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.
|
|
- **`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. `certs` — CA generated once and never replaced, leaf reissued every start. One `product: &str`.
|
|
2. The RON `format` house rules — byte-identical today, so this is pure deletion.
|
|
3. 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.
|
|
4. 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.
|
|
|
|
## The name
|
|
|
|
`wg-server-app` describes the two things it was extracted from. `wg-link`
|
|
— the crate's name here — describes what it actually is: neither a server
|
|
nor an app, but the link between them. Renaming the repo to match is a
|
|
`mv` and a rename on gitea; the crate can also be renamed the other way.
|
|
Whichever is preferred, it should be one name rather than two.
|
|
|
|
## 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.
|