1use tokio::io::{AsyncReadExt, AsyncWriteExt};
12
13#[derive(Debug, thiserror::Error)]
16pub enum AdminClientError {
17 #[error("invalid --admin '{url}': {reason}")]
19 InvalidUrl {
20 url: String,
23 reason: String,
25 },
26
27 #[error("failed to connect to admin at {addr}: {reason}")]
29 Connect {
30 addr: String,
32 reason: String,
34 },
35
36 #[error("admin request failed: {0}")]
38 Transport(String),
39
40 #[error("admin returned {status}: {body}")]
42 Status {
43 status: u16,
45 body: String,
47 },
48
49 #[error("failed to parse admin response: {0}")]
51 Parse(String),
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct AdminEndpoint {
57 pub host: String,
59 pub port: u16,
61 pub path: String,
63}
64
65pub fn parse_admin_url(url: &str) -> Result<AdminEndpoint, String> {
70 if url.starts_with("https://") {
71 return Err("TLS admin URLs are not supported; use http://".to_string());
72 }
73 let without_proto = url.strip_prefix("http://").unwrap_or(url);
74 let (host_port, path) = match without_proto.find('/') {
75 Some(i) => (&without_proto[..i], &without_proto[i..]),
76 None => (without_proto, "/"),
77 };
78 if host_port.is_empty() {
79 return Err("missing host in admin URL".to_string());
80 }
81 let (host, port) = if let Some(rest) = host_port.strip_prefix('[') {
82 let close = rest
83 .find(']')
84 .ok_or_else(|| "missing closing ']' in IPv6 admin host".to_string())?;
85 let host = rest[..close].to_string();
86 if host.is_empty() {
87 return Err("missing host in admin URL".to_string());
88 }
89 let after = &rest[close + 1..];
90 let port = match after.strip_prefix(':') {
91 Some(port_str) => port_str.parse::<u16>().map_err(|_| {
92 format!("invalid port '{port_str}' in admin URL (expected 1-65535)")
93 })?,
94 None if after.is_empty() => 9090,
95 None => {
96 return Err(format!(
97 "invalid IPv6 admin host '{host_port}' (expected [host] or [host]:port)"
98 ));
99 }
100 };
101 (host, port)
102 } else {
103 match host_port.rfind(':') {
104 Some(i) => {
105 let port_str = &host_port[i + 1..];
106 let port = port_str.parse::<u16>().map_err(|_| {
107 format!("invalid port '{port_str}' in admin URL (expected 1-65535)")
108 })?;
109 let host = host_port[..i].to_string();
110 if host.is_empty() {
111 return Err("missing host in admin URL".to_string());
112 }
113 (host, port)
114 }
115 None => (host_port.to_string(), 9090),
116 }
117 };
118 Ok(AdminEndpoint {
119 host,
120 port,
121 path: path.to_string(),
122 })
123}
124
125impl AdminEndpoint {
126 fn dial_addr(&self) -> String {
127 if self.host.contains(':') {
128 format!("[{}]:{}", self.host, self.port)
129 } else {
130 format!("{}:{}", self.host, self.port)
131 }
132 }
133
134 fn host_header(&self) -> String {
135 self.dial_addr()
136 }
137}
138
139pub async fn route_explain(
146 admin_url: &str,
147 target: &str,
148 listener: &str,
149 protocol: &str,
150) -> Result<eggress_routing::RouteExplanation, AdminClientError> {
151 let endpoint = parse_admin_url(admin_url).map_err(|reason| AdminClientError::InvalidUrl {
152 url: admin_url.to_string(),
153 reason,
154 })?;
155 let path = if endpoint.path == "/" {
159 "/-/route-explain".to_string()
160 } else {
161 endpoint.path.clone()
162 };
163 let body = serde_json::json!({
164 "target": target,
165 "listener": listener,
166 "protocol": protocol,
167 });
168 let body_str = body.to_string();
169 let request = format!(
170 "POST {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body_str}",
171 endpoint.host_header(),
172 body_str.len(),
173 );
174
175 let dial = endpoint.dial_addr();
176 let mut stream =
177 tokio::net::TcpStream::connect(&dial)
178 .await
179 .map_err(|e| AdminClientError::Connect {
180 addr: dial.clone(),
181 reason: e.to_string(),
182 })?;
183
184 stream
185 .write_all(request.as_bytes())
186 .await
187 .map_err(|e| AdminClientError::Transport(format!("failed to send request: {e}")))?;
188 stream
189 .flush()
190 .await
191 .map_err(|e| AdminClientError::Transport(format!("failed to send request: {e}")))?;
192 let mut response = Vec::new();
197 loop {
198 let mut buf = [0u8; 4096];
199 match stream.read(&mut buf).await {
200 Ok(0) => break,
201 Ok(n) => response.extend_from_slice(&buf[..n]),
202 Err(e) => {
203 return Err(AdminClientError::Transport(format!(
204 "failed to read response: {e}"
205 )));
206 }
207 }
208 }
209 let text = String::from_utf8_lossy(&response).to_string();
210 let body_start = text.find("\r\n\r\n").map(|i| i + 4).unwrap_or(0);
211 let body = text[body_start..].to_string();
212 let status_line = text.lines().next().unwrap_or("");
213 let status = status_line
214 .split_whitespace()
215 .nth(1)
216 .and_then(|s| s.parse::<u16>().ok())
217 .unwrap_or(0);
218
219 if status != 200 {
220 return Err(AdminClientError::Status { status, body });
221 }
222 serde_json::from_str::<eggress_routing::RouteExplanation>(&body)
223 .map_err(|e| AdminClientError::Parse(e.to_string()))
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn parses_ipv4_with_default_port() {
232 let ep = parse_admin_url("http://127.0.0.1/admin").unwrap();
233 assert_eq!(
234 ep,
235 AdminEndpoint {
236 host: "127.0.0.1".to_string(),
237 port: 9090,
238 path: "/admin".to_string(),
239 }
240 );
241 }
242
243 #[test]
244 fn parses_bracketed_ipv6_loopback() {
245 let ep = parse_admin_url("http://[::1]/-/route-explain").unwrap();
246 assert_eq!(ep.host, "::1");
247 assert_eq!(ep.port, 9090);
248 assert_eq!(ep.path, "/-/route-explain");
249 }
250
251 #[test]
252 fn parses_bracketed_ipv6_with_port() {
253 let ep = parse_admin_url("http://[2001:db8::1]:8080/-/route-explain").unwrap();
254 assert_eq!(ep.host, "2001:db8::1");
255 assert_eq!(ep.port, 8080);
256 }
257
258 #[test]
259 fn parses_domain_with_port() {
260 let ep = parse_admin_url("http://admin.example.com:8080/-/x").unwrap();
261 assert_eq!(ep.host, "admin.example.com");
262 assert_eq!(ep.port, 8080);
263 }
264
265 #[test]
266 fn rejects_malformed_ports() {
267 assert!(parse_admin_url("http://host:notaport/path").is_err());
268 assert!(parse_admin_url("http://host:99999/path").is_err());
269 assert!(parse_admin_url("http://[::1]:notaport/admin").is_err());
270 }
271
272 #[test]
273 fn rejects_tls_admin_urls() {
274 let err = parse_admin_url("https://127.0.0.1:9090/-/route-explain").unwrap_err();
275 assert!(err.contains("TLS"), "unexpected error: {err}");
276 }
277
278 #[tokio::test]
279 async fn reports_connection_failure_without_panicking() {
280 let err = route_explain("http://127.0.0.1:1", "example.com:443", "cli", "http")
283 .await
284 .unwrap_err();
285 assert!(
286 matches!(err, AdminClientError::Connect { .. }),
287 "unexpected error: {err:?}"
288 );
289 }
290
291 #[tokio::test]
292 async fn surfaces_non_200_admin_responses() {
293 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
294 let addr = listener.local_addr().unwrap();
295 tokio::spawn(async move {
296 let (mut stream, _) = listener.accept().await.unwrap();
297 let mut buf = [0u8; 4096];
298 let _ = stream.read(&mut buf).await;
299 let body = r#"{"error":"missing 'target' field"}"#;
300 let response = format!(
301 "HTTP/1.1 400 Bad Request\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
302 body.len()
303 );
304 let _ = stream.write_all(response.as_bytes()).await;
305 });
306 let err = route_explain(&format!("http://{addr}"), "example.com:443", "cli", "http")
307 .await
308 .unwrap_err();
309 match err {
310 AdminClientError::Status { status, .. } => assert_eq!(status, 400),
311 other => panic!("unexpected error: {other:?}"),
312 }
313 }
314
315 #[tokio::test]
316 async fn rejects_malformed_200_bodies() {
317 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
318 let addr = listener.local_addr().unwrap();
319 tokio::spawn(async move {
320 let (mut stream, _) = listener.accept().await.unwrap();
321 let mut buf = [0u8; 4096];
322 let _ = stream.read(&mut buf).await;
323 let body = "not-json";
324 let response = format!(
325 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
326 body.len()
327 );
328 let _ = stream.write_all(response.as_bytes()).await;
329 });
330 let err = route_explain(&format!("http://{addr}"), "example.com:443", "cli", "http")
331 .await
332 .unwrap_err();
333 assert!(
334 matches!(err, AdminClientError::Parse(_)),
335 "unexpected error: {err:?}"
336 );
337 }
338
339 #[tokio::test]
340 async fn round_trips_route_explain_against_live_admin() {
341 use std::sync::Arc;
342 use std::time::Instant;
343
344 let router = Arc::new(eggress_routing::Router::new(
345 vec![],
346 eggress_routing::RouteActionSpec::Direct,
347 ));
348 let snapshot = crate::server::AdminSnapshot {
349 generation: 7,
350 router,
351 pac: None,
352 static_routes: vec![],
353 listeners: vec![],
354 };
355 let state = crate::server::AdminState {
356 metrics: Arc::new(eggress_metrics::MetricsRegistry::new()),
357 start_time: Instant::now(),
358 readiness: Arc::new(std::sync::atomic::AtomicBool::new(true)),
359 active_connections: None,
360 provider: Arc::new(crate::server::StaticAdminSnapshot { snapshot }),
361 udp_registry: Arc::new(eggress_udp::registry::UdpAssociationRegistry::new(
362 eggress_udp::limits::UdpLimits::default(),
363 )),
364 reverse_registry: Arc::new(crate::reverse::ReverseRegistry::new()),
365 metrics_enabled: true,
366 auth: None,
367 };
368 let cancel = tokio_util::sync::CancellationToken::new();
369 let server = crate::server::AdminServer::new("127.0.0.1:0", cancel.clone())
370 .await
371 .unwrap();
372 let addr = server.listener.local_addr().unwrap().to_string();
373 tokio::spawn(async move { server.run(state).await.unwrap() });
374
375 let explanation =
376 route_explain(&format!("http://{addr}"), "example.com:443", "cli", "http")
377 .await
378 .expect("live admin route-explain must succeed");
379 assert_eq!(explanation.target, "example.com:443");
380 }
381}