diff --git a/Cargo.lock b/Cargo.lock index 0ae4b24..8f376d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,22 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1403,6 +1419,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -1530,6 +1556,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1605,6 +1640,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.30.1" @@ -2133,7 +2179,7 @@ dependencies = [ "if-addrs", "libc", "log", - "nix", + "nix 0.30.1", "num", "num-derive", "num-traits", @@ -2340,6 +2386,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2599,6 +2663,7 @@ dependencies = [ "rand", "rs-matter", "rs-matter-stack", + "serialport", "static_cell", ] @@ -2700,6 +2765,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2893,6 +2967,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index d31d337..8c4a823 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ log = { version = "0.4", optional = true } rand = { version = "0.8", features = ["std", "std_rng"] } rs-matter = { version = "0.1", default-features = false } rs-matter-stack = { git = "https://git.fishmt.net/nakajima/rs-matter-stack", branch = "master", default-features = false, optional = true } +serialport = { version = "4.9.0", default-features = false } static_cell = { version = "2.1", optional = true } [patch.crates-io] diff --git a/README.md b/README.md index 475d844..9c65c37 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml -- --device-id device-001 ``` -If the serial console is already at a shell prompt, omit login flags. If it is at `login:`, provide credentials: +The tool waits for either a shell prompt or `login:` before sending the install script. If the device 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-credentials -- \ @@ -71,7 +71,7 @@ cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml -- --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>'`. +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 it sees `login:` and no login was provided, it exits with an instruction to set `--login` / `SERIAL_LOGIN`. If your prompt is unusual, add `--prompt 'my-prompt>'`. The wrapper script supports serial via environment too: diff --git a/scripts/provision-device.sh b/scripts/provision-device.sh index 88ec9ca..d1dd03a 100755 --- a/scripts/provision-device.sh +++ b/scripts/provision-device.sh @@ -8,6 +8,9 @@ set -euo pipefail # 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] # +# The tool waits for either a shell prompt or login:. If it sees login:, set +# SERIAL_LOGIN and, if required by the image, SERIAL_PASSWORD. +# # Environment: # CREDS_DIR Local output dir. Default: toes-matter/manufacturing//creds # REMOTE_CREDS_DIR Remote creds dir. Default: /var/lib/toes-matter diff --git a/src/bin/toes-matter-credentials.rs b/src/bin/toes-matter-credentials.rs index 9e899c6..84cc6f2 100644 --- a/src/bin/toes-matter-credentials.rs +++ b/src/bin/toes-matter-credentials.rs @@ -1,6 +1,6 @@ #![recursion_limit = "256"] -use std::io::{Read, Write}; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -188,7 +188,7 @@ fn print_usage() { --service NAME Service to restart [default: {DEFAULT_SERVICE}]\n\ --no-restart Do not restart the service after provisioning\n\ --device-id ID Device id (if not provided positionally)\n\ - --serial PORT Write install script to a serial shell [env: SERIAL_PORT]\n\ + --serial PORT Install credentials through 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\ @@ -351,19 +351,17 @@ fn render_qr(qr_code: &str) { } fn write_to_serial(port: &str, opts: &Options) -> Result<()> { - configure_serial(port, opts.baud)?; - - let script = build_remote_install_script(opts)?; + let commands = build_remote_install_commands(opts)?; println!(); println!( - "==> Writing install script to serial port {port} at {} baud", + "==> Writing install commands to serial port {port} at {} baud", opts.baud ); if opts.serial_login.is_some() { - println!(" Will log in on the serial console first."); + println!(" Will wait for the serial console and log in if needed."); } else { - println!(" This assumes the serial console is already at a shell prompt."); + println!(" Will wait for a shell prompt. If the device is at login:, set SERIAL_LOGIN."); } if cfg!(target_os = "macos") && port.starts_with("/dev/tty.") { println!( @@ -371,81 +369,43 @@ fn write_to_serial(port: &str, opts: &Options) -> Result<()> { ); } - let mut serial = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(port)?; + let mut serial = open_serial(port, opts.baud)?; + // Many serial consoles expect CRLF for Enter. serial.write_all(b"\r\n")?; serial.flush()?; - if opts.serial_login.is_some() { - login_to_serial(&mut serial, opts)?; + ensure_serial_shell(serial.as_mut(), opts)?; + + println!("==> Sending serial install commands..."); + for command in commands { + send_serial_command(serial.as_mut(), opts, &command)?; } - for line in script.lines() { - serial.write_all(line.as_bytes())?; - serial.write_all(b"\r\n")?; - serial.flush()?; - std::thread::sleep(opts.serial_delay); - } - - 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(()) } -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" - }; +fn open_serial(port: &str, baud: u32) -> Result> { + let mut serial = serialport::new(port, baud) + .data_bits(serialport::DataBits::Eight) + .parity(serialport::Parity::None) + .stop_bits(serialport::StopBits::One) + .flow_control(serialport::FlowControl::None) + .timeout(Duration::from_millis(100)) + .open() + .map_err(|err| format!("failed to open serial port {port}: {err}"))?; - 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()?; + // Some USB serial consoles do not present input until DTR is asserted. + let _ = serial.write_data_terminal_ready(true); + let _ = serial.write_request_to_send(true); + let _ = serial.clear(serialport::ClearBuffer::All); - if status.success() { - Ok(()) - } else { - Err(format!("stty failed for {port} with status {status}").into()) - } + Ok(serial) } -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")?; - +fn ensure_serial_shell(serial: &mut dyn serialport::SerialPort, opts: &Options) -> Result<()> { println!("==> Waiting for serial login prompt or shell prompt..."); serial.write_all(b"\r\n")?; serial.flush()?; @@ -460,10 +420,15 @@ fn login_to_serial(serial: &mut std::fs::File, opts: &Options) -> Result<()> { 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"); + println!("==> Serial console is at a shell prompt"); return Ok(()); } + let login = opts + .serial_login + .as_deref() + .ok_or("serial login prompt detected; pass --login USER or set SERIAL_LOGIN=USER")?; + println!("==> Sending serial login username: {login}"); serial.write_all(login.as_bytes())?; serial.write_all(b"\r\n")?; @@ -502,8 +467,29 @@ fn login_to_serial(serial: &mut std::fs::File, opts: &Options) -> Result<()> { Ok(()) } +fn send_serial_command( + serial: &mut dyn serialport::SerialPort, + opts: &Options, + command: &str, +) -> Result<()> { + serial.write_all(command.as_bytes())?; + serial.write_all(b"\r\n")?; + serial.flush()?; + std::thread::sleep(opts.serial_delay); + + let prompt_needles = opts + .serial_prompts + .iter() + .map(|prompt| prompt.as_bytes().to_vec()) + .collect::>(); + + wait_for_any(serial, &prompt_needles, Duration::from_secs(60))?; + + Ok(()) +} + fn wait_for_any( - serial: &mut std::fs::File, + serial: &mut dyn serialport::SerialPort, needles: &[Vec], timeout: Duration, ) -> Result { @@ -530,7 +516,11 @@ fn wait_for_any( return Ok(index); } } - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::TimedOut + ) => {} Err(err) => return Err(err.into()), } } @@ -548,42 +538,35 @@ fn find_needle(haystack: &[u8], needles: &[Vec]) -> Option { }) } -fn build_remote_install_script(opts: &Options) -> Result { - let mut script = String::new(); +fn build_remote_install_commands(opts: &Options) -> Result> { + let mut commands = Vec::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), + // Keep commands single-line so each one can be sent and acknowledged at an + // interactive shell prompt. Multi-line here-docs are fragile over serial + // consoles because a failed parse turns payload data into shell commands. + commands.push("set -e".into()); + commands.push("umask 077".into()); + commands.push(format!( + "REMOTE_CREDS_DIR={}", + shell_quote(&opts.remote_creds_dir) + )); + commands.push(format!( + "REMOTE_STATE_DIR={}", + shell_quote(&opts.remote_state_dir) )); // 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", - ); + // - Otherwise, fail early with a clear message. + commands.push(format!( + "if [ \"$(id -u)\" -eq 0 ]; then SUDO=\"\"; elif command -v sudo >/dev/null 2>&1; then if sudo -n true >/dev/null 2>&1; then SUDO=\"sudo\"; else echo {} >&2; exit 1; fi; else echo {} >&2; exit 1; fi", + shell_quote("ERROR: sudo requires a password. Either provision using a root shell, or configure passwordless sudo for this user."), + shell_quote("ERROR: sudo not found and not running as root."), + )); // Create directories up-front. - script.push_str("$SUDO mkdir -p \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"\n"); + commands.push("$SUDO mkdir -p \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"".into()); // 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. @@ -597,36 +580,39 @@ fn build_remote_install_script(opts: &Options) -> Result { ] { 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" + commands.push(format!( + "tmp_b64=$(mktemp \"${{TMPDIR:-/tmp}}/toes-matter-{file}.b64.XXXXXX\")" )); - script.push_str(&base64_wrapped(&data)); - script.push_str(&format!("\n{marker}\n")); + commands.push(format!( + "tmp_out=$(mktemp \"${{TMPDIR:-/tmp}}/toes-matter-{file}.decoded.XXXXXX\")" + )); + + for chunk in base64_wrapped(&data).lines() { + commands.push(format!("printf %s {} >> \"$tmp_b64\"", shell_quote(chunk))); + } + + commands.push("base64 -d < \"$tmp_b64\" > \"$tmp_out\"".into()); + commands.push(format!( + "$SUDO tee \"$REMOTE_CREDS_DIR/{file}\" < \"$tmp_out\" >/dev/null" + )); + commands.push("rm -f \"$tmp_b64\" \"$tmp_out\"".into()); } // Leave creds/state readable only by root by default. - script.push_str("$SUDO chmod -R go-rwx \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"\n"); + commands.push("$SUDO chmod -R go-rwx \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"".into()); 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", + commands.push(format!( + "if command -v systemctl >/dev/null 2>&1 && (systemctl list-unit-files {service} >/dev/null 2>&1 || systemctl status {service} >/dev/null 2>&1); then $SUDO systemctl restart {service}; else echo {message}; fi", service = shell_quote(service), - service_display = service, + message = shell_quote(&format!("Service {service} not found; skipping restart")), )); } - script.push_str("echo 'toes-matter serial provisioning complete'\n"); + commands.push("echo 'toes-matter serial provisioning complete'".into()); - Ok(script) + Ok(commands) } fn base64_wrapped(data: &[u8]) -> String {