diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6f76f13 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "wg-app-link"] + path = wg-app-link + url = git@git.arirex.me:iris/wg-app-link.git diff --git a/AGENTS.md b/AGENTS.md index 8c53483..f8e4013 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ repo is in PLAN.md's "Backend layout" section. - `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP table in its module doc comment, `auth.rs` the bearer-token middleware, - `config.rs` the persisted schema and the RON its file is written in, + `config.rs` the persisted schema (written in the shared RON house rules), `session/` the manager (registry pattern), `Driver` trait + event model, `EchoDriver`, and transcripts. - `app/` — Compose Android app, single `:androidApp` module, package @@ -52,11 +52,24 @@ repo is in PLAN.md's "Backend layout" section. `server/service`) and then the APK (built in `app/`), in that order. The project it serves is the repository, not either half of it, which is why this sits at the root rather than in `app/`. -- `server/src/certs.rs` — the TLS certificates, generated in process on - first start into `$XDG_CONFIG_HOME/ai-app/certs`: idempotent CA, leaf - reissued every start covering every local IPv4 plus 127.0.0.1 and - 10.0.2.2 (emulator → host). Regenerating the CA strands the installed - app — the one-way door. +- `wg-app-link/` — a **git submodule**, and the half of this backend that + dev-updater also needed: the pinned CA and leaf (`certs`), QR enrollment + and the bearer token (`enroll`), wg0 binding and the certificate's SANs + (`netif`), owner-only files (`private`), and the RON house rules + (`format`). Both projects had written all five, and they had drifted; + see that repo's `README.md` for the diff that decided each one and the + permissions bug the extraction found. Clone with + `git clone --recurse-submodules`, or `git submodule update --init` in an + existing checkout — `server/` will not build without it, since it is a + path dependency rather than a registry one, which is what keeps the two + projects version-locked to the commit this repo pins. + The certificates are still the one-way door: the CA is generated once on + first start into `$XDG_CONFIG_HOME/ai-app/certs` and regenerating it + strands the installed app. + What deliberately did **not** move is the API surface and the config + *schema* — routes, drivers, sessions and setups are what makes this + project itself, and a shared vocabulary for them would be inventing one + neither project wants. ## Status @@ -129,7 +142,8 @@ mangles an argument. ## Checking your work -- Server: from `server/`, `./run-tests.sh` (or `cargo test`) + +- Server: `./run-tests.sh` from the repo root (or `cargo test` from + `server/`) + `cargo clippy --all-targets` + `cargo fmt`. The build stays warning-clean and rustfmt-clean at the defaults — there is no `rustfmt.toml` and there should not be one. diff --git a/server/Cargo.lock b/server/Cargo.lock index 58fea9e..f7b7ef6 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -26,16 +26,12 @@ dependencies = [ "axum-server", "base64 0.23.1", "clap", - "if-addrs", - "qrcode", "rand", - "rcgen", "ron", "rustls", "serde", "serde_json", "sha2", - "subtle", "tempfile", "thiserror", "tokio", @@ -44,6 +40,7 @@ dependencies = [ "tracing", "tracing-subscriber", "ureq", + "wg-app-link", ] [[package]] @@ -1868,6 +1865,23 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wg-app-link" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.23.1", + "if-addrs", + "qrcode", + "rand", + "rcgen", + "ron", + "serde", + "sha2", + "subtle", + "tracing", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/server/Cargo.toml b/server/Cargo.toml index c72c265..9fb03c4 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -8,6 +8,11 @@ name = "ai-server" path = "src/main.rs" [dependencies] +# The link both this and dev-updater need in order to be reached from a +# phone: wg binding, the pinned CA, QR enrollment, owner-only files, and +# the RON house rules. Extracted from the two copies that had drifted -- +# see that repo's README for the evidence and the bug the extraction found. +wg-app-link = { path = "../wg-app-link/server" } axum = { version = "0.8", features = ["json", "multipart"] } axum-server = { version = "0.8", features = ["tls-rustls"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "process", "io-util", "signal"] } @@ -17,32 +22,19 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1", features = ["derive"] } serde_json = "1" # The config file's format. Not JSON, because this file is written and read -# by hand and RON says a sum type as syntax -- the same choice, and the same -# house rules, as the sibling dev-updater project's config. +# by hand and RON says a sum type as syntax. The two house rules both +# projects write it under live in wg-app-link; this is here for the error +# types the schema's own signatures name. ron = "0.12.2" clap = { version = "4", features = ["derive"] } anyhow = "1" thiserror = "2" -# Token auth: hash for storage, constant-time compare for verification, -# CSPRNG-backed generation, base64url for the enrollment string. +# Verifying a downloaded model against HuggingFace's published digest. sha2 = "0.11" -subtle = "2" +# Naming a session directory, and an attachment inside one. rand = "0.10" +# Decoding the images a phone attaches, and encoding them for a driver. base64 = "0.23" -# Renders the enrollment QR straight to the terminal; no image output needed. -qrcode = { version = "0.14", default-features = false } -# The wg0-bound listener needs the interface's address; the stdlib has no -# getifaddrs. This is the smallest crate that wraps just that. -if-addrs = "0.15" -# Generates this server's TLS certificates on first start, replacing a -# setup script that shelled out to whatever openssl happened to be -# installed. In process means one place decides the extensions, the file -# modes, and which addresses the leaf covers. x509-parser so the issuer is -# read back from the CA actually on disk: reconstructing it from the same -# parameters would work only as long as nothing ever changed them, and a -# mismatched issuer name yields a chain that fails to validate rather than -# anything that looks wrong at generation time. -rcgen = { version = "0.14", features = ["pem", "x509-parser"] } # Outbound HTTPS for the usage endpoint. A small blocking client fits an # every-few-minutes poll better than pulling in reqwest's tower stack; # rustls-backed like the rest of the TLS here. diff --git a/server/src/auth.rs b/server/src/auth.rs index 758eeb0..b3319a4 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -21,9 +21,7 @@ use axum::extract::{ConnectInfo, Request, State}; use axum::http::{StatusCode, header}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; -use base64::Engine; -use sha2::{Digest, Sha256}; -use subtle::ConstantTimeEq; +use wg_app_link::enroll::token_matches; use crate::session::SessionManager; @@ -32,35 +30,6 @@ use crate::session::SessionManager; /// drip rather than a fast one. const REJECT_DELAY: Duration = Duration::from_millis(300); -/// 256 bits from the OS CSPRNG, base64url. A machine credential carried by -/// a QR code, never typed, so unguessable costs nothing. -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 `config.ron` stores instead of the token: hex SHA-256. A plain -/// hash is enough for high-entropy random input, and buys that a leaked -/// config doesn't leak the credential. -pub fn token_hash_hex(token: &str) -> String { - Sha256::digest(token.as_bytes()) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - -/// Hash-then-constant-time-compare against every enrolled hash. The fold -/// visits every entry regardless of match so the timing doesn't say which -/// entry (if any) matched. -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())) - }) -} - pub async fn require_token( State(manager): State>, request: Request, @@ -104,6 +73,8 @@ mod tests { use axum::routing::get; use tower::ServiceExt; + use wg_app_link::enroll::{generate_token, token_hash_hex}; + use crate::config::TokenEntry; fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc { @@ -139,19 +110,6 @@ mod tests { builder.body(Body::empty()).expect("request") } - #[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, &[])); - } - /// One test rather than separate gating and logging tests, /// deliberately: tracing caches callsite interest process-wide, so a /// test that hits the rejection path with no subscriber installed can diff --git a/server/src/certs.rs b/server/src/certs.rs deleted file mode 100644 index c6e28be..0000000 --- a/server/src/certs.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! The TLS certificates this server presents, generated in process on -//! first start. -//! -//! There used to be a `gen-dev-cert.sh` calling openssl, which meant a -//! setup step to remember, a second place for the "which SANs?" answer to -//! live, and a dependency on whatever openssl was installed. Doing it here -//! means the server can simply ensure its own certificates exist, with the -//! file modes and extensions it wants, and with the address it is actually -//! about to bind already in the leaf. -//! -//! The split that matters is between the two: -//! -//! - The **CA** is generated once and then left alone. The app -//! pins it, so replacing it strands every installed copy -- recovery is -//! a reinstall over the plain-HTTP bootstrap port. It is the one thing -//! here that is a one-way door. -//! - The **leaf** is cheap and reissued on every start, signed by that -//! same unchanged CA. Nothing pins it, so covering a new address is just -//! a restart rather than anything the phone has to be told about. -//! -//! Everything is written owner-only into a directory outside the repo (see -//! `config_home`): the repo is a mount shared with a VM that is not -//! trusted, and a CA private key that VM can read is one it can sign with -//! -- a certificate signed by a pinned CA is accepted without question, -//! which is exactly the attack pinning exists to stop. - -use std::net::IpAddr; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use rcgen::{ - BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType, -}; - -use crate::private; - -/// Where the leaf lives, for handing to the TLS listener. -pub struct Certificates { - pub leaf_cert: PathBuf, - pub leaf_key: PathBuf, - /// True when the CA was created just now, i.e. anything already - /// installed pins the wrong one and has to be reinstalled. - pub ca_is_new: bool, -} - -/// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what -/// is missing. Safe to call on every start. -pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result { - private::create_dir(dir)?; - - let ca_cert_path = dir.join("ca.pem"); - let ca_key_path = dir.join("ca-key.pem"); - let ca_is_new = !ca_cert_path.is_file() || !ca_key_path.is_file(); - - let (ca_pem, ca_key_pem) = if ca_is_new { - let (pem, key) = generate_ca()?; - private::write_file(&ca_key_path, key.as_bytes())?; - private::write_file(&ca_cert_path, pem.as_bytes())?; - tracing::info!("generated a new CA in {}", dir.display()); - (pem, key) - } else { - ( - std::fs::read_to_string(&ca_cert_path) - .with_context(|| format!("read {}", ca_cert_path.display()))?, - std::fs::read_to_string(&ca_key_path) - .with_context(|| format!("read {}", ca_key_path.display()))?, - ) - }; - - let (leaf_pem, leaf_key_pem) = generate_leaf(&ca_pem, &ca_key_pem, addresses)?; - let leaf_cert = dir.join("leaf.pem"); - let leaf_key = dir.join("leaf-key.pem"); - private::write_file(&leaf_key, leaf_key_pem.as_bytes())?; - private::write_file(&leaf_cert, leaf_pem.as_bytes())?; - - Ok(Certificates { - leaf_cert, - leaf_key, - ca_is_new, - }) -} - -fn generate_ca() -> Result<(String, String)> { - let key = KeyPair::generate().context("generate CA key")?; - let mut params = CertificateParams::default(); - params - .distinguished_name - .push(DnType::OrganizationName, "ai-app dev"); - params - .distinguished_name - .push(DnType::CommonName, "ai-app dev CA"); - params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); - // Explicit, because strict verifiers reject a CA without them -- and - // that rejection surfaces as an opaque handshake failure on a phone. - params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; - let certificate = params.self_signed(&key).context("self-sign CA")?; - Ok((certificate.pem(), key.serialize_pem())) -} - -fn generate_leaf(ca_pem: &str, ca_key_pem: &str, addresses: &[IpAddr]) -> Result<(String, String)> { - let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?; - let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?; - - let key = KeyPair::generate().context("generate leaf key")?; - let mut params = CertificateParams::default(); - params - .distinguished_name - .push(DnType::OrganizationName, "ai-app dev"); - params.distinguished_name.push( - DnType::CommonName, - addresses - .first() - .map(|a| a.to_string()) - .unwrap_or_else(|| "ai-app".to_string()), - ); - params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect(); - params.is_ca = IsCa::ExplicitNoCa; - params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; - params.use_authority_key_identifier_extension = true; - let certificate = params.signed_by(&key, &issuer).context("sign leaf")?; - Ok((certificate.pem(), key.serialize_pem())) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::os::unix::fs::PermissionsExt; - - fn addresses() -> Vec { - vec!["10.66.0.1".parse().unwrap(), "127.0.0.1".parse().unwrap()] - } - - #[test] - fn generates_once_then_keeps_the_ca_and_reissues_the_leaf() { - let dir = tempfile::tempdir().expect("tempdir"); - - let first = ensure(dir.path(), &addresses()).expect("generate"); - assert!(first.ca_is_new); - let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca"); - let leaf = std::fs::read_to_string(&first.leaf_cert).expect("leaf"); - assert!(ca.starts_with("-----BEGIN CERTIFICATE-----")); - - let second = ensure(dir.path(), &addresses()).expect("regenerate"); - // The CA is the pinned one: replacing it would strand every - // installed app, so it must survive a restart untouched. - assert!(!second.ca_is_new); - assert_eq!( - ca, - std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca") - ); - // The leaf is not pinned, and is reissued so a new address is just - // a restart away. - assert_ne!( - leaf, - std::fs::read_to_string(&second.leaf_cert).expect("leaf") - ); - } - - #[test] - fn everything_is_owner_only() { - let dir = tempfile::tempdir().expect("tempdir"); - let certs = ensure(dir.path(), &addresses()).expect("generate"); - assert_eq!( - std::fs::metadata(dir.path()) - .expect("dir") - .permissions() - .mode() - & 0o777, - 0o700, - ); - for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] { - let mode = std::fs::metadata(dir.path().join(file)) - .expect(file) - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o600, "{file} is not owner-only"); - } - assert!(certs.leaf_key.is_file()); - } - - /// The pair has to be loadable by the TLS stack that will actually - /// serve it -- a "file exists" check wouldn't catch a key that doesn't - /// match its certificate, which fails at the first handshake instead. - #[tokio::test] - async fn the_leaf_loads_into_the_real_tls_config() { - // main() installs this; tests don't run main. Both rustls crypto - // providers are in the graph (ureq brings ring, axum-server - // aws-lc-rs), so rustls refuses to pick one on its own. Ignoring - // the result because another test may have installed it first. - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - - let dir = tempfile::tempdir().expect("tempdir"); - let certs = ensure(dir.path(), &addresses()).expect("generate"); - axum_server::tls_rustls::RustlsConfig::from_pem_file(&certs.leaf_cert, &certs.leaf_key) - .await - .expect("the generated leaf and key should load as a TLS identity"); - } -} diff --git a/server/src/config.rs b/server/src/config.rs index 5cd8d22..d1d2696 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -7,9 +7,10 @@ //! funnels through `SessionManager` (the registry pattern), so in-memory //! and on-disk state can't come apart. //! -//! The file is RON, in the shape the [`mod@format`] module describes -- the +//! The file is RON, in the shape [`wg_app_link::format`] describes -- the //! same format, and the same two house rules, as the sibling dev-updater -//! project's config, because both are written and read by hand. +//! project's config, because both are written and read by hand, and both +//! now read and write them through the one module. //! //! Transcripts do NOT live here -- each session's events are an append-only //! JSONL file in its own directory (see `session::transcript`); this file @@ -21,64 +22,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use crate::private; - -/// Reading and writing the RON this file is in. -/// -/// Two things are house rules rather than plain RON, and they are here -/// together because they are inverses of each other -- change one and the -/// other stops round-tripping. -/// -/// **No outer parentheses.** A file *is* the body of the struct, so nothing -/// in it is indented for the sake of a wrapper. RON has no implicit -/// top-level struct (`de/mod.rs` requires the `(`), so [`format::parse`] -/// adds it and [`format::render`] takes it back off. The opening paren is -/// not followed by a newline, so a parse error's line number still points -/// at the real line. -/// -/// **`Some` is implicit.** Enabled on the deserializer rather than by a -/// `#![enable(implicit_some)]` header the file would have to carry, and -/// matched on the writing side by `skip_serializing_if` on every optional -/// field so nothing writes back a `Some(...)` a person didn't type. -pub(crate) mod format { - use serde::{Serialize, de::DeserializeOwned}; - - fn options() -> ron::Options { - ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME) - } - - pub fn parse(text: &str) -> Result { - options().from_str(&format!("({text})")) - } - - pub fn render(value: &T) -> Result { - let pretty = ron::ser::PrettyConfig::new(); - let text = options().to_string_pretty(value, pretty)?; - Ok(unwrap_outer(&text)) - } - - /// Strips the outer `(`/`)` the writer always emits and removes the - /// indent level they cost. Deliberately narrow: it accepts only the - /// exact shape `PrettyConfig` produces, and leaves anything else alone - /// rather than guessing -- a file with stray parentheses is better than - /// one silently mangled. `parse` round-trips either way, since a - /// wrapped body parses the same as an unwrapped one re-wrapped. - fn unwrap_outer(text: &str) -> String { - let Some(body) = text - .strip_prefix("(\n") - .and_then(|rest| rest.strip_suffix("\n)")) - else { - return text.to_string(); - }; - let mut out: String = body - .lines() - .map(|line| line.strip_prefix(" ").unwrap_or(line)) - .collect::>() - .join("\n"); - out.push('\n'); - out - } -} +use wg_app_link::{format, private}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] diff --git a/server/src/main.rs b/server/src/main.rs index 1b55a20..744d895 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,11 +14,9 @@ //! unencrypted by misconfiguration -- even inside the tunnel. mod auth; -mod certs; mod config; mod media; mod models; -mod private; mod routes; mod session; mod setups; @@ -33,11 +31,13 @@ use anyhow::{Context, Result}; use clap::Parser; use tokio::signal::unix::{SignalKind, signal}; +use wg_app_link::enroll; +use wg_app_link::netif::{self, WG_INTERFACE}; + use config::TokenEntry; use session::SessionManager; const DEFAULT_PORT: u16 = 8443; -const WG_INTERFACE: &str = "wg0"; /// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.ron` /// and `certs/`. @@ -107,68 +107,6 @@ struct Args { rotate_token: bool, } -/// Every address this machine answers on, for the leaf's SANs -- so the -/// certificate covers whatever the phone actually dials without anyone -/// maintaining a hardcoded IP. In production that is the WireGuard -/// address; loopback is included for curl and tests, and 10.0.2.2 is the -/// alias an Android emulator reaches its host by, which is not a real -/// interface anywhere. -fn local_addresses() -> Vec { - let mut addresses = vec![IpAddr::from([127, 0, 0, 1]), IpAddr::from([10, 0, 2, 2])]; - 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); - } - } - } - // Not fatal: the certificate still covers loopback, which is - // enough to start and to diagnose from the machine itself. - 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 here (rather than falling back to a wider bind) is part -/// of the security posture -- see the module doc comment. -fn wg_address() -> 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} -- this server binds only to the \ - WireGuard tunnel and refuses to fall back to a wider address. Bring the tunnel \ - up, or pass --bind explicitly for development." - ) - }) -} - -/// Prints the one-time enrollment QR: an `aiapp://enroll` URI carrying -/// where to connect and the bearer token. The CA stays embedded in the APK, -/// so this carries no trust material -- photographing the terminal leaks -/// only the token, which is rotatable (`--rotate-token`). Printed to -/// stdout, not the log: it is for the human at the terminal, once. -fn print_enrollment(host: IpAddr, port: u16, token: &str) -> Result<()> { - let uri = format!("aiapp://enroll?host={host}&port={port}&token={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 `--rotate-token`.\n"); - Ok(()) -} - #[tokio::main] async fn main() -> Result<()> { // Both rustls crypto providers are in the dependency graph (ureq @@ -226,7 +164,7 @@ async fn main() -> Result<()> { // obtainable on a machine whose tunnel isn't up yet. The leaf is // reissued on every start, so once wg0 exists the next start covers it. let certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs")); - let certificates = certs::ensure(&certs_dir, &local_addresses()) + let certificates = wg_app_link::certs::ensure("ai-app", &certs_dir, &netif::local_addresses()) .with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?; if certificates.ca_is_new { tracing::warn!( @@ -244,7 +182,7 @@ async fn main() -> Result<()> { ); ip } - None => wg_address()?, + None => netif::wg_address("ai-server")?, }; // Token bootstrap: first run generates one; --rotate-token replaces @@ -252,15 +190,15 @@ async fn main() -> Result<()> { // the QR printed here. if args.rotate_token || manager.tokens().is_empty() { let rotating = args.rotate_token && !manager.tokens().is_empty(); - let token = auth::generate_token(); + let token = enroll::generate_token(); manager.set_tokens(vec![TokenEntry { name: "phone".to_string(), - sha256: auth::token_hash_hex(&token), + sha256: enroll::token_hash_hex(&token), }])?; if rotating { tracing::info!("rotated the enrolled token; the previous one is now invalid"); } - print_enrollment(bind_ip, args.port, &token)?; + enroll::print_enrollment("aiapp", bind_ip, args.port, &token)?; } let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file( diff --git a/server/src/models.rs b/server/src/models.rs index 77f1cd0..6f3ae7a 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -33,7 +33,7 @@ use std::sync::{Arc, Mutex}; use anyhow::{Context, Result, bail}; use serde::Serialize; -use crate::private; +use wg_app_link::private; /// Identifies this client to HuggingFace. They ask for one, and a request /// without it is more likely to be rate-limited. @@ -471,8 +471,8 @@ fn sha256_of(path: &Path) -> Result { } hasher.update(&buffer[..read]); } - // Hex the same way auth.rs does, since this sha2 version's output - // type does not implement LowerHex. + // Hex by hand, as wg_app_link::enroll::token_hash_hex also has to, + // since this sha2 version's output type does not implement LowerHex. Ok(hasher .finalize() .iter() diff --git a/server/src/private.rs b/server/src/private.rs deleted file mode 100644 index 22bd891..0000000 --- a/server/src/private.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Creating files and directories this server alone can read. -//! -//! Everything the server writes outside the repo goes through here: the -//! config (token hashes, hosts, sessions), the TLS private keys, and the -//! session directories holding whole transcripts. 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`. - -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. -pub fn create_dir(dir: &Path) -> Result<()> { - std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(dir) - .with_context(|| format!("create {}", dir.display()))?; - // Set explicitly as well: `mode` applies only when the directory is - // created, so one that already existed -- made by hand, or by a - // version that didn't do this -- would otherwise keep whatever - // permissions it had while holding secrets. - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) - .with_context(|| format!("restrict {}", dir.display())) -} - -/// Opens `path` for writing, truncating it, owner-readable only. -/// -/// `mode` covers the file this call creates; [`restrict`] covers the one -/// that was already there. Both are needed, and the second is the one that -/// is easy to miss: the leaf certificate's private key is rewritten on -/// every start, so a key that ever existed with loose permissions would -/// keep them for the rest of its life. -pub fn create_file(path: &Path) -> Result { - let file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path) - .with_context(|| format!("write {}", path.display()))?; - restrict(&file, path)?; - Ok(file) -} - -/// Narrows an already-open file to owner-only. -/// -/// Through the handle rather than the path, deliberately: `set_permissions` -/// on a path re-resolves it, so between opening and chmod-ing something -/// could put a different file -- or a symlink to one -- where this one was, -/// and the mode would land on that instead. The handle cannot be -/// redirected. -fn restrict(file: &File, path: &Path) -> Result<()> { - file.set_permissions(std::fs::Permissions::from_mode(0o600)) - .with_context(|| format!("restrict {}", path.display())) -} - -/// Writes `contents` to `path`, owner-readable only. -pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> { - use std::io::Write; - create_file(path)? - .write_all(contents) - .with_context(|| format!("write {}", path.display())) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - /// The case `mode` alone does not cover: a file that already exists - /// keeps whatever permissions it had, because `mode` applies only to a - /// file this call creates. `create_dir` three functions up has carried - /// a comment about exactly this since it was written; the file path - /// did not, until dev-updater's session read the module as a unit and - /// noticed the same line was wrong twice. - fn rewriting_a_file_narrows_permissions_it_did_not_set() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("secret"); - - // As an older version, or a hand-edit, might have left it. - std::fs::write(&path, b"old").expect("plant"); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("loosen"); - - write_file(&path, b"new").expect("rewrite"); - let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777; - assert_eq!(mode, 0o600, "rewriting left it at {mode:o}"); - } - - #[test] - fn a_new_file_is_owner_only_from_the_start() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("fresh"); - write_file(&path, b"x").expect("write"); - let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - } - - #[test] - fn an_existing_directory_is_narrowed_too() { - let dir = tempfile::tempdir().expect("tempdir"); - let nested = dir.path().join("state"); - std::fs::create_dir(&nested).expect("create"); - std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o755)).expect("loosen"); - create_dir(&nested).expect("recreate"); - let mode = std::fs::metadata(&nested) - .expect("stat") - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o700, "left at {mode:o}"); - } -} diff --git a/server/src/session/claude/translate.rs b/server/src/session/claude/translate.rs index 3ac8baa..7d05868 100644 --- a/server/src/session/claude/translate.rs +++ b/server/src/session/claude/translate.rs @@ -341,7 +341,7 @@ impl Translator { .unwrap_or("png"); let name = format!("{}.{extension}", super::super::random_hex()); let dir = self.session_dir.join("files"); - if let Err(err) = crate::private::create_dir(&dir) + if let Err(err) = wg_app_link::private::create_dir(&dir) .map_err(std::io::Error::other) .and_then(|()| std::fs::write(dir.join(&name), bytes)) { diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index cae987e..c2ad10e 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -167,7 +167,7 @@ impl LiveSession { let extension = crate::media::extension_for(content_type).unwrap_or("jpg"); let name = format!("{}.{extension}", random_hex()); let dir = self.dir().join("attachments"); - crate::private::create_dir(&dir)?; + wg_app_link::private::create_dir(&dir)?; std::fs::write(dir.join(&name), bytes) .with_context(|| format!("write attachment {name}"))?; Ok(name) @@ -216,7 +216,7 @@ impl SessionManager { /// session spawns its event pump). pub fn new(config_path: PathBuf, data_dir: PathBuf, models_dir: PathBuf) -> Result { let config = Config::load(&config_path)?; - crate::private::create_dir(&data_dir)?; + wg_app_link::private::create_dir(&data_dir)?; let mut live = HashMap::new(); for meta in &config.sessions { @@ -625,7 +625,7 @@ fn launch( models_dir: &Path, ) -> Result> { let dir = data_dir.join(&meta.id); - crate::private::create_dir(&dir)?; + wg_app_link::private::create_dir(&dir)?; let transcript_path = dir.join("transcript.jsonl"); let transcript = Transcript::open(&transcript_path)?; diff --git a/wg-app-link b/wg-app-link new file mode 160000 index 0000000..841a3a8 --- /dev/null +++ b/wg-app-link @@ -0,0 +1 @@ +Subproject commit 841a3a8372d62b1069dd0b54e0056e66e7f2410f