use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use flate2::write::GzEncoder;
use flate2::Compression;
use serde::Deserialize;
use crate::assets::Assets;
use crate::auth::Auth;
use crate::catalog::Catalog;
use crate::command::Command;
use crate::source::StateSource;
pub trait Transport: Send + 'static {
type Conn: Read + Write + Send + 'static;
fn accept(&self) -> std::io::Result<Self::Conn>;
fn describe(&self) -> String;
}
pub struct TcpTransport {
listener: TcpListener,
}
impl TcpTransport {
pub fn bind(addr: impl ToSocketAddrs) -> std::io::Result<Self> {
Ok(Self {
listener: TcpListener::bind(addr)?,
})
}
}
impl Transport for TcpTransport {
type Conn = TcpStream;
fn accept(&self) -> std::io::Result<Self::Conn> {
self.listener.accept().map(|(stream, _)| stream)
}
fn describe(&self) -> String {
match self.listener.local_addr() {
Ok(addr) => format!("http://{addr}"),
Err(_) => "http://?".to_owned(),
}
}
}
pub struct Server<S> {
source: Arc<Mutex<S>>,
assets: Assets,
push_interval: Duration,
auth: Arc<Auth>,
catalog: Option<Arc<String>>,
}
impl<S: StateSource + Send + 'static> Server<S> {
pub fn new(source: S, assets: Assets) -> Self {
Self {
source: Arc::new(Mutex::new(source)),
assets,
push_interval: Duration::from_secs(1),
auth: Arc::new(Auth::new(Auth::generate_secret())),
catalog: None,
}
}
pub fn with_catalog(mut self, catalog: Catalog) -> Self {
self.catalog = catalog
.to_json()
.ok()
.filter(|_| !catalog.is_empty())
.map(Arc::new);
self
}
pub fn with_pairing_secret(mut self, secret: impl Into<String>) -> Self {
self.auth = Arc::new(Auth::new(secret));
self
}
pub fn with_push_interval(mut self, interval: Duration) -> Self {
self.push_interval = interval;
self
}
pub fn run(self, addr: impl ToSocketAddrs) -> std::io::Result<()> {
let transport = TcpTransport::bind(addr)?;
self.run_on(transport)
}
pub fn run_on<T: Transport>(self, transport: T) -> std::io::Result<()> {
println!("pamoja-dashboard: serving on {}", transport.describe());
loop {
let conn = match transport.accept() {
Ok(conn) => conn,
Err(_) => continue,
};
let source = Arc::clone(&self.source);
let assets = self.assets.clone();
let interval = self.push_interval;
let auth = Arc::clone(&self.auth);
let catalog = self.catalog.clone();
thread::spawn(move || {
let _ = handle(conn, source, assets, interval, auth, catalog);
});
}
}
}
struct Request {
method: String,
path: String,
query: String,
body: Vec<u8>,
accept_gzip: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ConfirmRequest {
session_id: String,
mac: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommandRequest {
session_id: String,
counter: u64,
cmd: String,
mac: String,
}
fn handle<S: StateSource, C: Read + Write>(
mut conn: C,
source: Arc<Mutex<S>>,
assets: Assets,
interval: Duration,
auth: Arc<Auth>,
catalog: Option<Arc<String>>,
) -> std::io::Result<()> {
let request = match read_request(&mut conn)? {
Some(request) => request,
None => return Ok(()),
};
if let Some(scenario) = query_value(&request.query, "scenario") {
if let Ok(mut source) = source.lock() {
source.select(&scenario);
}
}
match (request.method.as_str(), request.path.as_str()) {
("GET", "/state") => {
let json = snapshot_json(&source);
write_response(
&mut conn,
200,
"OK",
"application/json; charset=utf-8",
json.as_bytes(),
)
}
("GET", "/catalog") => match &catalog {
Some(json) => write_json(&mut conn, 200, "OK", json),
None => write_response(
&mut conn,
204,
"No Content",
"application/json; charset=utf-8",
b"",
),
},
("GET", "/locales") => {
let tags = crate::assets::embedded_locales();
let json = format!(
"[{}]",
tags.iter()
.map(|tag| format!("\"{tag}\""))
.collect::<Vec<_>>()
.join(",")
);
write_json(&mut conn, 200, "OK", &json)
}
("GET", "/lite") => {
let html = match source.lock() {
Ok(mut source) => crate::lite::render_lite(&source.snapshot()),
Err(_) => crate::lite::render_unavailable(),
};
write_response(
&mut conn,
200,
"OK",
"text/html; charset=utf-8",
html.as_bytes(),
)
}
("GET", "/events") => stream_events(&mut conn, &source, interval),
("GET", "/pair/challenge") => {
let challenge = auth.challenge();
let json = format!(
r#"{{"sessionId":"{}","nonce":"{}"}}"#,
challenge.session_id, challenge.nonce
);
write_json(&mut conn, 200, "OK", &json)
}
("POST", "/pair/confirm") => {
match serde_json::from_slice::<ConfirmRequest>(&request.body) {
Ok(confirm) => match auth.confirm(&confirm.session_id, &confirm.mac) {
Ok(()) => write_json(&mut conn, 200, "OK", "{}"),
Err(error) => write_json(
&mut conn,
401,
"Unauthorized",
&format!(r#"{{"error":"{}"}}"#, error.code()),
),
},
Err(_) => write_json(&mut conn, 400, "Bad Request", r#"{"error":"bad_request"}"#),
}
}
("POST", "/command") => handle_command(&mut conn, &source, &auth, &request.body),
("GET", path) => match assets.get(path) {
Some((content_type, bytes)) => {
write_asset(&mut conn, content_type, &bytes, request.accept_gzip)
}
None => write_response(&mut conn, 404, "Not Found", "text/plain", b"not found"),
},
_ => write_response(
&mut conn,
405,
"Method Not Allowed",
"text/plain",
b"method not allowed",
),
}
}
fn handle_command<S: StateSource, W: Write>(
conn: &mut W,
source: &Arc<Mutex<S>>,
auth: &Arc<Auth>,
body: &[u8],
) -> std::io::Result<()> {
let request: CommandRequest = match serde_json::from_slice(body) {
Ok(request) => request,
Err(_) => return write_json(conn, 400, "Bad Request", r#"{"error":"bad_request"}"#),
};
if let Err(error) = auth.verify_command(
&request.session_id,
request.counter,
&request.cmd,
&request.mac,
) {
return write_json(
conn,
401,
"Unauthorized",
&format!(r#"{{"error":"{}"}}"#, error.code()),
);
}
let command: Command = match serde_json::from_str(&request.cmd) {
Ok(command) => command,
Err(_) => return write_json(conn, 400, "Bad Request", r#"{"error":"bad_request"}"#),
};
let outcome = match source.lock() {
Ok(mut source) => source.command(&command),
Err(_) => {
return write_json(
conn,
500,
"Internal Server Error",
r#"{"error":"internal"}"#,
)
}
};
match outcome {
Ok(()) => write_json(conn, 200, "OK", "{}"),
Err(error) => write_json(
conn,
422,
"Unprocessable Entity",
&format!(r#"{{"error":"{}"}}"#, error.code()),
),
}
}
fn read_request<C: Read>(conn: &mut C) -> std::io::Result<Option<Request>> {
let mut reader = BufReader::new(conn);
let mut line = String::new();
if reader.read_line(&mut line)? == 0 {
return Ok(None);
}
let mut parts = line.split_whitespace();
let (Some(method), Some(target)) = (parts.next(), parts.next()) else {
return Ok(None);
};
let (path, query) = match target.split_once('?') {
Some((path, query)) => (path.to_owned(), query.to_owned()),
None => (target.to_owned(), String::new()),
};
let mut content_length = 0usize;
let mut accept_gzip = false;
loop {
let mut header = String::new();
if reader.read_line(&mut header)? == 0 {
break;
}
let header = header.trim_end();
if header.is_empty() {
break;
}
if let Some((name, value)) = header.split_once(':') {
if name.eq_ignore_ascii_case("content-length") {
content_length = value.trim().parse().unwrap_or(0);
} else if name.eq_ignore_ascii_case("accept-encoding") {
accept_gzip = value.to_ascii_lowercase().contains("gzip");
}
}
}
let mut body = Vec::new();
if content_length > 0 {
body = vec![0u8; content_length];
reader.read_exact(&mut body)?;
}
Ok(Some(Request {
method: method.to_owned(),
path,
query,
body,
accept_gzip,
}))
}
fn query_value(query: &str, key: &str) -> Option<String> {
query.split('&').find_map(|pair| {
let (name, value) = pair.split_once('=')?;
(name == key).then(|| value.to_owned())
})
}
fn snapshot_json<S: StateSource>(source: &Arc<Mutex<S>>) -> String {
match source.lock() {
Ok(mut source) => source
.snapshot()
.to_json()
.unwrap_or_else(|_| "{}".to_owned()),
Err(_) => "{}".to_owned(),
}
}
fn stream_events<S: StateSource, W: Write>(
conn: &mut W,
source: &Arc<Mutex<S>>,
interval: Duration,
) -> std::io::Result<()> {
let headers = "HTTP/1.1 200 OK\r\n\
Content-Type: text/event-stream\r\n\
Cache-Control: no-cache\r\n\
Connection: keep-alive\r\n\r\n";
conn.write_all(headers.as_bytes())?;
conn.flush()?;
loop {
let json = snapshot_json(source);
if conn
.write_all(format!("data: {json}\n\n").as_bytes())
.is_err()
{
break;
}
if conn.flush().is_err() {
break;
}
thread::sleep(interval);
}
Ok(())
}
fn write_asset<W: Write>(
conn: &mut W,
content_type: &str,
bytes: &[u8],
gzip: bool,
) -> std::io::Result<()> {
if !gzip {
return write_response(conn, 200, "OK", content_type, bytes);
}
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
encoder.write_all(bytes)?;
let compressed = encoder.finish()?;
let header = format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: {content_type}\r\n\
Content-Encoding: gzip\r\n\
Content-Length: {len}\r\n\
Connection: close\r\n\r\n",
len = compressed.len(),
);
conn.write_all(header.as_bytes())?;
conn.write_all(&compressed)?;
conn.flush()
}
fn write_json<W: Write>(conn: &mut W, code: u16, reason: &str, json: &str) -> std::io::Result<()> {
write_response(
conn,
code,
reason,
"application/json; charset=utf-8",
json.as_bytes(),
)
}
fn write_response<W: Write>(
conn: &mut W,
code: u16,
reason: &str,
content_type: &str,
body: &[u8],
) -> std::io::Result<()> {
let header = format!(
"HTTP/1.1 {code} {reason}\r\n\
Content-Type: {content_type}\r\n\
Content-Length: {len}\r\n\
Connection: close\r\n\r\n",
len = body.len(),
);
conn.write_all(header.as_bytes())?;
conn.write_all(body)?;
conn.flush()
}
#[cfg(all(test, feature = "mock"))]
mod tests {
use super::*;
use crate::{Mock, Scenario};
#[test]
fn query_value_extracts_a_named_parameter() {
assert_eq!(
query_value("scenario=alarm&locale=sw", "scenario").as_deref(),
Some("alarm")
);
assert_eq!(query_value("locale=sw", "scenario"), None);
assert_eq!(query_value("", "scenario"), None);
}
fn handle_request(request: &[u8], auth: Arc<Auth>) -> String {
let mut conn = MemConn::new(request);
let source = Arc::new(Mutex::new(Mock::new(Scenario::Normal)));
handle(
&mut conn,
source,
Assets::Embedded,
Duration::from_millis(0),
auth,
None,
)
.expect("handled");
String::from_utf8_lossy(&conn.output).into_owned()
}
#[test]
fn a_get_state_request_is_served_as_json_over_any_stream() {
let mut conn = MemConn::new(b"GET /state HTTP/1.1\r\nHost: x\r\n\r\n");
let source = Arc::new(Mutex::new(Mock::new(Scenario::Alarm)));
handle(
&mut conn,
source,
Assets::Embedded,
Duration::from_millis(0),
Arc::new(Auth::new("secret")),
None,
)
.expect("handled");
let written = String::from_utf8_lossy(&conn.output);
assert!(written.contains("200 OK"));
assert!(written.contains("\"status\":\"alarm\""));
}
#[test]
fn an_unknown_path_is_a_404() {
let written = handle_request(b"GET /nope HTTP/1.1\r\n\r\n", Arc::new(Auth::new("secret")));
assert!(written.contains("404 Not Found"));
}
#[test]
fn a_get_catalog_serves_the_presentation_catalog_when_one_is_set() {
use pamoja_profile::{ElementSpec, Presentation, Profile, Viz};
let profile = Profile::well_level().with_presentation(Presentation::new().with_element(
ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge).with_band(0.0, 5.0),
));
let catalog = Catalog::from_profiles(&[&profile])
.to_json()
.expect("serialize catalog");
let mut conn = MemConn::new(b"GET /catalog HTTP/1.1\r\n\r\n");
handle(
&mut conn,
Arc::new(Mutex::new(Mock::new(Scenario::Normal))),
Assets::Embedded,
Duration::from_millis(0),
Arc::new(Auth::new("s")),
Some(Arc::new(catalog)),
)
.expect("handled");
let written = String::from_utf8_lossy(&conn.output);
assert!(written.contains("200 OK"));
assert!(written.contains("water_turbidity"));
assert!(written.contains("\"viz\":\"radial\""));
}
#[test]
fn a_get_catalog_is_no_content_without_a_catalog() {
let written = handle_request(b"GET /catalog HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
assert!(written.contains("204 No Content"));
}
#[cfg(not(feature = "tier-c"))]
#[test]
fn a_get_locales_lists_the_embedded_locales() {
let written = handle_request(b"GET /locales HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
assert!(written.contains("200 OK"));
assert!(written.contains("\"en\""));
}
#[test]
fn a_get_lite_serves_a_no_script_status_table() {
let written = handle_request(b"GET /lite HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
assert!(written.contains("200 OK"));
assert!(written.contains("text/html"));
assert!(written.contains("http-equiv=\"refresh\""));
assert!(!written.contains("<script"));
}
#[test]
fn a_challenge_then_confirm_pairs_over_http() {
use pamoja_session::{hkdf_sha256, hmac_sha256};
let auth = Arc::new(Auth::new("s3cret"));
let challenge = handle_request(b"GET /pair/challenge HTTP/1.1\r\n\r\n", Arc::clone(&auth));
assert!(challenge.contains("200 OK"));
let body = challenge.split("\r\n\r\n").nth(1).expect("body");
let session_id = field(body, "sessionId");
let nonce = field(body, "nonce");
let mut key = [0u8; 32];
hkdf_sha256(
nonce.as_bytes(),
b"s3cret",
b"pamoja/dashboard/cmd v1",
&mut key,
);
let mac = hmac_sha256(&key, format!("confirm\n{session_id}").as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
let confirm_body = format!(r#"{{"sessionId":"{session_id}","mac":"{mac}"}}"#);
let request = format!(
"POST /pair/confirm HTTP/1.1\r\nContent-Length: {}\r\n\r\n{}",
confirm_body.len(),
confirm_body
);
let confirm = handle_request(request.as_bytes(), Arc::clone(&auth));
assert!(confirm.contains("200 OK"), "confirm response: {confirm}");
}
fn field(json: &str, key: &str) -> String {
let needle = format!("\"{key}\":\"");
let start = json.find(&needle).expect("key present") + needle.len();
let rest = &json[start..];
rest[..rest.find('"').expect("closing quote")].to_owned()
}
#[test]
fn an_asset_is_gzipped_when_the_client_accepts_it() {
use flate2::read::GzDecoder;
use std::io::Read as _;
let mut conn = MemConn::new(b"GET / HTTP/1.1\r\nAccept-Encoding: gzip, deflate\r\n\r\n");
handle(
&mut conn,
Arc::new(Mutex::new(Mock::new(Scenario::Normal))),
Assets::Embedded,
Duration::from_millis(0),
Arc::new(Auth::new("secret")),
None,
)
.expect("handled");
let split = conn
.output
.windows(4)
.position(|w| w == b"\r\n\r\n")
.expect("headers end")
+ 4;
let head = String::from_utf8_lossy(&conn.output[..split]);
assert!(head.contains("Content-Encoding: gzip"), "head: {head}");
let mut decoded = Vec::new();
GzDecoder::new(&conn.output[split..])
.read_to_end(&mut decoded)
.expect("gunzip");
let (_, original) = Assets::Embedded.get("/").expect("asset");
assert_eq!(decoded, original, "gunzipped body matches the source asset");
}
#[test]
fn an_asset_is_identity_without_accept_encoding() {
let written = handle_request(b"GET / HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
assert!(written.contains("200 OK"));
assert!(!written.contains("Content-Encoding"));
}
struct MemConn {
input: std::io::Cursor<Vec<u8>>,
output: Vec<u8>,
}
impl MemConn {
fn new(request: &[u8]) -> Self {
Self {
input: std::io::Cursor::new(request.to_vec()),
output: Vec::new(),
}
}
}
impl Read for MemConn {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.input.read(buf)
}
}
impl Write for MemConn {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.output.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
}