server/cli/src/passkey_cli.rs
//! **`holger-server passkey …` — the people, from a shell.**
//!
//! holger owns its people: their passkeys, the invites that let them in, their
//! sessions and the role each one holds (`server_lib::passkey`, served as
//! `/-/auth/passkey/*` on the HTTP gateway). The console used to be the only
//! place an invite could be minted or a key revoked; that made those acts
//! UI-only, and a box with no console (or a console that is down) had no way to
//! do them. These verbs speak the same door the console does, with whatever
//! credential the operator holds — the box's static bearer (`--token-file
//! /var/lib/holger/admin-token`) or a passkey session token — and holger
//! decides exactly as it does for the console: admin for the administration,
//! step-up for a passkey session, never an admin invite.
//!
//! The door is plain HTTP (`ron_http_url`, on the estate's private network or
//! loopback), so this is a small HTTP/1.1 client over `std::net` — no TLS, no
//! new dependency.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use clap::{Args, Subcommand};
use serde_json::{json, Value};
#[derive(Args)]
pub struct PasskeyArgs {
/// holger's HTTP door, `host:port` or `http://host:port` (the RON's
/// `ron_http_url`; on the box itself `127.0.0.1:8443`).
#[arg(long, default_value = "127.0.0.1:8443")]
door: String,
/// A file holding the credential to act with: the box's static bearer, or
/// a passkey session token. Else `$HOLGER_TOKEN`.
#[arg(long)]
token_file: Option<PathBuf>,
#[command(subcommand)]
verb: PasskeyVerb,
}
#[derive(Subcommand)]
enum PasskeyVerb {
/// Who the credential is, and the role holger gives it.
Whoami,
/// Mint a single-use invite (24 h). Prints the secret ONCE — carry it out of
/// band; the console's link is `https://<web host>/login?invite=<secret>`.
Invite {
/// reader or writer (an invite never grants admin).
#[arg(long, default_value = "reader")]
role: String,
/// Pin the person's name; omitted, the invitee chooses one.
#[arg(long)]
name: Option<String>,
},
/// Every invite, open and spent.
Invites,
/// Revoke an open invite by its id.
RevokeInvite {
#[arg(long)]
id: String,
},
/// Every registered passkey, with the role holger gives it.
Credentials,
/// Revoke a passkey (never the last admin) and end its person's sessions.
RevokeCredential {
#[arg(long)]
id: String,
},
/// End every session on this server (nobody's key is touched).
RevokeSessions,
}
pub fn run(a: PasskeyArgs) -> Result<()> {
let token = match &a.token_file {
Some(f) => std::fs::read_to_string(f)
.with_context(|| format!("reading {}", f.display()))?
.trim()
.to_owned(),
None => std::env::var("HOLGER_TOKEN")
.unwrap_or_default()
.trim()
.to_owned(),
};
if token.is_empty() {
bail!("no credential: --token-file FILE (the box's static bearer or a passkey session token) or $HOLGER_TOKEN");
}
let (path, body) = match &a.verb {
PasskeyVerb::Whoami => ("session", json!({})),
PasskeyVerb::Invite { role, name } => {
let mut b = json!({ "role": role });
if let Some(n) = name {
b["display"] = json!(n);
}
("invites", b)
}
PasskeyVerb::Invites => ("invites/list", json!({})),
PasskeyVerb::RevokeInvite { id } => ("invites/revoke", json!({ "id": id })),
PasskeyVerb::Credentials => ("credentials/list", json!({})),
PasskeyVerb::RevokeCredential { id } => ("credentials/revoke", json!({ "id": id })),
PasskeyVerb::RevokeSessions => ("sessions/revoke-all", json!({})),
};
let (status, answer) = post(&a.door, &format!("/-/auth/passkey/{path}"), &token, &body)?;
if !(200..300).contains(&status) {
let why = answer
.get("error")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| answer.to_string());
bail!("holger refused (HTTP {status}): {why}");
}
println!("{}", serde_json::to_string_pretty(&answer)?);
Ok(())
}
/// Pure: `host:port` out of `host:port` or `http://host:port[/]`.
fn host_port(door: &str) -> Result<String> {
if door.starts_with("https://") {
bail!(
"{door}: the passkey door is plain http:// on the estate's private network or loopback"
);
}
let hp = door.trim_start_matches("http://").trim_end_matches('/');
if hp.is_empty() || hp.contains('/') {
bail!("{door:?} is not host:port");
}
Ok(hp.to_owned())
}
/// One HTTP/1.1 POST, `Connection: close`, JSON in and out.
fn post(door: &str, path: &str, token: &str, body: &Value) -> Result<(u16, Value)> {
let hp = host_port(door)?;
let mut stream = TcpStream::connect(&hp)
.with_context(|| format!("holger's door at {hp} was not reached"))?;
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
let payload = body.to_string();
write!(
stream,
"POST {path} HTTP/1.1\r\nHost: {hp}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}",
payload.len()
)?;
let mut raw = Vec::new();
stream.read_to_end(&mut raw)?;
parse_response(&raw)
}
/// Pure: an HTTP/1.1 response's status and JSON body (`Content-Length` or
/// chunked, the two shapes hyper answers with).
fn parse_response(raw: &[u8]) -> Result<(u16, Value)> {
let split = raw
.windows(4)
.position(|w| w == b"\r\n\r\n")
.ok_or_else(|| anyhow!("holger's answer has no header end"))?;
let head = String::from_utf8_lossy(&raw[..split]);
let status: u16 = head
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse().ok())
.ok_or_else(|| anyhow!("holger's answer has no status line"))?;
let mut body = raw[split + 4..].to_vec();
if head.lines().any(|l| {
l.to_ascii_lowercase().starts_with("transfer-encoding:")
&& l.to_ascii_lowercase().contains("chunked")
}) {
body = dechunk(&body)?;
}
let value = serde_json::from_slice(&body)
.unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&body).into_owned()));
Ok((status, value))
}
fn dechunk(mut b: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
loop {
let line_end = b
.windows(2)
.position(|w| w == b"\r\n")
.ok_or_else(|| anyhow!("a chunk without its size line"))?;
let size = usize::from_str_radix(
String::from_utf8_lossy(&b[..line_end])
.split(';')
.next()
.unwrap_or("")
.trim(),
16,
)?;
b = &b[line_end + 2..];
if size == 0 {
return Ok(out);
}
if b.len() < size + 2 {
bail!("a chunk shorter than it said");
}
out.extend_from_slice(&b[..size]);
b = &b[size + 2..];
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_door_is_host_port_over_plain_http() {
assert_eq!(host_port("127.0.0.1:8443").unwrap(), "127.0.0.1:8443");
assert_eq!(
host_port("http://10.13.8.2:8443/").unwrap(),
"10.13.8.2:8443"
);
assert!(host_port("https://box:8443").is_err());
assert!(host_port("http://box:8443/-/auth").is_err());
}
#[test]
fn an_answer_is_read_whole_or_chunked() {
let (s, v) = parse_response(
b"HTTP/1.1 403 Forbidden\r\ncontent-length: 21\r\n\r\n{\"error\":\"no admin\"}\n",
)
.unwrap();
assert_eq!((s, v["error"].as_str()), (403, Some("no admin")));
let (s, v) = parse_response(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n6\r\n{\"ok\":\r\n5\r\ntrue}\r\n0\r\n\r\n").unwrap();
assert_eq!((s, v["ok"].as_bool()), (200, Some(true)));
}
}