1use std::{
4 borrow::Cow,
5 fmt, fs,
6 io::{self, BufRead, BufReader, Write},
7 net::{Ipv4Addr, SocketAddrV4, TcpListener, TcpStream},
8 path::{Path, PathBuf},
9 thread,
10 time::Duration,
11};
12
13use bindport_core::{DEFAULT_PORT_RANGE, DEFAULT_SKIP_PORTS, PortRange};
14use bindport_registry::{CleanState, CleanSummary, Registry};
15
16pub const DEFAULT_DASHBOARD_PORT: u16 = 27_080;
17const DASHBOARD_APP_NAME: &str = "BindPort";
18const MAX_REQUEST_LINE_BYTES: usize = 8 * 1024;
19const MAX_HEADER_LINE_BYTES: usize = 8 * 1024;
20const MAX_HEADER_BYTES: usize = 16 * 1024;
21const DASHBOARD_ACTION_HEADER: &str = "X-BindPort-Dashboard-Action";
22
23#[derive(Debug, Clone)]
24pub struct DashboardOptions {
25 pub host: Ipv4Addr,
26 pub preferred_port: u16,
27 pub fallback_range: PortRange,
28 pub skip_ports: Vec<u16>,
29 pub allowed_hosts: Vec<String>,
30 pub auth: DashboardAuth,
31 pub static_dir: Option<PathBuf>,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct DashboardAuth {
36 pub required: bool,
37 pub token: Option<String>,
38}
39
40impl Default for DashboardOptions {
41 fn default() -> Self {
42 Self {
43 host: Ipv4Addr::LOCALHOST,
44 preferred_port: DEFAULT_DASHBOARD_PORT,
45 fallback_range: DEFAULT_PORT_RANGE,
46 skip_ports: DEFAULT_SKIP_PORTS.to_vec(),
47 allowed_hosts: default_allowed_hosts(),
48 auth: DashboardAuth::default(),
49 static_dir: None,
50 }
51 }
52}
53
54#[derive(Debug)]
55pub enum DashboardError {
56 NoAvailablePort { range: PortRange },
57 Bind { port: u16, source: io::Error },
58 LocalAddress(io::Error),
59}
60
61impl fmt::Display for DashboardError {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 Self::NoAvailablePort { range } => write!(
65 f,
66 "no dashboard port available in range {}-{}",
67 range.start, range.end
68 ),
69 Self::Bind { port, source } => {
70 write!(f, "failed to bind dashboard port {port}: {source}")
71 }
72 Self::LocalAddress(source) => write!(f, "failed to read dashboard address: {source}"),
73 }
74 }
75}
76
77impl std::error::Error for DashboardError {
78 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
79 match self {
80 Self::Bind { source, .. } | Self::LocalAddress(source) => Some(source),
81 Self::NoAvailablePort { .. } => None,
82 }
83 }
84}
85
86pub struct DashboardServer {
87 listener: TcpListener,
88 options: DashboardOptions,
89 port: u16,
90}
91
92impl DashboardServer {
93 pub fn bind(options: DashboardOptions) -> Result<Self, DashboardError> {
94 let listener = bind_dashboard_listener(&options)?;
95 let port = listener
96 .local_addr()
97 .map_err(DashboardError::LocalAddress)?
98 .port();
99
100 Ok(Self {
101 listener,
102 options,
103 port,
104 })
105 }
106
107 pub const fn port(&self) -> u16 {
108 self.port
109 }
110
111 pub fn url(&self) -> String {
112 format!("http://{}:{}", self.options.host, self.port)
113 }
114
115 pub fn serve(self) -> Result<(), DashboardError> {
116 let options = self.options;
117 for stream in self.listener.incoming() {
118 match stream {
119 Ok(stream) => {
120 let options = options.clone();
121 thread::spawn(move || {
122 if let Err(error) = handle_connection(stream, &options)
123 && !is_routine_client_error(&error)
124 {
125 eprintln!("dashboard: request error: {error}");
126 }
127 });
128 }
129 Err(error) => {
130 eprintln!("dashboard: accept error: {error}");
131 }
132 }
133 }
134
135 Ok(())
136 }
137}
138
139fn bind_dashboard_listener(options: &DashboardOptions) -> Result<TcpListener, DashboardError> {
140 match TcpListener::bind(SocketAddrV4::new(options.host, options.preferred_port)) {
141 Ok(listener) => return Ok(listener),
142 Err(error) if error.kind() != io::ErrorKind::AddrInUse => {
143 return Err(DashboardError::Bind {
144 port: options.preferred_port,
145 source: error,
146 });
147 }
148 Err(_) => {}
149 }
150
151 for port in fallback_ports(options) {
152 match TcpListener::bind(SocketAddrV4::new(options.host, port)) {
153 Ok(listener) => return Ok(listener),
154 Err(error) if error.kind() == io::ErrorKind::AddrInUse => continue,
155 Err(error) => {
156 return Err(DashboardError::Bind {
157 port,
158 source: error,
159 });
160 }
161 }
162 }
163
164 Err(DashboardError::NoAvailablePort {
165 range: options.fallback_range,
166 })
167}
168
169fn default_allowed_hosts() -> Vec<String> {
170 vec![String::from("localhost"), Ipv4Addr::LOCALHOST.to_string()]
171}
172
173fn fallback_ports(options: &DashboardOptions) -> impl Iterator<Item = u16> + '_ {
174 let range = options.fallback_range;
175 (0..range.len()).filter_map(move |offset| {
176 let port = range.start as u32 + offset;
177 let port = u16::try_from(port).ok()?;
178
179 (!options.skip_ports.contains(&port)).then_some(port)
180 })
181}
182
183fn handle_connection(mut stream: TcpStream, options: &DashboardOptions) -> io::Result<()> {
184 stream.set_read_timeout(Some(Duration::from_secs(5)))?;
185
186 let request = match read_request(&stream) {
187 Ok(Some(request)) => request,
188 Ok(None) => return Ok(()),
189 Err(error) if is_routine_client_error(&error) => return Ok(()),
190 Err(error) if error.kind() == io::ErrorKind::InvalidData => {
191 let response = if error.to_string().contains("too large") {
192 HttpResponse::request_too_large()
193 } else {
194 HttpResponse::bad_request()
195 };
196 write_response(&mut stream, response)?;
197 return Ok(());
198 }
199 Err(error) => return Err(error),
200 };
201 let response = response_for_request(&request, options);
202
203 write_response(&mut stream, response)
204}
205
206fn write_response(stream: &mut TcpStream, response: HttpResponse) -> io::Result<()> {
207 stream.write_all(&response.into_bytes())?;
208 stream.flush()
209}
210
211fn is_routine_client_error(error: &io::Error) -> bool {
212 matches!(
213 error.kind(),
214 io::ErrorKind::BrokenPipe
215 | io::ErrorKind::ConnectionAborted
216 | io::ErrorKind::ConnectionReset
217 | io::ErrorKind::TimedOut
218 | io::ErrorKind::UnexpectedEof
219 | io::ErrorKind::WouldBlock
220 )
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
224struct HttpRequest {
225 method: String,
226 path: String,
227 host: Option<String>,
228 authorization: Option<String>,
229 dashboard_action: Option<String>,
230}
231
232fn read_request(stream: &TcpStream) -> io::Result<Option<HttpRequest>> {
233 let mut reader = BufReader::new(stream);
234 let request_line = read_limited_line(&mut reader, MAX_REQUEST_LINE_BYTES)?;
235 if request_line.is_empty() {
236 return Ok(None);
237 }
238
239 let mut host = None;
240 let mut authorization = None;
241 let mut dashboard_action = None;
242 let mut header_bytes = 0;
243 loop {
244 let header = read_limited_line(&mut reader, MAX_HEADER_LINE_BYTES)?;
245 if header.is_empty() || header == "\r\n" || header == "\n" {
246 break;
247 }
248 header_bytes += header.len();
249 if header_bytes > MAX_HEADER_BYTES {
250 return Err(request_too_large_error());
251 }
252
253 if let Some((name, value)) = header.trim_end().split_once(':')
254 && name.eq_ignore_ascii_case("host")
255 && host.is_none()
256 {
257 host = Some(value.trim().to_string());
258 }
259 if let Some((name, value)) = header.trim_end().split_once(':')
260 && name.eq_ignore_ascii_case("authorization")
261 && authorization.is_none()
262 {
263 authorization = Some(value.trim().to_string());
264 }
265 if let Some((name, value)) = header.trim_end().split_once(':')
266 && name.eq_ignore_ascii_case(DASHBOARD_ACTION_HEADER)
267 && dashboard_action.is_none()
268 {
269 dashboard_action = Some(value.trim().to_string());
270 }
271 }
272
273 let mut parts = request_line.split_whitespace();
274 let Some(method) = parts.next() else {
275 return Err(invalid_request_error());
276 };
277 let Some(path) = parts.next() else {
278 return Err(invalid_request_error());
279 };
280
281 Ok(Some(HttpRequest {
282 method: method.to_string(),
283 path: path.to_string(),
284 host,
285 authorization,
286 dashboard_action,
287 }))
288}
289
290fn read_limited_line(reader: &mut impl BufRead, limit: usize) -> io::Result<String> {
291 let mut bytes = Vec::new();
292
293 loop {
294 let available = reader.fill_buf()?;
295 if available.is_empty() {
296 break;
297 }
298 let length = available
299 .iter()
300 .position(|byte| *byte == b'\n')
301 .map_or(available.len(), |index| index + 1);
302
303 if bytes.len() + length > limit {
304 return Err(request_too_large_error());
305 }
306
307 bytes.extend_from_slice(&available[..length]);
308 reader.consume(length);
309
310 if bytes.last() == Some(&b'\n') {
311 break;
312 }
313 }
314
315 String::from_utf8(bytes).map_err(|_| invalid_request_error())
316}
317
318fn request_too_large_error() -> io::Error {
319 io::Error::new(io::ErrorKind::InvalidData, "dashboard request too large")
320}
321
322fn invalid_request_error() -> io::Error {
323 io::Error::new(io::ErrorKind::InvalidData, "invalid dashboard request")
324}
325
326fn response_for_request(request: &HttpRequest, options: &DashboardOptions) -> HttpResponse {
327 if !host_allowed(request.host.as_deref(), options) {
328 return HttpResponse::forbidden();
329 }
330
331 match request_route(request) {
332 Some(Route::Index) => dashboard_index_response(options),
333 Some(Route::Css) => {
334 static_asset_response("app.css", APP_CSS, "text/css; charset=utf-8", options)
335 }
336 Some(Route::Js) => {
337 static_asset_response("app.js", APP_JS, "text/javascript; charset=utf-8", options)
338 }
339 #[cfg(debug_assertions)]
340 Some(Route::DevReload) => static_asset_response(
341 "dev-reload.js",
342 DEV_RELOAD_JS,
343 "text/javascript; charset=utf-8",
344 options,
345 ),
346 #[cfg(debug_assertions)]
347 Some(Route::DevVersion) => dev_version_response(options),
348 Some(Route::Status) if request_authorized(request, options) => status_response(),
349 Some(Route::Status) => HttpResponse::unauthorized(),
350 Some(Route::Clean(states)) => clean_response(request, options, &states),
351 Some(Route::Health) => HttpResponse::ok("text/plain; charset=utf-8", "ok\n"),
352 _ => HttpResponse::not_found(),
353 }
354}
355
356enum Route {
357 Index,
358 Css,
359 Js,
360 #[cfg(debug_assertions)]
361 DevReload,
362 #[cfg(debug_assertions)]
363 DevVersion,
364 Status,
365 Clean(Vec<CleanState>),
366 Health,
367}
368
369fn request_route(request: &HttpRequest) -> Option<Route> {
370 match (request.method.as_str(), request.path.as_str()) {
371 ("GET", "/") => Some(Route::Index),
372 ("GET", "/assets/app.css") => Some(Route::Css),
373 ("GET", "/assets/app.js") => Some(Route::Js),
374 #[cfg(debug_assertions)]
375 ("GET", "/assets/dev-reload.js") => Some(Route::DevReload),
376 #[cfg(debug_assertions)]
377 ("GET", "/assets/dev-version") => Some(Route::DevVersion),
378 ("GET", "/api/status") => Some(Route::Status),
379 ("POST", "/api/clean" | "/api/clean/all") => {
380 Some(Route::Clean(vec![CleanState::Stopped, CleanState::Stale]))
381 }
382 ("POST", "/api/clean/stopped") => Some(Route::Clean(vec![CleanState::Stopped])),
383 ("POST", "/api/clean/stale") => Some(Route::Clean(vec![CleanState::Stale])),
384 ("GET", "/healthz") => Some(Route::Health),
385 _ => None,
386 }
387}
388
389fn host_allowed(host: Option<&str>, options: &DashboardOptions) -> bool {
390 let Some(host) = host.map(str::trim).filter(|host| !host.is_empty()) else {
391 return false;
392 };
393 let (name, port) = match host.rsplit_once(':') {
394 Some((name, port)) if !name.contains(':') => (name, Some(port)),
395 _ => (host, None),
396 };
397
398 if let Some(port) = port
399 && (port.is_empty() || !port.chars().all(|character| character.is_ascii_digit()))
400 {
401 return false;
402 }
403
404 if options.auth.required && options.host.is_unspecified() {
405 return true;
406 }
407
408 name.eq_ignore_ascii_case("localhost")
409 || name == "127.0.0.1"
410 || name == options.host.to_string()
411 || options
412 .allowed_hosts
413 .iter()
414 .any(|allowed| allowed.eq_ignore_ascii_case(name))
415}
416
417fn status_response() -> HttpResponse {
418 match Registry::open_default().and_then(|mut registry| registry.status_snapshot()) {
419 Ok(snapshot) => match serde_json::to_string_pretty(&snapshot) {
420 Ok(json) => HttpResponse::ok("application/json; charset=utf-8", &json),
421 Err(error) => HttpResponse::internal_error(&json_error_body(format!(
422 "failed to serialize status JSON: {error}"
423 ))),
424 },
425 Err(error) => HttpResponse::service_unavailable(&json_error_body(format!(
426 "registry unavailable: {error}"
427 ))),
428 }
429}
430
431fn clean_response(
432 request: &HttpRequest,
433 options: &DashboardOptions,
434 states: &[CleanState],
435) -> HttpResponse {
436 if !request_authorized(request, options) {
437 return HttpResponse::unauthorized();
438 }
439 if !request_dashboard_action(request, "clean") {
440 return HttpResponse::bad_json_request(&json_error_body(format!(
441 "{DASHBOARD_ACTION_HEADER}: clean is required"
442 )));
443 }
444
445 match Registry::open_default().and_then(|mut registry| registry.clean_leases(states, false)) {
446 Ok(summary) => match serde_json::to_string_pretty(&clean_summary_json(summary)) {
447 Ok(json) => HttpResponse::ok("application/json; charset=utf-8", &json),
448 Err(error) => HttpResponse::internal_error(&json_error_body(format!(
449 "failed to serialize clean JSON: {error}"
450 ))),
451 },
452 Err(error) => HttpResponse::service_unavailable(&json_error_body(format!(
453 "registry unavailable: {error}"
454 ))),
455 }
456}
457
458fn clean_summary_json(summary: CleanSummary) -> serde_json::Value {
459 serde_json::json!({
460 "leases": summary.total_leases(),
461 "runs": summary.runs,
462 "states": {
463 "stopped": summary.stopped_leases,
464 "stale": summary.stale_leases,
465 },
466 })
467}
468
469fn json_error_body(message: String) -> String {
470 format!("{}\n", serde_json::json!({ "error": message }))
471}
472
473fn request_authorized(request: &HttpRequest, options: &DashboardOptions) -> bool {
474 if !options.auth.required {
475 return true;
476 }
477
478 let Some(expected) = options.auth.token.as_deref() else {
479 return false;
480 };
481 let Some(actual) = request
482 .authorization
483 .as_deref()
484 .and_then(authorization_bearer_token)
485 else {
486 return false;
487 };
488
489 constant_time_eq(actual.as_bytes(), expected.as_bytes())
490}
491
492fn request_dashboard_action(request: &HttpRequest, expected: &str) -> bool {
493 request
494 .dashboard_action
495 .as_deref()
496 .is_some_and(|actual| actual.eq_ignore_ascii_case(expected))
497}
498
499fn authorization_bearer_token(value: &str) -> Option<&str> {
500 let (scheme, token) = value.trim().split_once(' ')?;
501 scheme
502 .eq_ignore_ascii_case("bearer")
503 .then_some(token.trim())
504 .filter(|token| !token.is_empty())
505}
506
507fn constant_time_eq(actual: &[u8], expected: &[u8]) -> bool {
508 if actual.len() != expected.len() {
509 return false;
510 }
511
512 actual
513 .iter()
514 .zip(expected)
515 .fold(0, |diff, (actual, expected)| diff | (actual ^ expected))
516 == 0
517}
518
519fn dashboard_index_response(options: &DashboardOptions) -> HttpResponse {
520 let body = match static_file(options.static_dir.as_deref(), "index.html", INDEX_HTML) {
521 Ok(page) => {
522 maybe_inject_dev_reload(inject_app_metadata(page), options.static_dir.as_deref())
523 }
524 Err(message) => Err(message),
525 };
526 static_response(body, "text/html; charset=utf-8")
527}
528
529fn inject_app_metadata(page: Cow<'static, str>) -> Cow<'static, str> {
530 Cow::Owned(
531 page.replace("{{APP_NAME}}", DASHBOARD_APP_NAME)
532 .replace("{{APP_VERSION}}", env!("CARGO_PKG_VERSION")),
533 )
534}
535
536fn static_asset_response(
537 filename: &'static str,
538 embedded: &'static str,
539 content_type: &'static str,
540 options: &DashboardOptions,
541) -> HttpResponse {
542 static_response(
543 static_file(options.static_dir.as_deref(), filename, embedded),
544 content_type,
545 )
546}
547
548#[cfg(debug_assertions)]
549fn dev_version_response(options: &DashboardOptions) -> HttpResponse {
550 static_response(
551 dev_static_version(options.static_dir.as_deref()),
552 "text/plain; charset=utf-8",
553 )
554}
555
556fn static_response(
557 body: Result<Cow<'static, str>, &'static str>,
558 content_type: &'static str,
559) -> HttpResponse {
560 match body {
561 Ok(body) => HttpResponse::ok(content_type, &body),
562 Err(message) => HttpResponse::internal_error(&json_error_body(message.to_string())),
563 }
564}
565
566fn static_file(
567 static_dir: Option<&Path>,
568 filename: &'static str,
569 embedded: &'static str,
570) -> Result<Cow<'static, str>, &'static str> {
571 if let Some(static_dir) = static_dir {
572 return fs::read_to_string(static_dir.join(filename))
573 .map(Cow::Owned)
574 .map_err(|_| "failed to read dashboard asset");
575 }
576
577 Ok(Cow::Borrowed(embedded))
578}
579
580fn maybe_inject_dev_reload(
581 page: Cow<'static, str>,
582 static_dir: Option<&Path>,
583) -> Result<Cow<'static, str>, &'static str> {
584 #[cfg(debug_assertions)]
585 {
586 if static_dir.is_none() {
587 return Ok(page);
588 }
589
590 let page = page.into_owned();
591 let Some(index) = page.rfind("</body>") else {
592 return Err("dashboard HTML is missing </body>");
593 };
594 let tag = r#" <script src="/assets/dev-reload.js"></script>
595"#;
596 let mut output = String::with_capacity(page.len() + tag.len());
597 output.push_str(&page[..index]);
598 output.push_str(tag);
599 output.push_str(&page[index..]);
600 Ok(Cow::Owned(output))
601 }
602
603 #[cfg(not(debug_assertions))]
604 {
605 let _ = static_dir;
606 Ok(page)
607 }
608}
609
610#[cfg(debug_assertions)]
611fn dev_static_version(static_dir: Option<&Path>) -> Result<Cow<'static, str>, &'static str> {
612 let Some(static_dir) = static_dir else {
613 return Err("dashboard static directory is not configured");
614 };
615 let version = ["index.html", "app.css", "app.js", "dev-reload.js"]
616 .into_iter()
617 .map(|filename| {
618 fs::metadata(static_dir.join(filename))
619 .and_then(|metadata| metadata.modified())
620 .and_then(|modified| {
621 modified
622 .duration_since(std::time::UNIX_EPOCH)
623 .map_err(io::Error::other)
624 })
625 .map(|duration| duration.as_millis().to_string())
626 })
627 .collect::<Result<Vec<_>, _>>()
628 .map_err(|_| "failed to read dashboard asset metadata")?
629 .join(".");
630
631 Ok(Cow::Owned(version))
632}
633
634struct HttpResponse {
635 status: &'static str,
636 content_type: &'static str,
637 body: String,
638}
639
640impl HttpResponse {
641 fn ok(content_type: &'static str, body: &str) -> Self {
642 Self {
643 status: "200 OK",
644 content_type,
645 body: body.to_string(),
646 }
647 }
648
649 fn not_found() -> Self {
650 Self {
651 status: "404 Not Found",
652 content_type: "text/plain; charset=utf-8",
653 body: String::from("not found\n"),
654 }
655 }
656
657 fn bad_request() -> Self {
658 Self {
659 status: "400 Bad Request",
660 content_type: "text/plain; charset=utf-8",
661 body: String::from("bad request\n"),
662 }
663 }
664
665 fn bad_json_request(body: &str) -> Self {
666 Self {
667 status: "400 Bad Request",
668 content_type: "application/json; charset=utf-8",
669 body: body.to_string(),
670 }
671 }
672
673 fn forbidden() -> Self {
674 Self {
675 status: "403 Forbidden",
676 content_type: "text/plain; charset=utf-8",
677 body: String::from("forbidden\n"),
678 }
679 }
680
681 fn unauthorized() -> Self {
682 Self {
683 status: "401 Unauthorized",
684 content_type: "application/json; charset=utf-8",
685 body: json_error_body(String::from("dashboard bearer token is required")),
686 }
687 }
688
689 fn request_too_large() -> Self {
690 Self {
691 status: "431 Request Header Fields Too Large",
692 content_type: "text/plain; charset=utf-8",
693 body: String::from("request too large\n"),
694 }
695 }
696
697 fn service_unavailable(body: &str) -> Self {
698 Self {
699 status: "503 Service Unavailable",
700 content_type: "application/json; charset=utf-8",
701 body: body.to_string(),
702 }
703 }
704
705 fn internal_error(body: &str) -> Self {
706 Self {
707 status: "500 Internal Server Error",
708 content_type: "application/json; charset=utf-8",
709 body: body.to_string(),
710 }
711 }
712
713 fn into_bytes(self) -> Vec<u8> {
714 let body = self.body.into_bytes();
715 let headers = format!(
716 "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
717 self.status,
718 self.content_type,
719 body.len()
720 );
721 let mut response = headers.into_bytes();
722 response.extend(body);
723 response
724 }
725}
726
727const INDEX_HTML: &str = include_str!("../static/index.html");
728const APP_CSS: &str = include_str!("../static/app.css");
729const APP_JS: &str = include_str!("../static/app.js");
730#[cfg(debug_assertions)]
731const DEV_RELOAD_JS: &str = include_str!("../static/dev-reload.js");
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736 use std::io::Cursor;
737
738 #[test]
739 fn root_request_serves_dashboard_html() {
740 let options = DashboardOptions::default();
741 let response = response_for_request(&test_request("/"), &options);
742 let bytes = response.into_bytes();
743 let text = String::from_utf8(bytes).expect("response utf8");
744
745 assert!(text.starts_with("HTTP/1.1 200 OK"));
746 assert!(text.contains("BindPort Dashboard"));
747 assert!(text.contains("/assets/app.css"));
748 assert!(text.contains("/assets/app.js"));
749 assert!(text.contains("service-search"));
750 assert!(text.contains("data-state-filter=\"active\""));
751 assert!(text.contains("auth-token"));
752 assert!(text.contains("action-status"));
753 assert!(text.contains("app-footer"));
754 assert!(text.contains("data-state-filter=\"conflict\""));
755 assert!(text.contains(&format!("v{}", env!("CARGO_PKG_VERSION"))));
756 assert!(!text.contains("{{APP_VERSION}}"));
757 }
758
759 #[test]
760 fn asset_routes_serve_embedded_dashboard_files() {
761 let options = DashboardOptions::default();
762 let css = response_for_request(&test_request("/assets/app.css"), &options);
763 let css = String::from_utf8(css.into_bytes()).expect("css utf8");
764 let js = response_for_request(&test_request("/assets/app.js"), &options);
765 let js = String::from_utf8(js.into_bytes()).expect("js utf8");
766
767 assert!(css.starts_with("HTTP/1.1 200 OK"));
768 assert!(css.contains("text/css"));
769 assert!(css.contains(".state-active"));
770 assert!(css.contains(".state-conflict"));
771 assert!(js.starts_with("HTTP/1.1 200 OK"));
772 assert!(js.contains("text/javascript"));
773 assert!(js.contains("REFRESH_INTERVAL_MS = 5000"));
774 assert!(js.contains("refreshStatus"));
775 assert!(js.contains("/api/clean/"));
776 assert!(js.contains("data-clean-state"));
777 assert!(js.contains("{ key: \"conflict\", label: \"Conflict\" }"));
778 assert!(js.contains("<dt>Port</dt>"));
779 assert!(js.contains("<dt>Health</dt>"));
780 assert!(js.contains("<dt>Proxy</dt>"));
781 assert!(js.contains("function proxyStatus(service)"));
782 assert!(js.contains("Not rendered"));
783 assert!(js.contains("No services match the current filters."));
784 }
785
786 #[test]
787 fn unknown_route_returns_404() {
788 let options = DashboardOptions::default();
789 let response = response_for_request(&test_request("/missing"), &options);
790 let text = String::from_utf8(response.into_bytes()).expect("response utf8");
791
792 assert!(text.starts_with("HTTP/1.1 404 Not Found"));
793 }
794
795 #[test]
796 fn rejects_unknown_host_header() {
797 let options = DashboardOptions::default();
798 let response = response_for_request(
799 &HttpRequest {
800 method: String::from("GET"),
801 path: String::from("/api/status"),
802 host: Some(String::from("example.com:27080")),
803 authorization: None,
804 dashboard_action: None,
805 },
806 &options,
807 );
808 let text = String::from_utf8(response.into_bytes()).expect("response utf8");
809
810 assert!(text.starts_with("HTTP/1.1 403 Forbidden"));
811 }
812
813 #[test]
814 fn accepts_configured_allowed_host_header() {
815 let options = DashboardOptions {
816 allowed_hosts: vec![String::from("devbox.test")],
817 ..DashboardOptions::default()
818 };
819
820 assert!(host_allowed(Some("devbox.test:27080"), &options));
821 }
822
823 #[test]
824 fn accepts_arbitrary_host_for_unspecified_bind_with_auth() {
825 let options = DashboardOptions {
826 host: Ipv4Addr::UNSPECIFIED,
827 auth: DashboardAuth {
828 required: true,
829 token: Some(String::from("secret")),
830 },
831 ..DashboardOptions::default()
832 };
833
834 assert!(host_allowed(Some("remote.example:27080"), &options));
835 }
836
837 #[test]
838 fn auth_required_rejects_missing_token() {
839 let options = DashboardOptions {
840 auth: DashboardAuth {
841 required: true,
842 token: Some(String::from("secret")),
843 },
844 ..DashboardOptions::default()
845 };
846 let response = response_for_request(&test_request("/api/status"), &options);
847 let text = String::from_utf8(response.into_bytes()).expect("response utf8");
848
849 assert!(text.starts_with("HTTP/1.1 401 Unauthorized"));
850 }
851
852 #[test]
853 fn clean_rejects_missing_dashboard_action_header() {
854 let options = DashboardOptions::default();
855 let response = response_for_request(
856 &HttpRequest {
857 method: String::from("POST"),
858 path: String::from("/api/clean/stopped"),
859 host: Some(String::from("127.0.0.1:27080")),
860 authorization: None,
861 dashboard_action: None,
862 },
863 &options,
864 );
865 let text = String::from_utf8(response.into_bytes()).expect("response utf8");
866
867 assert!(text.starts_with("HTTP/1.1 400 Bad Request"));
868 assert!(text.contains(DASHBOARD_ACTION_HEADER));
869 }
870
871 #[test]
872 fn clean_requires_auth_when_auth_is_enabled() {
873 let options = DashboardOptions {
874 auth: DashboardAuth {
875 required: true,
876 token: Some(String::from("secret")),
877 },
878 ..DashboardOptions::default()
879 };
880 let response = response_for_request(
881 &HttpRequest {
882 method: String::from("POST"),
883 path: String::from("/api/clean/stopped"),
884 host: Some(String::from("127.0.0.1:27080")),
885 authorization: None,
886 dashboard_action: Some(String::from("clean")),
887 },
888 &options,
889 );
890 let text = String::from_utf8(response.into_bytes()).expect("response utf8");
891
892 assert!(text.starts_with("HTTP/1.1 401 Unauthorized"));
893 }
894
895 #[test]
896 fn auth_required_accepts_bearer_token() {
897 let options = DashboardOptions {
898 auth: DashboardAuth {
899 required: true,
900 token: Some(String::from("secret")),
901 },
902 ..DashboardOptions::default()
903 };
904 let mut request = test_request("/api/status");
905 request.authorization = Some(String::from("Bearer secret"));
906
907 assert!(request_authorized(&request, &options));
908 }
909
910 #[test]
911 fn limited_line_rejects_oversized_input() {
912 let mut reader = Cursor::new(vec![b'a'; MAX_REQUEST_LINE_BYTES + 1]);
913
914 let error = read_limited_line(&mut reader, MAX_REQUEST_LINE_BYTES)
915 .expect_err("oversized request line");
916
917 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
918 }
919
920 #[test]
921 fn json_error_body_escapes_message() {
922 let body = json_error_body(String::from("registry unavailable: \"bad\"\npath"));
923 let value = serde_json::from_str::<serde_json::Value>(&body).expect("json body");
924
925 assert_eq!(value["error"], "registry unavailable: \"bad\"\npath");
926 }
927
928 #[test]
929 fn fallback_ports_skip_configured_ports() {
930 let options = DashboardOptions {
931 fallback_range: PortRange {
932 start: 29_100,
933 end: 29_102,
934 },
935 skip_ports: vec![29_101],
936 ..DashboardOptions::default()
937 };
938 let ports = fallback_ports(&options).collect::<Vec<_>>();
939
940 assert_eq!(ports, vec![29_100, 29_102]);
941 }
942
943 fn test_request(path: &str) -> HttpRequest {
944 HttpRequest {
945 method: String::from("GET"),
946 path: path.to_string(),
947 host: Some(String::from("127.0.0.1:27080")),
948 authorization: None,
949 dashboard_action: None,
950 }
951 }
952}