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:
1 parent
a83dbcff6a
commit
aa05ff9336
13 files changed
+76
-526
No files matched your search
+8
-70
@@ -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<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]
|
||||
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(
|
||||
|
||||
Reference in new issue
Block a user