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            // Register with the manager first: the renewal task's 24h sweep
230            // and fast-retry loop only iterate registered domains, so a
231            // failed or timed-out provision here is retried instead of
232            // staying broken until the next restart.
233            acme_mgr.add_domain(domain).await;
234            let fut = acme_mgr.ensure_cert_for_resolver(domain, &resolver_clone);
235            match tokio::time::timeout(PER_DOMAIN_PROVISION_TIMEOUT, fut).await {
236                Ok(Ok(())) => {}
237                Ok(Err(e)) => {
238                    error!(domain = %domain, error = %e, "Failed to provision cert");
239                }
240                Err(_) => {
241                    warn!(
242                        domain = %domain,
243                        timeout_secs = PER_DOMAIN_PROVISION_TIMEOUT.as_secs(),
244                        "Cert provisioning timed out — skipping (HTTPS will start without this cert; reconciler may retry on demand)"
245                    );
246                }
247            }
248        }
249
250        // Build TlsAcceptor with SNI resolver for multi-domain support
251        let config = rustls::ServerConfig::builder()
252            .with_no_client_auth()
253            .with_cert_resolver(resolver_clone);
254
255        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config));
256        info!(
257            "Starting HTTPS with SNI resolver ({} domains)",
258            domains.len()
259        );
260
261        let routes = routes_clone;
262        let triggers = triggers_clone;
263        let invoker = invoker_clone;
264        if let Err(e) = run_proxy_with_fallback(
265            routes,
266            triggers,
267            invoker,
268            443,
269            Some(acceptor),
270            Some(acme_mgr),
271            fallback_tls,
272        )
273        .await
274        {
275            error!("HTTPS listener failed: {e}");
276        }
277    });
278
279    // Don't block — return the resolver so the control plane can hot-add certs.
280    // The HTTP and HTTPS listeners run in the background.
281    tokio::spawn(async move {
282        tokio::select! {
283            _ = http_handle => warn!("HTTP listener exited"),
284            _ = https_handle => warn!("HTTPS listener exited"),
285        }
286    });
287
288    Ok(resolver)
289}
290
291/// Core accept loop shared by HTTP and HTTPS listeners.
292async fn serve_loop(
293    listener: TcpListener,
294    route_table: Arc<RwLock<HashMap<String, Vec<RouteTarget>>>>,
295    wasm_triggers: SharedWasmTriggers,
296    wasm_invoker: Option<WasmInvoker>,
297    tls_acceptor: Option<tokio_rustls::TlsAcceptor>,
298    acme_manager: Option<AcmeManager>,
299) -> anyhow::Result<()> {
300    serve_loop_with_fallback(
301        listener,
302        route_table,
303        wasm_triggers,
304        wasm_invoker,
305        tls_acceptor,
306        acme_manager,
307        None,
308    )
309    .await
310}
311
312/// Serve loop variant with fallback support for SNI passthrough and HTTP forwarding.
313#[allow(clippy::too_many_arguments)]
314pub(crate) async fn serve_loop_with_fallback(
315    listener: TcpListener,
316    route_table: Arc<RwLock<HashMap<String, Vec<RouteTarget>>>>,
317    wasm_triggers: SharedWasmTriggers,
318    wasm_invoker: Option<WasmInvoker>,
319    tls_acceptor: Option<tokio_rustls::TlsAcceptor>,
320    acme_manager: Option<AcmeManager>,
321    fallback: Option<FallbackConfig>,
322) -> anyhow::Result<()> {
323    let counter = Arc::new(AtomicUsize::new(0));
324    let client = Arc::new(
325        reqwest::Client::builder()
326            .no_proxy()
327            .redirect(reqwest::redirect::Policy::none())
328            // Timeouts are mandatory: without them, a hung upstream (slow
329            // backend, dead fallback, slowloris) parks the per-request task
330            // forever. But a *total* request timeout is wrong for a proxy that
331            // streams large bodies — a 300s cap killed Docker registry blob
332            // pushes/pulls partway through (a ~2GB upload over a typical link
333            // exceeds 300s and died mid-transfer). Use an inactivity
334            // (`read_timeout`) instead: it still recovers from a hung backend
335            // (no bytes → fires) but never caps a transfer that keeps making
336            // progress, so arbitrarily large blobs go through.
337            .connect_timeout(std::time::Duration::from_secs(10))
338            .read_timeout(std::time::Duration::from_secs(120))
339            .pool_idle_timeout(std::time::Duration::from_secs(90))
340            .build()
341            .expect("failed to build HTTP client"),
342    );
343    // A TLS endpoint exists if this listener terminates TLS itself, or if
344    // an ACME manager is present — the plain-HTTP listener of the ACME
345    // dual-listener setup carries one for HTTP-01 challenges, which is
346    // exactly the "HTTPS runs alongside on 443" signal. With neither, the
347    // HTTP→HTTPS redirect has nowhere to send clients and must not fire
348    // (#123: ACME unconfigured meant every routed host redirected into a
349    // closed port).
350    let https_enabled = tls_acceptor.is_some() || acme_manager.is_some();
351    let acme = acme_manager.map(Arc::new);
352    let is_tls = tls_acceptor.is_some();
353    let rate_limiter = RateLimiter::new();
354
355    let fallback = Arc::new(fallback);
356    loop {
357        let (stream, peer) = match listener.accept().await {
358            Ok(conn) => conn,
359            Err(e) => {
360                warn!("Proxy accept error: {e}");
361                continue;
362            }
363        };
364
365        let routes = route_table.clone();
366        let triggers = wasm_triggers.clone();
367        let invoker = wasm_invoker.clone();
368        let counter = counter.clone();
369        let client = client.clone();
370        let acme = acme.clone();
371        let tls = tls_acceptor.clone();
372        let rl = rate_limiter.clone();
373        let fb = fallback.clone();
374        let routes_for_sni = routes.clone();
375
376        let fb_for_service = fb.clone();
377        tokio::spawn(async move {
378            let service = service_fn(move |req: Request<Incoming>| {
379                let routes = routes.clone();
380                let triggers = triggers.clone();
381                let invoker = invoker.clone();
382                let counter = counter.clone();
383                let client = client.clone();
384                let acme = acme.clone();
385                let rl = rl.clone();
386                let fb = fb_for_service.clone();
387                async move {
388                    if let Some(resp) = handle_acme_challenge(&req, acme.as_deref()).await {
389                        return Ok(resp);
390                    }
391                    let mut resp = handle_request(
392                        req,
393                        &routes,
394                        &triggers,
395                        invoker.as_ref(),
396                        &counter,
397                        &client,
398                        is_tls,
399                        https_enabled,
400                        &rl,
401                        peer,
402                        fb.as_ref().as_ref(),
403                    )
404                    .await?;
405                    // Inject baseline security headers (add-if-absent; HSTS only
406                    // over TLS). No-op unless a policy was installed at startup.
407                    security_headers::apply(&mut resp, is_tls);
408                    Ok::<_, hyper::Error>(resp)
409                }
410            });
411            if let Some(acceptor) = tls {
412                let mut stream = stream;
413                // Peek SNI to decide between local TLS termination and pass-through
414                let sni = sni::peek_sni(&mut stream).await;
415                let should_passthrough = if let Some(ref host) = sni {
416                    let routes_lock = routes_for_sni.read().await;
417                    let known = routes_lock.contains_key(host);
418                    drop(routes_lock);
419                    !known && fb.as_ref().as_ref().and_then(|f| f.tls.as_ref()).is_some()
420                } else {
421                    false
422                };
423
424                if should_passthrough {
425                    let target = fb
426                        .as_ref()
427                        .as_ref()
428                        .and_then(|f| f.tls.clone())
429                        .expect("checked above");
430                    debug!(?sni, %target, "SNI passthrough");
431                    match tokio::net::TcpStream::connect(&target).await {
432                        Ok(mut backend) => {
433                            if let Err(e) =
434                                tokio::io::copy_bidirectional(&mut stream, &mut backend).await
435                            {
436                                debug!("Passthrough copy error from {peer}: {e}");
437                            }
438                        }
439                        Err(e) => warn!("Failed to connect to TLS fallback {target}: {e}"),
440                    }
441                    return;
442                }
443
444                match acceptor.accept(stream).await {
445                    Ok(tls_stream) => {
446                        let io = TokioIo::new(tls_stream);
447                        if let Err(e) = http1::Builder::new()
448                            .serve_connection(io, service)
449                            .with_upgrades()
450                            .await
451                        {
452                            debug!("TLS proxy error from {peer}: {e}");
453                        }
454                    }
455                    Err(e) => debug!("TLS handshake failed from {peer}: {e}"),
456                }
457            } else {
458                let io = TokioIo::new(stream);
459                if let Err(e) = http1::Builder::new()
460                    .serve_connection(io, service)
461                    .with_upgrades()
462                    .await
463                {
464                    debug!("Proxy connection error from {peer}: {e}");
465                }
466            }
467        });
468    }
469}