diff --git a/Cargo.lock b/Cargo.lock index 2a52f9ed..fdcd37a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,7 +136,6 @@ dependencies = [ "rcgen", "reqwest", "rust-embed", - "rustls", "serde", "serde_json", "sha2 0.11.1", @@ -158,7 +157,9 @@ version = "0.1.1" dependencies = [ "anyhow", "humantime-serde", + "rcgen", "reqwest", + "rustls", "schemars 1.2.3", "serde", "serde-transcode", diff --git a/README.md b/README.md index b5062843..0510bcd8 100644 --- a/README.md +++ b/README.md @@ -697,7 +697,7 @@ routes: issuer: agentdesktop-controller audiences: [agentgateway] jwks: - url: https:///.well-known/jwks.json + url: https:///.well-known/jwks.json # fleet listener or gatewayJwt.jwksListener urlRewrite: authority: full: api.business.githubcopilot.com @@ -815,6 +815,13 @@ cross-site browser mutations and non-loopback `Host` headers. Reach it remotely through an SSH tunnel. Remote management (`adminAccess.mode: authenticated`) requires a controller build with a compiled-in authentication provider; the standard executable rejects it at startup. Device enrollment is configured separately. +LLM gateways need the controller's JWKS (`/.well-known/jwks.json`) to verify +the JWTs it issues. The management listener does not serve it. Gateways fetch +it from the fleet listener, or from an optional `gatewayJwt.jwksListener` that +serves nothing else. With the dedicated listener, a NetworkPolicy or firewall +can allow the gateway that one port and block the management API, so a +compromised gateway cannot read the device inventory or delete devices. A +gateway that exports usage to the controller also needs the fleet port. See the [controller configuration schema](schema/controller-config.md). ### Configuration status diff --git a/crates/controller/Cargo.toml b/crates/controller/Cargo.toml index 5cb495a4..eef56d7e 100644 --- a/crates/controller/Cargo.toml +++ b/crates/controller/Cargo.toml @@ -27,7 +27,6 @@ rand.workspace = true rcgen.workspace = true reqwest.workspace = true rust-embed = "8" -rustls.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/crates/controller/src/application.rs b/crates/controller/src/application.rs index 6620999a..b7545c81 100644 --- a/crates/controller/src/application.rs +++ b/crates/controller/src/application.rs @@ -65,7 +65,18 @@ pub async fn run( tracing::info!("LLM gateway JWT issuance enabled"); } let gateway_jwks = gateway_jwt_issuer.as_ref().map(GatewayJwtIssuer::jwks); - let admin_gateway_jwks = gateway_jwks.clone(); + let jwks_listener = config + .gateway_jwt + .as_ref() + .and_then(|gateway| gateway.jwks_listener.as_ref()) + .zip(gateway_jwks.clone()) + .map(|(listener, jwks)| { + let tls = listener.tls.then(|| gateway_jwt::JwksTls { + certificate: tls.certificate.clone(), + key: tls.key.clone(), + }); + gateway_jwt::serve(listener.listen, jwks, tls) + }); let usage_token = config .usage .as_ref() @@ -145,13 +156,13 @@ pub async fn run( .await .context("serve fleet gRPC API") }; - let admin = management::serve( - admin_listen, - admin_state, - admin_gateway_jwks, - config.admin_access, - extensions, - ); - tokio::try_join!(fleet, admin)?; + let admin = management::serve(admin_listen, admin_state, config.admin_access, extensions); + let jwks = async move { + match jwks_listener { + Some(serve) => serve.await, + None => std::future::pending().await, + } + }; + tokio::try_join!(fleet, admin, jwks)?; Ok(()) } diff --git a/crates/controller/src/gateway_jwt.rs b/crates/controller/src/gateway_jwt.rs index 86ea4f96..c2dba7c7 100644 --- a/crates/controller/src/gateway_jwt.rs +++ b/crates/controller/src/gateway_jwt.rs @@ -1,7 +1,8 @@ use std::{ collections::BTreeMap, fs, - path::Path, + net::SocketAddr, + path::{Path, PathBuf}, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -10,11 +11,13 @@ use anyhow::Context; use aws_lc_rs::{rsa::KeyPair as RsaKeyPair, signature::KeyPair as _}; use axum::{ Json, Router, - extract::State, + extract::{Request, State}, http::{StatusCode, header}, + middleware::{self, Next}, response::{IntoResponse, Response}, routing::get, }; +use axum_server::tls_rustls::RustlsConfig; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use serde::Serialize; @@ -34,13 +37,71 @@ pub struct GatewayJwks { keys: Vec, } -/// HTTP routes published on the controller's device-facing TLS listener. +/// The JWKS route, mounted on the fleet listener and the dedicated JWKS listener. pub fn routes(jwks: Option) -> Router { Router::>::new() .route("/.well-known/jwks.json", get(serve_jwks)) .with_state(jwks) } +const JWKS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +/// PEM certificate and key for an HTTPS JWKS listener. +pub struct JwksTls { + pub certificate: PathBuf, + pub key: PathBuf, +} + +/// Serves only the gateway JWKS, over HTTPS when `tls` is set. +pub async fn serve( + address: SocketAddr, + jwks: GatewayJwks, + tls: Option, +) -> anyhow::Result<()> { + let listener = tokio::net::TcpListener::bind(address) + .await + .with_context(|| format!("bind gateway JWKS listener on {address}"))?; + serve_listener(listener, jwks, tls).await +} + +pub async fn serve_listener( + listener: tokio::net::TcpListener, + jwks: GatewayJwks, + tls: Option, +) -> anyhow::Result<()> { + let address = listener.local_addr()?; + let app = routes(Some(jwks)).layer(middleware::from_fn(safeguard_request)); + match tls { + Some(tls) => { + let config = RustlsConfig::from_config(Arc::new( + agentdesktop_core::tls::server_config(&tls.certificate, &tls.key, "gateway JWKS")?, + )); + tracing::info!(listen = %address, tls = true, "gateway JWKS listening"); + axum_server::from_tcp_rustls(listener.into_std()?, config)? + .serve(app.into_make_service()) + .await + .context("serve gateway JWKS over HTTPS") + } + None => { + tracing::info!(listen = %address, tls = false, "gateway JWKS listening"); + axum::serve(listener, app) + .await + .context("serve gateway JWKS") + } + } +} + +async fn safeguard_request(request: Request, next: Next) -> Response { + let mut response = tokio::time::timeout(JWKS_REQUEST_TIMEOUT, next.run(request)) + .await + .unwrap_or_else(|_| StatusCode::GATEWAY_TIMEOUT.into_response()); + response.headers_mut().insert( + header::X_CONTENT_TYPE_OPTIONS, + header::HeaderValue::from_static("nosniff"), + ); + response +} + async fn serve_jwks(State(jwks): State>) -> Result { jwks.map(|jwks| { ( @@ -195,8 +256,7 @@ mod tests { use super::{Actor, Claims, GatewayJwtIssuer}; - #[test] - fn published_jwks_verifies_issued_credentials() { + fn test_issuer() -> GatewayJwtIssuer { let key_pair = KeyPair::generate(KeySize::Rsa2048).expect("generate RSA key"); let der: Pkcs8V1Der<'static> = key_pair.as_der().expect("encode private key"); let pem = pem_rfc7468::encode_string("PRIVATE KEY", LineEnding::LF, der.as_ref()) @@ -207,7 +267,6 @@ mod tests { rand::random::() )); fs::write(&path, pem).expect("write test private key"); - let issuer = GatewayJwtIssuer::from_rsa_pem( &path, "agentdesktop-controller".to_owned(), @@ -215,6 +274,99 @@ mod tests { std::time::Duration::from_secs(300), ) .expect("create issuer"); + let _ = fs::remove_file(path); + issuer + } + + async fn get_with_retry(client: &reqwest::Client, url: &str) -> reqwest::Response { + for _ in 0..50 { + match client.get(url).send().await { + Ok(response) => return response, + Err(error) if error.is_connect() => { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + Err(error) => panic!("JWKS probe failed: {error}"), + } + } + panic!("JWKS listener did not start"); + } + + async fn assert_serves_jwks(client: &reqwest::Client, origin: &str) { + let response = get_with_retry(client, &format!("{origin}/.well-known/jwks.json")).await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response.headers()["content-type"], + "application/jwk-set+json" + ); + assert_eq!(response.headers()["cache-control"], "public, max-age=300"); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + let jwks: JwkSet = response.json().await.expect("decode JWKS"); + assert!(jwks.find("agentdesktop").is_some()); + + let other = client + .get(format!("{origin}/api/v1/devices")) + .send() + .await + .expect("probe other path"); + assert_eq!(other.status(), reqwest::StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn dedicated_listener_serves_only_jwks_over_http() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(super::serve_listener(listener, test_issuer().jwks(), None)); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + assert_serves_jwks(&client, &format!("http://{address}")).await; + server.abort(); + } + + #[tokio::test] + async fn dedicated_listener_serves_jwks_over_https_when_configured() { + let directory = std::env::temp_dir().join(format!( + "agentdesktop-jwks-tls-{}-{}", + std::process::id(), + rand::random::() + )); + fs::create_dir(&directory).unwrap(); + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec!["127.0.0.1".into()]).unwrap(); + let certificate = directory.join("controller.pem"); + let key = directory.join("controller-key.pem"); + fs::write(&certificate, cert.pem()).unwrap(); + fs::write(&key, signing_key.serialize_pem()).unwrap(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(super::serve_listener( + listener, + test_issuer().jwks(), + Some(super::JwksTls { certificate, key }), + )); + let client = reqwest::Client::builder() + .add_root_certificate(reqwest::Certificate::from_pem(cert.pem().as_bytes()).unwrap()) + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + assert_serves_jwks(&client, &format!("https://{address}")).await; + assert!( + client + .get(format!("http://{address}/.well-known/jwks.json")) + .send() + .await + .map_or(true, |response| !response.status().is_success()), + "plaintext must not reach the TLS listener" + ); + server.abort(); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn published_jwks_verifies_issued_credentials() { + let issuer = test_issuer(); let (credential, _) = issuer .issue("user", "device", "codex", "agentgateway", None, false) .expect("issue credential"); @@ -231,8 +383,6 @@ mod tests { validation.set_audience(&["agentgateway"]); decode::(&credential, &decoding_key, &validation) .expect("verify credential with published JWKS"); - - let _ = fs::remove_file(path); } #[test] diff --git a/crates/controller/src/management.rs b/crates/controller/src/management.rs index 0c47cf3a..cedbf6b3 100644 --- a/crates/controller/src/management.rs +++ b/crates/controller/src/management.rs @@ -2,7 +2,7 @@ //! Handler authorization is explicit; authentication and policy implementations are //! supplied through `auth`, independently of the fleet enrollment API. -use std::{borrow::Cow, net::SocketAddr, path::Path, sync::Arc}; +use std::{borrow::Cow, net::SocketAddr, sync::Arc}; use anyhow::Context as _; use axum::{ @@ -17,11 +17,7 @@ use tracing::info; use agentdesktop_core::config::{ControllerAdminAccess, ControllerAdminTransport}; -use crate::{ - daemon_config::DaemonConfigStore, - database::Database, - gateway_jwt::{self, GatewayJwks}, -}; +use crate::{daemon_config::DaemonConfigStore, database::Database}; pub mod auth; mod handlers; @@ -45,7 +41,7 @@ pub struct Extensions { /// Required for authenticated access, and rejected for local access, which always /// uses the built-in loopback provider. pub authentication: Option>, - /// Extension-owned APIs, protected separately from public auth/JWKS routes. + /// Extension-owned APIs, protected separately from public auth routes. pub routes: Vec, /// Optional complete UI bundle that replaces the built-in one, e.g. to add pages. pub assets: Option, @@ -112,11 +108,10 @@ pub fn auth(access: &ControllerAdminAccess, extensions: &Extensions) -> anyhow:: pub async fn serve( address: SocketAddr, state: State, - gateway_jwks: Option, access: ControllerAdminAccess, extensions: Extensions, ) -> anyhow::Result<()> { - let app = router(state, gateway_jwks, &access, extensions)?; + let app = router(state, &access, extensions)?; let listener = tokio::net::TcpListener::bind(address).await?; serve_router(listener, app, access).await } @@ -130,10 +125,9 @@ pub async fn serve_router( if let ControllerAdminAccess::Authenticated(config) = &access && let ControllerAdminTransport::Tls { certificate, key } = &config.transport { - let tls = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(admin_tls_config( - certificate, - key, - )?)); + let tls = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new( + agentdesktop_core::tls::server_config(certificate, key, "admin")?, + )); info!(listen = %address, "controller authenticated admin HTTPS listening"); axum_server::from_tcp_rustls(listener.into_std()?, tls)? .serve(app.into_make_service()) @@ -146,37 +140,11 @@ pub async fn serve_router( Ok(()) } -/// Admin TLS identity with an explicit crypto provider, so the listener does not -/// depend on which rustls backends the final binary happens to enable. -fn admin_tls_config(certificate: &Path, key: &Path) -> anyhow::Result { - use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; - let certificates = CertificateDer::pem_file_iter(certificate) - .and_then(Iterator::collect::, _>>) - .with_context(|| format!("read admin TLS certificate {}", certificate.display()))?; - anyhow::ensure!( - !certificates.is_empty(), - "admin TLS certificate {} contains no certificates", - certificate.display() - ); - let key = PrivateKeyDer::from_pem_file(key) - .with_context(|| format!("read admin TLS key {}", key.display()))?; - let mut config = rustls::ServerConfig::builder_with_provider(Arc::new( - rustls::crypto::aws_lc_rs::default_provider(), - )) - .with_safe_default_protocol_versions()? - .with_no_client_auth() - .with_single_cert(certificates, key) - .context("load admin TLS identity")?; - config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - Ok(config) -} - -/// Compose authenticated APIs/UI and deliberately public auth/JWKS endpoints. +/// Compose authenticated APIs/UI and deliberately public auth endpoints. /// The provider and HTTP safeguards both derive from `access`, so they cannot disagree. /// Permissions are checked by handlers; providers must not infer them from paths. pub fn router( state: State, - gateway_jwks: Option, access: &ControllerAdminAccess, extensions: Extensions, ) -> anyhow::Result { @@ -224,9 +192,7 @@ pub fn router( for routes in extensions.routes { app = app.route(routes.path, auth.authenticate_api(routes.methods)); } - // Private routes default to no-store; public JWKS keeps its own caching. Common - // HTTP safeguards still wrap all routes. - Ok(policy.apply(http::private_routes(app).merge(gateway_jwt::routes(gateway_jwks)))) + Ok(policy.apply(http::private_routes(app))) } fn asset(assets: AssetLookup, uri: &Uri) -> Response { diff --git a/crates/controller/src/management/auth/tests.rs b/crates/controller/src/management/auth/tests.rs index 84c36d04..d5bd2848 100644 --- a/crates/controller/src/management/auth/tests.rs +++ b/crates/controller/src/management/auth/tests.rs @@ -190,6 +190,28 @@ async fn request(app: Router, path: &str, method: Method, headers: HeaderMap) -> app.oneshot(request).await.unwrap() } +#[tokio::test] +async fn management_router_does_not_publish_the_gateway_jwks() { + let (database, path) = database().await; + let router = super::super::router( + state(database), + &ControllerAdminAccess::Local, + super::super::Extensions::default(), + ) + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, "127.0.0.1:8080".parse().unwrap()); + let response = request(router, "/.well-known/jwks.json", Method::GET, headers).await; + assert_ne!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/jwk-set+json") + ); + std::fs::remove_file(path).unwrap(); +} + #[tokio::test] async fn local_mode_keeps_ui_api_and_bootstrap_without_session_tables() { let (database, path) = database().await; @@ -348,7 +370,6 @@ async fn authenticated_configuration_is_rejected_without_a_provider() { let error = match super::super::serve( "127.0.0.1:0".parse().unwrap(), state(database), - None, authenticated_access(), super::super::Extensions::default(), ) @@ -432,7 +453,6 @@ async fn management_routes_check_precise_operations() { let calls = Arc::new(std::sync::Mutex::new(Vec::new())); let router = super::super::router( state(database), - None, &authenticated_access(), super::super::Extensions { authentication: Some(Arc::new(RecordingProvider(calls.clone()))), @@ -475,7 +495,6 @@ async fn local_configuration_is_rejected_with_a_provider() { let (database, path) = database().await; let error = match super::super::router( state(database), - None, &ControllerAdminAccess::Local, super::super::Extensions { authentication: Some(Arc::new(TokenProvider)), @@ -639,7 +658,6 @@ async fn extension_providers_authenticate_and_authorize_the_management_router() let (database, path) = database().await; let router = super::super::router( state(database), - None, &authenticated_access(), super::super::Extensions { authentication: Some(Arc::new(TokenProvider)), @@ -757,7 +775,6 @@ async fn direct_tls_transport_serves_the_authenticated_router_over_https_only() let access = ControllerAdminAccess::Authenticated(admin); let router = super::super::router( state(database), - None, &access, super::super::Extensions { authentication: Some(Arc::new(TokenProvider)), diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e285de5f..3e5f38c0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -12,6 +12,7 @@ schema = ["dep:schemars"] anyhow.workspace = true humantime-serde.workspace = true reqwest.workspace = true +rustls.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true @@ -22,3 +23,6 @@ tracing-appender.workspace = true tracing-subscriber.workspace = true url.workspace = true + +[dev-dependencies] +rcgen.workspace = true diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 84ad790b..5a85da43 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -620,6 +620,24 @@ pub struct ControllerGatewayJwtConfig { #[serde(default = "default_gateway_jwt_lifetime", with = "humantime_serde")] #[cfg_attr(feature = "schema", schemars(with = "String"))] pub lifetime: Duration, + /// Listener that serves only `/.well-known/jwks.json`, which the fleet listener also serves. + /// + /// LLM gateways need the JWKS to verify controller-issued JWTs. Point them here so a NetworkPolicy or firewall can allow them this port and block the management API, which can read the device inventory and delete devices. Gateways that export usage also need the fleet port. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jwks_listener: Option, +} + +/// Dedicated listener for the LLM gateway JWKS. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ControllerGatewayJwksListener { + /// Address on which the JWKS listener accepts connections. + #[cfg_attr(feature = "schema", schemars(with = "String"))] + pub listen: SocketAddr, + /// Serve HTTPS with the controller certificate from `tls` instead of plain HTTP. + #[serde(default)] + pub tls: bool, } /// Collection of LLM token and cost records that agentgateway exports to the @@ -1414,6 +1432,15 @@ pub fn parse_controller(contents: &str) -> anyhow::Result { if gateway.lifetime.is_zero() { anyhow::bail!("gatewayJwt.lifetime must be greater than zero"); } + if let Some(jwks) = &gateway.jwks_listener + && [config.fleet_listen, config.admin_listen] + .iter() + .any(|other| other.port() == jwks.listen.port()) + { + anyhow::bail!( + "gatewayJwt.jwksListener.listen must use a port other than fleetListen and adminListen" + ); + } } Ok(config) } @@ -2222,6 +2249,48 @@ oidc: ); } + #[test] + fn controller_gateway_jwks_listener_is_optional_and_separate_from_other_listeners() { + let base = r#" +tls: /etc/agentdesktop/tls +oidc: + issuer: https://idp.example.com + clientId: agentdesktop +gatewayJwt: + privateKey: /gateway-jwt-key.pem +"#; + let controller = parse_controller(base).expect("gateway JWT without JWKS listener"); + assert!(controller.gateway_jwt.unwrap().jwks_listener.is_none()); + + let controller = parse_controller(&format!( + "{base} jwksListener:\n listen: 0.0.0.0:8081\n" + )) + .expect("plain HTTP JWKS listener"); + let listener = controller.gateway_jwt.unwrap().jwks_listener.unwrap(); + assert_eq!(listener.listen, "0.0.0.0:8081".parse().unwrap()); + assert!(!listener.tls); + + let controller = parse_controller(&format!( + "{base} jwksListener:\n listen: 127.0.0.1:8444\n tls: true\n" + )) + .expect("HTTPS JWKS listener"); + assert!(controller.gateway_jwt.unwrap().jwks_listener.unwrap().tls); + + for listen in ["0.0.0.0:8443", "127.0.0.1:8080"] { + assert!( + parse_controller(&format!("{base} jwksListener:\n listen: {listen}\n")) + .is_err(), + "{listen}" + ); + } + assert!( + parse_controller(&format!( + "{base} jwksListener:\n listen: 127.0.0.1:8081\n path: /jwks\n" + )) + .is_err() + ); + } + #[test] fn controller_usage_token_file_is_relative_to_the_configuration() { let unique = std::time::SystemTime::now() diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 7adf78c0..71ca3b1a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -3,6 +3,7 @@ pub mod http; pub mod model; pub mod serdes; pub mod telemetry; +pub mod tls; use std::{env, path::PathBuf}; diff --git a/crates/core/src/tls.rs b/crates/core/src/tls.rs new file mode 100644 index 00000000..c95adf42 --- /dev/null +++ b/crates/core/src/tls.rs @@ -0,0 +1,77 @@ +use std::{path::Path, sync::Arc}; + +use anyhow::Context; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; + +/// Server TLS identity with an explicit crypto provider, so a listener does not depend on +/// which rustls backends the final binary happens to enable. `purpose` labels errors. +pub fn server_config( + certificate: &Path, + key: &Path, + purpose: &str, +) -> anyhow::Result { + let certificates = CertificateDer::pem_file_iter(certificate) + .and_then(Iterator::collect::, _>>) + .with_context(|| format!("read {purpose} TLS certificate {}", certificate.display()))?; + anyhow::ensure!( + !certificates.is_empty(), + "{purpose} TLS certificate {} contains no certificates", + certificate.display() + ); + let key = PrivateKeyDer::from_pem_file(key) + .with_context(|| format!("read {purpose} TLS key {}", key.display()))?; + let mut config = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_no_client_auth() + .with_single_cert(certificates, key) + .with_context(|| format!("load {purpose} TLS identity"))?; + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::server_config; + + #[test] + fn loads_a_pem_identity_and_labels_errors_with_the_purpose() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let directory = + std::env::temp_dir().join(format!("agentdesktop-tls-{}-{unique}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec!["127.0.0.1".into()]).unwrap(); + let certificate = directory.join("certificate.pem"); + let key = directory.join("key.pem"); + let empty = directory.join("empty.pem"); + std::fs::write(&certificate, cert.pem()).unwrap(); + std::fs::write(&key, signing_key.serialize_pem()).unwrap(); + std::fs::write(&empty, "").unwrap(); + + let config = server_config(&certificate, &key, "test").expect("load identity"); + assert_eq!( + config.alpn_protocols, + [b"h2".to_vec(), b"http/1.1".to_vec()] + ); + + let error = server_config(&empty, &key, "test").unwrap_err(); + assert!( + error.to_string().starts_with("test TLS certificate") + && error.to_string().ends_with("contains no certificates"), + "{error}" + ); + let error = + server_config(&certificate, &directory.join("missing.pem"), "test").unwrap_err(); + assert!( + error.to_string().starts_with("read test TLS key"), + "{error}" + ); + + std::fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/deploy/helm/agentdesktop-controller/templates/NOTES.txt b/deploy/helm/agentdesktop-controller/templates/NOTES.txt index 5de77bac..6ed0f917 100644 --- a/deploy/helm/agentdesktop-controller/templates/NOTES.txt +++ b/deploy/helm/agentdesktop-controller/templates/NOTES.txt @@ -1,6 +1,11 @@ The Agentdesktop fleet API is available through Service: {{ include "agentdesktop-controller.fullname" . }}:{{ .Values.service.port }} +{{ if include "agentdesktop-controller.gatewayJwksEnabled" . -}} +LLM gateways fetch the gateway JWKS from Service: + {{ ternary "https" "http" .Values.gatewayJwks.tls }}://{{ include "agentdesktop-controller.gatewayJwksServiceName" . }}.{{ .Release.Namespace }}.svc:{{ include "agentdesktop-controller.gatewayJwksServicePort" . }}/.well-known/jwks.json + +{{ end -}} The management UI binds to loopback and is not exposed by the Service. Open it with: kubectl port-forward deployment/{{ include "agentdesktop-controller.fullname" . }} 8080:8080 diff --git a/deploy/helm/agentdesktop-controller/templates/_helpers.tpl b/deploy/helm/agentdesktop-controller/templates/_helpers.tpl index 38a16c1e..02dd90c6 100644 --- a/deploy/helm/agentdesktop-controller/templates/_helpers.tpl +++ b/deploy/helm/agentdesktop-controller/templates/_helpers.tpl @@ -18,6 +18,18 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} {{- end }} +{{- define "agentdesktop-controller.gatewayJwksEnabled" -}} +{{- if and .Values.gatewayJwks.enabled (or .Values.gatewayJwt .Values.existingConfigSecret) }}true{{- end }} +{{- end }} + +{{- define "agentdesktop-controller.gatewayJwksServiceName" -}} +{{- printf "%s-jwks" (include "agentdesktop-controller.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "agentdesktop-controller.gatewayJwksServicePort" -}} +{{- .Values.gatewayJwks.service.port | default (ternary 443 80 .Values.gatewayJwks.tls) }} +{{- end }} + {{- define "agentdesktop-controller.selectorLabels" -}} app.kubernetes.io/name: {{ include "agentdesktop-controller.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/deploy/helm/agentdesktop-controller/templates/configmap.yaml b/deploy/helm/agentdesktop-controller/templates/configmap.yaml index 1a522b9e..ea802137 100644 --- a/deploy/helm/agentdesktop-controller/templates/configmap.yaml +++ b/deploy/helm/agentdesktop-controller/templates/configmap.yaml @@ -18,7 +18,12 @@ data: redirectUri: {{ .Values.oidc.redirectUri | quote }} {{- with .Values.gatewayJwt }} gatewayJwt: -{{ toYaml . | indent 6 }} +{{ toYaml (ternary (omit . "jwksListener") . $.Values.gatewayJwks.enabled) | indent 6 }} + {{- if $.Values.gatewayJwks.enabled }} + jwksListener: + listen: 0.0.0.0:{{ $.Values.gatewayJwks.port }} + tls: {{ $.Values.gatewayJwks.tls }} + {{- end }} {{- end }} {{- with .Values.daemonConfig }} daemonConfig: diff --git a/deploy/helm/agentdesktop-controller/templates/deployment.yaml b/deploy/helm/agentdesktop-controller/templates/deployment.yaml index f5d47b4c..1766844b 100644 --- a/deploy/helm/agentdesktop-controller/templates/deployment.yaml +++ b/deploy/helm/agentdesktop-controller/templates/deployment.yaml @@ -39,6 +39,11 @@ spec: - name: admin containerPort: 8080 protocol: TCP + {{- if include "agentdesktop-controller.gatewayJwksEnabled" . }} + - name: jwks + containerPort: {{ .Values.gatewayJwks.port }} + protocol: TCP + {{- end }} securityContext: allowPrivilegeEscalation: false capabilities: diff --git a/deploy/helm/agentdesktop-controller/templates/service-jwks.yaml b/deploy/helm/agentdesktop-controller/templates/service-jwks.yaml new file mode 100644 index 00000000..b6438e24 --- /dev/null +++ b/deploy/helm/agentdesktop-controller/templates/service-jwks.yaml @@ -0,0 +1,17 @@ +{{- if include "agentdesktop-controller.gatewayJwksEnabled" . }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "agentdesktop-controller.gatewayJwksServiceName" . }} + labels: + {{- include "agentdesktop-controller.labels" . | nindent 4 }} +spec: + type: {{ .Values.gatewayJwks.service.type }} + selector: + {{- include "agentdesktop-controller.selectorLabels" . | nindent 4 }} + ports: + - name: {{ ternary "https" "http" .Values.gatewayJwks.tls }} + port: {{ include "agentdesktop-controller.gatewayJwksServicePort" . }} + targetPort: jwks + protocol: TCP +{{- end }} diff --git a/deploy/helm/agentdesktop-controller/values.yaml b/deploy/helm/agentdesktop-controller/values.yaml index 230c7918..8b6a03d5 100644 --- a/deploy/helm/agentdesktop-controller/values.yaml +++ b/deploy/helm/agentdesktop-controller/values.yaml @@ -22,6 +22,23 @@ allowInsecureDev: false # Optional short-lived JWT issuer for authenticated LLM gateways. gatewayJwt: {} +# Listener and Service that serve only the gateway JWKS (/.well-known/jwks.json). +# Point LLM gateways at this Service so a NetworkPolicy can allow them this port +# and nothing else on the controller (usage export also needs the fleet port). +# The chart creates them when gatewayJwt or existingConfigSecret is set. With +# existingConfigSecret, that configuration must set gatewayJwt.jwksListener to +# listen on 0.0.0.0:, with the same tls setting. +gatewayJwks: + enabled: true + port: 8081 + # Serve HTTPS with the controller certificate from tlsSecretName. The + # certificate must then include the JWKS Service name. + tls: false + service: + type: ClusterIP + # Defaults to 443 with tls, otherwise 80. + port: "" + # Daemon configuration distributed to enrolled devices. Leave empty to # disable managed configuration. daemonConfig: {} diff --git a/docs/device-identity.md b/docs/device-identity.md index d1f3587d..13d0055e 100644 --- a/docs/device-identity.md +++ b/docs/device-identity.md @@ -340,7 +340,9 @@ managed tools obtain short-lived gateway credentials through the daemon: 4. The daemon caches the JWT for at most 60 seconds per client ID, never within 30 seconds of its expiry, and never across logout or re-enrollment. 5. The gateway verifies the JWT with the controller's public keys, served at - `/.well-known/jwks.json`. + `/.well-known/jwks.json` on the fleet listener and, when configured, on the + dedicated `gatewayJwt.jwksListener` (plain HTTP, or HTTPS with the + controller certificate). The management listener does not serve them. With `authentication.type: oidc`, the controller is not involved. The daemon signs the user in to the gateway's own OIDC client, using the browser or diff --git a/examples/claude/agentgateway.yaml b/examples/claude/agentgateway.yaml index 48e07321..b9605a76 100644 --- a/examples/claude/agentgateway.yaml +++ b/examples/claude/agentgateway.yaml @@ -43,7 +43,7 @@ llm: issuer: agentdesktop-controller audiences: [agentgateway] jwks: - url: http://127.0.0.1:8080/.well-known/jwks.json + url: http://127.0.0.1:8081/.well-known/jwks.json models: - name: "*" provider: anthropic diff --git a/examples/claude/controller.yaml b/examples/claude/controller.yaml index 98d9ae85..1ba489c5 100644 --- a/examples/claude/controller.yaml +++ b/examples/claude/controller.yaml @@ -13,6 +13,8 @@ daemonConfig: gatewayJwt: privateKey: /tmp/agentdesktop-keys/gateway-jwt-key.pem + jwksListener: + listen: 127.0.0.1:8081 usage: tokenFile: /tmp/agentdesktop-keys/usage-token diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f3ff8bbe..08aa7b07 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -133,11 +133,11 @@ importers: ui: dependencies: lucide-react: - specifier: ^1.31.0 - version: 1.31.0(react@19.2.8) + specifier: ^1.67.0 + version: 1.67.0(@types/react@19.3.0)(react@19.3.1) react: - specifier: ^19.2.8 - version: 19.2.8 + specifier: ^19.3.1 + version: 19.3.1 devDependencies: '@types/react': specifier: ^19.3.0 @@ -1640,11 +1640,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.31.0: - resolution: {integrity: sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lucide-react@1.67.0: resolution: {integrity: sha512-z7kd21YNm/Gw3E7mTnq5dTuvV+yc+OEwA//oDENSG1SbZ/NVgKdjxALJFNdGoC0Ji7HaJR2Dha9ev7VUPxy9CQ==} peerDependencies: @@ -1767,10 +1762,6 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react@19.2.8: - resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} - engines: {node: '>=0.10.0'} - react@19.3.1: resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} engines: {node: '>=0.10.0'} @@ -3158,10 +3149,6 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.31.0(react@19.2.8): - dependencies: - react: 19.2.8 - lucide-react@1.67.0(@types/react@19.3.0)(react@19.3.1): dependencies: react: 19.3.1 @@ -3309,8 +3296,6 @@ snapshots: react-is@17.0.2: {} - react@19.2.8: {} - react@19.3.1: {} recast@0.23.21: diff --git a/frontend/ui/package.json b/frontend/ui/package.json index 12edfce5..b665e546 100644 --- a/frontend/ui/package.json +++ b/frontend/ui/package.json @@ -8,8 +8,8 @@ "./styles.css": "./src/styles.css" }, "peerDependencies": { - "lucide-react": "^1.31.0", - "react": "^19.2.8" + "lucide-react": "^1.67.0", + "react": "^19.3.1" }, "devDependencies": { "@types/react": "^19.3.0" diff --git a/schema/controller-config.json b/schema/controller-config.json index 774c2998..eefde999 100644 --- a/schema/controller-config.json +++ b/schema/controller-config.json @@ -191,6 +191,25 @@ "path" ] }, + "ControllerGatewayJwksListener": { + "description": "Dedicated listener for the LLM gateway JWKS.", + "type": "object", + "properties": { + "listen": { + "description": "Address on which the JWKS listener accepts connections.", + "type": "string" + }, + "tls": { + "description": "Serve HTTPS with the controller certificate from `tls` instead of plain HTTP.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "required": [ + "listen" + ] + }, "ControllerGatewayJwtConfig": { "description": "Settings for issuing short-lived LLM gateway JWTs.", "type": "object", @@ -200,6 +219,17 @@ "type": "string", "default": "agentdesktop-controller" }, + "jwksListener": { + "description": "Listener that serves only `/.well-known/jwks.json`, which the fleet listener also serves.\n\nLLM gateways need the JWKS to verify controller-issued JWTs. Point them here so a NetworkPolicy or firewall can allow them this port and block the management API, which can read the device inventory and delete devices. Gateways that export usage also need the fleet port.", + "anyOf": [ + { + "$ref": "#/$defs/ControllerGatewayJwksListener" + }, + { + "type": "null" + } + ] + }, "keyId": { "description": "Key identifier placed in generated JWT headers.", "type": "string", diff --git a/schema/controller-config.md b/schema/controller-config.md index 9461c745..5e385bc1 100644 --- a/schema/controller-config.md +++ b/schema/controller-config.md @@ -21,6 +21,9 @@ |`fleetListen`|string|Address on which the device-facing gRPC fleet API listens.| |`gatewayJwt`|object|LLM gateway JWT signing settings.| |`gatewayJwt.issuer`|string|Issuer claim placed in generated JWTs.| +|`gatewayJwt.jwksListener`|object|Listener that serves only `/.well-known/jwks.json`, which the fleet listener also serves.

LLM gateways need the JWKS to verify controller-issued JWTs. Point them here so a NetworkPolicy or firewall can allow them this port and block the management API, which can read the device inventory and delete devices. Gateways that export usage also need the fleet port.| +|`gatewayJwt.jwksListener.listen`|string|Address on which the JWKS listener accepts connections.| +|`gatewayJwt.jwksListener.tls`|boolean|Serve HTTPS with the controller certificate from `tls` instead of plain HTTP.| |`gatewayJwt.keyId`|string|Key identifier placed in generated JWT headers.| |`gatewayJwt.lifetime`|string|Lifetime of generated JWTs. Defaults to `5m`.| |`gatewayJwt.privateKey`|string|Path to the PEM-encoded RSA private signing key.

Relative paths are resolved from the controller configuration directory.|