This commit is contained in:
Pat Nakajima 2026-05-19 06:54:25 +00:00
parent 50dd6a459a
commit 6c37043638
4 changed files with 16 additions and 144 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/.vscode
/target
/Cargo.lock
manufacturing/

View File

@ -46,7 +46,7 @@ cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --
user@device-host 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/creds`, 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:
@ -58,7 +58,7 @@ Set these if your service uses different paths:
```bash
cargo run --no-default-features --manifest-path ~/apps/toes-matter/Cargo.toml --bin toes-matter-credentials -- \
--remote-creds-dir /var/lib/my-app/creds \
--remote-creds-dir /var/lib/my-app \
--remote-state-dir /var/lib/my-app/state \
--service my-app.service \
user@device-host device-001

View File

@ -13,7 +13,7 @@ set -euo pipefail
#
# 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
# REMOTE_CREDS_DIR Remote creds dir. Default: /var/lib/toes-matter
# 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.
@ -46,7 +46,7 @@ else
fi
CREDS_DIR="${CREDS_DIR:-$CRATE_DIR/manufacturing/$DEVICE_ID/creds}"
REMOTE_CREDS_DIR="${REMOTE_CREDS_DIR:-/var/lib/toes-matter/creds}"
REMOTE_CREDS_DIR="${REMOTE_CREDS_DIR:-/var/lib/toes-matter}"
REMOTE_STATE_DIR="${REMOTE_STATE_DIR:-/var/lib/toes-matter/state}"
SERVICE="${SERVICE-toes-matter.service}"

View File

@ -1,6 +1,5 @@
#![recursion_limit = "256"]
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
@ -17,7 +16,9 @@ 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";
// These defaults should match the on-device image built by `toes-image-builder`.
// See: `toes-image-builder/matter/toes-matter.service`.
const DEFAULT_REMOTE_CREDS_DIR: &str = "/var/lib/toes-matter";
const DEFAULT_REMOTE_STATE_DIR: &str = "/var/lib/toes-matter/state";
const DEFAULT_SERVICE: &str = "toes-matter.service";
const DEFAULT_BAUD: u32 = 115_200;
@ -38,18 +39,16 @@ fn main() -> Result<()> {
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!("==> No 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() {
if 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);
@ -60,14 +59,12 @@ fn main() -> Result<()> {
#[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>,
@ -80,7 +77,6 @@ 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);
@ -93,7 +89,6 @@ impl Options {
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())
@ -131,7 +126,6 @@ impl Options {
"--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" => {
@ -146,25 +140,15 @@ impl Options {
}
_ if arg.starts_with('-') => return Err(format!("unknown option: {arg}").into()),
_ => {
if target.is_none() {
target = Some(arg);
} else if device_id.is_none() {
if device_id.is_none() {
device_id = Some(arg);
} else {
return Err("too many positional arguments".into());
return Err("unexpected positional argument".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")
@ -173,14 +157,12 @@ impl Options {
});
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,
@ -193,16 +175,15 @@ impl Options {
fn print_usage() {
eprintln!(
"Usage: toes-matter-credentials [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\
"Usage: toes-matter-credentials [OPTIONS] [device-id]\n\n\
Generates Matter setup data, prints the QR/manual code, and optionally installs it onto a Linux device over a 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\
--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\
--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\
@ -227,13 +208,6 @@ fn default_device_id() -> String {
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,
@ -648,109 +622,6 @@ fn base64_wrapped(data: &[u8]) -> String {
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('\'');