Skip to main content

orca_proxy/
lib.rs

1//! Reverse proxy with HTTP routing for containers and Wasm trigger dispatch.
2//!
3//! Routes HTTP traffic by `Host` header to container backends (round-robin),
4//! and by path pattern to Wasm component invocations via a callback.
5//! Supports automatic TLS via ACME/Let's Encrypt (Caddy-style zero-config).
6
7pub mod acme;
8mod body;
9mod error_page;
10mod forward;
11mod handler;
12pub mod rate_limit;
13mod routing;
14mod security_headers;
15pub mod sni;
16pub mod tls;
17mod websocket;
18
19pub use orca_core::config::{FallbackConfig, SecurityHeadersConfig};
20/// Install the proxy's security-header policy (call once at startup). See
21/// [`security_headers`].
22pub use security_headers::init as init_security_headers;
23
24use std::collections::HashMap;
25use std::sync::Arc;
26use std::sync::atomic::AtomicUsize;
27
28use hyper::Request;
29use hyper::body::Incoming;
30use hyper::server::conn::http1;
31use hyper::service::service_fn;
32use hyper_util::rt::{TokioExecutor, TokioIo};
33use hyper_util::server::conn::auto;
34use tokio::net::TcpListener;
35use tokio::sync::RwLock;
36use tracing::{debug, error, info, warn};
37
38use acme::AcmeManager;
39use handler::{handle_acme_challenge, handle_request};
40use rate_limit::RateLimiter;
41
42/// Largest HTTP/2 header list the TLS listener accepts. Matches what the
43/// HTTP/1.1 path tolerates in practice, so large SSO cookie jars work on both.
44const H2_MAX_HEADER_LIST: u32 = 64 * 1024;
45
46/// A backend target for container routing.
47#[derive(Debug, Clone)]
48pub struct RouteTarget {
49    /// Address in the form `ip:port`.
50    pub address: String,
51    /// Owning service name.
52    pub service_name: String,
53    /// Optional path pattern (e.g., `"/api/*"`). When `None`, this target is a
54    /// catch-all for the domain. When `Some`, only requests whose path matches
55    /// the pattern are routed here. Longest-prefix match wins.
56    pub path_pattern: Option<String>,
57    /// Traffic weight (1-100, default 100). Used for weighted routing
58    /// during canary deployments. Higher weight = more traffic.
59    pub weight: u32,
60    /// Prefix to strip from the request path before forwarding upstream,
61    /// e.g. `"/admin"`. With `path_pattern = "/admin/*"` and
62    /// `strip_prefix = Some("/admin")`, a request for `/admin/users` is
63    /// forwarded as `/users` — same semantics as Caddy's `handle_path`.
64    pub strip_prefix: Option<String>,
65}
66
67/// A Wasm HTTP trigger: maps a path pattern to a Wasm runtime instance.
68#[derive(Debug, Clone)]
69pub struct WasmTrigger {
70    /// Path pattern (e.g., "/api/edge/*").
71    pub pattern: String,
72    /// Wasm runtime instance ID.
73    pub runtime_id: String,
74    /// Service name for logging.
75    pub service_name: String,
76}
77
78/// Callback invoked when a request matches a Wasm trigger.
79/// Receives (runtime_id, method, path, body) and returns the response body string.
80pub type WasmInvoker =
81    Arc<dyn Fn(String, String, String, String) -> WasmInvokeFuture + Send + Sync>;
82
83/// Future type returned by the Wasm invoker.
84pub type WasmInvokeFuture =
85    std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, String>> + Send>>;
86
87/// Shared Wasm trigger table type.
88pub type SharedWasmTriggers = Arc<RwLock<Vec<WasmTrigger>>>;
89
90/// Run the reverse proxy on the given port.
91pub async fn run_proxy(
92    route_table: Arc<RwLock<HashMap<String, Vec<RouteTarget>>>>,
93    wasm_triggers: SharedWasmTriggers,
94    wasm_invoker: Option<WasmInvoker>,
95    port: u16,
96    tls_acceptor: Option<tokio_rustls::TlsAcceptor>,
97    acme_manager: Option<AcmeManager>,
98) -> anyhow::Result<()> {
99    let addr = format!("0.0.0.0:{port}");
100    let listener = TcpListener::bind(&addr).await?;
101    let proto = if tls_acceptor.is_some() {
102        "HTTPS"
103    } else {
104        "HTTP"
105    };
106    info!("Reverse proxy listening on {addr} ({proto})");
107
108    serve_loop(
109        listener,
110        route_table,
111        wasm_triggers,
112        wasm_invoker,
113        tls_acceptor,
114        acme_manager,
115    )
116    .await
117}
118
119/// Run the proxy with optional fallback support.
120#[allow(clippy::too_many_arguments)]
121pub async fn run_proxy_with_fallback(
122    route_table: Arc<RwLock<HashMap<String, Vec<RouteTarget>>>>,
123    wasm_triggers: SharedWasmTriggers,
124    wasm_invoker: Option<WasmInvoker>,
125    port: u16,
126    tls_acceptor: Option<tokio_rustls::TlsAcceptor>,
127    acme_manager: Option<AcmeManager>,
128    fallback: Option<FallbackConfig>,
129) -> anyhow::Result<()> {
130    let addr = format!("0.0.0.0:{port}");
131    let listener = TcpListener::bind(&addr).await?;
132    let proto = if tls_acceptor.is_some() {
133        "HTTPS"
134    } else {
135        "HTTP"
136    };
137    info!("Reverse proxy listening on {addr} ({proto})");
138
139    serve_loop_with_fallback(
140        listener,
141        route_table,
142        wasm_triggers,
143        wasm_invoker,
144        tls_acceptor,
145        acme_manager,
146        fallback,
147    )
148    .await
149}
150
151/// Shared dynamic cert resolver for hot-provisioning.
152pub type SharedCertResolver = Arc<acme::DynCertResolver>;
153
154/// Run HTTP on port 80 (for ACME challenges + redirect) and HTTPS on port 443.
155///
156/// Automatically provisions certs for all given domains via Let's Encrypt.
157/// Returns a `SharedCertResolver` that can be used to hot-provision certs
158/// for new domains added later via `orca deploy`.
159pub async fn run_proxy_with_acme(
160    route_table: Arc<RwLock<HashMap<String, Vec<RouteTarget>>>>,
161    wasm_triggers: SharedWasmTriggers,
162    wasm_invoker: Option<WasmInvoker>,
163    acme_manager: AcmeManager,
164    domains: Vec<String>,
165) -> anyhow::Result<SharedCertResolver> {
166    run_proxy_with_acme_and_fallback(
167        route_table,
168        wasm_triggers,
169        wasm_invoker,
170        acme_manager,
171        domains,
172        None,
173    )
174    .await
175}
176
177/// Run HTTP+HTTPS with ACME and optional fallback to another reverse proxy.
178#[allow(clippy::too_many_arguments)]
179pub async fn run_proxy_with_acme_and_fallback(
180    route_table: Arc<RwLock<HashMap<String, Vec<RouteTarget>>>>,
181    wasm_triggers: SharedWasmTriggers,
182    wasm_invoker: Option<WasmInvoker>,
183    acme_manager: AcmeManager,
184    domains: Vec<String>,
185    fallback: Option<FallbackConfig>,
186) -> anyhow::Result<SharedCertResolver> {
187    let resolver = Arc::new(acme::DynCertResolver::new());
188
189    let acme_mgr = acme_manager.clone();
190    let routes_clone = route_table.clone();
191    let triggers_clone = wasm_triggers.clone();
192    let invoker_clone = wasm_invoker.clone();
193    let fallback_http = fallback.clone();
194    let fallback_tls = fallback.clone();
195
196    // Start HTTP on port 80 first (needed for ACME challenge validation)
197    let http_handle = tokio::spawn({
198        let acme = acme_mgr.clone();
199        let routes = routes_clone.clone();
200        let triggers = triggers_clone.clone();
201        let invoker = invoker_clone.clone();
202        async move {
203            if let Err(e) = run_proxy_with_fallback(
204                routes,
205                triggers,
206                invoker,
207                80,
208                None,
209                Some(acme),
210                fallback_http,
211            )
212            .await
213            {
214                error!("HTTP listener failed: {e}");
215            }
216        }
217    });
218
219    // Provision certs for initial domains, then start HTTPS with SNI resolver
220    let resolver_clone = resolver.clone();
221    let https_handle = tokio::spawn(async move {
222        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
223
224        // Provision all initial domain certs. Each call gets its own 60s
225        // timeout: without it, a single domain whose LE HTTP-01 challenge
226        // hangs (DNS pointing elsewhere, port 80 firewalled, LE rate limit
227        // backoff) blocks the entire HTTPS listener startup forever. 60s is
228        // generous — a healthy LE order completes in 5-15s — so a timeout
229        // here is a real problem, but we'd rather serve the other domains
230        // than serve nothing.
231        const PER_DOMAIN_PROVISION_TIMEOUT: std::time::Duration =
232            std::time::Duration::from_secs(60);
233        for domain in &domains {
234            // Register with the manager first: the renewal task's 24h sweep
235            // and fast-retry loop only iterate registered domains, so a
236            // failed or timed-out provision here is retried instead of
237            // staying broken until the next restart.
238            acme_mgr.add_domain(domain).await;
239            let fut = acme_mgr.ensure_cert_for_resolver(domain, &resolver_clone);
240            match tokio::time::timeout(PER_DOMAIN_PROVISION_TIMEOUT, fut).await {
241                Ok(Ok(())) => {}
242                Ok(Err(e)) => {
243                    error!(domain = %domain, error = %e, "Failed to provision cert");
244                }
245                Err(_) => {
246                    warn!(
247                        domain = %domain,
248                        timeout_secs = PER_DOMAIN_PROVISION_TIMEOUT.as_secs(),
249                        "Cert provisioning timed out — skipping (HTTPS will start without this cert; reconciler may retry on demand)"
250                    );
251                }
252            }
253        }
254
255        // Build TlsAcceptor with SNI resolver for multi-domain support
256        let config = tls::with_h2_alpn(
257            rustls::ServerConfig::builder()
258                .with_no_client_auth()
259                .with_cert_resolver(resolver_clone),
260        );
261
262        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config));
263        info!(
264            "Starting HTTPS with SNI resolver ({} domains)",
265            domains.len()
266        );
267
268        let routes = routes_clone;
269        let triggers = triggers_clone;
270        let invoker = invoker_clone;
271        if let Err(e) = run_proxy_with_fallback(
272            routes,
273            triggers,
274            invoker,
275            443,
276            Some(acceptor),
277            Some(acme_mgr),
278            fallback_tls,
279        )
280        .await
281        {
282            error!("HTTPS listener failed: {e}");
283        }
284    });
285
286    // Don't block — return the resolver so the control plane can hot-add certs.
287    // The HTTP and HTTPS listeners run in the background.
288    tokio::spawn(async move {
289        tokio::select! {
290            _ = http_handle => warn!("HTTP listener exited"),
291            _ = https_handle => warn!("HTTPS listener exited"),
292        }
293    });
294
295    Ok(resolver)
296}
297
298/// Core accept loop shared by HTTP and HTTPS listeners.
299async fn serve_loop(
300    listener: TcpListener,
301    route_table: Arc<RwLock<HashMap<String, Vec<RouteTarget>>>>,
302    wasm_triggers: SharedWasmTriggers,
303    wasm_invoker: Option<WasmInvoker>,
304    tls_acceptor: Option<tokio_rustls::TlsAcceptor>,
305    acme_manager: Option<AcmeManager>,
306) -> anyhow::Result<()> {
307    serve_loop_with_fallback(
308        listener,
309        route_table,
310        wasm_triggers,
311        wasm_invoker,
312        tls_acceptor,
313        acme_manager,
314        None,
315    )
316    .await
317}
318
319/// Serve loop variant with fallback support for SNI passthrough and HTTP forwarding.
320#[allow(clippy::too_many_arguments)]
321pub(crate) async fn serve_loop_with_fallback(
322    listener: TcpListener,
323    route_table: Arc<RwLock<HashMap<String, Vec<RouteTarget>>>>,
324    wasm_triggers: SharedWasmTriggers,
325    wasm_invoker: Option<WasmInvoker>,
326    tls_acceptor: Option<tokio_rustls::TlsAcceptor>,
327    acme_manager: Option<AcmeManager>,
328    fallback: Option<FallbackConfig>,
329) -> anyhow::Result<()> {
330    let counter = Arc::new(AtomicUsize::new(0));
331    let client = Arc::new(
332        reqwest::Client::builder()
333            .no_proxy()
334            .redirect(reqwest::redirect::Policy::none())
335            // Timeouts are mandatory: without them, a hung upstream (slow
336            // backend, dead fallback, slowloris) parks the per-request task
337            // forever. But a *total* request timeout is wrong for a proxy that
338            // streams large bodies — a 300s cap killed Docker registry blob
339            // pushes/pulls partway through (a ~2GB upload over a typical link
340            // exceeds 300s and died mid-transfer). Use an inactivity
341            // (`read_timeout`) instead: it still recovers from a hung backend
342            // (no bytes → fires) but never caps a transfer that keeps making
343            // progress, so arbitrarily large blobs go through.
344            .connect_timeout(std::time::Duration::from_secs(10))
345            .read_timeout(std::time::Duration::from_secs(120))
346            .pool_idle_timeout(std::time::Duration::from_secs(90))
347            .build()
348            .expect("failed to build HTTP client"),
349    );
350    // A TLS endpoint exists if this listener terminates TLS itself, or if
351    // an ACME manager is present — the plain-HTTP listener of the ACME
352    // dual-listener setup carries one for HTTP-01 challenges, which is
353    // exactly the "HTTPS runs alongside on 443" signal. With neither, the
354    // HTTP→HTTPS redirect has nowhere to send clients and must not fire
355    // (#123: ACME unconfigured meant every routed host redirected into a
356    // closed port).
357    let https_enabled = tls_acceptor.is_some() || acme_manager.is_some();
358    let acme = acme_manager.map(Arc::new);
359    let is_tls = tls_acceptor.is_some();
360    let rate_limiter = RateLimiter::new();
361
362    let fallback = Arc::new(fallback);
363    loop {
364        let (stream, peer) = match listener.accept().await {
365            Ok(conn) => conn,
366            Err(e) => {
367                warn!("Proxy accept error: {e}");
368                continue;
369            }
370        };
371
372        let routes = route_table.clone();
373        let triggers = wasm_triggers.clone();
374        let invoker = wasm_invoker.clone();
375        let counter = counter.clone();
376        let client = client.clone();
377        let acme = acme.clone();
378        let tls = tls_acceptor.clone();
379        let rl = rate_limiter.clone();
380        let fb = fallback.clone();
381        let routes_for_sni = routes.clone();
382
383        let fb_for_service = fb.clone();
384        tokio::spawn(async move {
385            let service = service_fn(move |req: Request<Incoming>| {
386                let routes = routes.clone();
387                let triggers = triggers.clone();
388                let invoker = invoker.clone();
389                let counter = counter.clone();
390                let client = client.clone();
391                let acme = acme.clone();
392                let rl = rl.clone();
393                let fb = fb_for_service.clone();
394                async move {
395                    if let Some(resp) = handle_acme_challenge(&req, acme.as_deref()).await {
396                        return Ok(resp);
397                    }
398                    let mut resp = handle_request(
399                        req,
400                        &routes,
401                        &triggers,
402                        invoker.as_ref(),
403                        &counter,
404                        &client,
405                        is_tls,
406                        https_enabled,
407                        &rl,
408                        peer,
409                        fb.as_ref().as_ref(),
410                    )
411                    .await?;
412                    // Inject baseline security headers (add-if-absent; HSTS only
413                    // over TLS). No-op unless a policy was installed at startup.
414                    security_headers::apply(&mut resp, is_tls);
415                    Ok::<_, hyper::Error>(resp)
416                }
417            });
418            if let Some(acceptor) = tls {
419                let mut stream = stream;
420                // Peek SNI to decide between local TLS termination and pass-through
421                let sni = sni::peek_sni(&mut stream).await;
422                let should_passthrough = if let Some(ref host) = sni {
423                    let routes_lock = routes_for_sni.read().await;
424                    let known = routes_lock.contains_key(host);
425                    drop(routes_lock);
426                    !known && fb.as_ref().as_ref().and_then(|f| f.tls.as_ref()).is_some()
427                } else {
428                    false
429                };
430
431                if should_passthrough {
432                    let target = fb
433                        .as_ref()
434                        .as_ref()
435                        .and_then(|f| f.tls.clone())
436                        .expect("checked above");
437                    debug!(?sni, %target, "SNI passthrough");
438                    match tokio::net::TcpStream::connect(&target).await {
439                        Ok(mut backend) => {
440                            if let Err(e) =
441                                tokio::io::copy_bidirectional(&mut stream, &mut backend).await
442                            {
443                                debug!("Passthrough copy error from {peer}: {e}");
444                            }
445                        }
446                        Err(e) => warn!("Failed to connect to TLS fallback {target}: {e}"),
447                    }
448                    return;
449                }
450
451                match acceptor.accept(stream).await {
452                    Ok(tls_stream) => {
453                        let io = TokioIo::new(tls_stream);
454                        // h1 or h2, picked by the ALPN result. WebSocket
455                        // upgrades stay on h1: extended CONNECT (RFC 8441)
456                        // is not enabled, so browsers open a separate
457                        // HTTP/1.1 connection for them, as before.
458                        // hyper's h2 default caps the header list at 16 KiB and
459                        // answers 431 above it; SSO cookie jars (Keycloak plus
460                        // the app's own) exceed that where h1 never refused.
461                        let mut builder = auto::Builder::new(TokioExecutor::new());
462                        builder.http2().max_header_list_size(H2_MAX_HEADER_LIST);
463                        if let Err(e) = builder.serve_connection_with_upgrades(io, service).await {
464                            debug!("TLS proxy error from {peer}: {e}");
465                        }
466                    }
467                    Err(e) => debug!("TLS handshake failed from {peer}: {e}"),
468                }
469            } else {
470                // The plain listener stays HTTP/1-only: browsers never speak
471                // cleartext h2 (h2c), and this port only serves redirects,
472                // ACME challenges and plain-HTTP routes, so accepting h2c
473                // would add parser surface for no client.
474                let io = TokioIo::new(stream);
475                if let Err(e) = http1::Builder::new()
476                    .serve_connection(io, service)
477                    .with_upgrades()
478                    .await
479                {
480                    debug!("Proxy connection error from {peer}: {e}");
481                }
482            }
483        });
484    }
485}