diff --git a/Cargo.toml b/Cargo.toml index 3ec84e1..46e2e80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,26 +6,46 @@ license = "MIT OR Apache-2.0" readme = "README.md" publish = false +[[bin]] +name = "toes-matter-creds" +required-features = ["device"] + +[[bin]] +name = "toes-matter-provision" + +[[example]] +name = "basic" +required-features = ["device"] + [features] -default = ["zeroconf"] +default = ["device"] +device = [ + "dep:async-io", + "dep:embassy-futures", + "dep:embassy-time", + "dep:embassy-time-queue-utils", + "dep:env_logger", + "dep:futures-lite", + "dep:log", + "rs-matter/log", + "rs-matter/os", + "rs-matter/rustcrypto", + "rs-matter/zbus", + "rs-matter/max-sessions-32", + "rs-matter/max-groups-per-fabric-12", + "rs-matter/max-group-keys-per-fabric-2", + "rs-matter/max-group-endpoints-per-fabric-3", + "zeroconf", +] zeroconf = ["rs-matter/zeroconf"] [dependencies] -async-io = "2" -embassy-futures = "0.1" -embassy-time = { version = "0.5", features = ["std"] } -embassy-time-queue-utils = { version = "0.3", features = ["generic-queue-64"] } -env_logger = "0.11" -futures-lite = "2" -log = "0.4" +async-io = { version = "2", optional = true } +embassy-futures = { version = "0.1", optional = true } +embassy-time = { version = "0.5", features = ["std"], optional = true } +embassy-time-queue-utils = { version = "0.3", features = ["generic-queue-64"], optional = true } +env_logger = { version = "0.11", optional = true } +futures-lite = { version = "2", optional = true } +log = { version = "0.4", optional = true } rand = { version = "0.8", features = ["std", "std_rng"] } -rs-matter = { path = "../../rs-matter/rs-matter", default-features = false, features = [ - "log", - "os", - "rustcrypto", - "zbus", - "max-sessions-32", - "max-groups-per-fabric-12", - "max-group-keys-per-fabric-2", - "max-group-endpoints-per-fabric-3", -] } +rs-matter = { path = "../forks/rs-matter/rs-matter", default-features = false } diff --git a/README.md b/README.md index 5ff82ac..2234805 100644 --- a/README.md +++ b/README.md @@ -39,19 +39,64 @@ fn main() -> toes_matter::Result<()> { ## Post-flash headless provisioning -After flashing a Linux device reachable over SSH: +After flashing a Linux device reachable over SSH, run the host-side provisioning command from Linux or macOS: ```bash -toes-matter/scripts/provision-device.sh user@device-host device-001 +cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-provision -- \ + user@device-host device-001 ``` -The script generates `manufacturing/device-001/creds`, prints the manual code / QR payload, renders a terminal QR if `qrencode` is installed, copies the creds to `/var/lib/toes-matter/creds`, and restarts `toes-matter.service` if present. +It generates `manufacturing/device-001/creds`, prints the manual code / QR payload, renders a terminal QR if `qrencode` is installed, copies the creds to `/var/lib/toes-matter/creds`, creates `/var/lib/toes-matter/state`, and restarts `toes-matter.service` if present. + +The older script is now just a wrapper around that command: + +```bash +~/apps/toes-matter/scripts/provision-device.sh user@device-host device-001 +``` Set these if your service uses different paths: ```bash -REMOTE_CREDS_DIR=/var/lib/my-app/creds \ -REMOTE_STATE_DIR=/var/lib/my-app/state \ -SERVICE=my-app.service \ -toes-matter/scripts/provision-device.sh user@device-host device-001 +cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-provision -- \ + --remote-creds-dir /var/lib/my-app/creds \ + --remote-state-dir /var/lib/my-app/state \ + --service my-app.service \ + user@device-host device-001 +``` + +Serial instead of network/SSH: + +```bash +cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-provision -- \ + --serial /dev/cu.usbserial-0001 \ + --device-id device-001 +``` + +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 -- \ + --serial /dev/cu.usbserial-0001 \ + --login root \ + --password 'your-password' \ + --device-id device-001 +``` + +For passwordless root/login, use `--login root` and omit `--password`. The tool waits for `login:`, optional `Password:`, then a shell prompt marker (`# ` or `$ ` by default) before sending the install script. If your prompt is unusual, add `--prompt 'my-prompt>'`. + +The wrapper script supports serial via environment too: + +```bash +SERIAL_PORT=/dev/cu.usbserial-0001 \ +SERIAL_LOGIN=root \ +SERIAL_PASSWORD='your-password' \ +~/apps/toes-matter/scripts/provision-device.sh device-001 +``` + +On macOS, `/dev/cu.*` is usually better for outbound serial than `/dev/tty.*`, though either can appear depending on the adapter. + +Useful macOS extras: + +```bash +brew install qrencode ``` diff --git a/scripts/provision-device.sh b/scripts/provision-device.sh index cf98a87..3913097 100755 --- a/scripts/provision-device.sh +++ b/scripts/provision-device.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash set -euo pipefail -# Post-flash headless provisioning helper. +# Compatibility wrapper for the Rust host provisioning command. +# Works from Linux/macOS as long as cargo is available. # -# Generates the Matter setup payload on this host, prints the QR/manual code, -# and copies the generated creds/config directory to a freshly flashed Linux device. -# -# Usage: +# SSH usage: # toes-matter/scripts/provision-device.sh user@device-host [device-id] # +# Serial usage: +# SERIAL_PORT=/dev/cu.usbserial-0001 toes-matter/scripts/provision-device.sh [device-id] +# SERIAL_PORT=/dev/cu.usbserial-0001 SERIAL_LOGIN=root SERIAL_PASSWORD=... toes-matter/scripts/provision-device.sh [device-id] +# # Environment: # CREDS_DIR Local output dir. Default: toes-matter/manufacturing//creds # REMOTE_CREDS_DIR Remote creds dir. Default: /var/lib/toes-matter/creds @@ -16,72 +18,48 @@ set -euo pipefail # SERVICE Optional service to restart. Default: toes-matter.service # Set SERVICE= to skip restart. # SSH_OPTS Extra ssh/scp options, e.g. '-p 2222' - -if [[ $# -lt 1 || $# -gt 2 ]]; then - echo "Usage: $0 user@device-host [device-id]" >&2 - exit 2 -fi - -TARGET="$1" -DEVICE_ID="${2:-$(date +%Y%m%d-%H%M%S)}" +# SERIAL_PORT Serial device, e.g. /dev/cu.usbserial-0001 or /dev/tty.usbserial-0001 +# SERIAL_LOGIN Optional serial login username +# SERIAL_PASSWORD Optional serial login password SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CRATE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +if [[ -n "${SERIAL_PORT:-}" ]]; then + if [[ $# -gt 1 ]]; then + echo "Usage with SERIAL_PORT: SERIAL_PORT=/dev/cu.* $0 [device-id]" >&2 + exit 2 + fi + + DEVICE_ID="${1:-$(date +%Y%m%d-%H%M%S)}" + TARGET_ARGS=(--serial "$SERIAL_PORT" --device-id "$DEVICE_ID") +else + if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: $0 user@device-host [device-id]" >&2 + echo " or: SERIAL_PORT=/dev/cu.* $0 [device-id]" >&2 + exit 2 + fi + + TARGET="$1" + DEVICE_ID="${2:-$(date +%Y%m%d-%H%M%S)}" + TARGET_ARGS=("$TARGET" "$DEVICE_ID") +fi + CREDS_DIR="${CREDS_DIR:-$CRATE_DIR/manufacturing/$DEVICE_ID/creds}" REMOTE_CREDS_DIR="${REMOTE_CREDS_DIR:-/var/lib/toes-matter/creds}" REMOTE_STATE_DIR="${REMOTE_STATE_DIR:-/var/lib/toes-matter/state}" SERVICE="${SERVICE-toes-matter.service}" -SSH_OPTS="${SSH_OPTS:-}" -REMOTE_TMP="/tmp/toes-matter-creds-$DEVICE_ID-$$" -# shellcheck disable=SC2206 -SSH_ARGS=($SSH_OPTS) +args=( + --creds-dir "$CREDS_DIR" + --remote-creds-dir "$REMOTE_CREDS_DIR" + --remote-state-dir "$REMOTE_STATE_DIR" +) -mkdir -p "$CREDS_DIR" - -echo "==> Generating development Matter credentials in $CREDS_DIR" -cargo run --quiet --manifest-path "$CRATE_DIR/Cargo.toml" --bin toes-matter-creds -- "$CREDS_DIR" - -SETUP_FILE="$CREDS_DIR/setup.txt" -MANUAL_CODE="$(awk -F= '$1 == "manual_code" { print $2 }' "$SETUP_FILE")" -QR_CODE="$(awk -F= '$1 == "qr_code" { print $2 }' "$SETUP_FILE")" - -echo -echo "==> Pairing info for device $DEVICE_ID" -echo "Manual code: $MANUAL_CODE" -echo "QR payload : $QR_CODE" - -if command -v qrencode >/dev/null 2>&1; then - echo - echo "==> QR code" - qrencode -t ANSIUTF8 "$QR_CODE" +if [[ -z "$SERVICE" ]]; then + args+=(--no-restart) else - echo - echo "Tip: install qrencode to render the QR in this terminal: sudo apt install qrencode" + args+=(--service "$SERVICE") fi -echo -echo "==> Copying creds to $TARGET:$REMOTE_CREDS_DIR" -ssh "${SSH_ARGS[@]}" "$TARGET" "rm -rf '$REMOTE_TMP' && mkdir -p '$REMOTE_TMP'" -scp "${SSH_ARGS[@]}" -r "$CREDS_DIR"/. "$TARGET:$REMOTE_TMP/" -ssh "${SSH_ARGS[@]}" "$TARGET" "set -e -sudo rm -rf '$REMOTE_CREDS_DIR' -sudo mkdir -p '$(dirname "$REMOTE_CREDS_DIR")' '$REMOTE_STATE_DIR' -sudo mv '$REMOTE_TMP' '$REMOTE_CREDS_DIR' -sudo chmod -R go-rwx '$REMOTE_CREDS_DIR' '$REMOTE_STATE_DIR' -if [ -n '$SERVICE' ]; then - if systemctl list-unit-files '$SERVICE' >/dev/null 2>&1 || systemctl status '$SERVICE' >/dev/null 2>&1; then - sudo systemctl restart '$SERVICE' - else - echo 'Service $SERVICE not found; skipping restart' - fi -fi -" - -echo -echo "==> Done" -echo "Local copy saved at: $CREDS_DIR" -echo "Remote app env should use:" -echo " TOES_MATTER_CREDS_DIR=$REMOTE_CREDS_DIR" -echo " TOES_MATTER_STATE_DIR=$REMOTE_STATE_DIR" +exec cargo run --quiet --no-default-features --manifest-path "$CRATE_DIR/Cargo.toml" --bin toes-matter-provision -- "${args[@]}" "${TARGET_ARGS[@]}" diff --git a/src/bin/toes-matter-provision.rs b/src/bin/toes-matter-provision.rs new file mode 100644 index 0000000..a545bbd --- /dev/null +++ b/src/bin/toes-matter-provision.rs @@ -0,0 +1,766 @@ +#![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, + ); + + 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/lib.rs b/src/lib.rs index 4a04700..956de5c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg(feature = "device")] #![recursion_limit = "256"] #![allow(async_fn_in_trait)]