use crate::db_dashboard::{escape_url_component, percent_decode};
use crate::{CliError, CliResult};
use rand::Rng;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
#[derive(Debug)]
pub struct HttpRequest {
pub method: String,
pub route: String,
pub query: String,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
impl HttpRequest {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).map(String::as_str)
}
}
pub fn parse_request(head: &str, body: Vec<u8>) -> HttpRequest {
let mut lines = head.split("\r\n");
let request_line = lines.next().unwrap_or("GET / HTTP/1.1");
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("GET").to_string();
let target = parts.next().unwrap_or("/");
let (route, query) = target.split_once('?').unwrap_or((target, ""));
let mut headers = HashMap::new();
for line in lines {
if let Some((name, value)) = line.split_once(':') {
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
}
}
HttpRequest {
method,
route: route.to_string(),
query: query.to_string(),
headers,
body,
}
}
pub fn mint_csrf_token() -> String {
let bytes: [u8; 32] = rand::thread_rng().gen();
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub fn authorize_write(request: &HttpRequest, authority: &str, csrf_token: &str) -> CliResult<()> {
if request.method != "POST" {
return Err(CliError::usage("writes require POST"));
}
if request.header("host") != Some(authority) {
return Err(CliError::usage("Host mismatch"));
}
let expected_origin = format!("http://{authority}");
if request.header("origin") != Some(expected_origin.as_str()) {
return Err(CliError::usage("Origin mismatch"));
}
let presented = request.header("x-zynk-csrf").unwrap_or("");
if presented.len() != csrf_token.len()
|| presented
.bytes()
.zip(csrf_token.bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
!= 0
{
return Err(CliError::usage("CSRF token mismatch"));
}
Ok(())
}
pub fn parse_form(body: &[u8]) -> HashMap<String, String> {
let text = String::from_utf8_lossy(body);
let mut fields = HashMap::new();
for pair in text.split('&') {
if let Some((key, value)) = pair.split_once('=') {
fields.insert(
percent_decode(&key.replace('+', " ")),
percent_decode(&value.replace('+', " ")),
);
}
}
fields
}
fn mint_mid() -> String {
let bytes: [u8; 8] = rand::thread_rng().gen();
let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
format!("op-{hex}")
}
#[allow(clippy::too_many_arguments)]
pub fn send_argv(
db: &Path,
root: &Path,
herdr_bin: &str,
session: &str,
to: &str,
message_type: &str,
mid: &str,
body: &str,
) -> Vec<String> {
let target_address = to.split_once(':').map(|(_, addr)| addr).unwrap_or(to);
vec![
"send".into(),
"herdr".into(),
"--herdr-bin".into(),
herdr_bin.into(),
"--pane".into(),
target_address.into(),
"--db".into(),
db.display().to_string(),
"--root".into(),
root.display().to_string(),
"--session-id".into(),
session.into(),
"--from".into(),
"operator:dashboard".into(),
"--to".into(),
to.into(),
"--command-origin".into(),
"operator".into(),
"--mid".into(),
mid.into(),
"--type".into(),
message_type.into(),
"--body".into(),
body.into(),
]
}
pub enum WriteOutcome {
Redirect(String),
Error {
status: &'static str,
message: String,
},
}
pub fn handle_send(request: &HttpRequest, db: &Path, root: &Path, herdr_bin: &str) -> WriteOutcome {
let form = parse_form(&request.body);
let (session, to, body) = match (form.get("session"), form.get("to"), form.get("body")) {
(Some(s), Some(t), Some(b)) if !s.is_empty() && !t.is_empty() && !b.is_empty() => (s, t, b),
_ => {
return WriteOutcome::Error {
status: "400 Bad Request",
message: "session, to, and body are required".to_string(),
};
}
};
let message_type = form
.get("type")
.map(String::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("status-update");
if let Err(outcome) = validate_send_target(db, session, to) {
return outcome;
}
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(error) => {
return WriteOutcome::Error {
status: "500 Internal Server Error",
message: format!("cannot resolve zynk binary: {error}"),
};
}
};
let argv = send_argv(
db,
root,
herdr_bin,
session,
to,
message_type,
&mint_mid(),
body,
);
match Command::new(exe).args(&argv).output() {
Ok(out) if out.status.success() => {
WriteOutcome::Redirect(format!("/?session={}", escape_url_component(session)))
}
Ok(out) => WriteOutcome::Error {
status: "502 Bad Gateway",
message: format!(
"send failed:\n{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
),
},
Err(error) => WriteOutcome::Error {
status: "500 Internal Server Error",
message: format!("failed to run zynk: {error}"),
},
}
}
fn validate_send_target(db: &Path, session: &str, to: &str) -> Result<(), WriteOutcome> {
let connection = crate::db::open_read_database(db).map_err(|error| WriteOutcome::Error {
status: "500 Internal Server Error",
message: format!("cannot open dashboard db: {}", error.message),
})?;
match crate::db_dashboard::session_exists(&connection, session) {
Ok(true) => {}
Ok(false) => {
return Err(WriteOutcome::Error {
status: "404 Not Found",
message: "unknown session — browser writes target an existing session".to_string(),
});
}
Err(error) => {
return Err(WriteOutcome::Error {
status: "500 Internal Server Error",
message: format!("session check failed: {}", error.message),
});
}
}
let targets = crate::db_dashboard::known_targets(&connection, session).map_err(|error| {
WriteOutcome::Error {
status: "500 Internal Server Error",
message: format!("target check failed: {}", error.message),
}
})?;
if !targets.iter().any(|known| known == to) {
return Err(WriteOutcome::Error {
status: "400 Bad Request",
message: "unknown target — choose a known agent:address row".to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn req(method: &str, host: &str, origin: Option<&str>, token: Option<&str>) -> HttpRequest {
let mut h = format!("Host: {host}");
if let Some(o) = origin {
h.push_str(&format!("\r\nOrigin: {o}"));
}
if let Some(t) = token {
h.push_str(&format!("\r\nX-Zynk-CSRF: {t}"));
}
parse_request(&format!("{method} /send HTTP/1.1\r\n{h}"), Vec::new())
}
#[test]
fn authorize_write_accepts_exact_host_origin_token_post() {
let authority = "127.0.0.1:8787";
let r = req(
"POST",
authority,
Some("http://127.0.0.1:8787"),
Some("secret"),
);
assert!(authorize_write(&r, authority, "secret").is_ok());
}
#[test]
fn authorize_write_rejects_bad_token_origin_host_method() {
let a = "127.0.0.1:8787";
let ok_origin = Some("http://127.0.0.1:8787");
assert!(authorize_write(&req("POST", a, ok_origin, Some("nope")), a, "secret").is_err());
assert!(authorize_write(
&req("POST", a, Some("http://evil.test"), Some("secret")),
a,
"secret"
)
.is_err());
assert!(authorize_write(
&req("POST", "evil.test", ok_origin, Some("secret")),
a,
"secret"
)
.is_err());
assert!(authorize_write(&req("GET", a, ok_origin, Some("secret")), a, "secret").is_err());
assert!(authorize_write(&req("POST", a, None, Some("secret")), a, "secret").is_err());
}
#[test]
fn mint_csrf_token_is_long_and_varies() {
let a = mint_csrf_token();
let b = mint_csrf_token();
assert!(
a.len() >= 32 && a.chars().all(|c| c.is_ascii_alphanumeric()),
"{a}"
);
assert_ne!(a, b);
}
#[test]
fn parse_form_decodes_fields() {
let f = parse_form(b"session=s1&to=codex%3Aw1-1&type=status-update&body=hello+world");
assert_eq!(f.get("session").map(String::as_str), Some("s1"));
assert_eq!(f.get("to").map(String::as_str), Some("codex:w1-1"));
assert_eq!(f.get("body").map(String::as_str), Some("hello world"));
}
#[test]
fn send_argv_is_typed_and_unflaggable() {
let argv = send_argv(
Path::new("/db"),
Path::new("/root"),
"herdr",
"s1",
"codex:w1-1",
"status-update",
"m9",
"--oops --no-audit",
);
let body_idx = argv.iter().position(|a| a == "--body").unwrap();
assert_eq!(argv[body_idx + 1], "--oops --no-audit");
assert!(argv.contains(&"--db".to_string()) && argv.contains(&"/db".to_string()));
let origin_idx = argv.iter().position(|a| a == "--command-origin").unwrap();
assert_eq!(argv[origin_idx + 1], "operator");
let mid_idx = argv.iter().position(|a| a == "--mid").unwrap();
assert_eq!(argv[mid_idx + 1], "m9");
}
#[test]
fn parse_request_splits_line_headers_body() {
let head = "POST /send?session=s HTTP/1.1\r\nHost: 127.0.0.1:8787\r\nX-Zynk-CSRF: abc";
let req = parse_request(head, b"a=1&b=2".to_vec());
assert_eq!(req.method, "POST");
assert_eq!(req.route, "/send");
assert_eq!(req.query, "session=s");
assert_eq!(req.header("host"), Some("127.0.0.1:8787"));
assert_eq!(req.header("x-zynk-csrf"), Some("abc"));
assert_eq!(req.body, b"a=1&b=2");
}
}