From db793d49d98d1c5636cf5babcc9a8ed6d54d8365 Mon Sep 17 00:00:00 2001 From: Pat Nakajima Date: Tue, 19 May 2026 15:51:41 +0000 Subject: [PATCH] Simplify credentials provisioning to serial + match toes-image-builder paths --- README.md | 18 ++------- scripts/provision-device.sh | 33 +++++---------- src/bin/toes-matter-credentials.rs | 65 ++++++++++++++++++++++++------ 3 files changed, 67 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 950c092..72082bd 100644 --- a/README.md +++ b/README.md @@ -39,21 +39,16 @@ fn main() -> toes_matter::Result<()> { ## Post-flash headless provisioning -After flashing a Linux device reachable over SSH, run the host-side credentials command from Linux or macOS: +After flashing a Linux device with a serial console, 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-credentials -- \ - user@device-host device-001 + --serial /dev/cu.usbserial-0001 \ + --device-id device-001 ``` 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`, 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 @@ -61,13 +56,6 @@ cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml -- --remote-creds-dir /var/lib/my-app \ --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-credentials -- \ --serial /dev/cu.usbserial-0001 \ --device-id device-001 ``` diff --git a/scripts/provision-device.sh b/scripts/provision-device.sh index 6cf0dad..88ec9ca 100755 --- a/scripts/provision-device.sh +++ b/scripts/provision-device.sh @@ -4,9 +4,6 @@ set -euo pipefail # Compatibility wrapper for the Rust host provisioning command. # Works from Linux/macOS as long as cargo is available. # -# 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] @@ -17,7 +14,6 @@ set -euo pipefail # REMOTE_STATE_DIR Remote Matter state dir. Default: /var/lib/toes-matter/state # SERVICE Optional service to restart. Default: toes-matter.service # Set SERVICE= to skip restart. -# SSH_OPTS Extra ssh/scp options, e.g. '-p 2222' # 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 @@ -25,26 +21,19 @@ set -euo pipefail 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") +if [[ -z "${SERIAL_PORT:-}" ]]; then + echo "Error: set SERIAL_PORT=/dev/cu.* (or /dev/tty.*)" >&2 + exit 2 fi +if [[ $# -gt 1 ]]; then + echo "Usage: 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") + CREDS_DIR="${CREDS_DIR:-$CRATE_DIR/manufacturing/$DEVICE_ID/creds}" REMOTE_CREDS_DIR="${REMOTE_CREDS_DIR:-/var/lib/toes-matter}" REMOTE_STATE_DIR="${REMOTE_STATE_DIR:-/var/lib/toes-matter/state}" diff --git a/src/bin/toes-matter-credentials.rs b/src/bin/toes-matter-credentials.rs index 841cb6f..1b92857 100644 --- a/src/bin/toes-matter-credentials.rs +++ b/src/bin/toes-matter-credentials.rs @@ -208,7 +208,6 @@ fn default_device_id() -> String { format!("device-{secs}") } - struct GeneratedCredentials { manual_code: String, qr_code: String, @@ -371,7 +370,8 @@ fn write_to_serial(port: &str, opts: &Options) -> Result<()> { .read(true) .write(true) .open(port)?; - serial.write_all(b"\n")?; + // Many serial consoles expect CRLF for Enter. + serial.write_all(b"\r\n")?; serial.flush()?; if opts.serial_login.is_some() { @@ -380,12 +380,24 @@ fn write_to_serial(port: &str, opts: &Options) -> Result<()> { for line in script.lines() { serial.write_all(line.as_bytes())?; - serial.write_all(b"\n")?; + serial.write_all(b"\r\n")?; serial.flush()?; std::thread::sleep(opts.serial_delay); } - println!("==> Serial install script sent"); + println!("==> Serial install script sent; waiting for completion..."); + + // Wait for the install script to print its completion marker so failures + // (e.g. sudo password prompts) are visible to the operator. + let done = b"toes-matter serial provisioning complete".to_vec(); + wait_for_any( + &mut serial, + &[done], + // The payload is small; give it some time on slower devices. + Duration::from_secs(60), + )?; + + println!("==> Serial provisioning complete"); Ok(()) } @@ -430,7 +442,7 @@ fn login_to_serial(serial: &mut std::fs::File, opts: &Options) -> Result<()> { .ok_or("serial login requested without a username")?; println!("==> Waiting for serial login prompt or shell prompt..."); - serial.write_all(b"\n")?; + serial.write_all(b"\r\n")?; serial.flush()?; let mut initial_needles = vec![b"login:".to_vec(), b"Login:".to_vec()]; @@ -449,7 +461,7 @@ fn login_to_serial(serial: &mut std::fs::File, opts: &Options) -> Result<()> { println!("==> Sending serial login username: {login}"); serial.write_all(login.as_bytes())?; - serial.write_all(b"\n")?; + serial.write_all(b"\r\n")?; serial.flush()?; let mut after_user_needles = vec![b"Password:".to_vec(), b"password:".to_vec()]; @@ -469,7 +481,7 @@ fn login_to_serial(serial: &mut std::fs::File, opts: &Options) -> Result<()> { println!("==> Sending serial login password"); serial.write_all(password.as_bytes())?; - serial.write_all(b"\n")?; + serial.write_all(b"\r\n")?; serial.flush()?; let prompt_needles = opts @@ -534,14 +546,42 @@ fn find_needle(haystack: &[u8], needles: &[Vec]) -> Option { fn build_remote_install_script(opts: &Options) -> Result { let mut script = String::new(); + // Note: this script is sent line-by-line over a serial console. + // Keep it POSIX-ish (sh-compatible) and avoid anything that would block + // waiting for interactive input. + 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"); + + // Determine how to escalate. + // - If already root, no sudo needed. + // - If sudo works without a password, use it. + // - Otherwise, fail early with a clear message (so we don't hang at a password prompt). + script.push_str( + "if [ \"$(id -u)\" -eq 0 ]; then\n\ + SUDO=\"\"\n\ + elif command -v sudo >/dev/null 2>&1; then\n\ + if sudo -n true >/dev/null 2>&1; then\n\ + SUDO=\"sudo\"\n\ + else\n\ + echo 'ERROR: sudo requires a password. Either provision using a root shell, or configure passwordless sudo for this user.' >&2\n\ + exit 1\n\ + fi\n\ + else\n\ + echo 'ERROR: sudo not found and not running as root.' >&2\n\ + exit 1\n\ + fi\n\n", + ); + + // Create directories up-front. + script.push_str("$SUDO mkdir -p \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"\n"); + // Don't `rm -rf` the whole creds dir; on our device image the creds dir is also the + // service StateDirectory parent (e.g. /var/lib/toes-matter), and deleting it can race + // with the running service. for file in [ "dac.der", @@ -558,18 +598,19 @@ fn build_remote_install_script(opts: &Options) -> Result { ); script.push_str(&format!( - "base64 -d <<'{marker}' | sudo tee \"$REMOTE_CREDS_DIR/{file}\" >/dev/null\n" + "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"); + // Leave creds/state readable only by root by default. + 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\ + $SUDO systemctl restart {service}\n\ else\n\ echo 'Service {service_display} not found; skipping restart'\n\ fi\n",