~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs-fedproxy

fedproxy and ingress modules for atlibs: SSH public keys on your PDS, did:key ingress; pages and web tiles

0
0
main
205 lines · 8.0 KB Raw History
// fedproxy: com.fedproxy.sshPublicKey records, and the public host each service is served at.
//
// Entries are step protocol handlers:
//   list_keys   -> listRecords (paged) -> keys grouped by service, with hosts and ssh commands
//   create_key  -> createRecord        -> uri and host of the new key

use abi::repo::{create, list_records};
use abi::{Call, Datetime, Did, Event, Handle, LexiconType, Run, entries};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

const COLLECTION: &str = "com.fedproxy.sshPublicKey";
const DOMAIN: &str = "fedproxy.com";
const PAGE_LIMIT: usize = 100;
const KEY_TYPES: [&str; 3] = ["ssh-", "ecdsa-sha2-", "sk-"];

#[derive(Deserialize)]
struct Args {
    did: Did,
    now: String,
    #[serde(default)]
    handle: Option<String>,
    #[serde(default)]
    name: Option<String>,
    #[serde(default)]
    service: Option<String>,
    #[serde(default)]
    key: Option<String>,
}

#[derive(Serialize, PartialEq)]
struct SshPublicKey {
    key: String,
    name: String,
    service: String,
    #[serde(rename = "createdAt")]
    created_at: Datetime,
}

impl LexiconType for SshPublicKey {
    fn lexicon_type() -> &'static str {
        COLLECTION
    }
}

// The service name and the handle fold into a SINGLE DNS label, dots becoming dashes,
// joined by "--". Every service then sits one level under fedproxy.com, covered by the
// one "*.fedproxy.com" wildcard certificate, so no per-service certificate is issued.
//   "app"   alice.bsky.social -> app--alice-bsky-social.fedproxy.com
//   "*.app" alice.bsky.social -> *.app--alice-bsky-social.fedproxy.com (any sub-host)
//   "*"     means the key is valid for ALL services: an authorization wildcard, no host.
fn service_host(service: &str, handle: &str) -> Option<String> {
    let flat = |s: &str| s.replace('.', "-");
    match service {
        "*" => None,
        _ => match service.strip_prefix("*.") {
            Some(rest) => Some(format!("*.{}--{}.{DOMAIN}", flat(rest), flat(handle))),
            None => Some(format!("{}--{}.{DOMAIN}", flat(service), flat(handle))),
        },
    }
}

// Line breaks are dropped, as a pasted key often wraps.
fn field(value: Option<&str>, label: &str) -> Result<String, String> {
    let value: String = value.unwrap_or_default().chars().filter(|c| *c != '\r' && *c != '\n').collect();
    let value = value.trim().to_string();
    if value.is_empty() {
        return Err(format!("{label} is required"));
    }
    if value.chars().any(char::is_control) {
        return Err(format!("{label} must not contain control characters"));
    }
    Ok(value)
}

fn validate_service(service: &str) -> Result<(), String> {
    let name = service.strip_prefix("*.").unwrap_or(service);
    if service == "*" {
        return Ok(());
    }
    let label_ok = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '.';
    if name.is_empty() || !name.chars().all(label_ok) || name.starts_with(['-', '.']) || name.ends_with(['-', '.']) {
        return Err("service must be a name, * (all), or *.name (wildcard) using letters, digits, - and .".into());
    }
    Ok(())
}

fn validate_key(key: &str) -> Result<(), String> {
    let mut parts = key.split_whitespace();
    let (kind, body) = (parts.next().unwrap_or_default(), parts.next().unwrap_or_default());
    let base64 = |c: char| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=';
    if !KEY_TYPES.iter().any(|prefix| kind.starts_with(prefix)) || body.is_empty() || !body.chars().all(base64) {
        return Err("key must be an OpenSSH public key, like ssh-ed25519 AAAA... comment".into());
    }
    Ok(())
}

// The tunnel to run for a service; 8080 stands for whichever local port serves it. A "*" key
// works for any service, so its example keeps a placeholder name. A wildcard bind is quoted,
// or the shell would expand its glob.
fn ssh_command(service: &str, handle: &str) -> String {
    let forward = match service {
        "*" => "my-cool-service:80:127.0.0.1:8080".to_string(),
        _ if service.starts_with("*.") => format!("'{service}:80:127.0.0.1:8080'"),
        _ => format!("{service}:80:127.0.0.1:8080"),
    };
    format!(
        "ssh -NnT -p 2222 \\\n  -o UserKnownHostsFile=/dev/null \\\n  -o StrictHostKeyChecking=no \\\n  -o PasswordAuthentication=no \\\n  -R {forward} \\\n  {handle}@{DOMAIN}"
    )
}

fn handle(value: Option<&str>) -> Result<String, String> {
    let handle = value.unwrap_or_default();
    if handle.is_empty() || !handle.contains('.') {
        return Err("handle is required to name hosts".into());
    }
    Ok(handle.to_string())
}

fn list_page(did: &Did, cursor: Option<&str>) -> Call {
    list_records(did.as_str(), COLLECTION, PAGE_LIMIT, cursor).id("page")
}

// Each page lands here; ask for the next while the PDS hands back a cursor.
fn list_keys_handler(run: Run<Args>) -> Result<Handle, String> {
    let Run { event, state, args } = run;
    Ok(match event {
        Event::Start => {
            let handle = handle(args.handle.as_deref())?;
            Handle::new().call(list_page(&args.did, None)).state(json!({ "handle": handle, "records": [] }))
        }
        Event::Result { result, .. } => {
            let output = result.output()?;
            let mut records = state["records"].as_array().cloned().unwrap_or_default();
            records.extend(output["records"].as_array().cloned().unwrap_or_default());
            let handle = handle(state["handle"].as_str())?;
            if let Some(cursor) = output["cursor"].as_str().filter(|cursor| !cursor.is_empty()) {
                return Ok(Handle::new().call(list_page(&args.did, Some(cursor))).state(json!({ "handle": handle, "records": records })));
            }
            Handle::new().result(json!({ "services": group_by_service(&records, &handle) })).done()
        }
        _ => Handle::new(),
    })
}

// Services keep the order their first key was listed in.
fn group_by_service(records: &[Value], handle: &str) -> Vec<Value> {
    let mut services: Vec<(String, Vec<Value>)> = Vec::new();
    for record in records {
        let value = &record["value"];
        let Some(service) = value["service"].as_str() else { continue };
        let key = json!({
            "uri": record["uri"],
            "name": value["name"],
            "key": value["key"],
            "createdAt": value["createdAt"],
        });
        match services.iter_mut().find(|(name, _)| name == service) {
            Some((_, keys)) => keys.push(key),
            None => services.push((service.to_string(), vec![key])),
        }
    }
    services
        .into_iter()
        .map(|(service, keys)| {
            json!({
                "service": service,
                "host": service_host(&service, handle),
                "command": ssh_command(&service, handle),
                "keys": keys,
            })
        })
        .collect()
}

fn create_key_handler(run: Run<Args>) -> Result<Handle, String> {
    let Run { event, state, args } = run;
    Ok(match event {
        Event::Start => {
            let handle = handle(args.handle.as_deref())?;
            let name = field(args.name.as_deref(), "name")?;
            let service = field(args.service.as_deref(), "service")?;
            let key = field(args.key.as_deref(), "key")?;
            validate_service(&service)?;
            validate_key(&key)?;
            let now = Datetime::new(args.now).map_err(|e| format!("invalid now: {e}"))?;
            let created_at = Datetime::try_from(now.to_utc()).map_err(|e| format!("invalid now: {e}"))?;
            let host = service_host(&service, &handle);
            let record = SshPublicKey { key, name, service: service.clone(), created_at };
            Handle::new()
                .call(create(args.did.as_str(), record).id("create"))
                .state(json!({ "service": service, "host": host }))
        }
        Event::Result { result, .. } => {
            let created = result.output()?;
            Handle::new().result(json!({ "uri": created["uri"], "service": state["service"], "host": state["host"] })).done()
        }
        _ => Handle::new(),
    })
}

entries!(
    list_keys => list_keys_handler,
    create_key => create_key_handler
);