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