diff --git a/Cargo.lock b/Cargo.lock index f3718eb..ebeaf3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ares-novacom" +version = "0.1.0" +dependencies = [ + "clap", + "common-connection", + "common-device", +] + [[package]] name = "ares-package" version = "0.1.4" @@ -151,6 +160,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "ares-setup-device" +version = "0.1.0" +dependencies = [ + "clap", + "common-device", + "serde_json", +] + [[package]] name = "ares-shell" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 9886650..10527d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ members = [ "ares-launch", "ares-device", "ares-shell", + "ares-setup-device", + "ares-novacom", ] [workspace.package] diff --git a/ares-novacom/Cargo.toml b/ares-novacom/Cargo.toml new file mode 100644 index 0000000..45e1cb6 --- /dev/null +++ b/ares-novacom/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ares-novacom" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "Low-level device transport helpers (SSH key retrieval)" + +[lints] +workspace = true + +[dependencies] +common-device = { path = "../common/device" } +common-connection = { path = "../common/connection" } +clap = { workspace = true, features = ["derive", "env"] } + +[package.metadata.deb] +section = "devel" diff --git a/ares-novacom/src/main.rs b/ares-novacom/src/main.rs new file mode 100644 index 0000000..6f2ef78 --- /dev/null +++ b/ares-novacom/src/main.rs @@ -0,0 +1,110 @@ +use std::path::Path; +use std::process::exit; + +use ares_connection_lib::DeviceSetupManager; +use ares_device_lib::{DeviceManager, PrivateKey}; +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(about)] +struct Cli { + #[arg( + short, + long, + value_name = "DEVICE", + env = "ARES_DEVICE", + help = "Specify DEVICE to use" + )] + device: Option, + #[arg( + short = 'k', + long, + help = "Fetch the SSH private key (webos_rsa) from the device" + )] + getkey: bool, + #[arg( + long, + value_name = "PASSPHRASE", + help = "Passphrase for the device's SSH key (the code shown in Developer Mode)" + )] + passphrase: Option, +} + +fn main() { + let cli = Cli::parse(); + let manager = DeviceManager::default(); + + if cli.getkey { + get_key( + &manager, + cli.device.as_deref(), + cli.passphrase.as_deref().unwrap_or(""), + ); + } else { + Cli::parse_from(["", "--help"]); + } +} + +fn get_key(manager: &DeviceManager, device: Option<&str>, passphrase: &str) { + let Some(device) = unwrap_or_exit(manager.find_or_default(device.as_ref()), "find device") + else { + eprintln!("Device not found"); + exit(1); + }; + + println!("Fetching key from {}...", device.host); + let content = unwrap_or_exit( + manager.novacom_getkey(&device.host, passphrase), + "fetch key", + ); + + let key_name = key_file_name(&device.name); + let key_dir = unwrap_or_exit(manager.ssh_key_dir(), "resolve ssh directory"); + let key_path = key_dir.join(&key_name); + unwrap_or_exit(write_key(&key_path, &content), "save key"); + + // Wire the fetched key into the device config (webOS dev mode is + // prisoner@:9922 with a passphrase-protected key). + let mut updated = device.clone(); + updated.private_key = Some(PrivateKey::Name { + name: key_name.clone(), + }); + updated.passphrase = (!passphrase.is_empty()).then(|| passphrase.to_string()); + updated.password = None; + updated.username = String::from("prisoner"); + updated.port = 9922; + unwrap_or_exit(manager.modify(&device.name, &updated), "update device"); + + println!( + "Saved key to {} and updated device {}.", + key_path.display(), + device.name + ); +} + +/// Builds the local key filename for a device, matching the repo convention +/// of a `webos_` prefix (so `ares-setup-device --remove` can clean it up). +fn key_file_name(device_name: &str) -> String { + let sanitized: String = device_name + .chars() + .map(|c| if c.is_whitespace() { '_' } else { c }) + .collect(); + format!("webos_{sanitized}") +} + +fn write_key(path: &Path, content: &str) -> Result<(), std::io::Error> { + std::fs::write(path, content)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +fn unwrap_or_exit(result: Result, action: &str) -> T { + result.unwrap_or_else(|e| { + eprintln!("Failed to {action}: {e}"); + exit(1); + }) +} diff --git a/ares-setup-device/Cargo.toml b/ares-setup-device/Cargo.toml new file mode 100644 index 0000000..020472b --- /dev/null +++ b/ares-setup-device/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ares-setup-device" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "Manage the list of webOS devices" + +[lints] +workspace = true + +[dependencies] +common-device = { path = "../common/device" } +clap = { workspace = true, features = ["derive", "env"] } +serde_json = { workspace = true } + +[package.metadata.deb] +section = "devel" diff --git a/ares-setup-device/src/info.rs b/ares-setup-device/src/info.rs new file mode 100644 index 0000000..669ed57 --- /dev/null +++ b/ares-setup-device/src/info.rs @@ -0,0 +1,244 @@ +use std::net::Ipv4Addr; + +use ares_device_lib::Device; +use serde_json::{Map, Value, json}; + +/// Builds a brand-new device from the `--add` name and parsed `--info` fields, +/// filling in sensible defaults for anything the user did not provide. +pub(crate) fn build_device(name: &str, info: &Map) -> Result { + let mut map = Map::new(); + map.insert(String::from("profile"), json!("ose")); + map.insert(String::from("port"), json!(9922)); + map.insert(String::from("username"), json!("root")); + map.insert(String::from("files"), json!("sftp")); + for (key, value) in info { + map.insert(key.clone(), value.clone()); + } + map.insert(String::from("name"), json!(name)); + + if !map.contains_key("host") { + return Err(String::from("Missing required field: host")); + } + apply_auth(&mut map, info); + validate(&map)?; + deserialize(map) +} + +/// Applies parsed `--info` changes on top of an existing device (for `--modify`). +pub(crate) fn modified_device( + existing: &Device, + info: &Map, +) -> Result { + let Ok(Value::Object(mut map)) = serde_json::to_value(existing) else { + return Err(String::from("Failed to read existing device")); + }; + for (key, value) in info { + map.insert(key.clone(), value.clone()); + } + apply_auth(&mut map, info); + validate(&map)?; + deserialize(map) +} + +/// Parses `--info` arguments, accepting either a single JSON object string or +/// repeated `key=value` pairs, and normalizes them to device field names. +pub(crate) fn parse_info(info: &[String]) -> Result, String> { + if let [single] = info + && single.trim_start().starts_with('{') + { + // Allow single-quoted JSON for shell friendliness. + let value: Value = serde_json::from_str(&single.replace('\'', "\"")) + .map_err(|e| format!("Invalid JSON in --info: {e}"))?; + let Value::Object(obj) = value else { + return Err(String::from("--info JSON must be an object")); + }; + let mut map = Map::new(); + for (key, value) in obj { + insert_field(&mut map, &key, &value)?; + } + return Ok(map); + } + + let mut map = Map::new(); + for item in info { + let (key, value) = item + .split_once('=') + .ok_or_else(|| format!("Invalid --info '{item}', expected key=value"))?; + insert_field( + &mut map, + key.trim(), + &Value::String(value.trim().to_string()), + )?; + } + Ok(map) +} + +/// Maps an input key (and its raw value) onto the device's JSON field shape. +fn insert_field(map: &mut Map, key: &str, value: &Value) -> Result<(), String> { + let text = || value.as_str().map(str::to_string).unwrap_or_default(); + match key { + "host" | "ipAddress" => { + map.insert(String::from("host"), json!(text())); + } + "port" => { + let port = match value { + Value::Number(n) => n.as_u64(), + Value::String(s) => s.parse::().ok(), + _ => None, + } + .ok_or_else(|| String::from("port must be a number"))?; + map.insert(String::from("port"), json!(port)); + } + "username" | "user" => { + map.insert(String::from("username"), json!(text())); + } + "profile" => { + map.insert(String::from("profile"), json!(text())); + } + "description" => { + map.insert(String::from("description"), json!(text())); + } + "password" => { + map.insert(String::from("password"), json!(text())); + } + "passphrase" => { + map.insert(String::from("passphrase"), json!(text())); + } + "privateKey" | "openSsh" => { + map.insert(String::from("privateKey"), json!({ "openSsh": text() })); + } + "openSshPath" | "keyPath" => { + map.insert(String::from("privateKey"), json!({ "openSshPath": text() })); + } + "files" => { + map.insert(String::from("files"), json!(text())); + } + "default" => { + let flag = matches!(value, Value::Bool(true)) + || value + .as_str() + .is_some_and(|s| s.eq_ignore_ascii_case("true")); + map.insert(String::from("default"), json!(flag)); + } + other => return Err(format!("Unknown --info field: {other}")), + } + Ok(()) +} + +/// Ensures only one authentication method is stored, based on what the user +/// explicitly provided: a password clears any key, and a key clears a password. +fn apply_auth(map: &mut Map, info: &Map) { + let has_key = info.contains_key("privateKey"); + let has_password = info.contains_key("password"); + if has_key { + map.remove("password"); + } else if has_password { + map.remove("privateKey"); + map.remove("passphrase"); + } +} + +fn validate(map: &Map) -> Result<(), String> { + let name = map + .get("name") + .and_then(Value::as_str) + .ok_or("Device name is required")?; + validate_name(name)?; + + let host = map + .get("host") + .and_then(Value::as_str) + .ok_or("Device host is required")?; + validate_host(host)?; + + validate_port(map.get("port"))?; + Ok(()) +} + +fn validate_name(name: &str) -> Result<(), String> { + match name.chars().next() { + None => Err(String::from("Device name must not be empty")), + Some('$' | '%') => Err(String::from("Device name must not start with '$' or '%'")), + Some(_) => Ok(()), + } +} + +fn validate_host(host: &str) -> Result<(), String> { + if host == "localhost" || host.parse::().is_ok() { + Ok(()) + } else { + Err(format!( + "Invalid host '{host}': expected 'localhost' or an IPv4 address" + )) + } +} + +fn validate_port(port: Option<&Value>) -> Result<(), String> { + let number = match port { + Some(Value::Number(n)) => n.as_u64(), + Some(Value::String(s)) => s.parse::().ok(), + _ => None, + } + .ok_or("Device port is required")?; + if (1..=65535).contains(&number) { + Ok(()) + } else { + Err(format!( + "Invalid port {number}: must be between 1 and 65535" + )) + } +} + +fn deserialize(map: Map) -> Result { + serde_json::from_value(Value::Object(map)).map_err(|e| format!("Invalid device info: {e}")) +} + +#[cfg(test)] +mod tests { + use super::{build_device, parse_info}; + + fn info(args: &[&str]) -> serde_json::Map { + parse_info(&args.iter().map(ToString::to_string).collect::>()).unwrap() + } + + #[test] + fn parses_key_value_with_aliases() { + let map = info(&["ipAddress=1.2.3.4", "user=root", "port=9922"]); + assert_eq!(map["host"], "1.2.3.4"); + assert_eq!(map["username"], "root"); + assert_eq!(map["port"], 9922); + } + + #[test] + fn parses_json_object() { + let map = info(&["{'host':'1.2.3.4','port':9922}"]); + assert_eq!(map["host"], "1.2.3.4"); + assert_eq!(map["port"], 9922); + } + + #[test] + fn add_fills_defaults_and_requires_host() { + let device = build_device("tv", &info(&["host=1.2.3.4"])).unwrap(); + assert_eq!(device.username, "root"); + assert_eq!(device.port, 9922); + assert_eq!(device.profile, "ose"); + + assert!(build_device("tv", &info(&["username=root"])).is_err()); + } + + #[test] + fn rejects_invalid_host_and_port() { + assert!(build_device("tv", &info(&["host=999.1.1.1"])).is_err()); + assert!(build_device("tv", &info(&["host=1.2.3.4", "port=70000"])).is_err()); + } + + #[test] + fn modifying_to_password_clears_existing_key() { + let with_key = build_device("tv", &info(&["host=1.2.3.4", "openSsh=k"])).unwrap(); + assert!(with_key.private_key.is_some()); + + let with_password = super::modified_device(&with_key, &info(&["password=p"])).unwrap(); + assert!(with_password.private_key.is_none()); + assert_eq!(with_password.password.as_deref(), Some("p")); + } +} diff --git a/ares-setup-device/src/main.rs b/ares-setup-device/src/main.rs new file mode 100644 index 0000000..59037f2 --- /dev/null +++ b/ares-setup-device/src/main.rs @@ -0,0 +1,140 @@ +use std::process::exit; + +use ares_device_lib::DeviceManager; +use clap::Parser; + +mod info; +mod output; + +use info::{build_device, modified_device, parse_info}; +use output::{print_list, print_list_full}; + +#[derive(Parser, Debug)] +#[command(about)] +struct Cli { + #[arg(short = 'l', long, group = "action", help = "List the devices")] + list: bool, + #[arg( + short = 'F', + long = "listfull", + group = "action", + help = "List the devices with detailed information" + )] + list_full: bool, + #[arg( + short = 'a', + long, + value_name = "NAME", + group = "action", + help = "Add a device with NAME (use --info to provide details)" + )] + add: Option, + #[arg( + short = 'm', + long, + value_name = "NAME", + group = "action", + help = "Modify the device with NAME (use --info to provide changes)" + )] + modify: Option, + #[arg( + short = 'r', + long, + value_name = "NAME", + group = "action", + help = "Remove the device with NAME" + )] + remove: Option, + #[arg( + short = 'f', + long, + value_name = "NAME", + group = "action", + help = "Set the device with NAME as default" + )] + default: Option, + #[arg( + short = 'R', + long, + group = "action", + help = "Reset the device list to the default" + )] + reset: bool, + #[arg( + short = 'i', + long, + value_name = "INFO", + help = "Device details as JSON or key=value (repeatable) for --add/--modify" + )] + info: Vec, +} + +fn main() { + let cli = Cli::parse(); + let manager = DeviceManager::default(); + + if cli.list { + print_devices(&manager, false); + } else if cli.list_full { + print_devices(&manager, true); + } else if let Some(name) = &cli.add { + run_add(&manager, name, &cli.info); + } else if let Some(name) = &cli.modify { + run_modify(&manager, name, &cli.info); + } else if let Some(name) = &cli.remove { + unwrap_or_exit(manager.remove(name, true), "remove device"); + print_devices(&manager, false); + } else if let Some(name) = &cli.default { + unwrap_or_exit(manager.set_default(name), "set default device"); + print_devices(&manager, false); + } else if cli.reset { + unwrap_or_exit(manager.reset(), "reset devices"); + print_devices(&manager, false); + } else { + Cli::parse_from(["", "--help"]); + } +} + +fn run_add(manager: &DeviceManager, name: &str, info: &[String]) { + let info = unwrap_or_exit(parse_info(info).map_err(into_error), "parse --info"); + let device = unwrap_or_exit( + build_device(name, &info).map_err(into_error), + "build device", + ); + unwrap_or_exit(manager.add(&device), "add device"); + print_devices(manager, false); +} + +fn run_modify(manager: &DeviceManager, name: &str, info: &[String]) { + let info = unwrap_or_exit(parse_info(info).map_err(into_error), "parse --info"); + let Some(existing) = unwrap_or_exit(manager.find_or_default(Some(&name)), "find device") else { + eprintln!("Device {name} not found"); + exit(1); + }; + let device = unwrap_or_exit( + modified_device(&existing, &info).map_err(into_error), + "build device", + ); + unwrap_or_exit(manager.modify(name, &device), "modify device"); + print_devices(manager, false); +} + +fn print_devices(manager: &DeviceManager, full: bool) { + let devices = unwrap_or_exit(manager.list(), "list devices"); + if full { + print_list_full(&devices); + } else { + print_list(&devices); + } +} + +fn into_error(message: String) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidInput, message) +} + +fn unwrap_or_exit(result: Result, action: &str) -> T { + result.unwrap_or_else(|e| { + eprintln!("Failed to {action}: {e}"); + exit(1); + }) +} diff --git a/ares-setup-device/src/output.rs b/ares-setup-device/src/output.rs new file mode 100644 index 0000000..5a0387a --- /dev/null +++ b/ares-setup-device/src/output.rs @@ -0,0 +1,101 @@ +use std::fmt::Write; + +use ares_device_lib::Device; +use serde_json::Value; + +/// Prints the device list as a padded table, mirroring the reference CLI: +/// name (with a `(default)` marker), deviceinfo, connection, profile, passphrase. +pub(crate) fn print_list(devices: &[Device]) { + let headers = ["name", "deviceinfo", "connection", "profile", "passphrase"]; + let rows: Vec<[String; 5]> = devices.iter().map(device_row).collect(); + print_table(&headers, &rows); +} + +/// Prints every field of every device, like the reference CLI's `--listfull`. +pub(crate) fn print_list_full(devices: &[Device]) { + for device in devices { + println!("name : {}", device.name); + if let Ok(Value::Object(mut obj)) = serde_json::to_value(device) { + obj.remove("name"); + print!("{}", convert_json_to_list(&Value::Object(obj), 0)); + } + println!(); + } +} + +fn device_row(device: &Device) -> [String; 5] { + let name = if device.default == Some(true) { + format!("{} (default)", device.name) + } else { + device.name.clone() + }; + [ + name, + format!("{}@{}:{}", device.username, device.host, device.port), + String::from("ssh"), + device.profile.clone(), + device.passphrase.clone().unwrap_or_default(), + ] +} + +fn print_table(headers: &[&str; N], rows: &[[String; N]]) { + let mut widths = headers.map(str::len); + for row in rows { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.len()); + } + } + + let print_row = |cells: &[String; N]| { + let line: Vec = cells + .iter() + .enumerate() + .map(|(i, cell)| format!("{cell: String { + let prefix = "-".repeat(level); + let mut out = String::new(); + match value { + Value::String(s) => { + let _ = writeln!(out, "{prefix}{s}"); + } + Value::Array(arr) if !arr.is_empty() => { + for item in arr { + out.push_str(&convert_json_to_list(item, level)); + } + } + Value::Object(map) => { + for (key, val) in map { + if val.is_object() || val.is_array() || val.is_null() { + let _ = writeln!(out, "{prefix}{key}"); + out.push_str(&convert_json_to_list(val, level + 1)); + } else { + let _ = writeln!(out, "{prefix}{key} : {}", scalar_to_string(val)); + } + } + } + _ => {} + } + out +} + +fn scalar_to_string(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} diff --git a/common/device/src/manager.rs b/common/device/src/manager.rs index 5538f51..e2bb5ec 100644 --- a/common/device/src/manager.rs +++ b/common/device/src/manager.rs @@ -1,15 +1,55 @@ use std::fs::remove_file; use std::io::{Error, ErrorKind}; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::io::{ensure_ssh_dir, read, write}; use crate::{Device, DeviceManager, PrivateKey}; +/// The bundled default device list, restored by [`DeviceManager::reset`]. +const DEFAULT_DEVICES_JSON: &str = r#"[ + { + "order": "0", + "default": true, + "profile": "ose", + "name": "emulator", + "description": "LG webOS Emulator", + "host": "127.0.0.1", + "port": 6622, + "username": "developer", + "privateKey": { "openSsh": "webos_emul" }, + "files": "sftp", + "noPortForwarding": false, + "indelible": true + } +]"#; + +/// Converts an absolute private-key path into a name relative to the ssh dir, +/// so it is stored portably in the device list. +fn normalize_private_key(device: &mut Device) -> Result<(), Error> { + if let Some(PrivateKey::Path { path }) = &device.private_key { + let path = Path::new(path); + if path.is_absolute() { + let name = String::from( + pathdiff::diff_paths(path, ensure_ssh_dir()?) + .ok_or(Error::from(ErrorKind::NotFound))? + .to_string_lossy(), + ); + device.private_key = Some(PrivateKey::Name { name }); + } + } + Ok(()) +} + impl DeviceManager { pub fn list(&self) -> Result, Error> { read() } + /// Returns the directory where SSH keys are stored (creating it if needed). + pub fn ssh_key_dir(&self) -> Result { + ensure_ssh_dir() + } + pub fn find_or_default>( &self, name: Option<&S>, @@ -45,26 +85,55 @@ impl DeviceManager { pub fn add(&self, device: &Device) -> Result { let mut device = device.clone(); - if let Some(PrivateKey::Path { path }) = &device.private_key { - let path = Path::new(path); - if path.is_absolute() { - let name = String::from( - pathdiff::diff_paths(path, ensure_ssh_dir()?) - .ok_or(Error::from(ErrorKind::NotFound))? - .to_string_lossy(), - ); - device.private_key = Some(PrivateKey::Name { name }); - } + normalize_private_key(&mut device)?; + let mut devices = read()?; + if devices.iter().any(|d| d.name == device.name) { + return Err(Error::new( + ErrorKind::AlreadyExists, + format!("Device {} already exists", device.name), + )); } log::info!("Save device {}", device.name); - let mut devices = read()?; devices.push(device.clone()); write(&devices)?; Ok(device) } + /// Replaces an existing device (matched by `name`) with `device`, keeping + /// its position in the list. The replacement may itself carry a new name. + pub fn modify(&self, name: &str, device: &Device) -> Result { + let mut device = device.clone(); + normalize_private_key(&mut device)?; + let mut devices = read()?; + let index = devices + .iter() + .position(|d| d.name == name) + .ok_or_else(|| Error::new(ErrorKind::NotFound, format!("Device {name} not found")))?; + log::info!("Modify device {name}"); + devices[index] = device.clone(); + write(&devices)?; + Ok(device) + } + + /// Restores the device list to the bundled default emulator entry. + pub fn reset(&self) -> Result<(), Error> { + let devices: Vec = serde_json::from_str(DEFAULT_DEVICES_JSON) + .map_err(|e| Error::new(ErrorKind::InvalidData, e))?; + write(&devices)?; + Ok(()) + } + pub fn remove(&self, name: &str, remove_key: bool) -> Result<(), Error> { let devices = read()?; + if devices + .iter() + .any(|d| d.name == name && d.indelible.unwrap_or(false)) + { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!("Device {name} can't be removed"), + )); + } let (will_delete, mut will_keep): (Vec, Vec) = devices.into_iter().partition(|d| d.name == name); let mut need_new_default = false;