From 5c9f1d07d0787a44dfe69541392de719f097685f Mon Sep 17 00:00:00 2001 From: Pat Nakajima Date: Mon, 18 May 2026 22:13:27 -0700 Subject: [PATCH] Update toes-matter and bump to 0.1.6 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 10 +- scripts/provision-device.sh | 2 +- src/bin/toes-matter-credentials.rs | 769 ++++++++++++++++++++++++++++- src/bin/toes-matter.rs | 766 +--------------------------- 6 files changed, 773 insertions(+), 778 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1908d2f..f978b17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2394,7 +2394,7 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "toes-matter" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-io", "embassy-futures", diff --git a/Cargo.toml b/Cargo.toml index 3858451..ef23e68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toes-matter" -version = "0.1.5" +version = "0.1.6" edition = "2021" license = "MIT OR Apache-2.0" readme = "README.md" diff --git a/README.md b/README.md index 2234805..a0abe33 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ fn main() -> toes_matter::Result<()> { ## Post-flash headless provisioning -After flashing a Linux device reachable over SSH, run the host-side provisioning command from Linux or macOS: +After flashing a Linux device reachable over SSH, run the host-side credentials command from Linux or macOS: ```bash -cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-provision -- \ +cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-credentials -- \ user@device-host device-001 ``` @@ -57,7 +57,7 @@ The older script is now just a wrapper around that command: Set these if your service uses different paths: ```bash -cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-provision -- \ +cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-credentials -- \ --remote-creds-dir /var/lib/my-app/creds \ --remote-state-dir /var/lib/my-app/state \ --service my-app.service \ @@ -67,7 +67,7 @@ cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml -- Serial instead of network/SSH: ```bash -cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-provision -- \ +cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-credentials -- \ --serial /dev/cu.usbserial-0001 \ --device-id device-001 ``` @@ -75,7 +75,7 @@ cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml -- If the serial console is already at a shell prompt, omit login flags. If it is at `login:`, provide credentials: ```bash -cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-provision -- \ +cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-credentials -- \ --serial /dev/cu.usbserial-0001 \ --login root \ --password 'your-password' \ diff --git a/scripts/provision-device.sh b/scripts/provision-device.sh index 3913097..b98ba34 100755 --- a/scripts/provision-device.sh +++ b/scripts/provision-device.sh @@ -62,4 +62,4 @@ else args+=(--service "$SERVICE") fi -exec cargo run --quiet --no-default-features --manifest-path "$CRATE_DIR/Cargo.toml" --bin toes-matter-provision -- "${args[@]}" "${TARGET_ARGS[@]}" +exec cargo run --quiet --no-default-features --manifest-path "$CRATE_DIR/Cargo.toml" --bin toes-matter-credentials -- "${args[@]}" "${TARGET_ARGS[@]}" diff --git a/src/bin/toes-matter-credentials.rs b/src/bin/toes-matter-credentials.rs index 18e914c..12ad4ca 100644 --- a/src/bin/toes-matter-credentials.rs +++ b/src/bin/toes-matter-credentials.rs @@ -1,17 +1,766 @@ #![recursion_limit = "256"] -fn main() -> toes_matter::Result<()> { - env_logger::init_from_env( - env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"), - ); +use std::ffi::OsString; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; - let mut args = std::env::args_os().skip(1); - let dir = args.next().unwrap_or_else(|| "./creds".into()); +use rand::RngCore; - if args.next().is_some() { - eprintln!("Usage: toes-matter-creds [CREDS_DIR]"); - std::process::exit(2); +use rs_matter::dm::clusters::dev_att::DeviceAttestation; +use rs_matter::dm::devices::test::{TEST_DEV_ATT, TEST_DEV_DET}; +use rs_matter::pairing::qr::{no_optional_data, CommFlowType, QrPayload}; +use rs_matter::pairing::DiscoveryCapabilities; +use rs_matter::sc::pase::{Spake2pVerifierPassword, Spake2pVerifierPasswordRef}; +use rs_matter::BasicCommData; + +type Result = std::result::Result>; + +const DEFAULT_REMOTE_CREDS_DIR: &str = "/var/lib/toes-matter/creds"; +const DEFAULT_REMOTE_STATE_DIR: &str = "/var/lib/toes-matter/state"; +const DEFAULT_SERVICE: &str = "toes-matter.service"; +const DEFAULT_BAUD: u32 = 115_200; +const DEFAULT_SERIAL_DELAY_MS: u64 = 15; +const DEFAULT_SERIAL_LOGIN_TIMEOUT_SECS: u64 = 30; + +fn main() -> Result<()> { + let opts = Options::parse()?; + + let generated = generate_credentials(&opts.creds_dir)?; + + println!(); + println!("==> Pairing info for device {}", opts.device_id); + println!("Manual code: {}", generated.manual_code); + println!("QR payload : {}", generated.qr_code); + + render_qr(&generated.qr_code); + + if let Some(serial) = opts.serial.as_deref() { + write_to_serial(serial, &opts)?; + } else if let Some(target) = opts.target.as_deref() { + copy_to_device(target, &opts)?; + } else { + println!(); + println!("==> No target/serial specified; generated local creds only"); } - futures_lite::future::block_on(toes_matter::generate_credentials(dir)) + println!(); + println!("==> Done"); + println!("Local copy saved at: {}", opts.creds_dir.display()); + + if opts.target.is_some() || opts.serial.is_some() { + println!("Remote app env should use:"); + println!(" TOES_MATTER_CREDS_DIR={}", opts.remote_creds_dir); + println!(" TOES_MATTER_STATE_DIR={}", opts.remote_state_dir); + } + + Ok(()) +} + +#[derive(Debug)] +struct Options { + target: Option, + serial: Option, + device_id: String, + creds_dir: PathBuf, + remote_creds_dir: String, + remote_state_dir: String, + service: Option, + ssh_opts: Vec, + baud: u32, + serial_delay: Duration, + serial_login: Option, + serial_password: Option, + serial_prompts: Vec, + serial_login_timeout: Duration, +} + +impl Options { + fn parse() -> Result { + let mut args = std::env::args().skip(1); + + let mut target = None; + let mut serial = std::env::var("SERIAL_PORT").ok(); + let mut device_id = None; + let mut creds_dir = std::env::var_os("CREDS_DIR").map(PathBuf::from); + let mut remote_creds_dir = + std::env::var("REMOTE_CREDS_DIR").unwrap_or_else(|_| DEFAULT_REMOTE_CREDS_DIR.into()); + let mut remote_state_dir = + std::env::var("REMOTE_STATE_DIR").unwrap_or_else(|_| DEFAULT_REMOTE_STATE_DIR.into()); + let mut service = match std::env::var("SERVICE") { + Ok(value) if value.is_empty() => None, + Ok(value) => Some(value), + Err(_) => Some(DEFAULT_SERVICE.into()), + }; + let mut ssh_opts = split_ssh_opts(std::env::var("SSH_OPTS").unwrap_or_default()); + let mut baud = std::env::var("SERIAL_BAUD") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_BAUD); + let mut serial_delay_ms = std::env::var("SERIAL_DELAY_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_SERIAL_DELAY_MS); + let mut serial_login = std::env::var("SERIAL_LOGIN").ok(); + let mut serial_password = std::env::var("SERIAL_PASSWORD").ok(); + let mut serial_prompts = std::env::var("SERIAL_PROMPT") + .ok() + .map(|prompt| vec![prompt]) + .unwrap_or_else(|| vec!["# ".into(), "$ ".into()]); + let mut serial_login_timeout_secs = std::env::var("SERIAL_LOGIN_TIMEOUT_SECS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_SERIAL_LOGIN_TIMEOUT_SECS); + + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => { + print_usage(); + std::process::exit(0); + } + "--creds-dir" => { + creds_dir = Some(PathBuf::from(next_arg(&mut args, "--creds-dir")?)) + } + "--remote-creds-dir" => { + remote_creds_dir = next_arg(&mut args, "--remote-creds-dir")? + } + "--remote-state-dir" => { + remote_state_dir = next_arg(&mut args, "--remote-state-dir")? + } + "--service" => service = Some(next_arg(&mut args, "--service")?), + "--no-restart" => service = None, + "--device-id" => device_id = Some(next_arg(&mut args, "--device-id")?), + "--ssh-opt" => ssh_opts.push(next_arg(&mut args, "--ssh-opt")?), + "--serial" => serial = Some(next_arg(&mut args, "--serial")?), + "--baud" => baud = next_arg(&mut args, "--baud")?.parse()?, + "--serial-delay-ms" => { + serial_delay_ms = next_arg(&mut args, "--serial-delay-ms")?.parse()? + } + "--login" => serial_login = Some(next_arg(&mut args, "--login")?), + "--password" => serial_password = Some(next_arg(&mut args, "--password")?), + "--prompt" => serial_prompts.push(next_arg(&mut args, "--prompt")?), + "--login-timeout-secs" => { + serial_login_timeout_secs = + next_arg(&mut args, "--login-timeout-secs")?.parse()? + } + _ if arg.starts_with('-') => return Err(format!("unknown option: {arg}").into()), + _ => { + if target.is_none() { + target = Some(arg); + } else if device_id.is_none() { + device_id = Some(arg); + } else { + return Err("too many positional arguments".into()); + } + } + } + } + + if serial.is_some() && target.is_some() && device_id.is_none() { + device_id = target.take(); + } + + if serial.is_some() && target.is_some() { + return Err("use either SSH target or --serial, not both".into()); + } + + let device_id = device_id.unwrap_or_else(default_device_id); + let creds_dir = creds_dir.unwrap_or_else(|| { + PathBuf::from("manufacturing") + .join(&device_id) + .join("creds") + }); + + Ok(Self { + target, + serial, + device_id, + creds_dir, + remote_creds_dir, + remote_state_dir, + service, + ssh_opts, + baud, + serial_delay: Duration::from_millis(serial_delay_ms), + serial_login, + serial_password, + serial_prompts, + serial_login_timeout: Duration::from_secs(serial_login_timeout_secs), + }) + } +} + +fn print_usage() { + eprintln!( + "Usage: toes-matter-credentials [OPTIONS] [user@device-host] [device-id]\n\n\ + Generates Matter setup data, prints the QR/manual code, and optionally copies it to a Linux device over SSH or serial console.\n\n\ + Options:\n\ + --creds-dir DIR Local output dir [env: CREDS_DIR]\n\ + --remote-creds-dir DIR Remote creds dir [default: {DEFAULT_REMOTE_CREDS_DIR}]\n\ + --remote-state-dir DIR Remote state dir [default: {DEFAULT_REMOTE_STATE_DIR}]\n\ + --service NAME Service to restart [default: {DEFAULT_SERVICE}]\n\ + --no-restart Do not restart a remote service\n\ + --device-id ID Device id for local-only generation\n\ + --ssh-opt ARG Extra ssh/scp option; may be repeated [env: SSH_OPTS]\n\ + --serial PORT Write install script to a serial shell [env: SERIAL_PORT]\n\ + --baud BAUD Configure serial baud [default: 115200, env: SERIAL_BAUD]\n\ + --serial-delay-ms MS Delay between serial lines [default: 15, env: SERIAL_DELAY_MS]\n\ + --login USER Log in on serial before provisioning [env: SERIAL_LOGIN]\n\ + --password PASS Serial login password [env: SERIAL_PASSWORD]\n\ + --prompt PROMPT Shell prompt marker; may repeat [env: SERIAL_PROMPT]\n\ + --login-timeout-secs N Serial login timeout [default: 30, env: SERIAL_LOGIN_TIMEOUT_SECS]\n\ + -h, --help Show this help" + ); +} + +fn next_arg(args: &mut impl Iterator, option: &str) -> Result { + args.next() + .ok_or_else(|| format!("{option} requires an argument").into()) +} + +fn default_device_id() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + format!("device-{secs}") +} + +fn split_ssh_opts(value: String) -> Vec { + value + .split_whitespace() + .filter(|part| !part.is_empty()) + .map(str::to_owned) + .collect() +} + +struct GeneratedCredentials { + manual_code: String, + qr_code: String, +} + +fn generate_credentials(path: &Path) -> Result { + std::fs::create_dir_all(path)?; + + std::fs::write(path.join("dac.der"), TEST_DEV_ATT.dac())?; + std::fs::write(path.join("pai.der"), TEST_DEV_ATT.pai())?; + std::fs::write( + path.join("certification-declaration.der"), + TEST_DEV_ATT.cert_declaration(), + )?; + std::fs::write( + path.join("dac-private-key.raw"), + TEST_DEV_ATT.dac_priv_key().access(), + )?; + + let comm = read_comm_data(path)?.unwrap_or_else(random_comm_data); + let payload = QrPayload::new_from_basic_info( + DiscoveryCapabilities::BLE, + CommFlowType::Standard, + comm.clone(), + &TEST_DEV_DET, + no_optional_data, + ); + + let mut qr_buf = [0_u8; 512]; + let (qr_text, _) = payload.as_str(&mut qr_buf)?; + + let manual_code = comm.compute_pretty_pairing_code().to_string(); + let qr_code = qr_text.to_owned(); + + let setup = format!( + "setup_passcode={}\n\ + discriminator={}\n\ + manual_code={}\n\ + qr_code={}\n", + comm_passcode(&comm), + comm.discriminator, + manual_code, + qr_code, + ); + + std::fs::write(path.join("setup.txt"), setup)?; + + Ok(GeneratedCredentials { + manual_code, + qr_code, + }) +} + +fn read_comm_data(path: &Path) -> Result> { + let setup_path = path.join("setup.txt"); + let setup = match std::fs::read_to_string(setup_path) { + Ok(setup) => setup, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + + let passcode = parse_setup_u32(&setup, "setup_passcode")?; + let discriminator = parse_setup_u32(&setup, "discriminator")?; + + if discriminator > 0x0fff || !valid_setup_passcode(passcode) { + return Err("invalid setup.txt passcode/discriminator".into()); + } + + Ok(Some(make_comm_data(passcode, discriminator as u16))) +} + +fn parse_setup_u32(setup: &str, key: &str) -> Result { + let value = setup + .lines() + .find_map(|line| line.strip_prefix(key)?.strip_prefix('=')) + .ok_or_else(|| format!("setup.txt missing {key}"))?; + + value + .parse() + .map_err(|err| format!("invalid {key}: {err}").into()) +} + +fn random_comm_data() -> BasicCommData { + let mut rng = rand::thread_rng(); + + let passcode = loop { + let passcode = (rng.next_u32() % 99_999_998) + 1; + if valid_setup_passcode(passcode) { + break passcode; + } + }; + + let discriminator = (rng.next_u32() & 0x0fff) as u16; + + make_comm_data(passcode, discriminator) +} + +fn make_comm_data(passcode: u32, discriminator: u16) -> BasicCommData { + BasicCommData { + password: Spake2pVerifierPassword::new_from_ref(Spake2pVerifierPasswordRef::new( + &passcode.to_le_bytes(), + )), + discriminator, + } +} + +fn comm_passcode(comm: &BasicCommData) -> u32 { + u32::from_le_bytes(*comm.password.access()) +} + +fn valid_setup_passcode(passcode: u32) -> bool { + const INVALID: &[u32] = &[ + 0, 11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777, 88888888, + 99999999, 12345678, 87654321, + ]; + + (1..=99_999_998).contains(&passcode) && !INVALID.contains(&passcode) +} + +fn render_qr(qr_code: &str) { + let status = Command::new("qrencode") + .arg("-t") + .arg("ANSIUTF8") + .arg(qr_code) + .status(); + + match status { + Ok(status) if status.success() => {} + Ok(_) | Err(_) => { + println!(); + println!("Tip: install qrencode to render the QR in this terminal:"); + println!(" macOS: brew install qrencode"); + println!(" Debian/Ubuntu: sudo apt install qrencode"); + } + } +} + +fn write_to_serial(port: &str, opts: &Options) -> Result<()> { + configure_serial(port, opts.baud)?; + + let script = build_remote_install_script(opts)?; + + println!(); + println!( + "==> Writing install script to serial port {port} at {} baud", + opts.baud + ); + if opts.serial_login.is_some() { + println!(" Will log in on the serial console first."); + } else { + println!(" This assumes the serial console is already at a shell prompt."); + } + if cfg!(target_os = "macos") && port.starts_with("/dev/tty.") { + println!( + " Tip: on macOS, /dev/cu.* is usually better than /dev/tty.* for outbound serial." + ); + } + + let mut serial = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(port)?; + serial.write_all(b"\n")?; + serial.flush()?; + + if opts.serial_login.is_some() { + login_to_serial(&mut serial, opts)?; + } + + for line in script.lines() { + serial.write_all(line.as_bytes())?; + serial.write_all(b"\n")?; + serial.flush()?; + std::thread::sleep(opts.serial_delay); + } + + println!("==> Serial install script sent"); + + Ok(()) +} + +fn configure_serial(port: &str, baud: u32) -> Result<()> { + let port_flag = if cfg!(any( + target_os = "macos", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd" + )) { + "-f" + } else { + "-F" + }; + + let status = Command::new("stty") + .arg(port_flag) + .arg(port) + .arg(baud.to_string()) + .arg("raw") + .arg("-echo") + .arg("-ixon") + .arg("-ixoff") + .arg("min") + .arg("0") + .arg("time") + .arg("5") + .status()?; + + if status.success() { + Ok(()) + } else { + Err(format!("stty failed for {port} with status {status}").into()) + } +} + +fn login_to_serial(serial: &mut std::fs::File, opts: &Options) -> Result<()> { + let login = opts + .serial_login + .as_deref() + .ok_or("serial login requested without a username")?; + + println!("==> Waiting for serial login prompt or shell prompt..."); + serial.write_all(b"\n")?; + serial.flush()?; + + let mut initial_needles = vec![b"login:".to_vec(), b"Login:".to_vec()]; + initial_needles.extend( + opts.serial_prompts + .iter() + .map(|prompt| prompt.as_bytes().to_vec()), + ); + + let initial = wait_for_any(serial, &initial_needles, opts.serial_login_timeout)?; + + if initial >= 2 { + println!("==> Serial console already appears to be at a shell prompt"); + return Ok(()); + } + + println!("==> Sending serial login username: {login}"); + serial.write_all(login.as_bytes())?; + serial.write_all(b"\n")?; + serial.flush()?; + + let mut after_user_needles = vec![b"Password:".to_vec(), b"password:".to_vec()]; + after_user_needles.extend( + opts.serial_prompts + .iter() + .map(|prompt| prompt.as_bytes().to_vec()), + ); + + let after_user = wait_for_any(serial, &after_user_needles, opts.serial_login_timeout)?; + + if after_user < 2 { + let password = opts + .serial_password + .as_deref() + .ok_or("serial password prompt detected; pass --password or set SERIAL_PASSWORD")?; + + println!("==> Sending serial login password"); + serial.write_all(password.as_bytes())?; + serial.write_all(b"\n")?; + serial.flush()?; + + let prompt_needles = opts + .serial_prompts + .iter() + .map(|prompt| prompt.as_bytes().to_vec()) + .collect::>(); + wait_for_any(serial, &prompt_needles, opts.serial_login_timeout)?; + } + + println!("==> Serial login complete"); + + Ok(()) +} + +fn wait_for_any( + serial: &mut std::fs::File, + needles: &[Vec], + timeout: Duration, +) -> Result { + let start = std::time::Instant::now(); + let mut seen = Vec::::new(); + let mut buf = [0_u8; 256]; + + while start.elapsed() < timeout { + match serial.read(&mut buf) { + Ok(0) => { + std::thread::sleep(Duration::from_millis(25)); + } + Ok(n) => { + std::io::stdout().write_all(&buf[..n])?; + std::io::stdout().flush()?; + + seen.extend_from_slice(&buf[..n]); + if seen.len() > 8192 { + let keep_from = seen.len() - 4096; + seen.drain(..keep_from); + } + + if let Some(index) = find_needle(&seen, needles) { + return Ok(index); + } + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} + Err(err) => return Err(err.into()), + } + } + + let tail = String::from_utf8_lossy(&seen); + Err(format!("timed out waiting for serial prompt; recent output: {tail:?}").into()) +} + +fn find_needle(haystack: &[u8], needles: &[Vec]) -> Option { + needles.iter().position(|needle| { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle.as_slice()) + }) +} + +fn build_remote_install_script(opts: &Options) -> Result { + let mut script = String::new(); + + script.push_str("set -e\n"); + script.push_str(&format!( + "REMOTE_CREDS_DIR={}\nREMOTE_STATE_DIR={}\n", + shell_quote(&opts.remote_creds_dir), + shell_quote(&opts.remote_state_dir), + )); + script.push_str("sudo rm -rf \"$REMOTE_CREDS_DIR\"\n"); + script.push_str("sudo mkdir -p \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"\n"); + + for file in [ + "dac.der", + "pai.der", + "certification-declaration.der", + "dac-private-key.raw", + "setup.txt", + ] { + let path = opts.creds_dir.join(file); + let data = std::fs::read(&path)?; + let marker = format!( + "TOES_MATTER_{}", + file.replace(['.', '-'], "_").to_uppercase() + ); + + script.push_str(&format!( + "base64 -d <<'{marker}' | sudo tee \"$REMOTE_CREDS_DIR/{file}\" >/dev/null\n" + )); + script.push_str(&base64_wrapped(&data)); + script.push_str(&format!("\n{marker}\n")); + } + + script.push_str("sudo chmod -R go-rwx \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"\n"); + + if let Some(service) = opts.service.as_deref() { + script.push_str(&format!( + "if command -v systemctl >/dev/null 2>&1 && \\\n (systemctl list-unit-files {service} >/dev/null 2>&1 || systemctl status {service} >/dev/null 2>&1); then\n\ + sudo systemctl restart {service}\n\ + else\n\ + echo 'Service {service_display} not found; skipping restart'\n\ + fi\n", + service = shell_quote(service), + service_display = service, + )); + } + + script.push_str("echo 'toes-matter serial provisioning complete'\n"); + + Ok(script) +} + +fn base64_wrapped(data: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + let mut out = String::new(); + let mut line_len = 0_usize; + + for chunk in data.chunks(3) { + let b0 = chunk[0]; + let b1 = *chunk.get(1).unwrap_or(&0); + let b2 = *chunk.get(2).unwrap_or(&0); + + let encoded = [ + TABLE[(b0 >> 2) as usize] as char, + TABLE[(((b0 & 0b0000_0011) << 4) | (b1 >> 4)) as usize] as char, + if chunk.len() > 1 { + TABLE[(((b1 & 0b0000_1111) << 2) | (b2 >> 6)) as usize] as char + } else { + '=' + }, + if chunk.len() > 2 { + TABLE[(b2 & 0b0011_1111) as usize] as char + } else { + '=' + }, + ]; + + for ch in encoded { + if line_len == 76 { + out.push('\n'); + line_len = 0; + } + out.push(ch); + line_len += 1; + } + } + + out +} + +fn copy_to_device(target: &str, opts: &Options) -> Result<()> { + let remote_tmp = format!( + "/tmp/toes-matter-creds-{}-{}", + sanitize_path_component(&opts.device_id), + std::process::id(), + ); + + println!(); + println!("==> Copying creds to {target}:{}", opts.remote_creds_dir); + + run_status( + "ssh", + command_with_args( + "ssh", + opts.ssh_opts.iter().map(OsString::from).chain([ + OsString::from(target), + OsString::from(format!( + "rm -rf {} && mkdir -p {}", + shell_quote(&remote_tmp), + shell_quote(&remote_tmp), + )), + ]), + ), + )?; + + let mut scp_args = opts.ssh_opts.iter().map(OsString::from).collect::>(); + scp_args.push(OsString::from("-r")); + scp_args.push(opts.creds_dir.join(".").into_os_string()); + scp_args.push(OsString::from(format!("{target}:{remote_tmp}/"))); + + run_status("scp", command_with_args("scp", scp_args))?; + + let parent = remote_parent(&opts.remote_creds_dir); + let mut remote_script = format!( + "set -e\n\ + sudo rm -rf {remote_creds}\n\ + sudo mkdir -p {parent} {remote_state}\n\ + sudo mv {remote_tmp} {remote_creds}\n\ + sudo chmod -R go-rwx {remote_creds} {remote_state}\n", + remote_creds = shell_quote(&opts.remote_creds_dir), + parent = shell_quote(&parent), + remote_state = shell_quote(&opts.remote_state_dir), + remote_tmp = shell_quote(&remote_tmp), + ); + + if let Some(service) = opts.service.as_deref() { + remote_script.push_str(&format!( + "if systemctl list-unit-files {service} >/dev/null 2>&1 || systemctl status {service} >/dev/null 2>&1; then\n\ + sudo systemctl restart {service}\n\ + else\n\ + echo 'Service {service_display} not found; skipping restart'\n\ + fi\n", + service = shell_quote(service), + service_display = service, + )); + } + + run_status( + "ssh", + command_with_args( + "ssh", + opts.ssh_opts + .iter() + .map(OsString::from) + .chain([OsString::from(target), OsString::from(remote_script)]), + ), + )?; + + Ok(()) +} + +fn command_with_args(program: &str, args: impl IntoIterator) -> Command { + let mut command = Command::new(program); + command.args(args); + command +} + +fn run_status(label: &str, mut command: Command) -> Result<()> { + let status = command.status()?; + if status.success() { + Ok(()) + } else { + Err(format!("{label} failed with status {status}").into()) + } +} + +fn sanitize_path_component(value: &str) -> String { + value + .chars() + .map(|ch| match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => ch, + _ => '_', + }) + .collect() +} + +fn remote_parent(path: &str) -> String { + path.rsplit_once('/') + .map(|(parent, _)| if parent.is_empty() { "/" } else { parent }) + .unwrap_or(".") + .to_owned() +} + +fn shell_quote(value: &str) -> String { + let mut quoted = String::with_capacity(value.len() + 2); + quoted.push('\''); + for ch in value.chars() { + if ch == '\'' { + quoted.push_str("'\\''"); + } else { + quoted.push(ch); + } + } + quoted.push('\''); + quoted } diff --git a/src/bin/toes-matter.rs b/src/bin/toes-matter.rs index a545bbd..96b8db9 100644 --- a/src/bin/toes-matter.rs +++ b/src/bin/toes-matter.rs @@ -1,766 +1,12 @@ #![recursion_limit = "256"] -use std::ffi::OsString; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use rand::RngCore; - -use rs_matter::dm::clusters::dev_att::DeviceAttestation; -use rs_matter::dm::devices::test::{TEST_DEV_ATT, TEST_DEV_DET}; -use rs_matter::pairing::qr::{no_optional_data, CommFlowType, QrPayload}; -use rs_matter::pairing::DiscoveryCapabilities; -use rs_matter::sc::pase::{Spake2pVerifierPassword, Spake2pVerifierPasswordRef}; -use rs_matter::BasicCommData; - -type Result = std::result::Result>; - -const DEFAULT_REMOTE_CREDS_DIR: &str = "/var/lib/toes-matter/creds"; -const DEFAULT_REMOTE_STATE_DIR: &str = "/var/lib/toes-matter/state"; -const DEFAULT_SERVICE: &str = "toes-matter.service"; -const DEFAULT_BAUD: u32 = 115_200; -const DEFAULT_SERIAL_DELAY_MS: u64 = 15; -const DEFAULT_SERIAL_LOGIN_TIMEOUT_SECS: u64 = 30; - -fn main() -> Result<()> { - let opts = Options::parse()?; - - let generated = generate_credentials(&opts.creds_dir)?; - - println!(); - println!("==> Pairing info for device {}", opts.device_id); - println!("Manual code: {}", generated.manual_code); - println!("QR payload : {}", generated.qr_code); - - render_qr(&generated.qr_code); - - if let Some(serial) = opts.serial.as_deref() { - write_to_serial(serial, &opts)?; - } else if let Some(target) = opts.target.as_deref() { - copy_to_device(target, &opts)?; - } else { - println!(); - println!("==> No target/serial specified; generated local creds only"); - } - - println!(); - println!("==> Done"); - println!("Local copy saved at: {}", opts.creds_dir.display()); - - if opts.target.is_some() || opts.serial.is_some() { - println!("Remote app env should use:"); - println!(" TOES_MATTER_CREDS_DIR={}", opts.remote_creds_dir); - println!(" TOES_MATTER_STATE_DIR={}", opts.remote_state_dir); - } - - Ok(()) -} - -#[derive(Debug)] -struct Options { - target: Option, - serial: Option, - device_id: String, - creds_dir: PathBuf, - remote_creds_dir: String, - remote_state_dir: String, - service: Option, - ssh_opts: Vec, - baud: u32, - serial_delay: Duration, - serial_login: Option, - serial_password: Option, - serial_prompts: Vec, - serial_login_timeout: Duration, -} - -impl Options { - fn parse() -> Result { - let mut args = std::env::args().skip(1); - - let mut target = None; - let mut serial = std::env::var("SERIAL_PORT").ok(); - let mut device_id = None; - let mut creds_dir = std::env::var_os("CREDS_DIR").map(PathBuf::from); - let mut remote_creds_dir = - std::env::var("REMOTE_CREDS_DIR").unwrap_or_else(|_| DEFAULT_REMOTE_CREDS_DIR.into()); - let mut remote_state_dir = - std::env::var("REMOTE_STATE_DIR").unwrap_or_else(|_| DEFAULT_REMOTE_STATE_DIR.into()); - let mut service = match std::env::var("SERVICE") { - Ok(value) if value.is_empty() => None, - Ok(value) => Some(value), - Err(_) => Some(DEFAULT_SERVICE.into()), - }; - let mut ssh_opts = split_ssh_opts(std::env::var("SSH_OPTS").unwrap_or_default()); - let mut baud = std::env::var("SERIAL_BAUD") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(DEFAULT_BAUD); - let mut serial_delay_ms = std::env::var("SERIAL_DELAY_MS") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(DEFAULT_SERIAL_DELAY_MS); - let mut serial_login = std::env::var("SERIAL_LOGIN").ok(); - let mut serial_password = std::env::var("SERIAL_PASSWORD").ok(); - let mut serial_prompts = std::env::var("SERIAL_PROMPT") - .ok() - .map(|prompt| vec![prompt]) - .unwrap_or_else(|| vec!["# ".into(), "$ ".into()]); - let mut serial_login_timeout_secs = std::env::var("SERIAL_LOGIN_TIMEOUT_SECS") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(DEFAULT_SERIAL_LOGIN_TIMEOUT_SECS); - - while let Some(arg) = args.next() { - match arg.as_str() { - "-h" | "--help" => { - print_usage(); - std::process::exit(0); - } - "--creds-dir" => { - creds_dir = Some(PathBuf::from(next_arg(&mut args, "--creds-dir")?)) - } - "--remote-creds-dir" => { - remote_creds_dir = next_arg(&mut args, "--remote-creds-dir")? - } - "--remote-state-dir" => { - remote_state_dir = next_arg(&mut args, "--remote-state-dir")? - } - "--service" => service = Some(next_arg(&mut args, "--service")?), - "--no-restart" => service = None, - "--device-id" => device_id = Some(next_arg(&mut args, "--device-id")?), - "--ssh-opt" => ssh_opts.push(next_arg(&mut args, "--ssh-opt")?), - "--serial" => serial = Some(next_arg(&mut args, "--serial")?), - "--baud" => baud = next_arg(&mut args, "--baud")?.parse()?, - "--serial-delay-ms" => { - serial_delay_ms = next_arg(&mut args, "--serial-delay-ms")?.parse()? - } - "--login" => serial_login = Some(next_arg(&mut args, "--login")?), - "--password" => serial_password = Some(next_arg(&mut args, "--password")?), - "--prompt" => serial_prompts.push(next_arg(&mut args, "--prompt")?), - "--login-timeout-secs" => { - serial_login_timeout_secs = - next_arg(&mut args, "--login-timeout-secs")?.parse()? - } - _ if arg.starts_with('-') => return Err(format!("unknown option: {arg}").into()), - _ => { - if target.is_none() { - target = Some(arg); - } else if device_id.is_none() { - device_id = Some(arg); - } else { - return Err("too many positional arguments".into()); - } - } - } - } - - if serial.is_some() && target.is_some() && device_id.is_none() { - device_id = target.take(); - } - - if serial.is_some() && target.is_some() { - return Err("use either SSH target or --serial, not both".into()); - } - - let device_id = device_id.unwrap_or_else(default_device_id); - let creds_dir = creds_dir.unwrap_or_else(|| { - PathBuf::from("manufacturing") - .join(&device_id) - .join("creds") - }); - - Ok(Self { - target, - serial, - device_id, - creds_dir, - remote_creds_dir, - remote_state_dir, - service, - ssh_opts, - baud, - serial_delay: Duration::from_millis(serial_delay_ms), - serial_login, - serial_password, - serial_prompts, - serial_login_timeout: Duration::from_secs(serial_login_timeout_secs), - }) - } -} - -fn print_usage() { - eprintln!( - "Usage: toes-matter-provision [OPTIONS] [user@device-host] [device-id]\n\n\ - Generates Matter setup data, prints the QR/manual code, and optionally copies it to a Linux device over SSH or serial console.\n\n\ - Options:\n\ - --creds-dir DIR Local output dir [env: CREDS_DIR]\n\ - --remote-creds-dir DIR Remote creds dir [default: {DEFAULT_REMOTE_CREDS_DIR}]\n\ - --remote-state-dir DIR Remote state dir [default: {DEFAULT_REMOTE_STATE_DIR}]\n\ - --service NAME Service to restart [default: {DEFAULT_SERVICE}]\n\ - --no-restart Do not restart a remote service\n\ - --device-id ID Device id for local-only generation\n\ - --ssh-opt ARG Extra ssh/scp option; may be repeated [env: SSH_OPTS]\n\ - --serial PORT Write install script to a serial shell [env: SERIAL_PORT]\n\ - --baud BAUD Configure serial baud [default: 115200, env: SERIAL_BAUD]\n\ - --serial-delay-ms MS Delay between serial lines [default: 15, env: SERIAL_DELAY_MS]\n\ - --login USER Log in on serial before provisioning [env: SERIAL_LOGIN]\n\ - --password PASS Serial login password [env: SERIAL_PASSWORD]\n\ - --prompt PROMPT Shell prompt marker; may repeat [env: SERIAL_PROMPT]\n\ - --login-timeout-secs N Serial login timeout [default: 30, env: SERIAL_LOGIN_TIMEOUT_SECS]\n\ - -h, --help Show this help" - ); -} - -fn next_arg(args: &mut impl Iterator, option: &str) -> Result { - args.next() - .ok_or_else(|| format!("{option} requires an argument").into()) -} - -fn default_device_id() -> String { - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0); - format!("device-{secs}") -} - -fn split_ssh_opts(value: String) -> Vec { - value - .split_whitespace() - .filter(|part| !part.is_empty()) - .map(str::to_owned) - .collect() -} - -struct GeneratedCredentials { - manual_code: String, - qr_code: String, -} - -fn generate_credentials(path: &Path) -> Result { - std::fs::create_dir_all(path)?; - - std::fs::write(path.join("dac.der"), TEST_DEV_ATT.dac())?; - std::fs::write(path.join("pai.der"), TEST_DEV_ATT.pai())?; - std::fs::write( - path.join("certification-declaration.der"), - TEST_DEV_ATT.cert_declaration(), - )?; - std::fs::write( - path.join("dac-private-key.raw"), - TEST_DEV_ATT.dac_priv_key().access(), - )?; - - let comm = read_comm_data(path)?.unwrap_or_else(random_comm_data); - let payload = QrPayload::new_from_basic_info( - DiscoveryCapabilities::BLE, - CommFlowType::Standard, - comm.clone(), - &TEST_DEV_DET, - no_optional_data, +fn main() -> toes_matter::Result<()> { + env_logger::init_from_env( + env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"), ); - let mut qr_buf = [0_u8; 512]; - let (qr_text, _) = payload.as_str(&mut qr_buf)?; - - let manual_code = comm.compute_pretty_pairing_code().to_string(); - let qr_code = qr_text.to_owned(); - - let setup = format!( - "setup_passcode={}\n\ - discriminator={}\n\ - manual_code={}\n\ - qr_code={}\n", - comm_passcode(&comm), - comm.discriminator, - manual_code, - qr_code, - ); - - std::fs::write(path.join("setup.txt"), setup)?; - - Ok(GeneratedCredentials { - manual_code, - qr_code, + futures_lite::future::block_on(async { + toes_matter::provision().await?; + toes_matter::listen().await }) } - -fn read_comm_data(path: &Path) -> Result> { - let setup_path = path.join("setup.txt"); - let setup = match std::fs::read_to_string(setup_path) { - Ok(setup) => setup, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err.into()), - }; - - let passcode = parse_setup_u32(&setup, "setup_passcode")?; - let discriminator = parse_setup_u32(&setup, "discriminator")?; - - if discriminator > 0x0fff || !valid_setup_passcode(passcode) { - return Err("invalid setup.txt passcode/discriminator".into()); - } - - Ok(Some(make_comm_data(passcode, discriminator as u16))) -} - -fn parse_setup_u32(setup: &str, key: &str) -> Result { - let value = setup - .lines() - .find_map(|line| line.strip_prefix(key)?.strip_prefix('=')) - .ok_or_else(|| format!("setup.txt missing {key}"))?; - - value - .parse() - .map_err(|err| format!("invalid {key}: {err}").into()) -} - -fn random_comm_data() -> BasicCommData { - let mut rng = rand::thread_rng(); - - let passcode = loop { - let passcode = (rng.next_u32() % 99_999_998) + 1; - if valid_setup_passcode(passcode) { - break passcode; - } - }; - - let discriminator = (rng.next_u32() & 0x0fff) as u16; - - make_comm_data(passcode, discriminator) -} - -fn make_comm_data(passcode: u32, discriminator: u16) -> BasicCommData { - BasicCommData { - password: Spake2pVerifierPassword::new_from_ref(Spake2pVerifierPasswordRef::new( - &passcode.to_le_bytes(), - )), - discriminator, - } -} - -fn comm_passcode(comm: &BasicCommData) -> u32 { - u32::from_le_bytes(*comm.password.access()) -} - -fn valid_setup_passcode(passcode: u32) -> bool { - const INVALID: &[u32] = &[ - 0, 11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777, 88888888, - 99999999, 12345678, 87654321, - ]; - - (1..=99_999_998).contains(&passcode) && !INVALID.contains(&passcode) -} - -fn render_qr(qr_code: &str) { - let status = Command::new("qrencode") - .arg("-t") - .arg("ANSIUTF8") - .arg(qr_code) - .status(); - - match status { - Ok(status) if status.success() => {} - Ok(_) | Err(_) => { - println!(); - println!("Tip: install qrencode to render the QR in this terminal:"); - println!(" macOS: brew install qrencode"); - println!(" Debian/Ubuntu: sudo apt install qrencode"); - } - } -} - -fn write_to_serial(port: &str, opts: &Options) -> Result<()> { - configure_serial(port, opts.baud)?; - - let script = build_remote_install_script(opts)?; - - println!(); - println!( - "==> Writing install script to serial port {port} at {} baud", - opts.baud - ); - if opts.serial_login.is_some() { - println!(" Will log in on the serial console first."); - } else { - println!(" This assumes the serial console is already at a shell prompt."); - } - if cfg!(target_os = "macos") && port.starts_with("/dev/tty.") { - println!( - " Tip: on macOS, /dev/cu.* is usually better than /dev/tty.* for outbound serial." - ); - } - - let mut serial = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(port)?; - serial.write_all(b"\n")?; - serial.flush()?; - - if opts.serial_login.is_some() { - login_to_serial(&mut serial, opts)?; - } - - for line in script.lines() { - serial.write_all(line.as_bytes())?; - serial.write_all(b"\n")?; - serial.flush()?; - std::thread::sleep(opts.serial_delay); - } - - println!("==> Serial install script sent"); - - Ok(()) -} - -fn configure_serial(port: &str, baud: u32) -> Result<()> { - let port_flag = if cfg!(any( - target_os = "macos", - target_os = "freebsd", - target_os = "openbsd", - target_os = "netbsd" - )) { - "-f" - } else { - "-F" - }; - - let status = Command::new("stty") - .arg(port_flag) - .arg(port) - .arg(baud.to_string()) - .arg("raw") - .arg("-echo") - .arg("-ixon") - .arg("-ixoff") - .arg("min") - .arg("0") - .arg("time") - .arg("5") - .status()?; - - if status.success() { - Ok(()) - } else { - Err(format!("stty failed for {port} with status {status}").into()) - } -} - -fn login_to_serial(serial: &mut std::fs::File, opts: &Options) -> Result<()> { - let login = opts - .serial_login - .as_deref() - .ok_or("serial login requested without a username")?; - - println!("==> Waiting for serial login prompt or shell prompt..."); - serial.write_all(b"\n")?; - serial.flush()?; - - let mut initial_needles = vec![b"login:".to_vec(), b"Login:".to_vec()]; - initial_needles.extend( - opts.serial_prompts - .iter() - .map(|prompt| prompt.as_bytes().to_vec()), - ); - - let initial = wait_for_any(serial, &initial_needles, opts.serial_login_timeout)?; - - if initial >= 2 { - println!("==> Serial console already appears to be at a shell prompt"); - return Ok(()); - } - - println!("==> Sending serial login username: {login}"); - serial.write_all(login.as_bytes())?; - serial.write_all(b"\n")?; - serial.flush()?; - - let mut after_user_needles = vec![b"Password:".to_vec(), b"password:".to_vec()]; - after_user_needles.extend( - opts.serial_prompts - .iter() - .map(|prompt| prompt.as_bytes().to_vec()), - ); - - let after_user = wait_for_any(serial, &after_user_needles, opts.serial_login_timeout)?; - - if after_user < 2 { - let password = opts - .serial_password - .as_deref() - .ok_or("serial password prompt detected; pass --password or set SERIAL_PASSWORD")?; - - println!("==> Sending serial login password"); - serial.write_all(password.as_bytes())?; - serial.write_all(b"\n")?; - serial.flush()?; - - let prompt_needles = opts - .serial_prompts - .iter() - .map(|prompt| prompt.as_bytes().to_vec()) - .collect::>(); - wait_for_any(serial, &prompt_needles, opts.serial_login_timeout)?; - } - - println!("==> Serial login complete"); - - Ok(()) -} - -fn wait_for_any( - serial: &mut std::fs::File, - needles: &[Vec], - timeout: Duration, -) -> Result { - let start = std::time::Instant::now(); - let mut seen = Vec::::new(); - let mut buf = [0_u8; 256]; - - while start.elapsed() < timeout { - match serial.read(&mut buf) { - Ok(0) => { - std::thread::sleep(Duration::from_millis(25)); - } - Ok(n) => { - std::io::stdout().write_all(&buf[..n])?; - std::io::stdout().flush()?; - - seen.extend_from_slice(&buf[..n]); - if seen.len() > 8192 { - let keep_from = seen.len() - 4096; - seen.drain(..keep_from); - } - - if let Some(index) = find_needle(&seen, needles) { - return Ok(index); - } - } - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} - Err(err) => return Err(err.into()), - } - } - - let tail = String::from_utf8_lossy(&seen); - Err(format!("timed out waiting for serial prompt; recent output: {tail:?}").into()) -} - -fn find_needle(haystack: &[u8], needles: &[Vec]) -> Option { - needles.iter().position(|needle| { - !needle.is_empty() - && haystack - .windows(needle.len()) - .any(|window| window == needle.as_slice()) - }) -} - -fn build_remote_install_script(opts: &Options) -> Result { - let mut script = String::new(); - - script.push_str("set -e\n"); - script.push_str(&format!( - "REMOTE_CREDS_DIR={}\nREMOTE_STATE_DIR={}\n", - shell_quote(&opts.remote_creds_dir), - shell_quote(&opts.remote_state_dir), - )); - script.push_str("sudo rm -rf \"$REMOTE_CREDS_DIR\"\n"); - script.push_str("sudo mkdir -p \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"\n"); - - for file in [ - "dac.der", - "pai.der", - "certification-declaration.der", - "dac-private-key.raw", - "setup.txt", - ] { - let path = opts.creds_dir.join(file); - let data = std::fs::read(&path)?; - let marker = format!( - "TOES_MATTER_{}", - file.replace(['.', '-'], "_").to_uppercase() - ); - - script.push_str(&format!( - "base64 -d <<'{marker}' | sudo tee \"$REMOTE_CREDS_DIR/{file}\" >/dev/null\n" - )); - script.push_str(&base64_wrapped(&data)); - script.push_str(&format!("\n{marker}\n")); - } - - script.push_str("sudo chmod -R go-rwx \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"\n"); - - if let Some(service) = opts.service.as_deref() { - script.push_str(&format!( - "if command -v systemctl >/dev/null 2>&1 && \\\n (systemctl list-unit-files {service} >/dev/null 2>&1 || systemctl status {service} >/dev/null 2>&1); then\n\ - sudo systemctl restart {service}\n\ - else\n\ - echo 'Service {service_display} not found; skipping restart'\n\ - fi\n", - service = shell_quote(service), - service_display = service, - )); - } - - script.push_str("echo 'toes-matter serial provisioning complete'\n"); - - Ok(script) -} - -fn base64_wrapped(data: &[u8]) -> String { - const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - - let mut out = String::new(); - let mut line_len = 0_usize; - - for chunk in data.chunks(3) { - let b0 = chunk[0]; - let b1 = *chunk.get(1).unwrap_or(&0); - let b2 = *chunk.get(2).unwrap_or(&0); - - let encoded = [ - TABLE[(b0 >> 2) as usize] as char, - TABLE[(((b0 & 0b0000_0011) << 4) | (b1 >> 4)) as usize] as char, - if chunk.len() > 1 { - TABLE[(((b1 & 0b0000_1111) << 2) | (b2 >> 6)) as usize] as char - } else { - '=' - }, - if chunk.len() > 2 { - TABLE[(b2 & 0b0011_1111) as usize] as char - } else { - '=' - }, - ]; - - for ch in encoded { - if line_len == 76 { - out.push('\n'); - line_len = 0; - } - out.push(ch); - line_len += 1; - } - } - - out -} - -fn copy_to_device(target: &str, opts: &Options) -> Result<()> { - let remote_tmp = format!( - "/tmp/toes-matter-creds-{}-{}", - sanitize_path_component(&opts.device_id), - std::process::id(), - ); - - println!(); - println!("==> Copying creds to {target}:{}", opts.remote_creds_dir); - - run_status( - "ssh", - command_with_args( - "ssh", - opts.ssh_opts.iter().map(OsString::from).chain([ - OsString::from(target), - OsString::from(format!( - "rm -rf {} && mkdir -p {}", - shell_quote(&remote_tmp), - shell_quote(&remote_tmp), - )), - ]), - ), - )?; - - let mut scp_args = opts.ssh_opts.iter().map(OsString::from).collect::>(); - scp_args.push(OsString::from("-r")); - scp_args.push(opts.creds_dir.join(".").into_os_string()); - scp_args.push(OsString::from(format!("{target}:{remote_tmp}/"))); - - run_status("scp", command_with_args("scp", scp_args))?; - - let parent = remote_parent(&opts.remote_creds_dir); - let mut remote_script = format!( - "set -e\n\ - sudo rm -rf {remote_creds}\n\ - sudo mkdir -p {parent} {remote_state}\n\ - sudo mv {remote_tmp} {remote_creds}\n\ - sudo chmod -R go-rwx {remote_creds} {remote_state}\n", - remote_creds = shell_quote(&opts.remote_creds_dir), - parent = shell_quote(&parent), - remote_state = shell_quote(&opts.remote_state_dir), - remote_tmp = shell_quote(&remote_tmp), - ); - - if let Some(service) = opts.service.as_deref() { - remote_script.push_str(&format!( - "if systemctl list-unit-files {service} >/dev/null 2>&1 || systemctl status {service} >/dev/null 2>&1; then\n\ - sudo systemctl restart {service}\n\ - else\n\ - echo 'Service {service_display} not found; skipping restart'\n\ - fi\n", - service = shell_quote(service), - service_display = service, - )); - } - - run_status( - "ssh", - command_with_args( - "ssh", - opts.ssh_opts - .iter() - .map(OsString::from) - .chain([OsString::from(target), OsString::from(remote_script)]), - ), - )?; - - Ok(()) -} - -fn command_with_args(program: &str, args: impl IntoIterator) -> Command { - let mut command = Command::new(program); - command.args(args); - command -} - -fn run_status(label: &str, mut command: Command) -> Result<()> { - let status = command.status()?; - if status.success() { - Ok(()) - } else { - Err(format!("{label} failed with status {status}").into()) - } -} - -fn sanitize_path_component(value: &str) -> String { - value - .chars() - .map(|ch| match ch { - 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => ch, - _ => '_', - }) - .collect() -} - -fn remote_parent(path: &str) -> String { - path.rsplit_once('/') - .map(|(parent, _)| if parent.is_empty() { "/" } else { parent }) - .unwrap_or(".") - .to_owned() -} - -fn shell_quote(value: &str) -> String { - let mut quoted = String::with_capacity(value.len() + 2); - quoted.push('\''); - for ch in value.chars() { - if ch == '\'' { - quoted.push_str("'\\''"); - } else { - quoted.push(ch); - } - } - quoted.push('\''); - quoted -}