ssh_browser/control/
mod.rs1use std::path::PathBuf;
17
18use anyhow::{Result, anyhow};
19use bytes::Bytes;
20use http_body_util::Full;
21use hyper::header::CONTENT_TYPE;
22use hyper::{Method, Response, StatusCode};
23use serde::Serialize;
24
25pub const TOKEN_HEADER: &str = "x-ssh-browser-token";
30
31pub const PATH_PREFIX: &str = "/_control/";
32
33pub const PROTOCOL_MIN: u32 = 1;
39pub const PROTOCOL_MAX: u32 = 1;
40
41const TOKEN_BYTES: usize = 32;
42
43pub struct Token(String);
49
50impl Token {
51 pub fn generate() -> Result<Self> {
52 let mut bytes = [0u8; TOKEN_BYTES];
53 getrandom::fill(&mut bytes)
56 .map_err(|e| anyhow!("reading OS entropy for the control token failed: {e}"))?;
57 Ok(Self(hex(&bytes)))
58 }
59
60 pub fn from_hex(s: &str) -> Self {
62 Self(s.to_string())
63 }
64
65 pub fn as_str(&self) -> &str {
66 &self.0
67 }
68
69 pub fn matches(&self, presented: &str) -> bool {
75 let (want, got) = (self.0.as_bytes(), presented.as_bytes());
76 if want.len() != got.len() {
77 return false;
78 }
79 let mut diff = 0u8;
80 for (a, b) in want.iter().zip(got) {
81 diff |= a ^ b;
82 }
83 diff == 0
84 }
85
86 pub fn write_to_disk(&self) -> Option<PathBuf> {
92 let path = token_path()?;
93 std::fs::create_dir_all(path.parent()?).ok()?;
94 std::fs::write(&path, &self.0).ok()?;
95 restrict(&path);
96 Some(path)
97 }
98}
99
100fn token_path() -> Option<PathBuf> {
106 let base = std::env::var_os("XDG_RUNTIME_DIR")
107 .or_else(|| std::env::var_os("XDG_CONFIG_HOME"))
108 .or_else(|| std::env::var_os("LOCALAPPDATA"))
109 .map(PathBuf::from)
110 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
111 Some(base.join("ssh-browser").join("token"))
112}
113
114#[cfg(unix)]
115fn restrict(path: &std::path::Path) {
116 use std::os::unix::fs::PermissionsExt;
117 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
118}
119
120#[cfg(not(unix))]
121fn restrict(_path: &std::path::Path) {
122 }
125
126fn hex(bytes: &[u8]) -> String {
127 let mut out = String::with_capacity(bytes.len() * 2);
128 for b in bytes {
129 out.push(nibble(b >> 4));
130 out.push(nibble(b & 0x0f));
131 }
132 out
133}
134
135fn nibble(n: u8) -> char {
136 match n {
137 0..=9 => (b'0' + n) as char,
138 _ => (b'a' + n - 10) as char,
139 }
140}
141
142#[derive(Serialize)]
143struct Protocol {
144 min: u32,
145 max: u32,
146}
147
148#[derive(Serialize)]
149struct Hello<'a> {
150 daemon: &'a str,
151 protocol: Protocol,
152 aliases: &'a [String],
153 suffix: &'a str,
160}
161
162pub fn gate(
168 method: &Method,
169 presented: Option<&str>,
170 token: &Token,
171) -> Option<Response<Full<Bytes>>> {
172 if method == Method::OPTIONS {
176 return Some(text(
177 StatusCode::METHOD_NOT_ALLOWED,
178 "the control API does not participate in CORS",
179 ));
180 }
181
182 match presented {
183 Some(p) if token.matches(p) => None,
184 _ => Some(text(StatusCode::UNAUTHORIZED, "control token required")),
187 }
188}
189
190pub fn route_of(path: &str) -> &str {
192 path.strip_prefix(PATH_PREFIX).unwrap_or("")
193}
194
195pub fn hello(aliases: &[String], suffix: &str) -> Response<Full<Bytes>> {
196 json(&Hello {
197 daemon: env!("CARGO_PKG_VERSION"),
198 protocol: Protocol {
199 min: PROTOCOL_MIN,
200 max: PROTOCOL_MAX,
201 },
202 aliases,
203 suffix,
204 })
205}
206
207pub fn json<T: Serialize>(value: &T) -> Response<Full<Bytes>> {
208 match serde_json::to_vec(value) {
209 Ok(body) => Response::builder()
210 .status(StatusCode::OK)
211 .header(CONTENT_TYPE, "application/json")
212 .body(Full::new(Bytes::from(body)))
213 .unwrap_or_else(|_| text(StatusCode::INTERNAL_SERVER_ERROR, "malformed response")),
214 Err(e) => text(
215 StatusCode::INTERNAL_SERVER_ERROR,
216 format!("serialising the response failed: {e}"),
217 ),
218 }
219}
220
221pub fn text(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
222 Response::builder()
223 .status(status)
224 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
225 .body(Full::new(Bytes::from(detail.into())))
226 .expect("a plain-text body with static headers always builds")
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn token() -> Token {
234 Token::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
235 }
236
237 #[test]
238 fn a_generated_token_is_long_and_random() {
239 let a = Token::generate().expect("OS entropy");
240 let b = Token::generate().expect("OS entropy");
241 assert_eq!(a.as_str().len(), TOKEN_BYTES * 2);
242 assert!(a.as_str().chars().all(|c| c.is_ascii_hexdigit()));
243 assert_ne!(
244 a.as_str(),
245 b.as_str(),
246 "two tokens from the same process must differ"
247 );
248 }
249
250 #[test]
251 fn the_right_token_passes_the_gate() {
252 assert!(gate(&Method::GET, Some(token().as_str()), &token()).is_none());
253 }
254
255 #[test]
256 fn a_missing_or_wrong_token_is_refused_identically() {
257 for presented in [None, Some(""), Some("wrong"), Some(&token().as_str()[..10])] {
258 let refusal = gate(&Method::GET, presented, &token()).expect("refused");
259 assert_eq!(refusal.status(), StatusCode::UNAUTHORIZED);
260 }
261 }
262
263 #[test]
266 fn a_preflight_is_refused_even_with_a_valid_token() {
267 let refusal = gate(&Method::OPTIONS, Some(token().as_str()), &token()).expect("refused");
268 assert_eq!(refusal.status(), StatusCode::METHOD_NOT_ALLOWED);
269 }
270
271 #[test]
274 fn no_response_carries_cors_headers() {
275 let mut responses = vec![hello(&["docs".to_string()], "ssh-browser")];
276 responses.extend(gate(&Method::OPTIONS, None, &token()));
277 responses.extend(gate(&Method::GET, None, &token()));
278 responses.push(text(StatusCode::NOT_FOUND, "nope"));
279
280 for res in responses {
281 for name in res.headers().keys() {
282 let lowered = name.as_str().to_ascii_lowercase();
283 assert!(
284 !lowered.starts_with("access-control-"),
285 "a control response carries {lowered}"
286 );
287 }
288 }
289 }
290
291 #[test]
292 fn hello_reports_a_protocol_range_and_the_aliases() {
293 let body = serde_json::to_string(&Hello {
294 daemon: env!("CARGO_PKG_VERSION"),
295 protocol: Protocol {
296 min: PROTOCOL_MIN,
297 max: PROTOCOL_MAX,
298 },
299 aliases: &["docs".to_string()],
300 suffix: "ssh-browser",
301 })
302 .expect("serialises");
303 assert!(body.contains("\"min\":1"));
304 assert!(body.contains("\"max\":1"));
305 assert!(body.contains("\"aliases\":[\"docs\"]"));
306 assert!(body.contains("\"daemon\":\""));
307 }
308
309 #[test]
310 fn routes_are_named_after_the_prefix() {
311 assert_eq!(route_of("/_control/hello"), "hello");
312 assert_eq!(route_of("/_control/annotations"), "annotations");
313 assert_eq!(route_of("/not-control"), "");
314 }
315}