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