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

johnandersen777.bsky.social/atlibs-iroh

iroh transport for atlibs hosts: com.fedfork.exec.iroh.connect in Rust (atlibs-host) and JavaScript

0
0
main
154 lines · 6.2 KB Raw History
// iroh-user-data: cloud-init for a socialweb.computer guest that serves ssh over iroh, the guest half of
// swck's irohModule (internal/atproto/cloudinit/modules.go). The bidder's onNetwork reporter reads
// /run/guest-fqdn and publishes it as vm.onNetwork.
//   iroh_user_data  computer.socialweb.vm.irohUserData {ssh_key, dumbpipe?, dumbpipe_version?, write_files?}
//                   -> {user_data}: root ssh with that key, dumbpipe in front of sshd (dumbpipe: false: ssh
//                   only), write_files appended after its own
use abi::{Handle, Run, entries};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

/// swck cloudinit.DumbpipeVersionDefault
pub const DUMBPIPE_VERSION: &str = "0.39.0";

const LISTENER: &str = r##"#!/bin/bash
set -euo pipefail
SECRET_FILE=/root/.iroh-secret
if [ ! -s "${SECRET_FILE}" ]; then
  umask 077
  head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "${SECRET_FILE}"
fi
export IROH_SECRET="$(cat "${SECRET_FILE}")"
LOG=/run/guest-iroh.log
rm -f /run/guest-iroh-ticket "${LOG}"
/usr/local/bin/dumbpipe listen-tcp --host 127.0.0.1:22 2>>"${LOG}" &
PID=$!
TICKET=""
for _ in $(seq 1 150); do
  TICKET="$(grep -a 'connect-tcp' "${LOG}" 2>/dev/null | tail -n 1 | awk '{print $NF}')" || true
  [ -n "${TICKET}" ] && break
  kill -0 "${PID}" 2>/dev/null || break
  sleep 1
done
if [ -n "${TICKET}" ]; then
  printf 'iroh://%s\n' "${TICKET}" > /run/guest-iroh-ticket
  printf 'iroh://%s\n' "${TICKET}" > /run/guest-fqdn.tmp
  mv -f /run/guest-fqdn.tmp /run/guest-fqdn
fi
wait "${PID}"
"##;

const SETUP: &str = r##"#!/bin/bash
set -euo pipefail
[ -x /usr/local/bin/dumbpipe ] && exit 0
case "$(uname -m)" in x86_64|amd64) A=x86_64 ;; aarch64|arm64) A=aarch64 ;; *) exit 1 ;; esac
URL="https://github.com/n0-computer/dumbpipe/releases/download/v{version}/dumbpipe-v{version}-linux-${A}.tar.gz"
T="$(mktemp -d)"
for i in 1 2 3 4 5; do
  curl -fsSL "${URL}" | tar -xz -C "${T}" && install -m 0755 "${T}/dumbpipe" /usr/local/bin/dumbpipe && exit 0
  sleep 5
done
exit 1
"##;

const UNIT: &str = "[Unit]
Description=iroh dumbpipe listener in front of local sshd
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/iroh-listener.sh
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
";

const SSHD: &str = "systemctl enable --now --no-block ssh || systemctl enable --now --no-block sshd";

/// A cloud-config document; fields serialize in this order, empty ones left out (write_files always there).
#[derive(Serialize, Default, Clone, Debug, PartialEq)]
pub struct CloudConfig {
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub packages: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub disable_root: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ssh_pwauth: Option<bool>,
    pub write_files: Vec<WriteFile>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub runcmd: Vec<String>,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct WriteFile {
    pub path: String,
    pub owner: String,
    pub permissions: String,
    pub content: String,
}

impl WriteFile {
    /// A root:root file.
    pub fn root(path: &str, content: impl Into<String>, permissions: &str) -> Self {
        WriteFile { path: path.into(), owner: "root:root".into(), permissions: permissions.into(), content: content.into() }
    }
}

impl CloudConfig {
    /// As user_data: `#cloud-config` and the document as JSON (JSON is YAML), one-space indent.
    pub fn user_data(&self) -> String {
        let mut out = b"#cloud-config\n".to_vec();
        let mut ser = serde_json::Serializer::with_formatter(&mut out, serde_json::ser::PrettyFormatter::with_indent(b" "));
        self.serialize(&mut ser).expect("strings and bools serialize");
        out.push(b'\n');
        String::from_utf8(out).expect("JSON is UTF-8")
    }
}

/// Root ssh with this public key only (no passwords).
pub fn ssh_config(pubkey: &str) -> CloudConfig {
    CloudConfig {
        packages: vec!["openssh-server".into()],
        disable_root: Some(false),
        ssh_pwauth: Some(false),
        write_files: vec![
            WriteFile::root("/root/.ssh/authorized_keys", format!("{}\n", pubkey.trim()), "0600"),
            WriteFile::root("/etc/ssh/sshd_config.d/10-iroh.conf", "PermitRootLogin prohibit-password\nPasswordAuthentication no\n", "0644"),
        ],
        runcmd: vec![SSHD.into()],
    }
}

/// ssh_config plus the dumbpipe listener in front of sshd (its ticket becomes vm.onNetwork).
pub fn iroh_config(pubkey: &str, version: &str) -> CloudConfig {
    let mut config = ssh_config(pubkey);
    config.write_files.extend([
        WriteFile::root("/usr/local/bin/setup-iroh.sh", SETUP.replace("{version}", version), "0755"),
        WriteFile::root("/usr/local/bin/iroh-listener.sh", LISTENER, "0755"),
        WriteFile::root("/etc/systemd/system/iroh.service", UNIT, "0644"),
    ]);
    config.runcmd = ["/usr/local/bin/setup-iroh.sh", "systemctl daemon-reload", "systemctl enable --now --no-block iroh.service", SSHD].map(String::from).to_vec();
    config
}

fn handler(run: Run<Value>) -> Result<Handle, String> {
    let a = &run.args;
    let key = a["ssh_key"].as_str().map(str::trim).filter(|k| !k.contains(['\n', '\r']) && k.split_once(' ').is_some_and(|(t, b)| !t.is_empty() && !b.trim_start().is_empty()));
    let key = key.ok_or("ssh_key must be an OpenSSH public key")?;
    let version = match &a["dumbpipe_version"] {
        Value::Null => DUMBPIPE_VERSION,
        Value::String(v) if !v.is_empty() && v.len() <= 32 && v.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') => v,
        _ => return Err("dumbpipe_version must be a version like 0.39.0".into()),
    };
    let mut config = match &a["dumbpipe"] {
        Value::Null | Value::Bool(true) => iroh_config(key, version),
        Value::Bool(false) => ssh_config(key),
        _ => return Err("dumbpipe must be a boolean".into()),
    };
    if !a["write_files"].is_null() {
        config.write_files.extend(Vec::<WriteFile>::deserialize(&a["write_files"]).map_err(|e| format!("write_files: {e}"))?);
    }
    Ok(Handle::new().result(json!({ "user_data": config.user_data() })))
}

entries! { iroh_user_data => handler }