use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use bytes::Bytes;
use http_body_util::Full;
use hyper::header::CONTENT_TYPE;
use hyper::{Method, Response, StatusCode};
use serde::Serialize;
pub const TOKEN_HEADER: &str = "x-ssh-browser-token";
pub const PATH_PREFIX: &str = "/_control/";
pub const FETCH_SITE_HEADER: &str = "sec-fetch-site";
pub const PROTOCOL_MIN: u32 = 1;
pub const PROTOCOL_MAX: u32 = 1;
const TOKEN_BYTES: usize = 32;
pub struct Token(String);
impl Token {
pub fn generate() -> Result<Self> {
let mut bytes = [0u8; TOKEN_BYTES];
getrandom::fill(&mut bytes)
.map_err(|e| anyhow!("reading OS entropy for the control token failed: {e}"))?;
Ok(Self(hex(&bytes)))
}
pub fn from_hex(s: &str) -> Self {
Self(s.to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn matches(&self, presented: &str) -> bool {
let (want, got) = (self.0.as_bytes(), presented.as_bytes());
if want.len() != got.len() {
return false;
}
let mut diff = 0u8;
for (a, b) in want.iter().zip(got) {
diff |= a ^ b;
}
diff == 0
}
pub fn write_to_disk(&self) -> Option<PathBuf> {
let path = token_path()?;
std::fs::create_dir_all(path.parent()?).ok()?;
std::fs::write(&path, &self.0).ok()?;
restrict(&path);
Some(path)
}
fn from_disk(path: &Path) -> Option<Self> {
let text = std::fs::read_to_string(path).ok()?;
let trimmed = text.trim();
let looks_right = trimmed.len() == TOKEN_BYTES * 2
&& trimmed
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase());
looks_right.then(|| Self(trimmed.to_string()))
}
pub fn load_or_generate(rotate: bool) -> Result<(Self, Source)> {
if !rotate {
if let Some(path) = token_path() {
if let Some(token) = Self::from_disk(&path) {
return Ok((token, Source::Reused(path)));
}
}
}
let token = Self::generate()?;
let written = token.write_to_disk();
Ok((token, Source::Fresh(written)))
}
}
pub enum Source {
Reused(PathBuf),
Fresh(Option<PathBuf>),
}
fn token_path() -> Option<PathBuf> {
Some(state_dir()?.join("token"))
}
pub fn state_dir() -> Option<PathBuf> {
let base = std::env::var_os("XDG_RUNTIME_DIR")
.or_else(|| std::env::var_os("XDG_CONFIG_HOME"))
.or_else(|| std::env::var_os("LOCALAPPDATA"))
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("ssh-browser"))
}
#[cfg(unix)]
fn restrict(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
fn restrict(_path: &std::path::Path) {
}
fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(nibble(b >> 4));
out.push(nibble(b & 0x0f));
}
out
}
fn nibble(n: u8) -> char {
match n {
0..=9 => (b'0' + n) as char,
_ => (b'a' + n - 10) as char,
}
}
#[derive(Serialize)]
struct Protocol {
min: u32,
max: u32,
}
#[derive(Serialize)]
struct Hello<'a> {
daemon: &'a str,
protocol: Protocol,
aliases: &'a [String],
suffix: &'a str,
trips: u64,
}
pub fn from_a_page(site: Option<&str>) -> bool {
match site {
None => false,
Some("none") => false,
Some(_) => true,
}
}
pub fn gate(
method: &Method,
fetch_site: Option<&str>,
presented: Option<&str>,
token: &Token,
) -> Option<Response<Full<Bytes>>> {
if method == Method::OPTIONS {
return Some(text(
StatusCode::METHOD_NOT_ALLOWED,
"the control API does not participate in CORS",
));
}
if from_a_page(fetch_site) {
return Some(text(
StatusCode::FORBIDDEN,
"the control API is not reachable from a page",
));
}
match presented {
Some(p) if token.matches(p) => None,
_ => Some(text(StatusCode::UNAUTHORIZED, "control token required")),
}
}
pub fn route_of(path: &str) -> &str {
path.strip_prefix(PATH_PREFIX).unwrap_or("")
}
pub fn hello(aliases: &[String], suffix: &str, trips: u64) -> Response<Full<Bytes>> {
json(&Hello {
daemon: env!("CARGO_PKG_VERSION"),
protocol: Protocol {
min: PROTOCOL_MIN,
max: PROTOCOL_MAX,
},
aliases,
suffix,
trips,
})
}
pub fn json<T: Serialize>(value: &T) -> Response<Full<Bytes>> {
match serde_json::to_vec(value) {
Ok(body) => Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from(body)))
.unwrap_or_else(|_| text(StatusCode::INTERNAL_SERVER_ERROR, "malformed response")),
Err(e) => text(
StatusCode::INTERNAL_SERVER_ERROR,
format!("serialising the response failed: {e}"),
),
}
}
pub fn text(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
Response::builder()
.status(status)
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Full::new(Bytes::from(detail.into())))
.expect("a plain-text body with static headers always builds")
}
#[cfg(test)]
mod tests {
use super::*;
fn token() -> Token {
Token::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
}
#[test]
fn a_generated_token_is_long_and_random() {
let a = Token::generate().expect("OS entropy");
let b = Token::generate().expect("OS entropy");
assert_eq!(a.as_str().len(), TOKEN_BYTES * 2);
assert!(a.as_str().chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(
a.as_str(),
b.as_str(),
"two tokens from the same process must differ"
);
}
#[test]
fn the_right_token_passes_the_gate() {
assert!(gate(&Method::GET, None, Some(token().as_str()), &token()).is_none());
}
#[test]
fn a_missing_or_wrong_token_is_refused_identically() {
for presented in [None, Some(""), Some("wrong"), Some(&token().as_str()[..10])] {
let refusal = gate(&Method::GET, None, presented, &token()).expect("refused");
assert_eq!(refusal.status(), StatusCode::UNAUTHORIZED);
}
}
#[test]
fn a_preflight_is_refused_even_with_a_valid_token() {
let refusal =
gate(&Method::OPTIONS, None, Some(token().as_str()), &token()).expect("refused");
assert_eq!(refusal.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[test]
fn no_response_carries_cors_headers() {
let mut responses = vec![hello(&["docs".to_string()], "ssh-browser", 0)];
responses.extend(gate(&Method::OPTIONS, None, None, &token()));
responses.extend(gate(&Method::GET, None, None, &token()));
responses.push(text(StatusCode::NOT_FOUND, "nope"));
for res in responses {
for name in res.headers().keys() {
let lowered = name.as_str().to_ascii_lowercase();
assert!(
!lowered.starts_with("access-control-"),
"a control response carries {lowered}"
);
}
}
}
#[test]
fn hello_reports_a_protocol_range_and_the_aliases() {
let body = serde_json::to_string(&Hello {
daemon: env!("CARGO_PKG_VERSION"),
protocol: Protocol {
min: PROTOCOL_MIN,
max: PROTOCOL_MAX,
},
aliases: &["docs".to_string()],
suffix: "ssh-browser",
trips: 7,
})
.expect("serialises");
assert!(body.contains("\"min\":1"));
assert!(body.contains("\"max\":1"));
assert!(body.contains("\"aliases\":[\"docs\"]"));
assert!(body.contains("\"daemon\":\""));
assert!(body.contains("\"trips\":7"), "{body}");
}
fn scratch(name: &str, contents: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("ssh-browser-token-{name}"));
std::fs::write(&path, contents).expect("a temp file");
path
}
#[test]
fn a_token_survives_the_round_trip_to_disk() {
let path = scratch("roundtrip", token().as_str());
let back = Token::from_disk(&path).expect("read back");
assert!(back.matches(token().as_str()));
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_file_that_is_not_a_token_is_refused() {
let cases = [
("empty", ""),
("short", "0123456789abcdef"),
("long", &"a".repeat(65) as &str),
("not-hex", &"z".repeat(64)),
("uppercase", &"A".repeat(64)),
("a sentence", "this file used to hold a token"),
];
for (name, contents) in cases {
let path = scratch(name, contents);
assert!(
Token::from_disk(&path).is_none(),
"{name:?} should not have read as a token"
);
let _ = std::fs::remove_file(&path);
}
}
#[test]
fn surrounding_whitespace_does_not_spoil_it() {
let path = scratch("whitespace", &format!("\n {}\t\n", token().as_str()));
assert!(
Token::from_disk(&path)
.expect("read back")
.matches(token().as_str())
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn routes_are_named_after_the_prefix() {
assert_eq!(route_of("/_control/hello"), "hello");
assert_eq!(route_of("/_control/open"), "open");
assert_eq!(route_of("/not-control"), "");
}
#[test]
fn a_page_is_told_apart_from_an_extension() {
assert!(!from_a_page(None));
assert!(!from_a_page(Some("none")));
for page in ["same-origin", "same-site", "cross-site"] {
assert!(from_a_page(Some(page)), "{page} is a page");
}
}
#[test]
fn a_page_cannot_reach_the_control_api_even_with_the_right_token() {
let refusal = gate(
&Method::GET,
Some("same-origin"),
Some(token().as_str()),
&token(),
)
.expect("refused");
assert_eq!(refusal.status(), StatusCode::FORBIDDEN);
}
}