Take the link from wg-app-link instead of keeping a second copy

The five modules underneath this backend that were never about AI
sessions -- the pinned CA and leaf, QR enrollment and the bearer token,
wg0 binding and the certificate's SANs, owner-only files, and the RON
house rules -- were written twice, once here and once in dev-updater,
and had drifted. They now come from the submodule, as a path dependency
so both projects stay locked to one commit.

What stayed is what makes this project itself: the routes, the drivers,
the config schema, and the auth middleware, which is generic over this
server's state. Sharing a transport is worth doing; sharing an API would
mean inventing a vocabulary neither project wants.

Four dependencies go with the code -- rcgen, qrcode, subtle and if-addrs
are no longer named here at all -- and the three that remain are now
described by what still uses them rather than by what used to.

Verified by running it, not only by building: a fresh server generates
its CA, prints an `aiapp://enroll` QR with the scheme now passed as a
parameter, covers 127.0.0.1, 10.0.2.2 and wg0's 10.66.0.1 in the leaf,
answers an enrolled token and returns 401 without one, and writes
config.ron in the house rules with every file owner-only. 36 tests pass,
clippy is silent, rustfmt is clean.
This commit is contained in:
iris committed 2026-08-28 17:14:33 -04:00
1 parent a83dbcff6a
commit aa05ff9336
13 files changed
+76 -526

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
[submodule "wg-app-link"]
path = wg-app-link
url = git@git.arirex.me:iris/wg-app-link.git
+21 -7
View File
@@ -39,7 +39,7 @@ repo is in PLAN.md's "Backend layout" section.
- `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the - `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the
auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP
table in its module doc comment, `auth.rs` the bearer-token middleware, 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, `session/` the manager (registry pattern), `Driver` trait + event model,
`EchoDriver`, and transcripts. `EchoDriver`, and transcripts.
- `app/` — Compose Android app, single `:androidApp` module, package - `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 `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 project it serves is the repository, not either half of it, which is why
this sits at the root rather than in `app/`. this sits at the root rather than in `app/`.
- `server/src/certs.rs` — the TLS certificates, generated in process on - `wg-app-link/` — a **git submodule**, and the half of this backend that
first start into `$XDG_CONFIG_HOME/ai-app/certs`: idempotent CA, leaf dev-updater also needed: the pinned CA and leaf (`certs`), QR enrollment
reissued every start covering every local IPv4 plus 127.0.0.1 and and the bearer token (`enroll`), wg0 binding and the certificate's SANs
10.0.2.2 (emulator → host). Regenerating the CA strands the installed (`netif`), owner-only files (`private`), and the RON house rules
app — the one-way door. (`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 ## Status
@@ -129,7 +142,8 @@ mangles an argument.
## Checking your work ## 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 `cargo clippy --all-targets` + `cargo fmt`. The build stays
warning-clean and rustfmt-clean at the defaults — there is no warning-clean and rustfmt-clean at the defaults — there is no
`rustfmt.toml` and there should not be one. `rustfmt.toml` and there should not be one.
+18 -4
View File
@@ -26,16 +26,12 @@ dependencies = [
"axum-server", "axum-server",
"base64 0.23.1", "base64 0.23.1",
"clap", "clap",
"if-addrs",
"qrcode",
"rand", "rand",
"rcgen",
"ron", "ron",
"rustls", "rustls",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"subtle",
"tempfile", "tempfile",
"thiserror", "thiserror",
"tokio", "tokio",
@@ -44,6 +40,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"ureq", "ureq",
"wg-app-link",
] ]
[[package]] [[package]]
@@ -1868,6 +1865,23 @@ dependencies = [
"rustls-pki-types", "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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
+11 -19
View File
@@ -8,6 +8,11 @@ name = "ai-server"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [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 = { version = "0.8", features = ["json", "multipart"] }
axum-server = { version = "0.8", features = ["tls-rustls"] } axum-server = { version = "0.8", features = ["tls-rustls"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "process", "io-util", "signal"] } 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 = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# The config file's format. Not JSON, because this file is written and read # 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 # by hand and RON says a sum type as syntax. The two house rules both
# house rules, as the sibling dev-updater project's config. # 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" ron = "0.12.2"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
anyhow = "1" anyhow = "1"
thiserror = "2" thiserror = "2"
# Token auth: hash for storage, constant-time compare for verification, # Verifying a downloaded model against HuggingFace's published digest.
# CSPRNG-backed generation, base64url for the enrollment string.
sha2 = "0.11" sha2 = "0.11"
subtle = "2" # Naming a session directory, and an attachment inside one.
rand = "0.10" rand = "0.10"
# Decoding the images a phone attaches, and encoding them for a driver.
base64 = "0.23" 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 # Outbound HTTPS for the usage endpoint. A small blocking client fits an
# every-few-minutes poll better than pulling in reqwest's tower stack; # every-few-minutes poll better than pulling in reqwest's tower stack;
# rustls-backed like the rest of the TLS here. # rustls-backed like the rest of the TLS here.
+3 -45
View File
@@ -21,9 +21,7 @@ use axum::extract::{ConnectInfo, Request, State};
use axum::http::{StatusCode, header}; use axum::http::{StatusCode, header};
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use base64::Engine; use wg_app_link::enroll::token_matches;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use crate::session::SessionManager; use crate::session::SessionManager;
@@ -32,35 +30,6 @@ use crate::session::SessionManager;
/// drip rather than a fast one. /// drip rather than a fast one.
const REJECT_DELAY: Duration = Duration::from_millis(300); 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( pub async fn require_token(
State(manager): State<Arc<SessionManager>>, State(manager): State<Arc<SessionManager>>,
request: Request, request: Request,
@@ -104,6 +73,8 @@ mod tests {
use axum::routing::get; use axum::routing::get;
use tower::ServiceExt; use tower::ServiceExt;
use wg_app_link::enroll::{generate_token, token_hash_hex};
use crate::config::TokenEntry; use crate::config::TokenEntry;
fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> { fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
@@ -139,19 +110,6 @@ mod tests {
builder.body(Body::empty()).expect("request") 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, /// One test rather than separate gating and logging tests,
/// deliberately: tracing caches callsite interest process-wide, so a /// deliberately: tracing caches callsite interest process-wide, so a
/// test that hits the rejection path with no subscriber installed can /// test that hits the rejection path with no subscriber installed can
-198
View File
@@ -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<Certificates> {
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<IpAddr> {
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");
}
}
+4 -60
View File
@@ -7,9 +7,10 @@
//! funnels through `SessionManager` (the registry pattern), so in-memory //! funnels through `SessionManager` (the registry pattern), so in-memory
//! and on-disk state can't come apart. //! 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 //! 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 //! Transcripts do NOT live here -- each session's events are an append-only
//! JSONL file in its own directory (see `session::transcript`); this file //! 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 anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::private; use wg_app_link::{format, 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<T: DeserializeOwned>(text: &str) -> Result<T, ron::error::SpannedError> {
options().from_str(&format!("({text})"))
}
pub fn render<T: Serialize>(value: &T) -> Result<String, ron::Error> {
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::<Vec<_>>()
.join("\n");
out.push('\n');
out
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
+8 -70
View File
@@ -14,11 +14,9 @@
//! unencrypted by misconfiguration -- even inside the tunnel. //! unencrypted by misconfiguration -- even inside the tunnel.
mod auth; mod auth;
mod certs;
mod config; mod config;
mod media; mod media;
mod models; mod models;
mod private;
mod routes; mod routes;
mod session; mod session;
mod setups; mod setups;
@@ -33,11 +31,13 @@ use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
use tokio::signal::unix::{SignalKind, signal}; use tokio::signal::unix::{SignalKind, signal};
use wg_app_link::enroll;
use wg_app_link::netif::{self, WG_INTERFACE};
use config::TokenEntry; use config::TokenEntry;
use session::SessionManager; use session::SessionManager;
const DEFAULT_PORT: u16 = 8443; const DEFAULT_PORT: u16 = 8443;
const WG_INTERFACE: &str = "wg0";
/// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.ron` /// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.ron`
/// and `certs/`. /// and `certs/`.
@@ -107,68 +107,6 @@ struct Args {
rotate_token: bool, 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<IpAddr> {
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<IpAddr> {
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 <ip> 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::<qrcode::render::unicode::Dense1x2>()
.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] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Both rustls crypto providers are in the dependency graph (ureq // 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 // 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. // 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 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()))?; .with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
if certificates.ca_is_new { if certificates.ca_is_new {
tracing::warn!( tracing::warn!(
@@ -244,7 +182,7 @@ async fn main() -> Result<()> {
); );
ip ip
} }
None => wg_address()?, None => netif::wg_address("ai-server")?,
}; };
// Token bootstrap: first run generates one; --rotate-token replaces // Token bootstrap: first run generates one; --rotate-token replaces
@@ -252,15 +190,15 @@ async fn main() -> Result<()> {
// the QR printed here. // the QR printed here.
if args.rotate_token || manager.tokens().is_empty() { if args.rotate_token || manager.tokens().is_empty() {
let rotating = 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 { manager.set_tokens(vec![TokenEntry {
name: "phone".to_string(), name: "phone".to_string(),
sha256: auth::token_hash_hex(&token), sha256: enroll::token_hash_hex(&token),
}])?; }])?;
if rotating { if rotating {
tracing::info!("rotated the enrolled token; the previous one is now invalid"); 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( let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
+3 -3
View File
@@ -33,7 +33,7 @@ use std::sync::{Arc, Mutex};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use serde::Serialize; use serde::Serialize;
use crate::private; use wg_app_link::private;
/// Identifies this client to HuggingFace. They ask for one, and a request /// Identifies this client to HuggingFace. They ask for one, and a request
/// without it is more likely to be rate-limited. /// without it is more likely to be rate-limited.
@@ -471,8 +471,8 @@ fn sha256_of(path: &Path) -> Result<String> {
} }
hasher.update(&buffer[..read]); hasher.update(&buffer[..read]);
} }
// Hex the same way auth.rs does, since this sha2 version's output // Hex by hand, as wg_app_link::enroll::token_hash_hex also has to,
// type does not implement LowerHex. // since this sha2 version's output type does not implement LowerHex.
Ok(hasher Ok(hasher
.finalize() .finalize()
.iter() .iter()
-116
View File
@@ -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<File> {
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}");
}
}
+1 -1
View File
@@ -341,7 +341,7 @@ impl Translator {
.unwrap_or("png"); .unwrap_or("png");
let name = format!("{}.{extension}", super::super::random_hex()); let name = format!("{}.{extension}", super::super::random_hex());
let dir = self.session_dir.join("files"); 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) .map_err(std::io::Error::other)
.and_then(|()| std::fs::write(dir.join(&name), bytes)) .and_then(|()| std::fs::write(dir.join(&name), bytes))
{ {
+3 -3
View File
@@ -167,7 +167,7 @@ impl LiveSession {
let extension = crate::media::extension_for(content_type).unwrap_or("jpg"); let extension = crate::media::extension_for(content_type).unwrap_or("jpg");
let name = format!("{}.{extension}", random_hex()); let name = format!("{}.{extension}", random_hex());
let dir = self.dir().join("attachments"); 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) std::fs::write(dir.join(&name), bytes)
.with_context(|| format!("write attachment {name}"))?; .with_context(|| format!("write attachment {name}"))?;
Ok(name) Ok(name)
@@ -216,7 +216,7 @@ impl SessionManager {
/// session spawns its event pump). /// session spawns its event pump).
pub fn new(config_path: PathBuf, data_dir: PathBuf, models_dir: PathBuf) -> Result<Self> { pub fn new(config_path: PathBuf, data_dir: PathBuf, models_dir: PathBuf) -> Result<Self> {
let config = Config::load(&config_path)?; 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(); let mut live = HashMap::new();
for meta in &config.sessions { for meta in &config.sessions {
@@ -625,7 +625,7 @@ fn launch(
models_dir: &Path, models_dir: &Path,
) -> Result<Arc<LiveSession>> { ) -> Result<Arc<LiveSession>> {
let dir = data_dir.join(&meta.id); 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_path = dir.join("transcript.jsonl");
let transcript = Transcript::open(&transcript_path)?; let transcript = Transcript::open(&transcript_path)?;
Submodule
+1
Submodule wg-app-link added at 841a3a8372.