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.
7.6 KiB
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.ktdiffers by itspackageline. That is all.PinnedCert.ktdiffers bypackageand one string,"dev-updater-dev-ca"against"ai-app-dev-ca".certs.rsdiffers by the organisation name in the certificate, and by ai-app having factored the file-mode handling into aprivatemodule that dev-updater still has inline in two places.auth.rsdiffers by the state type the middleware is generic over (AppStateagainstSessionManager), and by dev-updater keepingprint_enrollmentinauth.rswhere ai-app keeps the same function inmain.rs.wg_addressandlocal_addressesare 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-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, andlocal_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 anappend_filealongsidecreate_filebecause 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:
certs— CA generated once and never replaced, leaf reissued every start. Oneproduct: &str.- The RON
formathouse rules — byte-identical today, so this is pure deletion. - The Kotlin
EnrollmentScanActivity,PinnedCert, and the enrollment/Keystore half ofServerConfig, 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. - 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.ktand ai-app'sApi.ktare 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.