Skip to main content

bindport_dashboard/
lib.rs

1// SPDX-License-Identifier: MIT
2
3use 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}