1use std::io::{BufRead, BufReader, Read, Write};
23use std::net::{TcpListener, TcpStream, ToSocketAddrs};
24use std::sync::{Arc, Mutex};
25use std::thread;
26use std::time::Duration;
27
28use flate2::write::GzEncoder;
29use flate2::Compression;
30use serde::Deserialize;
31
32use crate::assets::Assets;
33use crate::auth::Auth;
34use crate::catalog::Catalog;
35use crate::command::Command;
36use crate::source::StateSource;
37
38pub trait Transport: Send + 'static {
44 type Conn: Read + Write + Send + 'static;
46
47 fn accept(&self) -> std::io::Result<Self::Conn>;
57
58 fn describe(&self) -> String;
64}
65
66pub struct TcpTransport {
68 listener: TcpListener,
69}
70
71impl TcpTransport {
72 pub fn bind(addr: impl ToSocketAddrs) -> std::io::Result<Self> {
86 Ok(Self {
87 listener: TcpListener::bind(addr)?,
88 })
89 }
90}
91
92impl Transport for TcpTransport {
93 type Conn = TcpStream;
94
95 fn accept(&self) -> std::io::Result<Self::Conn> {
96 self.listener.accept().map(|(stream, _)| stream)
97 }
98
99 fn describe(&self) -> String {
100 match self.listener.local_addr() {
101 Ok(addr) => format!("http://{addr}"),
102 Err(_) => "http://?".to_owned(),
103 }
104 }
105}
106
107pub struct Server<S> {
131 source: Arc<Mutex<S>>,
132 assets: Assets,
133 push_interval: Duration,
134 auth: Arc<Auth>,
135 catalog: Option<Arc<String>>,
136}
137
138impl<S: StateSource + Send + 'static> Server<S> {
139 pub fn new(source: S, assets: Assets) -> Self {
156 Self {
157 source: Arc::new(Mutex::new(source)),
158 assets,
159 push_interval: Duration::from_secs(1),
160 auth: Arc::new(Auth::new(Auth::generate_secret())),
161 catalog: None,
162 }
163 }
164
165 pub fn with_catalog(mut self, catalog: Catalog) -> Self {
180 self.catalog = catalog
181 .to_json()
182 .ok()
183 .filter(|_| !catalog.is_empty())
184 .map(Arc::new);
185 self
186 }
187
188 pub fn with_pairing_secret(mut self, secret: impl Into<String>) -> Self {
201 self.auth = Arc::new(Auth::new(secret));
202 self
203 }
204
205 pub fn with_push_interval(mut self, interval: Duration) -> Self {
215 self.push_interval = interval;
216 self
217 }
218
219 pub fn run(self, addr: impl ToSocketAddrs) -> std::io::Result<()> {
233 let transport = TcpTransport::bind(addr)?;
234 self.run_on(transport)
235 }
236
237 pub fn run_on<T: Transport>(self, transport: T) -> std::io::Result<()> {
255 println!("pamoja-dashboard: serving on {}", transport.describe());
256 loop {
257 let conn = match transport.accept() {
258 Ok(conn) => conn,
259 Err(_) => continue,
260 };
261 let source = Arc::clone(&self.source);
262 let assets = self.assets.clone();
263 let interval = self.push_interval;
264 let auth = Arc::clone(&self.auth);
265 let catalog = self.catalog.clone();
266 thread::spawn(move || {
267 let _ = handle(conn, source, assets, interval, auth, catalog);
268 });
269 }
270 }
271}
272
273struct Request {
276 method: String,
277 path: String,
278 query: String,
279 body: Vec<u8>,
280 accept_gzip: bool,
281}
282
283#[derive(Deserialize)]
285#[serde(rename_all = "camelCase")]
286struct ConfirmRequest {
287 session_id: String,
288 mac: String,
289}
290
291#[derive(Deserialize)]
294#[serde(rename_all = "camelCase")]
295struct CommandRequest {
296 session_id: String,
297 counter: u64,
298 cmd: String,
299 mac: String,
300}
301
302fn handle<S: StateSource, C: Read + Write>(
303 mut conn: C,
304 source: Arc<Mutex<S>>,
305 assets: Assets,
306 interval: Duration,
307 auth: Arc<Auth>,
308 catalog: Option<Arc<String>>,
309) -> std::io::Result<()> {
310 let request = match read_request(&mut conn)? {
311 Some(request) => request,
312 None => return Ok(()),
313 };
314
315 if let Some(scenario) = query_value(&request.query, "scenario") {
317 if let Ok(mut source) = source.lock() {
318 source.select(&scenario);
319 }
320 }
321
322 match (request.method.as_str(), request.path.as_str()) {
323 ("GET", "/state") => {
324 let json = snapshot_json(&source);
325 write_response(
326 &mut conn,
327 200,
328 "OK",
329 "application/json; charset=utf-8",
330 json.as_bytes(),
331 )
332 }
333 ("GET", "/catalog") => match &catalog {
334 Some(json) => write_json(&mut conn, 200, "OK", json),
335 None => write_response(
336 &mut conn,
337 204,
338 "No Content",
339 "application/json; charset=utf-8",
340 b"",
341 ),
342 },
343 ("GET", "/locales") => {
344 let tags = crate::assets::embedded_locales();
348 let json = format!(
349 "[{}]",
350 tags.iter()
351 .map(|tag| format!("\"{tag}\""))
352 .collect::<Vec<_>>()
353 .join(",")
354 );
355 write_json(&mut conn, 200, "OK", &json)
356 }
357 ("GET", "/lite") => {
358 let html = match source.lock() {
361 Ok(mut source) => crate::lite::render_lite(&source.snapshot()),
362 Err(_) => crate::lite::render_unavailable(),
363 };
364 write_response(
365 &mut conn,
366 200,
367 "OK",
368 "text/html; charset=utf-8",
369 html.as_bytes(),
370 )
371 }
372 ("GET", "/events") => stream_events(&mut conn, &source, interval),
373 ("GET", "/pair/challenge") => {
374 let challenge = auth.challenge();
375 let json = format!(
376 r#"{{"sessionId":"{}","nonce":"{}"}}"#,
377 challenge.session_id, challenge.nonce
378 );
379 write_json(&mut conn, 200, "OK", &json)
380 }
381 ("POST", "/pair/confirm") => {
382 match serde_json::from_slice::<ConfirmRequest>(&request.body) {
383 Ok(confirm) => match auth.confirm(&confirm.session_id, &confirm.mac) {
384 Ok(()) => write_json(&mut conn, 200, "OK", "{}"),
385 Err(error) => write_json(
386 &mut conn,
387 401,
388 "Unauthorized",
389 &format!(r#"{{"error":"{}"}}"#, error.code()),
390 ),
391 },
392 Err(_) => write_json(&mut conn, 400, "Bad Request", r#"{"error":"bad_request"}"#),
393 }
394 }
395 ("POST", "/command") => handle_command(&mut conn, &source, &auth, &request.body),
396 ("GET", path) => match assets.get(path) {
397 Some((content_type, bytes)) => {
398 write_asset(&mut conn, content_type, &bytes, request.accept_gzip)
399 }
400 None => write_response(&mut conn, 404, "Not Found", "text/plain", b"not found"),
401 },
402 _ => write_response(
403 &mut conn,
404 405,
405 "Method Not Allowed",
406 "text/plain",
407 b"method not allowed",
408 ),
409 }
410}
411
412fn handle_command<S: StateSource, W: Write>(
414 conn: &mut W,
415 source: &Arc<Mutex<S>>,
416 auth: &Arc<Auth>,
417 body: &[u8],
418) -> std::io::Result<()> {
419 let request: CommandRequest = match serde_json::from_slice(body) {
420 Ok(request) => request,
421 Err(_) => return write_json(conn, 400, "Bad Request", r#"{"error":"bad_request"}"#),
422 };
423 if let Err(error) = auth.verify_command(
424 &request.session_id,
425 request.counter,
426 &request.cmd,
427 &request.mac,
428 ) {
429 return write_json(
430 conn,
431 401,
432 "Unauthorized",
433 &format!(r#"{{"error":"{}"}}"#, error.code()),
434 );
435 }
436 let command: Command = match serde_json::from_str(&request.cmd) {
437 Ok(command) => command,
438 Err(_) => return write_json(conn, 400, "Bad Request", r#"{"error":"bad_request"}"#),
439 };
440 let outcome = match source.lock() {
441 Ok(mut source) => source.command(&command),
442 Err(_) => {
443 return write_json(
444 conn,
445 500,
446 "Internal Server Error",
447 r#"{"error":"internal"}"#,
448 )
449 }
450 };
451 match outcome {
452 Ok(()) => write_json(conn, 200, "OK", "{}"),
453 Err(error) => write_json(
454 conn,
455 422,
456 "Unprocessable Entity",
457 &format!(r#"{{"error":"{}"}}"#, error.code()),
458 ),
459 }
460}
461
462fn read_request<C: Read>(conn: &mut C) -> std::io::Result<Option<Request>> {
465 let mut reader = BufReader::new(conn);
466
467 let mut line = String::new();
468 if reader.read_line(&mut line)? == 0 {
469 return Ok(None);
470 }
471 let mut parts = line.split_whitespace();
472 let (Some(method), Some(target)) = (parts.next(), parts.next()) else {
473 return Ok(None);
474 };
475 let (path, query) = match target.split_once('?') {
476 Some((path, query)) => (path.to_owned(), query.to_owned()),
477 None => (target.to_owned(), String::new()),
478 };
479
480 let mut content_length = 0usize;
483 let mut accept_gzip = false;
484 loop {
485 let mut header = String::new();
486 if reader.read_line(&mut header)? == 0 {
487 break;
488 }
489 let header = header.trim_end();
490 if header.is_empty() {
491 break;
492 }
493 if let Some((name, value)) = header.split_once(':') {
495 if name.eq_ignore_ascii_case("content-length") {
496 content_length = value.trim().parse().unwrap_or(0);
497 } else if name.eq_ignore_ascii_case("accept-encoding") {
498 accept_gzip = value.to_ascii_lowercase().contains("gzip");
499 }
500 }
501 }
502 let mut body = Vec::new();
503 if content_length > 0 {
504 body = vec![0u8; content_length];
505 reader.read_exact(&mut body)?;
506 }
507
508 Ok(Some(Request {
509 method: method.to_owned(),
510 path,
511 query,
512 body,
513 accept_gzip,
514 }))
515}
516
517fn query_value(query: &str, key: &str) -> Option<String> {
519 query.split('&').find_map(|pair| {
520 let (name, value) = pair.split_once('=')?;
521 (name == key).then(|| value.to_owned())
522 })
523}
524
525fn snapshot_json<S: StateSource>(source: &Arc<Mutex<S>>) -> String {
526 match source.lock() {
527 Ok(mut source) => source
528 .snapshot()
529 .to_json()
530 .unwrap_or_else(|_| "{}".to_owned()),
531 Err(_) => "{}".to_owned(),
532 }
533}
534
535fn stream_events<S: StateSource, W: Write>(
538 conn: &mut W,
539 source: &Arc<Mutex<S>>,
540 interval: Duration,
541) -> std::io::Result<()> {
542 let headers = "HTTP/1.1 200 OK\r\n\
543 Content-Type: text/event-stream\r\n\
544 Cache-Control: no-cache\r\n\
545 Connection: keep-alive\r\n\r\n";
546 conn.write_all(headers.as_bytes())?;
547 conn.flush()?;
548 loop {
549 let json = snapshot_json(source);
550 if conn
551 .write_all(format!("data: {json}\n\n").as_bytes())
552 .is_err()
553 {
554 break;
555 }
556 if conn.flush().is_err() {
557 break;
558 }
559 thread::sleep(interval);
560 }
561 Ok(())
562}
563
564fn write_asset<W: Write>(
568 conn: &mut W,
569 content_type: &str,
570 bytes: &[u8],
571 gzip: bool,
572) -> std::io::Result<()> {
573 if !gzip {
574 return write_response(conn, 200, "OK", content_type, bytes);
575 }
576 let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
577 encoder.write_all(bytes)?;
578 let compressed = encoder.finish()?;
579 let header = format!(
580 "HTTP/1.1 200 OK\r\n\
581 Content-Type: {content_type}\r\n\
582 Content-Encoding: gzip\r\n\
583 Content-Length: {len}\r\n\
584 Connection: close\r\n\r\n",
585 len = compressed.len(),
586 );
587 conn.write_all(header.as_bytes())?;
588 conn.write_all(&compressed)?;
589 conn.flush()
590}
591
592fn write_json<W: Write>(conn: &mut W, code: u16, reason: &str, json: &str) -> std::io::Result<()> {
594 write_response(
595 conn,
596 code,
597 reason,
598 "application/json; charset=utf-8",
599 json.as_bytes(),
600 )
601}
602
603fn write_response<W: Write>(
604 conn: &mut W,
605 code: u16,
606 reason: &str,
607 content_type: &str,
608 body: &[u8],
609) -> std::io::Result<()> {
610 let header = format!(
611 "HTTP/1.1 {code} {reason}\r\n\
612 Content-Type: {content_type}\r\n\
613 Content-Length: {len}\r\n\
614 Connection: close\r\n\r\n",
615 len = body.len(),
616 );
617 conn.write_all(header.as_bytes())?;
618 conn.write_all(body)?;
619 conn.flush()
620}
621
622#[cfg(all(test, feature = "mock"))]
625mod tests {
626 use super::*;
627 use crate::{Mock, Scenario};
628
629 #[test]
630 fn query_value_extracts_a_named_parameter() {
631 assert_eq!(
632 query_value("scenario=alarm&locale=sw", "scenario").as_deref(),
633 Some("alarm")
634 );
635 assert_eq!(query_value("locale=sw", "scenario"), None);
636 assert_eq!(query_value("", "scenario"), None);
637 }
638
639 fn handle_request(request: &[u8], auth: Arc<Auth>) -> String {
640 let mut conn = MemConn::new(request);
641 let source = Arc::new(Mutex::new(Mock::new(Scenario::Normal)));
642 handle(
643 &mut conn,
644 source,
645 Assets::Embedded,
646 Duration::from_millis(0),
647 auth,
648 None,
649 )
650 .expect("handled");
651 String::from_utf8_lossy(&conn.output).into_owned()
652 }
653
654 #[test]
655 fn a_get_state_request_is_served_as_json_over_any_stream() {
656 let mut conn = MemConn::new(b"GET /state HTTP/1.1\r\nHost: x\r\n\r\n");
659 let source = Arc::new(Mutex::new(Mock::new(Scenario::Alarm)));
660 handle(
661 &mut conn,
662 source,
663 Assets::Embedded,
664 Duration::from_millis(0),
665 Arc::new(Auth::new("secret")),
666 None,
667 )
668 .expect("handled");
669 let written = String::from_utf8_lossy(&conn.output);
670 assert!(written.contains("200 OK"));
671 assert!(written.contains("\"status\":\"alarm\""));
672 }
673
674 #[test]
675 fn an_unknown_path_is_a_404() {
676 let written = handle_request(b"GET /nope HTTP/1.1\r\n\r\n", Arc::new(Auth::new("secret")));
677 assert!(written.contains("404 Not Found"));
678 }
679
680 #[test]
681 fn a_get_catalog_serves_the_presentation_catalog_when_one_is_set() {
682 use pamoja_profile::{ElementSpec, Presentation, Profile, Viz};
683
684 let profile = Profile::well_level().with_presentation(Presentation::new().with_element(
685 ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge).with_band(0.0, 5.0),
686 ));
687 let catalog = Catalog::from_profiles(&[&profile])
688 .to_json()
689 .expect("serialize catalog");
690
691 let mut conn = MemConn::new(b"GET /catalog HTTP/1.1\r\n\r\n");
692 handle(
693 &mut conn,
694 Arc::new(Mutex::new(Mock::new(Scenario::Normal))),
695 Assets::Embedded,
696 Duration::from_millis(0),
697 Arc::new(Auth::new("s")),
698 Some(Arc::new(catalog)),
699 )
700 .expect("handled");
701
702 let written = String::from_utf8_lossy(&conn.output);
703 assert!(written.contains("200 OK"));
704 assert!(written.contains("water_turbidity"));
705 assert!(written.contains("\"viz\":\"radial\""));
706 }
707
708 #[test]
709 fn a_get_catalog_is_no_content_without_a_catalog() {
710 let written = handle_request(b"GET /catalog HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
711 assert!(written.contains("204 No Content"));
712 }
713
714 #[cfg(not(feature = "tier-c"))]
715 #[test]
716 fn a_get_locales_lists_the_embedded_locales() {
717 let written = handle_request(b"GET /locales HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
718 assert!(written.contains("200 OK"));
719 assert!(written.contains("\"en\""));
721 }
722
723 #[test]
724 fn a_get_lite_serves_a_no_script_status_table() {
725 let written = handle_request(b"GET /lite HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
726 assert!(written.contains("200 OK"));
727 assert!(written.contains("text/html"));
728 assert!(written.contains("http-equiv=\"refresh\""));
729 assert!(!written.contains("<script"));
730 }
731
732 #[test]
733 fn a_challenge_then_confirm_pairs_over_http() {
734 use pamoja_session::{hkdf_sha256, hmac_sha256};
735
736 let auth = Arc::new(Auth::new("s3cret"));
737
738 let challenge = handle_request(b"GET /pair/challenge HTTP/1.1\r\n\r\n", Arc::clone(&auth));
740 assert!(challenge.contains("200 OK"));
741 let body = challenge.split("\r\n\r\n").nth(1).expect("body");
742 let session_id = field(body, "sessionId");
743 let nonce = field(body, "nonce");
744
745 let mut key = [0u8; 32];
747 hkdf_sha256(
748 nonce.as_bytes(),
749 b"s3cret",
750 b"pamoja/dashboard/cmd v1",
751 &mut key,
752 );
753 let mac = hmac_sha256(&key, format!("confirm\n{session_id}").as_bytes())
754 .iter()
755 .map(|b| format!("{b:02x}"))
756 .collect::<String>();
757 let confirm_body = format!(r#"{{"sessionId":"{session_id}","mac":"{mac}"}}"#);
758 let request = format!(
759 "POST /pair/confirm HTTP/1.1\r\nContent-Length: {}\r\n\r\n{}",
760 confirm_body.len(),
761 confirm_body
762 );
763 let confirm = handle_request(request.as_bytes(), Arc::clone(&auth));
764 assert!(confirm.contains("200 OK"), "confirm response: {confirm}");
765 }
766
767 fn field(json: &str, key: &str) -> String {
769 let needle = format!("\"{key}\":\"");
770 let start = json.find(&needle).expect("key present") + needle.len();
771 let rest = &json[start..];
772 rest[..rest.find('"').expect("closing quote")].to_owned()
773 }
774
775 #[test]
776 fn an_asset_is_gzipped_when_the_client_accepts_it() {
777 use flate2::read::GzDecoder;
778 use std::io::Read as _;
779
780 let mut conn = MemConn::new(b"GET / HTTP/1.1\r\nAccept-Encoding: gzip, deflate\r\n\r\n");
782 handle(
783 &mut conn,
784 Arc::new(Mutex::new(Mock::new(Scenario::Normal))),
785 Assets::Embedded,
786 Duration::from_millis(0),
787 Arc::new(Auth::new("secret")),
788 None,
789 )
790 .expect("handled");
791
792 let split = conn
793 .output
794 .windows(4)
795 .position(|w| w == b"\r\n\r\n")
796 .expect("headers end")
797 + 4;
798 let head = String::from_utf8_lossy(&conn.output[..split]);
799 assert!(head.contains("Content-Encoding: gzip"), "head: {head}");
800
801 let mut decoded = Vec::new();
802 GzDecoder::new(&conn.output[split..])
803 .read_to_end(&mut decoded)
804 .expect("gunzip");
805 let (_, original) = Assets::Embedded.get("/").expect("asset");
806 assert_eq!(decoded, original, "gunzipped body matches the source asset");
807 }
808
809 #[test]
810 fn an_asset_is_identity_without_accept_encoding() {
811 let written = handle_request(b"GET / HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
812 assert!(written.contains("200 OK"));
813 assert!(!written.contains("Content-Encoding"));
814 }
815
816 struct MemConn {
818 input: std::io::Cursor<Vec<u8>>,
819 output: Vec<u8>,
820 }
821
822 impl MemConn {
823 fn new(request: &[u8]) -> Self {
824 Self {
825 input: std::io::Cursor::new(request.to_vec()),
826 output: Vec::new(),
827 }
828 }
829 }
830
831 impl Read for MemConn {
832 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
833 self.input.read(buf)
834 }
835 }
836
837 impl Write for MemConn {
838 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
839 self.output.extend_from_slice(buf);
840 Ok(buf.len())
841 }
842
843 fn flush(&mut self) -> std::io::Result<()> {
844 Ok(())
845 }
846 }
847}