Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
14 kB · 393 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394//! 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<PathBuf> { 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<Session, OperatorError> { 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<Session, OperatorError> { 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<Vec<Session>, 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<Session> = 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<Session, OperatorError> { 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<Session> { 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<serde_json::Value>,) -> Result<serde_json::Value, OperatorError> { 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" ); } }}