Generate the TLS certificates in process
gen-dev-cert.sh is gone. The server ensures its own certificates on start, which removes a setup step to remember, a dependency on whatever openssl was installed, and a second place for the "which addresses?" answer to live -- the leaf now covers every local IPv4 plus loopback and the emulator's host alias, so nobody maintains a hardcoded IP. The split that mattered in the script is kept and now enforced by tests: the CA is generated once and left alone, because the app pins it and replacing it strands every installed copy; the leaf is cheap and reissued every start, so covering a new address is a restart. Both are written owner-only into a directory outside the repo. Two things the tests caught. DirBuilder's mode applies only when the directory is created, so a directory that already existed kept whatever permissions it had while holding a private key -- the mode is now set explicitly, in the session directories too. And loading the leaf into the real RustlsConfig needs the crypto provider installed, which main does but tests don't. Verified end to end: deleted the certs, started the server, watched it generate a CA and warn that installed apps now pin the wrong one, rebuilt the APK against the new CA, and reinstalled -- the emulator connects over a certificate that never existed as a pasted constant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
eeb2dde4ab
commit
65743b899d
8 files changed
+550
-175
No files matched your search
+50
-18
@@ -14,6 +14,7 @@
|
||||
//! unencrypted by misconfiguration -- even inside the tunnel.
|
||||
|
||||
mod auth;
|
||||
mod certs;
|
||||
mod config;
|
||||
mod routes;
|
||||
mod session;
|
||||
@@ -24,7 +25,7 @@ use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
use config::TokenEntry;
|
||||
@@ -79,8 +80,8 @@ struct Args {
|
||||
#[arg(long)]
|
||||
data_dir: Option<PathBuf>,
|
||||
|
||||
/// Directory holding `leaf.pem`/`leaf-key.pem`, as produced by
|
||||
/// `gen-dev-cert.sh`. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
|
||||
/// Directory holding the TLS certificates, generated here on first
|
||||
/// start. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
|
||||
#[arg(long)]
|
||||
certs: Option<PathBuf>,
|
||||
|
||||
@@ -90,6 +91,30 @@ 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.
|
||||
@@ -157,6 +182,22 @@ async fn main() -> Result<()> {
|
||||
tracing::info!(" session {} ({}, {:?})", info.id, info.provider, info.status);
|
||||
}
|
||||
|
||||
// Before the interface check below, deliberately: the certificates are
|
||||
// also what the phone app embeds at build time, so they need to be
|
||||
// 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())
|
||||
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
|
||||
if certificates.ca_is_new {
|
||||
tracing::warn!(
|
||||
"a new CA was generated in {} -- any installed app pins the previous one and can no \
|
||||
longer reach this server. Rebuild it with app/build-apk.sh, which embeds this CA, \
|
||||
and reinstall through Local Updater.",
|
||||
certs_dir.display(),
|
||||
);
|
||||
}
|
||||
|
||||
let bind_ip = match args.bind {
|
||||
Some(ip) => {
|
||||
tracing::warn!(
|
||||
@@ -183,21 +224,12 @@ async fn main() -> Result<()> {
|
||||
print_enrollment(bind_ip, args.port, &token)?;
|
||||
}
|
||||
|
||||
let certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs"));
|
||||
let leaf_cert = certs_dir.join("leaf.pem");
|
||||
let leaf_key = certs_dir.join("leaf-key.pem");
|
||||
if !leaf_cert.is_file() || !leaf_key.is_file() {
|
||||
bail!(
|
||||
"missing {} / {} -- run ./gen-dev-cert.sh on this machine first. The app pins the CA \
|
||||
it generates and this server refuses to serve without TLS, so the private keys must \
|
||||
be generated here and stay here.",
|
||||
leaf_cert.display(),
|
||||
leaf_key.display(),
|
||||
);
|
||||
}
|
||||
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&leaf_cert, &leaf_key)
|
||||
.await
|
||||
.context("failed to load TLS cert/key")?;
|
||||
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
|
||||
&certificates.leaf_cert,
|
||||
&certificates.leaf_key,
|
||||
)
|
||||
.await
|
||||
.context("failed to load TLS cert/key")?;
|
||||
|
||||
let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new(usage::ClaudeUsage {
|
||||
credentials_path: std::env::home_dir()
|
||||
|
||||
Reference in new issue
Block a user