Simplify credentials provisioning to serial + match toes-image-builder paths

This commit is contained in:
Pat Nakajima 2026-05-19 15:51:41 +00:00
parent da0e01d437
commit db793d49d9
3 changed files with 67 additions and 49 deletions

View File

@ -39,21 +39,16 @@ fn main() -> toes_matter::Result<()> {
## Post-flash headless provisioning ## 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 ```bash
cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-credentials -- \ 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. 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: Set these if your service uses different paths:
```bash ```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-creds-dir /var/lib/my-app \
--remote-state-dir /var/lib/my-app/state \ --remote-state-dir /var/lib/my-app/state \
--service my-app.service \ --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 \ --serial /dev/cu.usbserial-0001 \
--device-id device-001 --device-id device-001
``` ```

View File

@ -4,9 +4,6 @@ set -euo pipefail
# Compatibility wrapper for the Rust host provisioning command. # Compatibility wrapper for the Rust host provisioning command.
# Works from Linux/macOS as long as cargo is available. # 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 usage:
# SERIAL_PORT=/dev/cu.usbserial-0001 toes-matter/scripts/provision-device.sh [device-id] # 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] # 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 # REMOTE_STATE_DIR Remote Matter state dir. Default: /var/lib/toes-matter/state
# SERVICE Optional service to restart. Default: toes-matter.service # SERVICE Optional service to restart. Default: toes-matter.service
# Set SERVICE= to skip restart. # 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_PORT Serial device, e.g. /dev/cu.usbserial-0001 or /dev/tty.usbserial-0001
# SERIAL_LOGIN Optional serial login username # SERIAL_LOGIN Optional serial login username
# SERIAL_PASSWORD Optional serial login password # SERIAL_PASSWORD Optional serial login password
@ -25,26 +21,19 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CRATE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" CRATE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
if [[ -n "${SERIAL_PORT:-}" ]]; then if [[ -z "${SERIAL_PORT:-}" ]]; then
if [[ $# -gt 1 ]]; then echo "Error: set SERIAL_PORT=/dev/cu.* (or /dev/tty.*)" >&2
echo "Usage with SERIAL_PORT: SERIAL_PORT=/dev/cu.* $0 [device-id]" >&2
exit 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 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}" CREDS_DIR="${CREDS_DIR:-$CRATE_DIR/manufacturing/$DEVICE_ID/creds}"
REMOTE_CREDS_DIR="${REMOTE_CREDS_DIR:-/var/lib/toes-matter}" REMOTE_CREDS_DIR="${REMOTE_CREDS_DIR:-/var/lib/toes-matter}"
REMOTE_STATE_DIR="${REMOTE_STATE_DIR:-/var/lib/toes-matter/state}" REMOTE_STATE_DIR="${REMOTE_STATE_DIR:-/var/lib/toes-matter/state}"

View File

@ -208,7 +208,6 @@ fn default_device_id() -> String {
format!("device-{secs}") format!("device-{secs}")
} }
struct GeneratedCredentials { struct GeneratedCredentials {
manual_code: String, manual_code: String,
qr_code: String, qr_code: String,
@ -371,7 +370,8 @@ fn write_to_serial(port: &str, opts: &Options) -> Result<()> {
.read(true) .read(true)
.write(true) .write(true)
.open(port)?; .open(port)?;
serial.write_all(b"\n")?; // Many serial consoles expect CRLF for Enter.
serial.write_all(b"\r\n")?;
serial.flush()?; serial.flush()?;
if opts.serial_login.is_some() { if opts.serial_login.is_some() {
@ -380,12 +380,24 @@ fn write_to_serial(port: &str, opts: &Options) -> Result<()> {
for line in script.lines() { for line in script.lines() {
serial.write_all(line.as_bytes())?; serial.write_all(line.as_bytes())?;
serial.write_all(b"\n")?; serial.write_all(b"\r\n")?;
serial.flush()?; serial.flush()?;
std::thread::sleep(opts.serial_delay); 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(()) 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")?; .ok_or("serial login requested without a username")?;
println!("==> Waiting for serial login prompt or shell prompt..."); println!("==> Waiting for serial login prompt or shell prompt...");
serial.write_all(b"\n")?; serial.write_all(b"\r\n")?;
serial.flush()?; serial.flush()?;
let mut initial_needles = vec![b"login:".to_vec(), b"Login:".to_vec()]; 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}"); println!("==> Sending serial login username: {login}");
serial.write_all(login.as_bytes())?; serial.write_all(login.as_bytes())?;
serial.write_all(b"\n")?; serial.write_all(b"\r\n")?;
serial.flush()?; serial.flush()?;
let mut after_user_needles = vec![b"Password:".to_vec(), b"password:".to_vec()]; 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"); println!("==> Sending serial login password");
serial.write_all(password.as_bytes())?; serial.write_all(password.as_bytes())?;
serial.write_all(b"\n")?; serial.write_all(b"\r\n")?;
serial.flush()?; serial.flush()?;
let prompt_needles = opts let prompt_needles = opts
@ -534,14 +546,42 @@ fn find_needle(haystack: &[u8], needles: &[Vec<u8>]) -> Option<usize> {
fn build_remote_install_script(opts: &Options) -> Result<String> { fn build_remote_install_script(opts: &Options) -> Result<String> {
let mut script = String::new(); 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("set -e\n");
script.push_str(&format!( script.push_str(&format!(
"REMOTE_CREDS_DIR={}\nREMOTE_STATE_DIR={}\n", "REMOTE_CREDS_DIR={}\nREMOTE_STATE_DIR={}\n",
shell_quote(&opts.remote_creds_dir), shell_quote(&opts.remote_creds_dir),
shell_quote(&opts.remote_state_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 [ for file in [
"dac.der", "dac.der",
@ -558,18 +598,19 @@ fn build_remote_install_script(opts: &Options) -> Result<String> {
); );
script.push_str(&format!( 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(&base64_wrapped(&data));
script.push_str(&format!("\n{marker}\n")); 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() { if let Some(service) = opts.service.as_deref() {
script.push_str(&format!( 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\ "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\ else\n\
echo 'Service {service_display} not found; skipping restart'\n\ echo 'Service {service_display} not found; skipping restart'\n\
fi\n", fi\n",