1pub 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#[derive(Debug, Clone)]
39pub struct RouteTarget {
40 pub address: String,
42 pub service_name: String,
44 pub path_pattern: Option<String>,
48 pub weight: u32,
51 pub strip_prefix: Option<String>,
56}
57
58#[derive(Debug, Clone)]
60pub struct WasmTrigger {
61 pub pattern: String,
63 pub runtime_id: String,
65 pub service_name: String,
67}
68
69pub type WasmInvoker =
72 Arc<dyn Fn(String, String, String, String) -> WasmInvokeFuture + Send + Sync>;
73
74pub type WasmInvokeFuture =
76 std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, String>> + Send>>;
77
78pub type SharedWasmTriggers = Arc<RwLock<Vec<WasmTrigger>>>;
80
81pub 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#[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
142pub type SharedCertResolver = Arc<acme::DynCertResolver>;
144
145pub 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#[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 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 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 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 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 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
282async 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#[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 .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 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}