#[cfg(all(
feature = "builtin_jwt",
not(any(feature = "rust_crypto", feature = "aws_lc_rs"))
))]
compile_error!(
"feature `builtin_jwt` needs a crypto backend: enable `rust_crypto` or `aws_lc_rs` \
(or neither, and inject a verifier with `ProxyServer::with_token_verifier`)"
);
pub mod auth;
pub mod config;
mod cors;
mod embed;
pub mod hooks;
pub mod oidc;
pub mod openapi;
pub mod shield;
mod tls;
pub mod transcode;
#[cfg(feature = "builtin_jwt")]
pub use auth::crypto::install_default_crypto_provider;
use axum::extract::State;
use axum::http::{Request, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use prost_reflect::DescriptorPool;
use std::net::SocketAddr;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::trace::TraceLayer;
use std::sync::Arc;
use config::{DescriptorSource, ProxyConfig};
use hooks::{AuthDecider, ExtraRoute, OidcBackend, TokenVerifier};
#[derive(Clone, Debug)]
pub struct ProxyState {
pub service_name: String,
pub grpc_upstream: String,
pub grpc_channel: tonic::transport::Channel,
pub maintenance_mode: bool,
pub maintenance_exempt: Vec<String>,
pub maintenance_message: String,
pub forwarded_headers: Vec<String>,
pub metrics_namespace: String,
pub metrics_classes: Vec<config::MetricsClassConfig>,
pub sse_keep_alive_secs: u64,
}
pub struct ProxyServer {
config: ProxyConfig,
descriptor_pool: Option<DescriptorPool>,
auth_decider: Option<Arc<dyn AuthDecider>>,
oidc_backend: Option<Arc<dyn OidcBackend>>,
extra_routes: Vec<ExtraRoute>,
verify_path: Option<String>,
token_verifier: Option<Arc<dyn TokenVerifier>>,
transcode: transcode::TranscodeOptions,
}
impl ProxyServer {
pub fn from_config(config: ProxyConfig) -> Self {
#[cfg(feature = "builtin_jwt")]
auth::crypto::install_default_crypto_provider();
Self {
config,
descriptor_pool: None,
auth_decider: None,
oidc_backend: None,
extra_routes: Vec::new(),
verify_path: None,
token_verifier: None,
transcode: transcode::TranscodeOptions::default(),
}
}
pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
let config = ProxyConfig::from_yaml_str(yaml)?;
for key in config::unknown_config_keys(yaml) {
tracing::warn!(%key, "unknown config key is ignored");
}
let settings: config::TranscodeFileConfig = serde_yaml::from_str(yaml)?;
let options = settings
.options()
.map_err(|e| anyhow::anyhow!("invalid transcoding config: {e}"))?;
let mut server = Self::from_config(config);
server.transcode = options;
Ok(server)
}
pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
Self::from_yaml_str(&std::fs::read_to_string(path)?)
}
pub fn config(&self) -> &ProxyConfig {
&self.config
}
pub fn with_descriptors(mut self, pool: DescriptorPool) -> Self {
self.descriptor_pool = Some(pool);
self
}
pub fn with_auth_decider(mut self, decider: Arc<dyn AuthDecider>) -> Self {
self.auth_decider = Some(decider);
self
}
pub fn with_oidc_backend(mut self, backend: Arc<dyn OidcBackend>) -> Self {
self.oidc_backend = Some(backend);
self
}
pub fn with_extra_routes(mut self, routes: impl IntoIterator<Item = ExtraRoute>) -> Self {
self.extra_routes.extend(routes);
self
}
pub fn with_verify_path(mut self, path: impl Into<String>) -> Self {
self.verify_path = Some(path.into());
self
}
pub fn with_token_verifier(mut self, verifier: Arc<dyn TokenVerifier>) -> Self {
self.token_verifier = Some(verifier);
self
}
pub fn with_error_details(mut self, policy: transcode::error::ErrorDetailsPolicy) -> Self {
self.transcode = self.transcode.with_error_details(policy);
self
}
pub fn with_ndjson_envelope(mut self, enabled: bool) -> Self {
self.transcode = self.transcode.with_ndjson_envelope(enabled);
self
}
pub fn with_denied_response_headers(
mut self,
names: impl IntoIterator<Item = http::HeaderName>,
) -> Self {
self.transcode = self.transcode.with_denied_response_headers(names);
self
}
fn load_descriptors(&self) -> anyhow::Result<DescriptorPool> {
if let Some(pool) = &self.descriptor_pool {
return Ok(pool.clone());
}
let mut pool = DescriptorPool::new();
for source in &self.config.descriptors {
match source {
DescriptorSource::File { file } => {
let bytes = std::fs::read(file).map_err(|e| {
anyhow::anyhow!("Failed to read descriptor file {:?}: {}", file, e)
})?;
pool.decode_file_descriptor_set(bytes.as_slice())
.map_err(|e| {
anyhow::anyhow!("Failed to decode descriptor file {:?}: {}", file, e)
})?;
tracing::info!("Loaded descriptor from {:?}", file);
}
DescriptorSource::Reflection { reflection } => {
tracing::warn!(
"gRPC reflection client not supported — use descriptor files instead (reflection endpoint: {})",
reflection
);
}
DescriptorSource::Embedded { bytes } => {
pool.decode_file_descriptor_set(*bytes).map_err(|e| {
anyhow::anyhow!("Failed to decode embedded descriptors: {}", e)
})?;
}
}
}
Ok(pool)
}
fn decider_verify_path(&self) -> String {
self.verify_path.clone().unwrap_or_else(|| {
self.config
.auth
.as_ref()
.and_then(|a| a.forward_auth.as_ref())
.map(|fa| fa.path.clone())
.unwrap_or_else(|| "/auth/verify".to_string())
})
}
fn mounted_verify_path(&self) -> Option<String> {
if self.auth_decider.is_some() {
return Some(self.decider_verify_path());
}
self.config.auth.as_ref().and_then(|a| {
if a.mode != "jwt" {
return None;
}
a.forward_auth
.as_ref()
.filter(|fa| fa.enabled)
.map(|fa| fa.path.clone())
})
}
fn reserved_routes(&self, pool: &DescriptorPool) -> anyhow::Result<Vec<(String, String)>> {
let mut routes = Vec::new();
let mut get = |path: String| routes.push(("GET".to_string(), path));
if self.config.health.enabled {
get(self.config.health.path.clone());
get(self.config.health.live_path.clone());
get(self.config.health.ready_path.clone());
get(self.config.health.startup_path.clone());
}
if self.config.metrics.enabled {
get(self.config.metrics.path.clone());
}
if let Some(openapi) = self.config.openapi.as_ref().filter(|o| o.enabled) {
get(openapi.path.clone());
get(openapi.docs_path.clone());
}
if let Some(backend) = &self.oidc_backend {
for doc in backend.metadata_documents() {
get(doc.path);
}
get(backend.jwks().path);
get(backend.userinfo_path());
} else if let Some(cfg) = &self.config.oidc_discovery {
if let Some(oidc) = oidc::Oidc::build(cfg)
.map_err(|e| anyhow::anyhow!("invalid oidc_discovery config: {e}"))?
{
for path in oidc.paths() {
get(path);
}
}
}
for route in &self.extra_routes {
routes.push((route.method.as_str().to_string(), route.path.clone()));
}
routes.extend(transcode::route_paths(pool, &self.config.aliases));
Ok(routes)
}
pub fn router(&self) -> anyhow::Result<Router> {
self.config.validate()?;
let pool = self.load_descriptors()?;
let grpc_upstream = self.config.upstream.default.clone();
let grpc_channel = tonic::transport::Channel::from_shared(grpc_upstream.clone())
.map_err(|e| anyhow::anyhow!("invalid gRPC upstream URL: {}", e))?
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(5))
.connect_lazy();
let service_name = self.config.service.name.clone();
let metrics_namespace = service_name.replace('-', "_");
let verify_path = self.mounted_verify_path();
let mut mounted = self.reserved_routes(&pool)?;
if let Some(vp) = &verify_path {
mounted.push(("*".to_string(), vp.clone()));
}
let mut methods_by_shape: std::collections::HashMap<
String,
std::collections::HashSet<&str>,
> = std::collections::HashMap::new();
for (method, path) in &mounted {
if !path.starts_with('/') {
anyhow::bail!("route path {path:?} must start with '/'");
}
let methods = methods_by_shape
.entry(normalize_route_shape(path))
.or_default();
let conflict = if method == "*" {
!methods.is_empty()
} else {
methods.contains("*") || methods.contains(method.as_str())
};
if conflict {
anyhow::bail!("route path {path:?} is registered by more than one endpoint");
}
methods.insert(method.as_str());
}
let mut maintenance_exempt = self.config.maintenance.exempt_paths.clone();
if self.config.health.enabled {
maintenance_exempt.push(self.config.health.path.clone());
maintenance_exempt.push(self.config.health.live_path.clone());
maintenance_exempt.push(self.config.health.ready_path.clone());
maintenance_exempt.push(self.config.health.startup_path.clone());
}
if self.config.metrics.enabled {
maintenance_exempt.push(self.config.metrics.path.clone());
}
if let Some(vp) = &verify_path {
maintenance_exempt.push(vp.clone());
}
let state = ProxyState {
service_name: service_name.clone(),
grpc_upstream,
grpc_channel,
maintenance_mode: self.config.maintenance.enabled,
maintenance_exempt,
maintenance_message: self.config.maintenance.message.clone(),
forwarded_headers: self.config.forwarded_headers.clone(),
metrics_namespace,
metrics_classes: self.config.metrics_classes.clone(),
sse_keep_alive_secs: self.config.streaming.sse_keep_alive_secs,
};
let cors = self.build_cors();
let mut transcode_routes =
transcode::routes_with_options(&pool, &self.config.aliases, &self.transcode);
let authz = match self.config.auth.as_ref().and_then(|a| a.authz.as_ref()) {
Some(cfg) => auth::authz::Authz::build(cfg)
.map_err(|e| anyhow::anyhow!("invalid authz config: {e}"))?,
None => None,
};
if let Some(decider) = &self.auth_decider {
transcode_routes = transcode_routes.layer(axum::middleware::from_fn_with_state(
decider.clone(),
embed::auth_decider_gate,
));
}
if let Some(authz) = authz {
transcode_routes = transcode_routes.layer(axum::middleware::from_fn_with_state(
authz,
auth::authz::middleware,
));
}
let health_routes = if self.config.health.enabled {
let health = &self.config.health;
let health_service_name = service_name.clone();
Router::new()
.route(
&health.path,
get({
let name = health_service_name.clone();
move || async move {
Json(serde_json::json!({
"status": "ok",
"service": name,
}))
}
}),
)
.route(&health.live_path, get(|| async { StatusCode::OK }))
.route(
&health.ready_path,
get(|State(state): State<ProxyState>| async move {
let mut client =
tonic_health::pb::health_client::HealthClient::new(state.grpc_channel);
match client
.check(tonic_health::pb::HealthCheckRequest {
service: String::new(),
})
.await
{
Ok(resp) => {
let status = resp.into_inner().status;
if status
== tonic_health::pb::health_check_response::ServingStatus::Serving
as i32
{
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}
Err(_) => StatusCode::SERVICE_UNAVAILABLE,
}
}),
)
.route(&health.startup_path, get(|| async { StatusCode::OK }))
} else {
Router::new()
};
let metrics_routes = if self.config.metrics.enabled {
Router::new().route(
&self.config.metrics.path,
get(|| async {
let encoder = prometheus::TextEncoder::new();
let metric_families = prometheus::default_registry().gather();
match encoder.encode_to_string(&metric_families) {
Ok(text) => (
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
text,
)
.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}),
)
} else {
Router::new()
};
let openapi_routes = self.build_openapi_routes(&pool);
let oidc_routes = match &self.oidc_backend {
Some(backend) => embed::oidc_backend_routes(backend.clone()),
None => match &self.config.oidc_discovery {
Some(cfg) => oidc::Oidc::build(cfg)
.map_err(|e| anyhow::anyhow!("invalid oidc_discovery config: {e}"))?
.map(|o| o.routes())
.unwrap_or_default(),
None => Router::new(),
},
};
let shield = match &self.config.shield {
Some(cfg) => shield::Shield::build(cfg)
.map_err(|e| anyhow::anyhow!("invalid shield config: {e}"))?,
None => None,
};
let auth = match &self.config.auth {
Some(cfg) => auth::Auth::build(cfg, self.token_verifier.clone())
.map_err(|e| anyhow::anyhow!("invalid auth config: {e}"))?,
None => None,
};
let mut router = Router::new()
.merge(health_routes)
.merge(metrics_routes)
.merge(openapi_routes)
.merge(oidc_routes)
.merge(embed::extra_routes_router(&self.extra_routes))
.merge(transcode_routes);
let forward_auth = auth.as_ref().and_then(|built| {
auth::forward::ForwardAuth::build(self.config.auth.as_ref()?, built.clone())
});
if let Some(shield) = &shield {
router = router.layer(axum::middleware::from_fn_with_state(
shield.clone(),
shield::post_auth_middleware,
));
}
if let Some(auth) = auth {
router = router.layer(axum::middleware::from_fn_with_state(auth, auth::middleware));
}
if let Some(decider) = &self.auth_decider {
let decider = decider.clone();
let path = self.decider_verify_path();
router = router.route(
&path,
axum::routing::any(move |req: axum::extract::Request| {
let decider = decider.clone();
async move { embed::verify_via_decider(decider, req).await }
}),
);
} else if let Some(forward_auth) = &forward_auth {
router = router.merge(forward_auth.routes());
}
if let Some(shield) = &shield {
router = router.layer(axum::middleware::from_fn_with_state(
shield.clone(),
shield::pre_auth_middleware,
));
}
let router = router
.layer(axum::middleware::from_fn_with_state(
state.clone(),
maintenance_middleware,
))
.layer(TraceLayer::new_for_http());
let router = cors::layer(router, cors).with_state(state);
Ok(router)
}
fn build_openapi_routes(&self, pool: &DescriptorPool) -> Router<ProxyState> {
let openapi_config = match &self.config.openapi {
Some(cfg) if cfg.enabled => cfg,
_ => return Router::new(),
};
let spec = openapi::generate(pool, openapi_config, &self.config.aliases);
let spec_json = serde_json::to_string_pretty(&spec).unwrap_or_default();
let openapi_path = openapi_config.path.clone();
let docs_path = openapi_config.docs_path.clone();
let title = openapi_config
.title
.clone()
.unwrap_or_else(|| self.config.service.name.clone());
let openapi_path_for_docs = openapi_path.clone();
tracing::info!("OpenAPI spec at {}, docs at {}", openapi_path, docs_path,);
Router::new()
.route(
&openapi_path,
get(move || async move {
(
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"application/json; charset=utf-8",
)],
spec_json,
)
}),
)
.route(
&docs_path,
get(move || async move {
let html = openapi::docs_html(&openapi_path_for_docs, &title);
(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
html,
)
}),
)
}
fn build_cors(&self) -> CorsLayer {
if self.config.cors.origins.is_empty() {
tracing::warn!("CORS origins not set — using permissive CORS (dev mode)");
CorsLayer::permissive()
} else {
let origins: Vec<_> = self
.config
.cors
.origins
.iter()
.filter_map(|o| o.parse().ok())
.collect();
CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any)
.allow_credentials(true)
.expose_headers([
"grpc-status".parse().unwrap(),
"grpc-message".parse().unwrap(),
"ratelimit-limit".parse().unwrap(),
"ratelimit-remaining".parse().unwrap(),
"ratelimit-reset".parse().unwrap(),
"retry-after".parse().unwrap(),
])
}
}
pub async fn serve(&self) -> anyhow::Result<()> {
let router = self.router()?;
let app = router.into_make_service_with_connect_info::<SocketAddr>();
let addr: SocketAddr = self.config.listen.http.parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!("{} listening on {}", self.config.service.name, addr);
axum::serve(listener, app).await?;
Ok(())
}
}
fn normalize_route_shape(path: &str) -> String {
path.split('/')
.map(|seg| {
if seg.starts_with("{*") && seg.ends_with('}') {
"{*}"
} else if seg.starts_with('{') && seg.ends_with('}') {
"{}"
} else {
seg
}
})
.collect::<Vec<_>>()
.join("/")
}
async fn maintenance_middleware(
State(state): State<ProxyState>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
if state.maintenance_mode {
let path = request.uri().path();
let exempt = state.maintenance_exempt.iter().any(|pattern| {
if pattern.ends_with("/**") {
let prefix = &pattern[..pattern.len() - 3];
path.starts_with(prefix)
} else {
path == pattern
}
});
if !exempt {
return (
StatusCode::SERVICE_UNAVAILABLE,
[("retry-after", "300")],
state.maintenance_message.clone(),
)
.into_response();
}
}
next.run(request).await
}
#[cfg(test)]
pub(crate) fn test_channel() -> tonic::transport::Channel {
tonic::transport::Channel::from_static("http://127.0.0.1:1")
.connect_timeout(std::time::Duration::from_millis(100))
.connect_lazy()
}
#[cfg(test)]
pub(crate) fn test_state() -> ProxyState {
ProxyState {
service_name: "test".into(),
grpc_upstream: "http://127.0.0.1:1".into(),
grpc_channel: test_channel(),
maintenance_mode: false,
maintenance_exempt: vec![],
maintenance_message: String::new(),
forwarded_headers: vec![],
metrics_namespace: "test".into(),
metrics_classes: vec![],
sse_keep_alive_secs: 15,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_route_shape_collapses_param_names() {
assert_eq!(
normalize_route_shape("/v1/x/{profile_id}"),
normalize_route_shape("/v1/x/{id}")
);
assert_eq!(normalize_route_shape("/a/{p}/b"), "/a/{}/b");
assert_eq!(normalize_route_shape("/a/{*rest}"), "/a/{*}");
assert_ne!(
normalize_route_shape("/a/{p}"),
normalize_route_shape("/a/b")
);
}
#[test]
fn test_minimal_config_server() {
let yaml = r#"
upstream:
default: "http://127.0.0.1:50051"
"#;
let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
let server = ProxyServer::from_config(config);
assert!(server.descriptor_pool.is_none());
}
#[tokio::test]
async fn test_maintenance_exempt_matching() {
let state = ProxyState {
service_name: "test".into(),
grpc_upstream: "http://localhost:50051".into(),
grpc_channel: test_channel(),
maintenance_mode: true,
maintenance_exempt: vec![
"/health/**".into(),
"/.well-known/**".into(),
"/metrics".into(),
],
maintenance_message: "Down".into(),
forwarded_headers: vec![],
metrics_namespace: "test".into(),
metrics_classes: vec![],
sse_keep_alive_secs: 15,
};
let check = |path: &str| -> bool {
state.maintenance_exempt.iter().any(|pattern| {
if pattern.ends_with("/**") {
let prefix = &pattern[..pattern.len() - 3];
path.starts_with(prefix)
} else {
path == pattern
}
})
};
assert!(check("/health"));
assert!(check("/health/ready"));
assert!(check("/.well-known/openid-configuration"));
assert!(check("/metrics"));
assert!(!check("/v1/auth/login"));
assert!(!check("/oauth2/token"));
}
}