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