add script for provisioning
This commit is contained in:
parent
e2a3d6e266
commit
1062d5ba7b
56
Cargo.toml
56
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 }
|
||||
|
|
|
|||
59
README.md
59
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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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/<device-id>/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[@]}"
|
||||
|
|
|
|||
766
src/bin/toes-matter-provision.rs
Normal file
766
src/bin/toes-matter-provision.rs
Normal file
|
|
@ -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<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
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<String>,
|
||||
serial: Option<String>,
|
||||
device_id: String,
|
||||
creds_dir: PathBuf,
|
||||
remote_creds_dir: String,
|
||||
remote_state_dir: String,
|
||||
service: Option<String>,
|
||||
ssh_opts: Vec<String>,
|
||||
baud: u32,
|
||||
serial_delay: Duration,
|
||||
serial_login: Option<String>,
|
||||
serial_password: Option<String>,
|
||||
serial_prompts: Vec<String>,
|
||||
serial_login_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
fn parse() -> Result<Self> {
|
||||
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<Item = String>, option: &str) -> Result<String> {
|
||||
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<String> {
|
||||
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<GeneratedCredentials> {
|
||||
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<Option<BasicCommData>> {
|
||||
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<u32> {
|
||||
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::<Vec<_>>();
|
||||
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<u8>],
|
||||
timeout: Duration,
|
||||
) -> Result<usize> {
|
||||
let start = std::time::Instant::now();
|
||||
let mut seen = Vec::<u8>::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<u8>]) -> Option<usize> {
|
||||
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<String> {
|
||||
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::<Vec<_>>();
|
||||
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<Item = OsString>) -> 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
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
#![cfg(feature = "device")]
|
||||
#![recursion_limit = "256"]
|
||||
#![allow(async_fn_in_trait)]
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user