diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50043eb --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +server/target/ diff --git a/README.md b/README.md index 48b140b..623671e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,102 @@ # 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 `://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. diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 0000000..23e4515 --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Runs this crate's tests. Extra arguments are forwarded to `cargo test`. +set -eu +cd "$(dirname "$0")/server" +exec cargo test "$@" diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..11115c5 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,327 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wg-link" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "if-addrs", + "qrcode", + "rand", + "sha2", + "subtle", + "tempfile", + "tracing", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..d8b8258 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "wg-link" +version = "0.1.0" +edition = "2024" +description = "The private link between a phone and a machine you run: WireGuard binding, a self-signed CA the app pins, and QR enrollment of a bearer token." + +[dependencies] +anyhow = "1" +# Enumerating this machine's addresses, and finding the tunnel's. +if-addrs = "0.15" +# Token auth: hash for storage, constant-time compare for verification, +# CSPRNG-backed generation, base64url for the enrollment string. +sha2 = "0.11" +subtle = "2" +rand = "0.10" +base64 = "0.23" +# Renders the enrollment QR straight to the terminal; no image output. +qrcode = { version = "0.14", default-features = false } +tracing = "0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/server/src/enroll.rs b/server/src/enroll.rs new file mode 100644 index 0000000..d6f94cd --- /dev/null +++ b/server/src/enroll.rs @@ -0,0 +1,146 @@ +//! The bearer token a phone carries, and the QR code that gets it there. +//! +//! Pinning authenticates the server to the phone but never the phone to +//! the server, so the token supplies the other direction. Binding the +//! WireGuard interface (see [`crate::netif`]) narrows who can try at all; +//! this narrows it to who was enrolled. +//! +//! The token is 256 bits from the OS CSPRNG and is never typed by a +//! human -- it travels once, in a QR code printed to the terminal -- so +//! being unguessable costs nothing and there is no manual-entry path to +//! design around. +//! +//! Only the hash is ever stored. That is what makes the plaintext a +//! once-only artifact: it exists in the QR at generation time and nowhere +//! afterwards, and a lost phone is answered by rotating rather than by +//! looking the old one up. +//! +//! # Never log the token +//! +//! Nothing here, and nothing that calls it, may log the Authorization +//! header or the token itself. Both existing projects hold a test that +//! drives the rejection path under a capturing subscriber and asserts the +//! token does not appear in the output; that tripwire belongs with the +//! middleware, which stays in each project because it is generic over +//! that project's state. + +use std::net::IpAddr; + +use anyhow::{Context, Result}; +use base64::Engine; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +/// 256 bits from the OS CSPRNG, base64url. +pub fn generate_token() -> String { + use rand::Rng; + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// What the config stores instead of the token: hex SHA-256. +/// +/// A plain hash, not a password KDF, and deliberately: the input is 256 +/// random bits, so there is nothing to dictionary-attack and stretching +/// would buy only latency on every request. +pub fn token_hash_hex(token: &str) -> String { + Sha256::digest(token.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Whether `presented` matches any enrolled hash. +/// +/// The fold visits every entry regardless of an earlier match, so the +/// time taken does not say which entry matched, or whether the first one +/// did. +pub fn token_matches(presented: &str, stored_hashes: &[String]) -> bool { + let presented = token_hash_hex(presented); + stored_hashes.iter().fold(false, |matched, stored| { + matched | bool::from(presented.as_bytes().ct_eq(stored.as_bytes())) + }) +} + +/// The `://enroll?...` URI a QR code carries. +/// +/// The scheme is the caller's because it is what routes the scan back to +/// the right app -- `devupdater`, `aiapp` -- and it is the only part of +/// enrollment that is per-project. +pub fn enrollment_uri(scheme: &str, host: IpAddr, port: u16, token: &str) -> String { + format!("{scheme}://enroll?host={host}&port={port}&token={token}") +} + +/// Prints the one-time enrollment QR, and the URI under it for a person +/// who would rather paste than scan. +/// +/// Printed to stdout rather than through `tracing`: it is for the human +/// at the terminal, once, and a log line is the wrong shape for something +/// that has to be photographed. +/// +/// The QR carries no trust material. The CA is embedded in the app at +/// build time, so photographing the terminal leaks only the token, which +/// is rotatable. +pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> Result<()> { + let uri = enrollment_uri(scheme, host, port, token); + let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?; + let rendered = code + .render::() + .quiet_zone(true) + .build(); + println!("\n{rendered}\n"); + println!("Scan with the phone's camera to enroll (or paste into the app's settings):"); + println!(" {uri}"); + println!("The token is not stored in the clear and won't be shown again;"); + println!("a lost phone means re-running with --rotate-token.\n"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hashing_is_stable_and_tokens_verify() { + let token = generate_token(); + assert_eq!(token_hash_hex(&token), token_hash_hex(&token)); + assert_ne!(token, generate_token(), "tokens must not repeat"); + + let hashes = vec![token_hash_hex(&token), token_hash_hex("other")]; + assert!(token_matches(&token, &hashes)); + assert!(token_matches("other", &hashes)); + assert!(!token_matches("wrong", &hashes)); + assert!( + !token_matches(&token, &[]), + "no enrolled token matches nothing" + ); + } + + /// The hash is what gets stored, so it must not be the token, and it + /// must be the shape the config files already hold. + #[test] + fn the_stored_form_reveals_nothing_and_is_hex() { + let token = generate_token(); + let hash = token_hash_hex(&token); + assert_ne!(hash, token); + assert_eq!(hash.len(), 64); + assert!( + hash.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + ); + } + + /// The scheme is the only per-project part, and the app parses this + /// back -- so the shape is a contract, not a formatting choice. + #[test] + fn the_enrollment_uri_carries_scheme_host_port_and_token() { + let uri = enrollment_uri("devupdater", "10.66.0.1".parse().unwrap(), 8090, "tok"); + assert_eq!( + uri, + "devupdater://enroll?host=10.66.0.1&port=8090&token=tok" + ); + let other = enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok"); + assert!(other.starts_with("aiapp://enroll?")); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 0000000..8578cc9 --- /dev/null +++ b/server/src/lib.rs @@ -0,0 +1,32 @@ +//! What dev-updater and ai-app both need in order to be reached from a +//! phone, and nothing either of them does afterwards. +//! +//! Both projects are the same shape underneath: a server on a machine +//! somebody owns, bound to a WireGuard interface so it is not on the LAN, +//! presenting a certificate from a CA the app pins, and answering only +//! requests carrying a bearer token that was enrolled by scanning a QR +//! code off the terminal. None of that is about serving APKs or running +//! model sessions -- it is the link, and it was written twice. +//! +//! # What belongs here +//! +//! Anything that would be *identical* in a third such project. The test +//! applied to each module below was to diff the two existing copies: if +//! the only differences were a product name and which state type the code +//! was generic over, it came here. +//! +//! # What deliberately does not +//! +//! The API surfaces. dev-updater's routes are about projects and builds, +//! ai-app's about sessions and providers, and their HTTP clients have +//! diverged to 14% similarity because they are genuinely different +//! programs. Sharing a transport is worth doing; sharing an API would mean +//! inventing a common vocabulary neither project wants. +//! +//! Config *schemas*, for the same reason -- though the RON house rules +//! that both files are written in are shared, since those were identical +//! to the byte. + +pub mod enroll; +pub mod netif; +pub mod private; diff --git a/server/src/netif.rs b/server/src/netif.rs new file mode 100644 index 0000000..41816c2 --- /dev/null +++ b/server/src/netif.rs @@ -0,0 +1,111 @@ +//! Which address to bind, and which addresses the certificate must cover. +//! +//! Both projects bind the WireGuard interface and nothing else, so that +//! neither is reachable from the LAN. That is the outer of two gates -- +//! the tunnel decides who can try, the token (see [`crate::enroll`]) +//! decides who is answered -- and it is worth having on its own account: +//! an unenrolled scanner never reaches the token check, and a plain-HTTP +//! bootstrap port travels inside the tunnel's encryption. + +use std::net::IpAddr; + +use anyhow::{Context, Result}; + +/// The interface both projects bind. A constant rather than a parameter +/// because a second answer would mean two ideas of what "the tunnel" is. +pub const WG_INTERFACE: &str = "wg0"; + +/// The alias an Android emulator reaches its host by. Not a real +/// interface anywhere, which is why it has to be added by hand. +const EMULATOR_HOST_ALIAS: [u8; 4] = [10, 0, 2, 2]; + +/// Every address this machine answers on, for the leaf certificate's SANs +/// -- so it covers whatever the phone actually dials without anyone +/// maintaining a hardcoded IP. +/// +/// Loopback is included for curl and tests, and the emulator's host alias +/// so a debug build can reach a server running beside it. +/// +/// Failing to enumerate is not fatal: the certificate still covers +/// loopback, which is enough to start and to diagnose from the machine +/// itself. +pub fn local_addresses() -> Vec { + let mut addresses = vec![ + IpAddr::from([127, 0, 0, 1]), + IpAddr::from(EMULATOR_HOST_ALIAS), + ]; + match if_addrs::get_if_addrs() { + Ok(interfaces) => { + for interface in interfaces { + let ip = interface.ip(); + if ip.is_ipv4() && !addresses.contains(&ip) { + addresses.push(ip); + } + } + } + Err(err) => tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"), + } + addresses +} + +/// The IPv4 address on the WireGuard interface, or a refusal to start. +/// +/// Failing closed rather than falling back to 0.0.0.0 is the point. The +/// escape hatch belongs to the caller as an explicit `--bind`, because +/// each of these servers is also how something stranded gets recovered, +/// and that recovery should not depend on the tunnel being healthy. +/// +/// `product` names the binary in the failure, so the message reads as +/// advice rather than as a library complaining. +pub fn wg_address(product: &str) -> Result { + let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?; + interfaces + .into_iter() + .find(|iface| iface.name == WG_INTERFACE && iface.ip().is_ipv4()) + .map(|iface| iface.ip()) + .ok_or_else(|| { + anyhow::anyhow!( + "no IPv4 address on interface {WG_INTERFACE} -- {product} binds only to the \ + WireGuard tunnel, so that only enrolled peers can reach its API. Bring the \ + tunnel up, or pass --bind 0.0.0.0 to serve the LAN while recovering." + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Whatever this machine has, the two that are not interfaces must be + /// there -- loopback for tests and curl, the alias for an emulator -- + /// and nothing may appear twice, since these become certificate SANs. + #[test] + fn the_certificate_always_covers_loopback_and_the_emulator_alias() { + let addresses = local_addresses(); + assert!(addresses.contains(&IpAddr::from([127, 0, 0, 1]))); + assert!(addresses.contains(&IpAddr::from(EMULATOR_HOST_ALIAS))); + + let mut seen = addresses.clone(); + seen.sort(); + seen.dedup(); + assert_eq!(seen.len(), addresses.len(), "duplicate SANs: {addresses:?}"); + assert!(addresses.iter().all(|ip| ip.is_ipv4())); + } + + /// The failure is the thing a person reads at 2am, so it has to name + /// the binary, the interface, and the way out. + #[test] + fn a_missing_tunnel_explains_itself() { + // Only meaningful where there is no wg0; where there is one, the + // call succeeds and there is no message to check. + if wg_address("demo-server").is_ok() { + return; + } + let err = wg_address("demo-server") + .expect_err("no tunnel") + .to_string(); + assert!(err.contains("demo-server"), "{err}"); + assert!(err.contains(WG_INTERFACE), "{err}"); + assert!(err.contains("--bind"), "{err}"); + } +} diff --git a/server/src/private.rs b/server/src/private.rs new file mode 100644 index 0000000..246bac3 --- /dev/null +++ b/server/src/private.rs @@ -0,0 +1,127 @@ +//! Creating files and directories this server alone can read. +//! +//! Everything a server writes outside its repo goes through here: the +//! config holding token hashes, the TLS private keys, and whatever state +//! it keeps. One module owns the modes, so "owner-only" is a property +//! that can be checked in one place rather than re-argued at every +//! `create`. +//! +//! Taken from ai-app, which had factored this out; dev-updater still has +//! the same logic inline in two places, which is the duplication this +//! crate exists to end. + +use std::fs::File; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; +use std::path::Path; + +use anyhow::{Context, Result}; + +/// Creates `dir` and its parents, owner-accessible only. +/// +/// The mode is set again after creation, deliberately: `DirBuilder::mode` +/// applies only when the directory is actually created, so one that +/// already existed -- made by hand, or by an older version -- would +/// otherwise keep whatever permissions it had while holding a private key. +pub fn create_dir(dir: &Path) -> Result<()> { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir) + .with_context(|| format!("create {}", dir.display()))?; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .with_context(|| format!("restrict {}", dir.display())) +} + +/// Writes `contents` to `path`, owner-readable only. +/// +/// The mode is set as the file is opened rather than chmod-ed afterwards, +/// so it is never briefly world-readable at its real path. +pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> { + use std::io::Write; + let mut file = create_file(path)?; + file.write_all(contents) + .with_context(|| format!("write {}", path.display())) +} + +/// Opens `path` for writing, owner-readable only, truncating what is +/// there. For a caller that streams rather than holding the whole body. +pub fn create_file(path: &Path) -> Result { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("write {}", path.display())) +} + +/// Opens `path` for appending, owner-readable only, creating it if needed. +/// +/// The append case is separate because a transcript must never be +/// truncated by being opened, and the two differ by one flag that is easy +/// to get wrong in a hurry. +pub fn append_file(path: &Path) -> Result { + std::fs::OpenOptions::new() + .append(true) + .create(true) + .mode(0o600) + .open(path) + .with_context(|| format!("append to {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mode_of(path: &Path) -> u32 { + std::fs::metadata(path).expect("stat").permissions().mode() & 0o777 + } + + #[test] + fn a_directory_is_owner_only_even_if_it_already_existed() { + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("state"); + + // Made by hand, wide open -- what an older version or a person + // might leave behind. + std::fs::create_dir(&target).expect("mkdir"); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + + create_dir(&target).expect("create_dir"); + assert_eq!( + mode_of(&target), + 0o700, + "an existing directory must be restricted too" + ); + } + + #[test] + fn files_are_owner_only_from_the_moment_they_exist() { + let dir = tempfile::tempdir().expect("tempdir"); + create_dir(dir.path()).expect("create_dir"); + + let written = dir.path().join("key.pem"); + write_file(&written, b"secret").expect("write"); + assert_eq!(mode_of(&written), 0o600); + assert_eq!(std::fs::read(&written).expect("read"), b"secret"); + + let appended = dir.path().join("transcript.jsonl"); + { + use std::io::Write; + let mut file = append_file(&appended).expect("append"); + file.write_all(b"one\n").expect("write"); + } + { + use std::io::Write; + let mut file = append_file(&appended).expect("append"); + file.write_all(b"two\n").expect("write"); + } + assert_eq!(mode_of(&appended), 0o600); + // The whole point of the separate opener: opening again must not + // have truncated what was there. + assert_eq!( + std::fs::read_to_string(&appended).expect("read"), + "one\ntwo\n" + ); + } +}