Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ readme = "README.md"
keywords = ["grpc", "rest", "proxy", "transcoding", "protobuf"]
categories = ["network-programming", "web-programming::http-server"]

# docs.rs builds with the `redis` feature on top of the defaults so the
# Redis-backed rate-limit store (and its intra-doc links) are documented.
[package.metadata.docs.rs]
features = ["redis"]

[[bin]]
name = "structured-proxy"
path = "src/main.rs"
Expand All @@ -25,6 +30,12 @@ path = "src/lib.rs"
axum = { version = "0.8", features = ["macros"] }
tower = "0.5"
tower-http = { version = "0.7", features = ["cors", "trace"] }
# Foundational HTTP types, used directly by the framework-agnostic embedding
# hooks (src/hooks.rs) so an embedder never names `axum`. Already in the tree
# transitively via both axum and tonic; pinned here to make the public API deps
# explicit.
http = "1"
bytes = "1"

# gRPC client (to upstream service)
tonic = "0.14"
Expand Down Expand Up @@ -112,6 +123,13 @@ redis = ["dep:redis"]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
# The embedding-hooks integration test (tests/hooks.rs) writes hook impls using
# only these (the same crates a real embedder uses — none is an HTTP framework)
# and drives the resulting router via axum + tower for assertions.
axum = { version = "0.8", features = ["macros"] }
http = "1"
bytes = "1"
async-trait = "0.1"
ed25519-dalek = "2"
rand = "0.10"
chrono = "0.4"
Expand Down
60 changes: 59 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Works with **any** gRPC service via proto descriptor files. No code generation,

## Non-goals

- **Session / BFF management** (cookie-based login, server-side token storage, refresh flows). This proxy is a stateless transcoding data plane with stateless auth primitives; session lifecycle is a separate, stateful concern. Put a dedicated BFF (e.g. `oauth2-proxy`, Pomerium) in front, or drive auth through the forward-auth / external-authz hooks above.
- **Session / BFF management** (cookie-based login, server-side token storage, refresh flows) and **stateful OIDC** (`authorize` / `token` with auth codes / PKCE state). The **default build** is a stateless transcoding data plane with stateless auth primitives; session lifecycle is a separate, stateful concern. Put a dedicated BFF (e.g. `oauth2-proxy`, Pomerium) in front, or drive auth through the stateless forward-auth / external-authz hooks below. (A stateful surface behind an opt-in, default-off `bff` Cargo feature is planned; it does not affect the default data-plane build.)

## Quick Start

Expand Down Expand Up @@ -76,6 +76,20 @@ aliases:
- from: "/api/v1/*"
to: "/my.package.v1.MyService/*"

# Optional: health-probe endpoints. Paths are configurable (relocate behind an
# internal prefix) and the whole group can be disabled. Defaults shown.
health:
enabled: true
path: "/health"
live_path: "/health/live"
ready_path: "/health/ready" # checks the upstream gRPC health
startup_path: "/health/startup"

# Optional: Prometheus metrics endpoint. Path configurable; can be disabled.
metrics:
enabled: true
path: "/metrics"

# Optional: maintenance mode (returns 503 except for exempt paths)
maintenance:
enabled: false
Expand Down Expand Up @@ -184,6 +198,50 @@ async fn main() -> anyhow::Result<()> {
}
```

### Embedding hooks (axum-free)

Inject *stateless* service-specific logic without naming an HTTP framework in
your own crate: implement the hook traits with foundational types (`http`,
`bytes`, `serde_json`) plus `async-trait` (the traits are `#[async_trait]`),
none of which is an HTTP framework. `cargo tree -i axum` in your crate then
shows `axum` solely under `structured-proxy`.

```rust
use std::sync::Arc;
use structured_proxy::{config::ProxyConfig, ProxyServer};
use structured_proxy::hooks::{AuthDecider, Decision, RequestParts};

struct MyPdp; // your forward-auth / policy decision

#[async_trait::async_trait]
impl AuthDecider for MyPdp {
async fn decide(&self, req: &RequestParts<'_>) -> Decision {
// method / path / headers / peer in, a decision out (no axum types)
Decision::Allow { inject_headers: http::HeaderMap::new() }
}
}

# async fn run(config: ProxyConfig) -> anyhow::Result<()> {
ProxyServer::from_config(config)
.with_auth_decider(Arc::new(MyPdp)) // inline gate + /verify endpoint
// .with_oidc_backend(...) // stateless discovery / JWKS / userinfo
// .with_extra_routes(...) // extra stateless routes, axum-free
.serve()
.await
# }
```

The hooks are:

- **`with_auth_decider`** — an in-process forward-auth / PDP decision, run inline
on every proxied request and exposed at `/verify` (path configurable via
`with_verify_path`).
- **`with_oidc_backend`** — backs the stateless OIDC surface (discovery, JWKS,
userinfo) with your key/client metadata; supersedes the config-driven static
discovery.
- **`with_extra_routes`** — registers extra stateless routes through a
framework-agnostic adapter (request parts in, response parts out).

Comment thread
polaz marked this conversation as resolved.
## How It Works

1. Load the proto descriptor from a pre-compiled descriptor file
Expand Down
192 changes: 192 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ pub struct ProxyConfig {
#[serde(default)]
pub oidc_discovery: Option<OidcDiscoveryConfig>,

/// Health-probe endpoints (paths configurable; can be disabled).
#[serde(default)]
pub health: HealthConfig,

/// Prometheus metrics endpoint (path configurable; can be disabled).
#[serde(default)]
pub metrics: MetricsConfig,

/// Maintenance mode.
#[serde(default)]
pub maintenance: MaintenanceConfig,
Expand Down Expand Up @@ -447,6 +455,81 @@ fn default_algorithm() -> String {
"EdDSA".into()
}

/// Health-probe endpoint configuration.
///
/// Paths are configurable so an embedder can relocate the probes (e.g. behind a
/// `/internal/` prefix) or disable them when a fronting platform supplies its
/// own. Defaults match the conventional `/health*` layout.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct HealthConfig {
/// Mount the health endpoints. Default: true.
#[serde(default = "default_true")]
pub enabled: bool,
/// Aggregate health endpoint. Default: `/health`.
#[serde(default = "default_health_path")]
pub path: String,
/// Liveness probe. Default: `/health/live`.
#[serde(default = "default_health_live_path")]
pub live_path: String,
/// Readiness probe (checks the upstream gRPC health). Default: `/health/ready`.
#[serde(default = "default_health_ready_path")]
pub ready_path: String,
/// Startup probe. Default: `/health/startup`.
#[serde(default = "default_health_startup_path")]
pub startup_path: String,
}

Comment thread
polaz marked this conversation as resolved.
fn default_health_path() -> String {
"/health".into()
}
fn default_health_live_path() -> String {
"/health/live".into()
}
fn default_health_ready_path() -> String {
"/health/ready".into()
}
fn default_health_startup_path() -> String {
"/health/startup".into()
}

impl Default for HealthConfig {
fn default() -> Self {
Self {
enabled: true,
path: default_health_path(),
live_path: default_health_live_path(),
ready_path: default_health_ready_path(),
startup_path: default_health_startup_path(),
}
}
}

/// Prometheus metrics endpoint configuration.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct MetricsConfig {
/// Mount the metrics endpoint. Default: true.
#[serde(default = "default_true")]
pub enabled: bool,
/// Scrape path. Default: `/metrics`.
#[serde(default = "default_metrics_path")]
pub path: String,
}

fn default_metrics_path() -> String {
"/metrics".into()
}

impl Default for MetricsConfig {
fn default() -> Self {
Self {
enabled: true,
path: default_metrics_path(),
}
}
}

/// Maintenance mode config.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
Expand Down Expand Up @@ -553,6 +636,38 @@ impl ProxyConfig {
if self.streaming.sse_keep_alive_secs == 0 {
anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
}
self.validate_edge_paths()?;
Ok(())
}

/// Reject malformed or duplicate built-in edge paths up front, so the router
/// does not panic at construction (axum rejects a route that does not start
/// with `/`, and panics on a path registered twice, e.g. setting
/// `health.path` to the default `live_path`).
fn validate_edge_paths(&self) -> anyhow::Result<()> {
let mut seen = std::collections::HashSet::new();
let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
if !path.starts_with('/') {
anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
}
if !seen.insert(path.to_string()) {
anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
}
Ok(())
};
if self.health.enabled {
check("health.path", &self.health.path)?;
check("health.live_path", &self.health.live_path)?;
check("health.ready_path", &self.health.ready_path)?;
check("health.startup_path", &self.health.startup_path)?;
}
if self.metrics.enabled {
check("metrics.path", &self.metrics.path)?;
}
Comment thread
polaz marked this conversation as resolved.
if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
check("openapi.path", &openapi.path)?;
check("openapi.docs_path", &openapi.docs_path)?;
}
Ok(())
}

Expand Down Expand Up @@ -586,6 +701,83 @@ upstream:
assert!(config.shield.is_none());
}

#[test]
fn health_and_metrics_defaults_and_overrides() {
// Defaults: enabled, conventional paths.
let min: ProxyConfig =
serde_yaml::from_str("upstream:\n default: \"grpc://x:1\"\n").unwrap();
assert!(min.health.enabled);
assert_eq!(min.health.path, "/health");
assert_eq!(min.health.ready_path, "/health/ready");
assert!(min.metrics.enabled);
assert_eq!(min.metrics.path, "/metrics");

// Overrides apply; unspecified sub-paths keep their defaults.
let yaml = r#"
upstream:
default: "grpc://x:1"
health:
path: "/internal/health"
metrics:
enabled: false
path: "/internal/metrics"
"#;
let cfg: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.health.path, "/internal/health");
// live_path was not overridden, so it stays at the default.
assert_eq!(cfg.health.live_path, "/health/live");
assert!(!cfg.metrics.enabled);
assert_eq!(cfg.metrics.path, "/internal/metrics");
}

#[test]
fn duplicate_probe_paths_are_rejected() {
// health.path set to the default live_path collides on a single GET
// route; reject at load instead of panicking in the router.
let yaml = r#"
upstream:
default: "grpc://x:1"
health:
path: "/health/live"
"#;
let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
assert!(err.to_string().contains("duplicate endpoint path"));

// A health path colliding with the metrics path is also rejected.
let yaml2 = r#"
upstream:
default: "grpc://x:1"
metrics:
path: "/health"
"#;
let err2 = ProxyConfig::from_yaml_str(yaml2).unwrap_err();
assert!(err2.to_string().contains("duplicate endpoint path"));

// Disabling a group frees its paths from the collision check.
let yaml3 = r#"
upstream:
default: "grpc://x:1"
health:
enabled: false
path: "/metrics"
"#;
assert!(ProxyConfig::from_yaml_str(yaml3).is_ok());
}

#[test]
fn malformed_edge_path_is_rejected() {
// A path without a leading '/' would make axum reject the route at
// construction; catch it at config load with a clear message.
let yaml = r#"
upstream:
default: "grpc://x:1"
health:
path: "health"
"#;
let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
assert!(err.to_string().contains("must start with '/'"));
}

#[test]
fn test_zero_sse_keep_alive_is_rejected() {
// A zero keep-alive would make axum's SSE timer fire continuously
Expand Down
Loading