oops wrong one
This commit is contained in:
parent
16f20dbc2e
commit
e1e6c99f08
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -2394,7 +2394,7 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
|||
|
||||
[[package]]
|
||||
name = "toes-matter"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"embassy-futures",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "toes-matter"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
readme = "README.md"
|
||||
|
|
@ -11,7 +11,7 @@ name = "toes-matter"
|
|||
required-features = ["device"]
|
||||
|
||||
[[bin]]
|
||||
name = "toes-matter-provision"
|
||||
name = "toes-matter-credentials"
|
||||
|
||||
[[example]]
|
||||
name = "basic"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
[project]
|
||||
name = "toes-matter"
|
||||
auto-tag = true # detect Cargo.toml version, create/push git tag v<version> before releasing to git
|
||||
# binary = "toes-matter" # defaults to project name
|
||||
binary = "toes-matter" # defaults to project name
|
||||
# package = "toes-matter" # optional workspace package override; auto-detected from binary when unique
|
||||
# binaries = ["toes-matter", "toes-matter-cli"] # optional extra release assets
|
||||
repo = "nakajima/toes-matter"
|
||||
|
|
|
|||
17
src/bin/toes-matter-credentials.rs
Normal file
17
src/bin/toes-matter-credentials.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#![recursion_limit = "256"]
|
||||
|
||||
fn main() -> toes_matter::Result<()> {
|
||||
env_logger::init_from_env(
|
||||
env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"),
|
||||
);
|
||||
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let dir = args.next().unwrap_or_else(|| "./creds".into());
|
||||
|
||||
if args.next().is_some() {
|
||||
eprintln!("Usage: toes-matter-creds [CREDS_DIR]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
futures_lite::future::block_on(toes_matter::generate_credentials(dir))
|
||||
}
|
||||
|
|
@ -1,766 +0,0 @@
|
|||
#![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,17 +1,766 @@
|
|||
#![recursion_limit = "256"]
|
||||
|
||||
fn main() -> toes_matter::Result<()> {
|
||||
env_logger::init_from_env(
|
||||
env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"),
|
||||
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 args = std::env::args_os().skip(1);
|
||||
let dir = args.next().unwrap_or_else(|| "./creds".into());
|
||||
let mut qr_buf = [0_u8; 512];
|
||||
let (qr_text, _) = payload.as_str(&mut qr_buf)?;
|
||||
|
||||
if args.next().is_some() {
|
||||
eprintln!("Usage: toes-matter-creds [CREDS_DIR]");
|
||||
std::process::exit(2);
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
futures_lite::future::block_on(toes_matter::generate_credentials(dir))
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user