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