1#[cfg(test)]
40mod tests;
41
42#[cfg(any(feature = "http-client", feature = "http2"))]
43mod tls;
44#[doc(hidden)]
48pub mod watch;
49
50use std::io::{Read, Write};
51use std::net::TcpStream;
52use std::sync::{Arc, RwLock};
53use std::time::Duration;
54
55use crate::application::Application;
56use crate::request::Request;
57use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
58use crate::server::ConnectionInfo;
59
60const WATCH_READ_TIMEOUT: Duration = Duration::from_secs(300);
64const WATCH_RECONNECT_BACKOFF: Duration = Duration::from_secs(2);
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum PathType {
73 Prefix,
77 Exact,
80 ImplementationSpecific,
83}
84
85impl PathType {
86 fn parse(s: &str) -> Self {
87 match s {
88 "Exact" => PathType::Exact,
89 "ImplementationSpecific" => PathType::ImplementationSpecific,
90 _ => PathType::Prefix,
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq)]
99pub struct IngressRule {
100 pub host: String,
102 pub path: String,
104 pub path_type: PathType,
107 pub service_name: String,
109 pub service_port: u16,
111 pub namespace: String,
113}
114
115impl IngressRule {
116 pub fn upstream_addr(&self) -> String {
120 format!(
121 "{}.{}.svc.cluster.local:{}",
122 self.service_name, self.namespace, self.service_port
123 )
124 }
125
126 pub fn matches(&self, host: &str, uri: &str) -> bool {
138 if !self.host.is_empty() && !self.host.eq_ignore_ascii_case(host) {
139 return false;
140 }
141 let request_path = uri.split('?').next().unwrap_or(uri);
142 match self.path_type {
143 PathType::Exact => request_path == self.path,
144 PathType::Prefix | PathType::ImplementationSpecific => {
145 path_prefix_matches(&self.path, request_path)
146 }
147 }
148 }
149}
150
151fn path_prefix_matches(prefix: &str, request_path: &str) -> bool {
153 if prefix == "/" {
154 return true;
155 }
156 let trimmed = prefix.trim_end_matches('/');
157 request_path == trimmed || request_path.starts_with(&format!("{trimmed}/"))
158}
159
160fn extract_str_field<'a>(json: &'a str, field: &str) -> Option<&'a str> {
164 let needle = format!("\"{}\":", field);
165 let start = json.find(needle.as_str())?;
166 let after_colon = &json[start + needle.len()..];
167 let after_colon = after_colon.trim_start_matches(' ');
168 if !after_colon.starts_with('"') {
169 return None;
170 }
171 let inner = &after_colon[1..];
172 let end = inner.find('"')?;
173 Some(&inner[..end])
174}
175
176fn extract_last_str_field<'a>(json: &'a str, field: &str) -> Option<&'a str> {
182 let needle = format!("\"{}\":", field);
183 let start = json.rfind(needle.as_str())?;
184 let after_colon = &json[start + needle.len()..];
185 let after_colon = after_colon.trim_start_matches(' ');
186 if !after_colon.starts_with('"') {
187 return None;
188 }
189 let inner = &after_colon[1..];
190 let end = inner.find('"')?;
191 Some(&inner[..end])
192}
193
194fn extract_u16_field(json: &str, field: &str) -> Option<u16> {
196 let needle = format!("\"{}\":", field);
197 let start = json.find(needle.as_str())?;
198 let after_colon = &json[start + needle.len()..];
199 let after_colon = after_colon.trim_start_matches(' ');
200 let end = after_colon.find(|c: char| !c.is_ascii_digit())?;
201 after_colon[..end].parse().ok()
202}
203
204pub fn parse_ingress_list(json: &str, ingress_class: Option<&str>) -> Vec<IngressRule> {
218 let mut rules = Vec::new();
219
220 let spec_positions: Vec<usize> = json.match_indices("\"spec\"").map(|(i, _)| i).collect();
227 for (idx, &pos) in spec_positions.iter().enumerate() {
228 let section_start = pos + "\"spec\"".len();
229 let section_end = spec_positions.get(idx + 1).copied().unwrap_or(json.len());
230 let section = &json[section_start..section_end];
231
232 let namespace = extract_last_str_field(&json[..pos], "namespace")
240 .unwrap_or("default")
241 .to_string();
242
243 if let Some(wanted) = ingress_class {
244 if extract_str_field(section, "ingressClassName") != Some(wanted) {
245 continue;
246 }
247 }
248
249 let rules_sections: Vec<&str> = section.split("\"rules\"").collect();
251 for rules_section in rules_sections.iter().skip(1) {
252 let host = extract_str_field(rules_section, "host").unwrap_or("").to_string();
254
255 let paths_sections: Vec<&str> = rules_section.split("\"paths\"").collect();
257 for paths_section in paths_sections.iter().skip(1) {
258 let path_entries: Vec<&str> = paths_section.split("\"path\"").collect();
262 for path_entry in path_entries.iter().skip(1) {
263 let path = extract_str_field(path_entry, "path")
264 .or_else(|| {
265 let after_colon = path_entry.trim_start_matches(':').trim_start_matches(' ');
267 if after_colon.starts_with('"') {
268 let inner = &after_colon[1..];
269 inner.find('"').map(|end| &inner[..end])
270 } else {
271 None
272 }
273 })
274 .unwrap_or("/")
275 .to_string();
276 let path_type = extract_str_field(path_entry, "pathType")
277 .map(PathType::parse)
278 .unwrap_or(PathType::Prefix);
279
280 let service_name =
281 extract_str_field(path_entry, "name").unwrap_or("").to_string();
282 let service_port =
283 extract_u16_field(path_entry, "number").unwrap_or(80);
284
285 if !service_name.is_empty() {
286 rules.push(IngressRule {
287 host: host.clone(),
288 path,
289 path_type,
290 service_name,
291 service_port,
292 namespace: namespace.clone(),
293 });
294 }
295 }
296 }
297 }
298 }
299
300 rules
301}
302
303pub struct KubernetesIngressWatcher {
308 api_server: String,
309 token: String,
310 namespace: String,
311 ingress_class: Option<String>,
312 poll_interval_secs: u64,
313 rules: Arc<RwLock<Vec<IngressRule>>>,
314 #[cfg(any(feature = "http-client", feature = "http2"))]
315 tls: Option<Arc<tls::InClusterConfig>>,
316}
317
318impl KubernetesIngressWatcher {
319 pub fn new(api_server: impl Into<String>, token: impl Into<String>) -> Self {
324 Self {
325 api_server: api_server.into(),
326 token: token.into(),
327 namespace: "default".to_string(),
328 ingress_class: None,
329 poll_interval_secs: 30,
330 rules: Arc::new(RwLock::new(Vec::new())),
331 #[cfg(any(feature = "http-client", feature = "http2"))]
332 tls: None,
333 }
334 }
335
336 #[cfg(any(feature = "http-client", feature = "http2"))]
348 pub fn from_service_account() -> Result<Self, String> {
349 let cfg = tls::load()?;
350 let namespace = cfg.namespace.clone();
351 let mut watcher = Self::new("", "");
352 watcher.namespace = namespace;
353 watcher.tls = Some(Arc::new(cfg));
354 Ok(watcher)
355 }
356
357 #[cfg(not(any(feature = "http-client", feature = "http2")))]
369 pub fn from_service_account() -> Result<Self, String> {
370 Err(
371 "In-cluster TLS (https://kubernetes.default.svc) requires the `http-client` or \
372 `http2` feature. Enable one of those, or use `kubectl proxy` and set \
373 RWS_K8S_API_SERVER=http://localhost:8001 along with RWS_K8S_TOKEN and \
374 RWS_K8S_NAMESPACE, then call KubernetesIngressWatcher::from_env()."
375 .to_string(),
376 )
377 }
378
379 pub fn from_env() -> Result<Self, String> {
384 let api_server = std::env::var("RWS_K8S_API_SERVER").map_err(|_| {
385 "RWS_K8S_API_SERVER environment variable is not set".to_string()
386 })?;
387 let token = std::env::var("RWS_K8S_TOKEN").unwrap_or_default();
388 let namespace = std::env::var("RWS_K8S_NAMESPACE").unwrap_or_else(|_| "default".to_string());
389 let mut watcher = Self::new(api_server, token);
390 watcher.namespace = namespace;
391 Ok(watcher)
392 }
393
394 pub fn namespace(mut self, ns: impl Into<String>) -> Self {
396 self.namespace = ns.into();
397 self
398 }
399
400 pub fn ingress_class(mut self, class: impl Into<String>) -> Self {
408 self.ingress_class = Some(class.into());
409 self
410 }
411
412 pub fn poll_interval_secs(mut self, secs: u64) -> Self {
421 self.poll_interval_secs = secs;
422 self
423 }
424
425 pub fn start(&self) {
431 self.clone_inner().poll_loop();
432 self.clone_inner().watch_loop();
433 }
434
435 fn clone_inner(&self) -> WatcherHandle {
436 WatcherHandle {
437 api_server: self.api_server.clone(),
438 token: self.token.clone(),
439 namespace: self.namespace.clone(),
440 ingress_class: self.ingress_class.clone(),
441 poll_interval_secs: self.poll_interval_secs,
442 rules: Arc::clone(&self.rules),
443 #[cfg(any(feature = "http-client", feature = "http2"))]
444 tls: self.tls.clone(),
445 }
446 }
447
448 pub fn rules(&self) -> Vec<IngressRule> {
450 self.rules.read().unwrap().clone()
451 }
452
453 pub fn poll(&self) -> Result<(), String> {
457 let new_rules = self.do_poll()?;
458 *self.rules.write().unwrap() = new_rules;
459 Ok(())
460 }
461
462 fn list_path(&self) -> String {
463 list_path(&self.namespace)
464 }
465
466 fn do_poll(&self) -> Result<Vec<IngressRule>, String> {
467 let path = self.list_path();
468 #[cfg(any(feature = "http-client", feature = "http2"))]
469 let body = fetch(&self.api_server, &self.token, self.tls.as_deref(), &path)?;
470 #[cfg(not(any(feature = "http-client", feature = "http2")))]
471 let body = http_get_plain(&self.api_server, &path, &self.token)?;
472 Ok(parse_ingress_list(&body, self.ingress_class.as_deref()))
473 }
474}
475
476fn list_path(namespace: &str) -> String {
477 if namespace.is_empty() || namespace == "all" {
478 "/apis/networking.k8s.io/v1/ingresses".to_string()
479 } else {
480 format!("/apis/networking.k8s.io/v1/namespaces/{namespace}/ingresses")
481 }
482}
483
484#[cfg(any(feature = "http-client", feature = "http2"))]
488fn fetch(
489 api_server: &str,
490 token: &str,
491 tls_cfg: Option<&tls::InClusterConfig>,
492 path: &str,
493) -> Result<String, String> {
494 if let Some(cfg) = tls_cfg {
495 return tls::https_get(
496 &cfg.host,
497 cfg.port,
498 "kubernetes.default.svc",
499 cfg.client_config.clone(),
500 &cfg.token,
501 path,
502 Duration::from_secs(10),
503 );
504 }
505 http_get_plain(api_server, path, token)
506}
507
508struct WatcherHandle {
511 api_server: String,
512 token: String,
513 namespace: String,
514 ingress_class: Option<String>,
515 poll_interval_secs: u64,
516 rules: Arc<RwLock<Vec<IngressRule>>>,
517 #[cfg(any(feature = "http-client", feature = "http2"))]
518 tls: Option<Arc<tls::InClusterConfig>>,
519}
520
521impl WatcherHandle {
522 fn poll_loop(self) {
523 self.poll_once();
525 let interval = Duration::from_secs(self.poll_interval_secs);
526 std::thread::spawn(move || loop {
527 std::thread::sleep(interval);
528 self.poll_once();
529 });
530 }
531
532 fn poll_once(&self) {
533 let path = list_path(&self.namespace);
534 let result = {
535 #[cfg(any(feature = "http-client", feature = "http2"))]
536 {
537 fetch(&self.api_server, &self.token, self.tls.as_deref(), &path)
538 }
539 #[cfg(not(any(feature = "http-client", feature = "http2")))]
540 {
541 http_get_plain(&self.api_server, &path, &self.token)
542 }
543 };
544 match result {
545 Ok(body) => {
546 let new_rules = parse_ingress_list(&body, self.ingress_class.as_deref());
547 *self.rules.write().unwrap() = new_rules;
548 }
549 Err(e) => {
550 eprintln!("ingress watcher: poll failed: {}", e);
551 }
552 }
553 }
554
555 fn watch_loop(self) {
559 std::thread::spawn(move || loop {
560 if let Err(e) = self.watch_once() {
561 eprintln!("ingress watcher: watch connection error: {e}");
562 }
563 std::thread::sleep(WATCH_RECONNECT_BACKOFF);
564 });
565 }
566
567 fn watch_once(&self) -> Result<(), String> {
568 let path = format!("{}?watch=true", list_path(&self.namespace));
569
570 #[cfg(any(feature = "http-client", feature = "http2"))]
571 if let Some(cfg) = &self.tls {
572 let stream = tls::tls_connect(
573 &cfg.host,
574 cfg.port,
575 "kubernetes.default.svc",
576 cfg.client_config.clone(),
577 WATCH_READ_TIMEOUT,
578 )?;
579 return self.stream_watch(stream, "kubernetes.default.svc", &cfg.token, &path);
580 }
581
582 let (host, addr) = parse_plain_host_addr(&self.api_server)?;
583 let tcp = TcpStream::connect(&addr)
584 .map_err(|e| format!("ingress watcher: connect to {addr} failed: {e}"))?;
585 tcp.set_read_timeout(Some(WATCH_READ_TIMEOUT)).map_err(|e| e.to_string())?;
586 tcp.set_write_timeout(Some(Duration::from_secs(5))).map_err(|e| e.to_string())?;
587 self.stream_watch(tcp, &host, &self.token, &path)
588 }
589
590 fn stream_watch<S: Read + Write>(
591 &self,
592 mut stream: S,
593 host_header: &str,
594 token: &str,
595 path: &str,
596 ) -> Result<(), String> {
597 let auth_header = if token.is_empty() {
598 String::new()
599 } else {
600 format!("Authorization: Bearer {token}\r\n")
601 };
602 let request = format!(
603 "GET {path} HTTP/1.1\r\nHost: {host_header}\r\n{auth_header}Accept: application/json\r\nConnection: keep-alive\r\n\r\n"
604 );
605 stream
606 .write_all(request.as_bytes())
607 .map_err(|e| format!("ingress watcher: watch request write failed: {e}"))?;
608
609 watch::read_chunked_lines(stream, |_line| {
610 self.poll_once();
615 })
616 }
617}
618
619fn parse_plain_host_addr(api_server: &str) -> Result<(String, String), String> {
622 let rest = api_server
623 .strip_prefix("http://")
624 .ok_or_else(|| format!("ingress watcher: api_server must start with http://, got: {api_server}"))?;
625 let host_port = rest.split('/').next().unwrap_or(rest);
626 let (host, port) = if let Some(colon) = host_port.rfind(':') {
627 let port_str = &host_port[colon + 1..];
628 if let Ok(p) = port_str.parse::<u16>() {
629 (&host_port[..colon], p)
630 } else {
631 (host_port, 80u16)
632 }
633 } else {
634 (host_port, 80u16)
635 };
636 Ok((host.to_string(), format!("{host}:{port}")))
637}
638
639fn http_get_plain(api_server: &str, path: &str, token: &str) -> Result<String, String> {
644 let (host, addr) = parse_plain_host_addr(api_server)?;
645 let mut stream = TcpStream::connect(&addr)
646 .map_err(|e| format!("ingress watcher: connect to {} failed: {}", addr, e))?;
647 stream.set_read_timeout(Some(Duration::from_secs(10))).map_err(|e| e.to_string())?;
648 stream.set_write_timeout(Some(Duration::from_secs(5))).map_err(|e| e.to_string())?;
649
650 let auth_header = if token.is_empty() {
651 String::new()
652 } else {
653 format!("Authorization: Bearer {}\r\n", token)
654 };
655
656 let request = format!(
657 "GET {} HTTP/1.1\r\nHost: {}\r\n{}Accept: application/json\r\nConnection: close\r\n\r\n",
658 path, host, auth_header
659 );
660
661 stream.write_all(request.as_bytes()).map_err(|e| e.to_string())?;
662
663 let mut buf = Vec::with_capacity(8192);
664 let mut tmp = [0u8; 4096];
665 loop {
666 match stream.read(&mut tmp) {
667 Ok(0) => break,
668 Ok(n) => buf.extend_from_slice(&tmp[..n]),
669 Err(e) => return Err(format!("ingress watcher: read failed: {}", e)),
670 }
671 }
672
673 parse_http1_response(&buf)
674}
675
676fn parse_http1_response(buf: &[u8]) -> Result<String, String> {
680 let header_end = buf
681 .windows(4)
682 .position(|w| w == b"\r\n\r\n")
683 .ok_or_else(|| "ingress watcher: incomplete HTTP response (no header end)".to_string())?;
684
685 let header_str = std::str::from_utf8(&buf[..header_end]).unwrap_or("");
686 let status_line = header_str.lines().next().unwrap_or("");
687 let parts: Vec<&str> = status_line.splitn(3, ' ').collect();
688 if parts.len() < 2 {
689 return Err(format!("ingress watcher: malformed status line: {}", status_line));
690 }
691 let status: u16 = parts[1].parse().unwrap_or(0);
692 if status < 200 || status >= 300 {
693 return Err(format!("ingress watcher: API returned status {}", status));
694 }
695
696 let body_bytes = &buf[header_end + 4..];
697 std::str::from_utf8(body_bytes)
698 .map(|s| s.to_string())
699 .map_err(|e| format!("ingress watcher: non-UTF-8 response body: {}", e))
700}
701
702pub struct IngressRouter {
710 watcher: KubernetesIngressWatcher,
711 connect_timeout: Duration,
712 read_timeout: Duration,
713}
714
715impl IngressRouter {
716 pub fn new(watcher: KubernetesIngressWatcher) -> Self {
718 Self {
719 watcher,
720 connect_timeout: Duration::from_secs(5),
721 read_timeout: Duration::from_secs(30),
722 }
723 }
724
725 pub fn connect_timeout_ms(mut self, ms: u64) -> Self {
727 self.connect_timeout = Duration::from_millis(ms);
728 self
729 }
730
731 pub fn read_timeout_ms(mut self, ms: u64) -> Self {
733 self.read_timeout = Duration::from_millis(ms);
734 self
735 }
736}
737
738impl Application for IngressRouter {
739 fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
740 let host = request
741 .get_header("host".to_string())
742 .map(|h| h.value.as_ref())
743 .unwrap_or("");
744
745 let rules = self.watcher.rules();
746 let matched = rules.iter().find(|r| r.matches(host, &request.request_uri));
747
748 match matched {
749 Some(rule) => {
750 let upstream_host = format!(
751 "{}.{}.svc.cluster.local",
752 rule.service_name, rule.namespace
753 );
754 crate::proxy::forward_pooled(
755 crate::proxy::shared_pool(),
756 request,
757 crate::proxy::Peer::of(connection),
758 &upstream_host,
759 rule.service_port,
760 self.connect_timeout,
761 self.read_timeout,
762 )
763 .map(|mut response| {
764 crate::proxy::status::received(&mut response, None);
765 response
766 })
767 .or_else(|e| {
768 let mut response = bad_gateway();
769 crate::proxy::status::failed_with(&mut response, &e, None);
770 Ok(response)
771 })
772 }
773 None => Ok(not_found()),
774 }
775 }
776}
777
778fn bad_gateway() -> Response {
779 crate::error::problem(STATUS_CODE_REASON_PHRASE.n502_bad_gateway, "")
780}
781
782fn not_found() -> Response {
783 crate::error::problem::problem_with_text(STATUS_CODE_REASON_PHRASE.n404_not_found, "no matching ingress rule", "404 No matching ingress rule")
784}