use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;
use axum::{
extract::{ConnectInfo, Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::Deserialize;
use serde_json::json;
use synaps_cli::auth;
const ALLOWED_PROVIDERS: &[&str] = &["anthropic", "openai-codex"];
#[derive(Clone, Debug)]
pub struct TlsConfig {
pub cert_pem: Vec<u8>,
pub key_pem: Vec<u8>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum BindDecision {
Http {
warn: bool,
},
Tls,
Refuse(String),
}
pub fn check_bind_policy(is_loopback: bool, has_tls: bool, insecure_http: bool) -> BindDecision {
if has_tls {
return BindDecision::Tls;
}
if is_loopback {
return BindDecision::Http { warn: false };
}
if insecure_http {
return BindDecision::Http { warn: true };
}
BindDecision::Refuse(
"refusing to start: non-loopback bind without TLS. \
Pass --tls-cert + --tls-key to enable HTTPS (recommended), \
or --insecure-http to acknowledge you are running behind WireGuard \
/ a private network and accept plaintext."
.to_string(),
)
}
pub fn load_and_validate_tls(
cert_path: &PathBuf,
key_path: &PathBuf,
) -> anyhow::Result<TlsConfig> {
let cert_pem = std::fs::read(cert_path).map_err(|e| {
anyhow::anyhow!("--tls-cert '{}': {e}", cert_path.display())
})?;
let key_pem = std::fs::read(key_path).map_err(|e| {
anyhow::anyhow!("--tls-key '{}': {e}", key_path.display())
})?;
validate_pem_pair(&cert_pem, &key_pem)?;
Ok(TlsConfig { cert_pem, key_pem })
}
pub fn validate_pem_pair(cert_pem: &[u8], key_pem: &[u8]) -> anyhow::Result<()> {
use rustls_pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer};
let certs: Vec<CertificateDer<'_>> =
CertificateDer::pem_slice_iter(cert_pem)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| anyhow::anyhow!("TLS cert PEM is malformed: {e}"))?;
if certs.is_empty() {
anyhow::bail!("TLS cert PEM contains no certificates (empty or wrong format)");
}
let keys: Vec<PrivateKeyDer<'_>> =
PrivateKeyDer::pem_slice_iter(key_pem)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| anyhow::anyhow!("TLS key PEM is malformed: {e}"))?;
if keys.is_empty() {
anyhow::bail!("TLS key PEM contains no private key (empty or wrong format)");
}
if keys.len() > 1 {
anyhow::bail!("TLS key PEM contains multiple private keys; it must contain exactly one");
}
Ok(())
}
#[derive(Clone)]
struct BrokerState {
machine_token: Option<String>,
client: reqwest::Client,
}
#[derive(Deserialize)]
struct TokenQuery {
provider: Option<String>,
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[allow(clippy::too_many_arguments)]
pub async fn run(
bind: String,
machine_token: Option<String>,
machine_token_file: Option<PathBuf>,
insecure_no_auth: bool,
tls_cert: Option<PathBuf>,
tls_key: Option<PathBuf>,
insecure_http: bool,
) -> anyhow::Result<()> {
let tls = match (tls_cert, tls_key) {
(Some(cert), Some(key)) => {
let cfg = load_and_validate_tls(&cert, &key)?;
Some(cfg)
}
(Some(_), None) => anyhow::bail!(
"--tls-cert was given but --tls-key is missing. Provide both to enable TLS."
),
(None, Some(_)) => anyhow::bail!(
"--tls-key was given but --tls-cert is missing. Provide both to enable TLS."
),
(None, None) => None,
};
let machine_token = match machine_token {
Some(t) => Some(t),
None => match machine_token_file {
Some(ref path) => Some(
std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("could not read --machine-token-file {}: {e}", path.display()))?
.trim()
.to_string(),
),
None => std::env::var("SYNAPS_BROKER_TOKEN").ok(),
},
}
.filter(|s| !s.trim().is_empty());
let addr: SocketAddr = bind
.parse()
.map_err(|e| anyhow::anyhow!("invalid --bind '{}': {}", bind, e))?;
let decision = check_bind_policy(addr.ip().is_loopback(), tls.is_some(), insecure_http);
match &decision {
BindDecision::Refuse(msg) => anyhow::bail!("{}", msg),
BindDecision::Http { warn: true } => {
eprintln!(
" ⚠ non-loopback plain-HTTP bind on {addr}.\n\
\n\
You have acknowledged this with --insecure-http. Ensure this broker is\n\
ONLY reachable via WireGuard or another encrypted private network layer.\n\
TLS in-process is available: pass --tls-cert <path> --tls-key <path>.\n"
);
}
BindDecision::Http { warn: false } | BindDecision::Tls => {}
}
if machine_token.is_none() && !addr.ip().is_loopback() && !insecure_no_auth {
anyhow::bail!(
"refusing to start: no machine token (set --machine-token / --machine-token-file / \
SYNAPS_BROKER_TOKEN) while bound to non-loopback {addr}. That would serve credentials \
unauthenticated to the network. Pass --insecure-no-auth to override, or bind 127.0.0.1."
);
}
match auth::load_auth() {
Ok(Some(_)) => {}
Ok(None) => anyhow::bail!(
"no credential at {}. Run `synaps login` on the broker host first.",
auth::auth_file_path().display()
),
Err(e) => anyhow::bail!("credential unreadable/corrupt: {e}"),
}
let client = reqwest::Client::builder()
.tls_built_in_webpki_certs(true)
.tls_built_in_native_certs(true)
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.build()?;
let state = BrokerState { machine_token: machine_token.clone(), client: client.clone() };
{
let refresh_client = client;
let handle = tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(60));
loop {
tick.tick().await;
if let Err(e) = auth::ensure_fresh_token(&refresh_client).await {
eprintln!("[auth-broker] proactive refresh failed: {e}");
}
}
});
tokio::spawn(async move {
if let Err(e) = handle.await {
eprintln!("[auth-broker] FATAL: proactive refresh task died: {e:?} — tokens will go stale; restart the broker.");
}
});
}
let app = build_router(state);
match decision {
BindDecision::Tls => {
eprintln!(
"synaps auth-broker listening on https://{addr} (machine auth: {}, TLS: ON)",
if machine_token.is_some() { "ON" } else { "OFF — loopback/insecure only!" }
);
let tls_cfg = tls.expect("Tls decision requires tls config");
let _ = rustls::crypto::ring::default_provider().install_default();
let rustls_config = axum_server::tls_rustls::RustlsConfig::from_pem(
tls_cfg.cert_pem,
tls_cfg.key_pem,
)
.await
.map_err(|e| anyhow::anyhow!("failed to build TLS config from PEM: {e}"))?;
let handle = axum_server::Handle::<SocketAddr>::new();
let h2 = handle.clone();
tokio::spawn(async move {
shutdown_signal().await;
eprintln!("[auth-broker] shutting down (draining in-flight requests)…");
h2.graceful_shutdown(Some(Duration::from_secs(30)));
});
axum_server::bind_rustls(addr, rustls_config)
.handle(handle)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
.await
.map_err(|e| anyhow::anyhow!("axum-server TLS serve error: {e}"))?;
}
BindDecision::Http { .. } => {
eprintln!(
"synaps auth-broker listening on http://{addr} (machine auth: {})",
if machine_token.is_some() { "ON" } else { "OFF — loopback/insecure only!" }
);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(shutdown_signal())
.await?;
}
BindDecision::Refuse(_) => unreachable!("Refuse is handled above"),
}
Ok(())
}
fn build_router(state: BrokerState) -> Router {
Router::new()
.route("/healthz", get(healthz))
.route("/token", get(token))
.layer(tower::limit::ConcurrencyLimitLayer::new(64))
.with_state(state)
}
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sig) => { sig.recv().await; }
Err(_) => std::future::pending::<()>().await,
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {}
_ = terminate => {}
}
}
async fn healthz() -> impl IntoResponse {
match auth::load_auth() {
Ok(Some(_)) => (StatusCode::OK, Json(json!({ "status": "ok" }))),
Ok(None) => (StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "status": "no_credential" }))),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "status": "error" }))),
}
}
async fn token(
State(st): State<BrokerState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Query(q): Query<TokenQuery>,
) -> impl IntoResponse {
if let Some(ref expected) = st.machine_token {
let got = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let expected_header = format!("Bearer {expected}");
if !ct_eq(got.as_bytes(), expected_header.as_bytes()) {
eprintln!("[auth-broker] DENIED from {} (bad machine auth)", peer.ip());
return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "bad machine auth" })))
.into_response();
}
}
let provider = q.provider.unwrap_or_else(|| "anthropic".to_string());
if !ALLOWED_PROVIDERS.contains(&provider.as_str()) {
eprintln!("[auth-broker] DENIED from {} (provider not allowed, {} chars)", peer.ip(), provider.len());
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "unknown provider" }))).into_response();
}
let creds = if provider == "anthropic" {
auth::ensure_fresh_token(&st.client).await
} else {
auth::ensure_fresh_provider_token(&st.client, &provider).await
};
match creds {
Ok(c) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let ttl_ms = c.expires.saturating_sub(now);
eprintln!("[auth-broker] issued {provider} token to {} (expires {})", peer.ip(), c.expires);
(StatusCode::OK, Json(json!({ "access_token": c.access, "expires": c.expires, "ttl_ms": ttl_ms })))
.into_response()
}
Err(e) => {
eprintln!("[auth-broker] refresh failed for {provider} from {}: {}", peer.ip(), e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "token refresh failed" })))
.into_response()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn ct_eq_matches_only_identical() {
assert!(ct_eq(b"Bearer secret", b"Bearer secret"));
assert!(!ct_eq(b"Bearer secret", b"Bearer secreT"));
assert!(!ct_eq(b"Bearer secret", b"Bearer wrong"));
assert!(!ct_eq(b"short", b"longer-value"));
assert!(!ct_eq(b"", b"x"));
assert!(ct_eq(b"", b""));
}
#[test]
fn policy_loopback_no_tls_is_plain_http() {
assert_eq!(check_bind_policy(true, false, false), BindDecision::Http { warn: false });
}
#[test]
fn policy_loopback_insecure_flag_is_still_plain_http_no_warn() {
assert_eq!(check_bind_policy(true, false, true), BindDecision::Http { warn: false });
}
#[test]
fn policy_loopback_with_tls_is_https() {
assert_eq!(check_bind_policy(true, true, false), BindDecision::Tls);
}
#[test]
fn policy_nonloopback_no_tls_no_flag_refuses() {
let d = check_bind_policy(false, false, false);
assert!(matches!(d, BindDecision::Refuse(_)));
if let BindDecision::Refuse(msg) = d {
assert!(msg.contains("non-loopback"), "message should mention non-loopback: {msg}");
assert!(msg.contains("--tls-cert"), "message should mention --tls-cert: {msg}");
assert!(msg.contains("--insecure-http"), "message should mention --insecure-http: {msg}");
}
}
#[test]
fn policy_nonloopback_no_tls_insecure_flag_warns() {
assert_eq!(check_bind_policy(false, false, true), BindDecision::Http { warn: true });
}
#[test]
fn policy_nonloopback_with_tls_is_https() {
assert_eq!(check_bind_policy(false, true, false), BindDecision::Tls);
}
#[test]
fn policy_nonloopback_tls_and_insecure_flag_still_tls() {
assert_eq!(check_bind_policy(false, true, true), BindDecision::Tls);
}
#[test]
fn validate_garbage_pem_cert_returns_error() {
let garbage = b"this is not a pem file at all";
let valid_key = generate_test_cert_key().1;
let err = validate_pem_pair(garbage, &valid_key);
assert!(err.is_err(), "garbage cert PEM should fail validation");
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("cert") || msg.contains("no certificates"),
"error should mention cert issue: {msg}"
);
}
#[test]
fn validate_garbage_pem_key_returns_error() {
let (valid_cert, _) = generate_test_cert_key();
let garbage_key = b"this is definitely not a private key";
let err = validate_pem_pair(&valid_cert, garbage_key);
assert!(err.is_err(), "garbage key PEM should fail validation");
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("key") || msg.contains("no private key"),
"error should mention key issue: {msg}"
);
}
#[test]
fn validate_valid_self_signed_pair_succeeds() {
let (cert_pem, key_pem) = generate_test_cert_key();
validate_pem_pair(&cert_pem, &key_pem).expect("self-signed pair should validate");
}
#[test]
fn validate_cert_pem_used_as_key_returns_error() {
let (cert_pem, _) = generate_test_cert_key();
let err = validate_pem_pair(&cert_pem, &cert_pem);
assert!(err.is_err(), "cert-as-key should fail validation");
}
#[test]
fn load_tls_missing_cert_file_returns_error() {
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("nonexistent-cert.pem");
let key_path = dir.path().join("nonexistent-key.pem");
let (_, key_pem) = generate_test_cert_key();
std::fs::write(&key_path, &key_pem).unwrap();
let err = load_and_validate_tls(&cert_path, &key_path);
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(msg.contains("--tls-cert"), "error should mention --tls-cert flag: {msg}");
}
#[test]
fn load_tls_missing_key_file_returns_error() {
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("nonexistent-key.pem");
let (cert_pem, _) = generate_test_cert_key();
std::fs::write(&cert_path, &cert_pem).unwrap();
let err = load_and_validate_tls(&cert_path, &key_path);
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(msg.contains("--tls-key"), "error should mention --tls-key flag: {msg}");
}
#[test]
fn load_tls_garbage_files_returns_error() {
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
std::fs::write(&cert_path, b"not a cert").unwrap();
std::fs::write(&key_path, b"not a key").unwrap();
let err = load_and_validate_tls(&cert_path, &key_path);
assert!(err.is_err(), "garbage files should fail validation");
}
#[test]
fn load_tls_valid_pair_from_tempdir() {
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
let (cert_pem, key_pem) = generate_test_cert_key();
std::fs::write(&cert_path, &cert_pem).unwrap();
std::fs::write(&key_path, &key_pem).unwrap();
load_and_validate_tls(&cert_path, &key_path).expect("valid pair from files should succeed");
}
fn build_test_router(machine_token: Option<String>) -> Router {
let tok = machine_token.clone();
Router::new()
.route("/healthz", get(|| async { Json(json!({ "status": "ok" })) }))
.route(
"/token",
get(move |headers: HeaderMap| {
let tok = tok.clone();
async move {
if let Some(ref expected) = tok {
let got = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let want = format!("Bearer {expected}");
if !ct_eq(got.as_bytes(), want.as_bytes()) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "bad machine auth" })),
);
}
}
(
StatusCode::OK,
Json(json!({
"access_token": "test-tok",
"expires": 9_999_999_999u64,
"ttl_ms": 999_999u64
})),
)
}
}),
)
}
async fn spawn_tls_server(
machine_token: Option<String>,
) -> (SocketAddr, axum_server::Handle<SocketAddr>) {
let _ = rustls::crypto::ring::default_provider().install_default();
let (cert_pem, key_pem) = generate_test_cert_key();
let rustls_config = axum_server::tls_rustls::RustlsConfig::from_pem(cert_pem, key_pem)
.await
.expect("test TLS config should build");
let app = build_test_router(machine_token);
let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
std_listener.set_nonblocking(true).unwrap();
let addr = std_listener.local_addr().unwrap();
let handle = axum_server::Handle::<SocketAddr>::new();
let h2 = handle.clone();
tokio::spawn(async move {
axum_server::tls_rustls::from_tcp_rustls(std_listener, rustls_config)
.expect("from_tcp_rustls should succeed")
.handle(h2)
.serve(app.into_make_service())
.await
.ok();
});
tokio::time::sleep(Duration::from_millis(80)).await;
(addr, handle)
}
fn insecure_client() -> reqwest::Client {
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.expect("insecure reqwest client should build")
}
#[tokio::test]
async fn tls_healthz_returns_200() {
let (addr, handle) = spawn_tls_server(None).await;
let url = format!("https://{addr}/healthz");
let resp = insecure_client().get(&url).send().await.expect("GET /healthz over TLS");
assert_eq!(resp.status(), 200, "/healthz should return 200 over TLS");
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "ok");
handle.graceful_shutdown(Some(Duration::from_millis(200)));
}
#[tokio::test]
async fn tls_token_no_auth_returns_401() {
let (addr, handle) = spawn_tls_server(Some("secret123".to_string())).await;
let url = format!("https://{addr}/token");
let resp = insecure_client().get(&url).send().await.expect("GET /token no auth");
assert_eq!(resp.status(), 401, "/token without bearer should 401 over TLS");
handle.graceful_shutdown(Some(Duration::from_millis(200)));
}
#[tokio::test]
async fn tls_token_with_correct_auth_returns_200() {
let (addr, handle) = spawn_tls_server(Some("my-machine-token".to_string())).await;
let url = format!("https://{addr}/token");
let resp = insecure_client()
.get(&url)
.header("Authorization", "Bearer my-machine-token")
.send()
.await
.expect("GET /token with correct auth");
assert_eq!(resp.status(), 200, "/token with correct bearer should 200 over TLS");
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["access_token"], "test-tok");
handle.graceful_shutdown(Some(Duration::from_millis(200)));
}
#[tokio::test]
async fn tls_token_wrong_auth_returns_401() {
let (addr, handle) = spawn_tls_server(Some("my-machine-token".to_string())).await;
let url = format!("https://{addr}/token");
let resp = insecure_client()
.get(&url)
.header("Authorization", "Bearer wrong-token")
.send()
.await
.expect("GET /token with wrong auth");
assert_eq!(resp.status(), 401, "/token with wrong bearer should 401 over TLS");
handle.graceful_shutdown(Some(Duration::from_millis(200)));
}
pub(super) fn generate_test_cert_key() -> (Vec<u8>, Vec<u8>) {
use rcgen::generate_simple_self_signed;
let san = vec!["localhost".to_string(), "127.0.0.1".to_string()];
let cert = generate_simple_self_signed(san)
.expect("rcgen cert generation should succeed");
let cert_pem = cert.cert.pem().into_bytes();
let key_pem = cert.signing_key.serialize_pem().into_bytes();
(cert_pem, key_pem)
}
}