//! The operator's command line, reached as `didbot operate`, `didbot login`, //! `didbot estop`, `didbot account` and `didbot announce`. //! //! Two credentials, two flows. [`operate`] is the claim: it authenticates to //! the operator's own atproto account and writes `bot.did.operator` there, //! which is what makes a deployment theirs. The rest of this crate is the //! deployment's controls, and an admission's create at the deployment. `CLAUDE.md`'s rule for this project is that an //! operator command is available on the web dashboard *or* from a command //! line, using the same auth, and this is the second half of that: every //! control verb is one HTTP call to a `/dashboard/api/*` route, and the //! session it carries is minted by the same sign-in a browser gets. //! //! # Why the sign-in goes through the server //! //! The obvious shape — this command runs its own OAuth against the //! operator's personal server, then tells the deployment who it is — does //! not work, because the deployment has no way to check that claim. What it //! can check is a session it minted itself. So [`login`] starts the *server's* //! sign-in and waits on a loopback address for the result: one session type, //! one verification path, and nothing here that the server has to believe. //! //! The server refuses to redirect anywhere but loopback; see //! `didbot_serve::operator`. pub mod operate; use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; /// A session this command holds, as it is kept on disk. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Session { /// The deployment this is a session for. pub server: String, /// The session cookie's value. pub token: String, /// Echoed on anything that changes state. pub csrf: String, } /// Everything that can go wrong. #[derive(Debug, thiserror::Error)] pub enum OperatorError { /// The loopback listener could not be opened, or the browser never came /// back to it. #[error("waiting for the sign-in failed: {0}")] Wait(String), /// The call to the deployment failed at the transport. #[error("cannot reach {server}: {source}")] Unreachable { /// Which deployment. server: String, /// What the transport said. #[source] source: reqwest::Error, }, /// The deployment answered, and refused. #[error("{server} refused: {status} {body}")] Refused { /// Which deployment. server: String, /// The status it answered with. status: u16, /// What it said. body: String, }, /// There is no stored session for this deployment. #[error("not signed in to {0}; run `didbot login --server {0}` first")] NotSignedIn(String), /// The session file could not be read or written. #[error("{path}: {source}")] Store { /// The file. path: PathBuf, /// What the filesystem said. #[source] source: std::io::Error, }, } /// Where a session is kept, honouring `XDG_CONFIG_HOME`. pub fn default_session_path() -> Option { std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) .map(|base| base.join("didbot").join("operator.json")) } /// Reads the session for `server`, if one is stored. pub fn load(path: &Path, server: &str) -> Result { read_sessions(path)? .into_iter() .find(|held| held.server == server) .ok_or_else(|| OperatorError::NotSignedIn(server.to_owned())) } /// Reads the session for the deployment at `hostname`, stored under that /// name or under it with the port `didbot login --server` was given. pub fn load_for_host(path: &Path, hostname: &str) -> Result { let sessions = read_sessions(path)?; let exact = sessions.iter().position(|held| held.server == hostname); let ported = || { sessions .iter() .position(|held| didbot_identity::did::host_without_port(&held.server) == hostname) }; match exact.or_else(ported) { Some(index) => Ok(sessions[index].clone()), None => Err(OperatorError::NotSignedIn(hostname.to_owned())), } } fn read_sessions(path: &Path) -> Result, OperatorError> { let raw = std::fs::read_to_string(path).map_err(|source| OperatorError::Store { path: path.to_owned(), source, })?; Ok(serde_json::from_str(&raw).unwrap_or_default()) } /// Stores `session`, replacing any for the same deployment. /// /// `0600`: this file holds a live operator session, which is every operator /// control on that deployment. pub fn store(path: &Path, session: Session) -> Result<(), OperatorError> { let wrap = |source| OperatorError::Store { path: path.to_owned(), source, }; if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).map_err(wrap)?; } let mut sessions: Vec = std::fs::read_to_string(path) .ok() .and_then(|raw| serde_json::from_str(&raw).ok()) .unwrap_or_default(); sessions.retain(|held| held.server != session.server); sessions.push(session); let body = serde_json::to_string_pretty(&sessions).expect("sessions serialize"); std::fs::write(path, body).map_err(wrap)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(wrap)?; } Ok(()) } /// Starts the deployment's own sign-in and waits on loopback for the result. /// /// Prints the URL rather than opening a browser: this runs as often over /// SSH as it does on a desktop, and a URL a person can paste works in both. pub fn login(server: &str) -> Result { let listener = TcpListener::bind("127.0.0.1:0").map_err(|err| OperatorError::Wait(err.to_string()))?; let port = listener .local_addr() .map_err(|err| OperatorError::Wait(err.to_string()))? .port(); let url = format!("https://{server}/dashboard/login?cli=http%3A%2F%2F127.0.0.1%3A{port}%2Fdone"); println!("open this to sign in as the operator of {server}:\n\n {url}\n"); println!("waiting for the sign-in to come back..."); let (mut stream, _) = listener .accept() .map_err(|err| OperatorError::Wait(err.to_string()))?; let mut request = String::new(); BufReader::new( stream .try_clone() .map_err(|err| OperatorError::Wait(err.to_string()))?, ) .read_line(&mut request) .map_err(|err| OperatorError::Wait(err.to_string()))?; let session = parse_handoff(server, &request) .ok_or_else(|| OperatorError::Wait(format!("the browser came back with {request:?}")))?; let _ = stream.write_all( b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\n\r\nsigned in; you can close this tab\n", ); Ok(session) } /// Reads `token` and `csrf` out of the request line the browser sent. /// /// Split out from [`login`] so the parsing is testable without a socket. fn parse_handoff(server: &str, request_line: &str) -> Option { let target = request_line.split_whitespace().nth(1)?; let query = target.split_once('?')?.1; let mut token = None; let mut csrf = None; for pair in query.split('&') { match pair.split_once('=') { Some(("token", value)) => token = Some(value.to_owned()), Some(("csrf", value)) => csrf = Some(value.to_owned()), _ => {} } } Some(Session { server: server.to_owned(), token: token?, csrf: csrf?, }) } /// Carries `session` on `request`: the session cookie, and the CSRF token /// a mutating call must echo — see `didbot_serve::operator`. pub fn present(request: reqwest::RequestBuilder, session: &Session) -> reqwest::RequestBuilder { request .header( reqwest::header::COOKIE, format!("didbot_operator={}", session.token), ) .header("x-didbot-csrf", &session.csrf) } /// Calls one operator route, carrying `session`. `body` present means a /// `POST`. pub async fn call( session: &Session, path: &str, body: Option, ) -> Result { let url = format!("https://{}{path}", session.server); let client = didbot_http::client(); let request = match &body { Some(body) => client.post(&url).json(body), None => client.get(&url), }; let response = present(request, session) .send() .await .map_err(|source| OperatorError::Unreachable { server: session.server.clone(), source, })?; let status = response.status(); let text = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY) .await .map(didbot_http::body_text) .unwrap_or_default(); if !status.is_success() { return Err(OperatorError::Refused { server: session.server.clone(), status: status.as_u16(), body: text, }); } Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text))) } #[cfg(test)] mod tests { use super::*; /// A stored session is a live operator control on that deployment, so /// the file it lands in must not be readable by anyone else on the /// machine. /// /// Fails if `store` stops setting the mode explicitly and inherits /// whatever the process umask happens to be. #[cfg(unix)] #[test] fn a_stored_session_is_private_to_this_user() { use std::os::unix::fs::PermissionsExt; let dir = std::env::temp_dir().join(format!("didbot-operator-mode-{}", std::process::id())); let path = dir.join("operator.json"); store( &path, Session { server: "pds.example".to_owned(), token: "alpha".to_owned(), csrf: "beta".to_owned(), }, ) .expect("the session stores"); let mode = std::fs::metadata(&path) .expect("the file is there") .permissions() .mode() & 0o777; assert_eq!(mode, 0o600, "a session file must be private to this user"); std::fs::remove_dir_all(&dir).ok(); } /// One file holds a session per deployment, and signing in again /// replaces that deployment's entry rather than appending a second one /// the next `load` might find first. #[test] fn signing_in_again_replaces_that_deployments_session() { let dir = std::env::temp_dir().join(format!("didbot-operator-store-{}", std::process::id())); let path = dir.join("operator.json"); let session = |server: &str, token: &str| Session { server: server.to_owned(), token: token.to_owned(), csrf: "beta".to_owned(), }; store(&path, session("one.example", "first")).expect("stores"); store(&path, session("two.example", "other")).expect("stores"); store(&path, session("one.example", "second")).expect("stores"); assert_eq!( load(&path, "one.example") .expect("one.example is there") .token, "second", "the newer session for a deployment replaces the older one" ); assert_eq!( load(&path, "two.example") .expect("two.example survives") .token, "other", "replacing one deployment's session must not disturb another's" ); assert!( matches!( load(&path, "three.example"), Err(OperatorError::NotSignedIn(_)) ), "a deployment never signed in to is not signed in to" ); std::fs::remove_dir_all(&dir).ok(); } /// `didbot login --server` is typed with the port a development /// deployment answers on, and an admission knows the server only by /// its hostname: the session is found either way. #[test] fn a_session_is_found_by_its_hostname_with_or_without_a_port() { let dir = std::env::temp_dir().join(format!("didbot-operator-host-{}", std::process::id())); let path = dir.join("operator.json"); for server in ["one.localhost:8443", "two.example"] { store( &path, Session { server: server.to_owned(), token: server.to_owned(), csrf: "beta".to_owned(), }, ) .expect("stores"); } let found = |host: &str| load_for_host(&path, host).map(|held| held.token); assert_eq!( found("one.localhost").ok().as_deref(), Some("one.localhost:8443") ); assert_eq!(found("two.example").ok().as_deref(), Some("two.example")); assert!(matches!( found("localhost"), Err(OperatorError::NotSignedIn(_)) )); std::fs::remove_dir_all(&dir).ok(); } /// The handoff is the whole point of the loopback listener, and a /// browser's request line is the only place the session arrives. /// /// Fails if the query parsing is keyed on position rather than name, or /// if a request missing either value is accepted as a session. #[test] fn a_handoff_carries_both_halves_or_is_not_a_session() { let session = parse_handoff( "pds.example", "GET /done?csrf=beta&token=alpha HTTP/1.1\r\n", ) .expect("both halves are there, in either order"); assert_eq!(session.token, "alpha"); assert_eq!(session.csrf, "beta"); assert_eq!(session.server, "pds.example"); for incomplete in [ "GET /done?token=alpha HTTP/1.1\r\n", "GET /done?csrf=beta HTTP/1.1\r\n", "GET /done HTTP/1.1\r\n", "nonsense\r\n", ] { assert!( parse_handoff("pds.example", incomplete).is_none(), "{incomplete:?} is not a session" ); } } }