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