From bc0c1b85ca29fea38653df2c01b4c96c21dd15a4 Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 19 Jul 2026 20:19:43 +0200 Subject: [PATCH] improve ssh-cli interaction surface - better CLI parameter parsing - an actual help message - a new command to list currently known locks --- ssh-cli/Cargo.lock | 140 +++++-------------------- ssh-cli/Cargo.toml | 5 +- ssh-cli/src/api_client.rs | 78 ++++++++++++++ ssh-cli/src/cli.rs | 104 ++++++++++++++++++ ssh-cli/src/main.rs | 214 ++++++++++++++++++++------------------ 5 files changed, 323 insertions(+), 218 deletions(-) create mode 100644 ssh-cli/src/api_client.rs create mode 100644 ssh-cli/src/cli.rs diff --git a/ssh-cli/Cargo.lock b/ssh-cli/Cargo.lock index 228d277..1ccaee5 100644 --- a/ssh-cli/Cargo.lock +++ b/ssh-cli/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -46,21 +31,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - [[package]] name = "base64" version = "0.22.1" @@ -129,33 +99,6 @@ dependencies = [ "cc", ] -[[package]] -name = "color-eyre" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" -dependencies = [ - "backtrace", - "color-spantrace", - "eyre", - "indenter", - "once_cell", - "owo-colors", - "tracing-error", -] - -[[package]] -name = "color-spantrace" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" -dependencies = [ - "once_cell", - "owo-colors", - "tracing-core", - "tracing-error", -] - [[package]] name = "combine" version = "4.6.7" @@ -277,7 +220,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -286,12 +228,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" -[[package]] -name = "futures-io" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" - [[package]] name = "futures-sink" version = "0.3.33" @@ -311,10 +247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", - "futures-io", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -346,12 +279,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "h2" version = "0.4.15" @@ -720,15 +647,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - [[package]] name = "mio" version = "1.2.2" @@ -749,15 +667,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -770,12 +679,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -919,9 +822,7 @@ dependencies = [ "base64", "bytes", "encoding_rs", - "futures-channel", "futures-core", - "futures-util", "h2", "http", "http-body", @@ -966,12 +867,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - [[package]] name = "rustc-hash" version = "2.1.3" @@ -1189,6 +1084,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple-eyre" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b561532e8ffe7ecf09108c4f662896a9ec3eac4999eba84015ec3dcb8cc630a" +dependencies = [ + "eyre", + "indenter", +] + [[package]] name = "slab" version = "0.4.12" @@ -1215,10 +1120,11 @@ dependencies = [ name = "ssh-cli" version = "0.1.0" dependencies = [ - "color-eyre", "eyre", "reqwest", "serde", + "simple-eyre", + "tokio", "tracing", "tracing-subscriber", ] @@ -1352,9 +1258,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -1455,16 +1373,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-error" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" -dependencies = [ - "tracing", - "tracing-subscriber", -] - [[package]] name = "tracing-log" version = "0.2.0" diff --git a/ssh-cli/Cargo.toml b/ssh-cli/Cargo.toml index 15136fe..8c6edfc 100644 --- a/ssh-cli/Cargo.toml +++ b/ssh-cli/Cargo.toml @@ -4,9 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] -color-eyre = "0.6.5" eyre = "0.6.12" -reqwest = { version = "0.13.4", features = ["blocking", "json"] } +reqwest = { version = "0.13.4", features = ["json"] } serde = { version = "1.0.228", features = ["derive"] } +simple-eyre = "0.3.1" +tokio = { version = "1.53.0", features = ["macros", "rt"] } tracing = "0.1.44" tracing-subscriber = "0.3.23" diff --git a/ssh-cli/src/api_client.rs b/ssh-cli/src/api_client.rs new file mode 100644 index 0000000..5e118fc --- /dev/null +++ b/ssh-cli/src/api_client.rs @@ -0,0 +1,78 @@ +use eyre::Context; +use eyre::eyre; +use reqwest::Url; +use reqwest::header::HeaderValue; + +use crate::api_models::ApiLock; +use crate::api_models::DesiredLockState; +use crate::api_models::LockOperationRequest; + +/// Client for interacting with the dooris API +pub struct ApiClient { + client: reqwest::Client, + authorization: HeaderValue, + server_url: Url, +} + +impl ApiClient { + pub fn new_from_env() -> eyre::Result { + let client = reqwest::Client::builder() + .user_agent("dooris-ssh-cli") + .redirect(reqwest::redirect::Policy::none()) + .http1_title_case_headers() + .build() + .expect("Could not construct http client even though all values are statically known to be valid"); + + let api_token = std::env::var("DOORIS_API_TOKEN").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_API_TOKEN environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?; + let api_token = format!("Static-Token {api_token}") + .try_into() + .context("Could not convert HTTP API Token to a valid HTTP header")?; + + let server_url = std::env::var("DOORIS_SERVER_URL").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_SERVER_URL environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?; + let server_url = Url::parse(&server_url).context("Configured DOORIS_SERVER_URL is not a valid URL. Contact your nearest server maid to fix the dooris setup")?; + + tracing::debug!("Client is connected to dooris at {server_url}"); + Ok(Self { + client, + authorization: api_token, + server_url, + }) + } + + /// Fetch the current list of locks along with their state from the API + pub async fn fetch_locks(&self) -> eyre::Result> { + tracing::debug!("Fetching locks from dooris API"); + let url = self.server_url.join("api/locks/")?; + self.client + .get(url) + .header(reqwest::header::AUTHORIZATION, self.authorization.clone()) + .send() + .await? + .error_for_status() + .context("Could not get list of locks from the dooris API")? + .json() + .await + .context("Could not parse list-locks response from dooris API") + } + + /// Put the lock identified by `lock_id` into the given desired state. + /// + /// Returns as soon as the operation has been handed of to the backend and does not wait for physical lock movements. + pub async fn operate_lock( + &self, + lock_id: &str, + desired_state: DesiredLockState, + ) -> eyre::Result<()> { + tracing::debug!("Calling API to put {lock_id:?} into state {desired_state:?}"); + let url = self.server_url.join(&format!("api/locks/{lock_id}"))?; + self.client + .patch(url) + .header(reqwest::header::AUTHORIZATION, self.authorization.clone()) + .json(&LockOperationRequest { desired_state }) + .send() + .await? + .error_for_status() + .context("Could not operate lock")?; + Ok(()) + } +} diff --git a/ssh-cli/src/cli.rs b/ssh-cli/src/cli.rs new file mode 100644 index 0000000..6a5b17b --- /dev/null +++ b/ssh-cli/src/cli.rs @@ -0,0 +1,104 @@ +use eyre::{OptionExt, eyre}; +use std::env::VarError; + +/// CLI parameters given to this program +#[derive(Debug, Eq, PartialEq)] +pub enum CliArgs { + /// The user requested a help message + Help, + /// The user requested to lock a specific lock + Lock { + /// The name of the lock that should be locked + lock_name: String, + /// Whether the CLI should wait until the lock operation is not just started but until it is completely finished + wait_until_done: bool, + }, + /// The user requested to unlock a specific lock + Unlock { + /// The name of the lock that should be unlocked + lock_name: String, + /// Whether the CLI should wait until the unlock operation is not just started but until it is completely finished + wait_until_done: bool, + }, + /// The user wants to see a list of currently connected locks as well as their status + ShowLocks, +} + +/// Get passed arguments from SSH_ORIGINAL_COMMAND environment variable but falling back to direct CLI args +fn get_arg_string() -> eyre::Result { + match std::env::var("SSH_ORIGINAL_COMMAND") { + Ok(original_command) => { + tracing::trace!( + SSH_ORIGINAL_COMMAND = ?original_command, + "Taking CLI arguments from SSH_ORIGINAL_COMMAND environment variable" + ); + Ok(original_command) + } + Err(VarError::NotUnicode(_)) => { + Err(eyre!("Your command is not very unicoded. Refusing >~<")) + } + Err(VarError::NotPresent) => { + let args = std::env::args().skip(1).collect::>().join(" "); + tracing::trace!( + argv = ?args, + "Taking CLI arguments from argv" + ); + Ok(args) + } + } +} + +impl CliArgs { + pub fn parse() -> eyre::Result { + let raw_args = get_arg_string()?; + let raw_args_list = raw_args.split_whitespace().collect::>(); + + if raw_args_list.len() == 0 + || raw_args_list[0] == "help" + || raw_args_list[0] == "-h" + || raw_args_list[0] == "--help" + { + Ok(CliArgs::Help) + } else if raw_args_list[0] == "unlock" { + let lock_name = raw_args_list.get(1).ok_or_eyre( + "Unlock cli needs a lock name as parameter. See \"help\" for usage information.", + )?; + let wait_until_done = match *raw_args_list.get(2).unwrap_or(&"wait") { + "wait" => true, + "nowait" => false, + _ => { + return Err(eyre!( + "Third unlock parameter needs to be either \"wait\" or \"nowait\". See \"help\" for usage information." + )); + } + }; + Ok(CliArgs::Unlock { + lock_name: lock_name.to_string(), + wait_until_done, + }) + } else if raw_args_list[0] == "lock" { + let lock_name = raw_args_list.get(1).ok_or_eyre( + "Lock cli needs a lock name as parameter. See \"help\" for usage information.", + )?; + let wait_until_done = match *raw_args_list.get(2).unwrap_or(&"wait") { + "wait" => true, + "nowait" => false, + _ => { + return Err(eyre!( + "Third lock parameter needs to be either \"wait\" or \"nowait\". See \"help\" for usage information." + )); + } + }; + Ok(CliArgs::Lock { + lock_name: lock_name.to_string(), + wait_until_done, + }) + } else if raw_args_list[0] == "show-locks" { + Ok(CliArgs::ShowLocks) + } else { + Err(eyre!( + "Unknown commandline {raw_args:?}. See \"help\" for usage information." + )) + } + } +} diff --git a/ssh-cli/src/main.rs b/ssh-cli/src/main.rs index 9c8ed62..b4690ee 100644 --- a/ssh-cli/src/main.rs +++ b/ssh-cli/src/main.rs @@ -9,114 +9,128 @@ //! SetEnv DOORIS_SERVER_URL=https://dooris.ccchh.net/ DOORIS_API_TOKEN=foobar123 //! ``` +mod api_client; mod api_models; +mod cli; -use eyre::{Context, OptionExt, eyre}; -use reqwest::{Url, header::HeaderValue}; -use std::{env::VarError, process::exit}; +use std::process::exit; -use crate::api_models::LockOperationRequest; +use eyre::eyre; -fn main() -> eyre::Result<()> { +use crate::{ + api_client::ApiClient, + api_models::{ApiLock, DesiredLockState}, +}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> eyre::Result<()> { tracing_subscriber::fmt::init(); - color_eyre::install()?; + simple_eyre::install()?; - let doors = fetch_doors()?; - let args = get_args()?; - let args = args.split(" ").collect::>(); + let args = cli::CliArgs::parse()?; + tracing::debug!(cli = ?args, "Parsed CLI parameters"); - if args.len() == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "" { - println!("Help is not yet available. Come back later >~<"); - exit(3); - } else if args[0] == "lock" || args[0] == "unlock" { - let cli_target_door = args - .get(1) - .ok_or_eyre("'unlock' and 'lock' cli requires the door name as second argument")?; - - let api_target_door = doors - .iter() - .find(|i_door| i_door.name.eq_ignore_ascii_case(cli_target_door)) - .ok_or({ - let door_names = doors - .iter() - .map(|i_door| i_door.name.to_owned()) - .collect::>(); - eyre!( - "No door named {cli_target_door:?} exists. Available doors are {door_names:?}.", - ) - })?; - - println!( - "Lock {} ({:?}) identified!", - api_target_door.id, api_target_door.name - ); - operate_lock( - &api_target_door.id, - match args[0] { - "lock" => api_models::DesiredLockState::CLOSED, - "unlock" => api_models::DesiredLockState::OPEN, - _ => unreachable!(), - }, - )?; - println!("Lock has been turned. Enjoy :3"); - return Ok(()); - } else { - println!("Unknown commandline. See --help for more info."); - exit(2); - } -} - -/// Get passed arguments from SSH_ORIGINAL_COMMAND environment variable but falling back to direct CLI args -fn get_args() -> eyre::Result { - match std::env::var("SSH_ORIGINAL_COMMAND") { - Ok(original_command) => Ok(original_command), - Err(VarError::NotUnicode(_)) => { - Err(eyre!("Your command is not very unicoded. Refusing >~<")) + match args { + cli::CliArgs::Help => { + println!( + "dooris-over-ssh: Your personal door assistant with an extra serving of chaos" + ); + println!( + "Usage: ssh dooris@dooris.ccchh.net help | lock [wait|nowait] | unlock [wait|nowait]" + ); + println!(); + println!("Commands:"); + println!(); + println!(" help -> The help command prints this message. Hello 👋"); + println!(""); + println!(" lock [wait|nowait] -> Locks the door identified with ."); + println!( + " Also takes an additional parameter of \"wait\" or \"nowait\"which dictates" + ); + println!(" whether the CLI exit as soon as the lock operation is queued or whether"); + println!(" it waits until the lock status changes to open."); + println!(" Defaults to \"wait\" if not specified."); + println!(); + println!(" unlock [wait|nowait] -> Unlocks the door identified with ."); + println!( + " Also takes an additional parameter of \"wait\" or \"nowait\"which dictates" + ); + println!(" whether the CLI exit as soon as the lock operation is queued or whether"); + println!(" it waits until the lock status changes to closed."); + println!(" Defaults to \"wait\" if not specified."); + exit(0); } - Err(VarError::NotPresent) => Ok(std::env::args().skip(1).collect::>().join(" ")), - } + cli::CliArgs::Lock { + lock_name, + wait_until_done, + } => { + let api = ApiClient::new_from_env()?; + let lock = find_lock(&api, &lock_name).await?; + + println!("dooris at your service, locking {} now…", lock.name); + api.operate_lock(&lock.id, DesiredLockState::CLOSED).await?; + + if wait_until_done { + tracing::warn!( + "wait feature is not yet implemented. lock should be turning now >~<" + ); + } + + exit(0); + } + cli::CliArgs::Unlock { + lock_name, + wait_until_done, + } => { + let api = ApiClient::new_from_env()?; + let lock = find_lock(&api, &lock_name).await?; + + println!("dooris at your service, locking {} now…", lock.name); + api.operate_lock(&lock.id, DesiredLockState::OPEN).await?; + + if wait_until_done { + tracing::warn!( + "wait feature is not yet implemented. lock should be turning now >~<" + ); + } + + exit(0); + } + cli::CliArgs::ShowLocks => { + let api = ApiClient::new_from_env()?; + let locks = api.fetch_locks().await?; + + for i_lock in locks.iter() { + println!(">> {}", i_lock.name); + if i_lock.status.is_unreachable { + println!(" !!! Lock is currently unreachable !!!"); + } + if i_lock.status.is_low_batteray { + println!(" !!! Lock has low battery !!!"); + } + if i_lock.status.is_error_jammed { + println!(" !!! Lock is jammed !!!"); + } + println!(" State: {}", i_lock.status.lock_state); + println!(" Desired State: {}", i_lock.status.lock_target_level); + println!(" Acitivty: {}", i_lock.status.activity_state); + println!(); + } + + exit(0); + } + }; } -fn http_client() -> eyre::Result { - return reqwest::blocking::ClientBuilder::new() - .user_agent("dooris-ssh-cli") - .redirect(reqwest::redirect::Policy::none()) - .http1_title_case_headers() - .build() - .context("Could not construct http client even though all values are statically known to be valid"); -} - -fn api_token() -> eyre::Result { - let server_token = std::env::var("DOORIS_API_TOKEN").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_API_TOKEN environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?; - format!("Static-Token {server_token}") - .try_into() - .context("Could not convert HTTP API Token to a valid HTTP header") -} - -fn server_url() -> eyre::Result { - let server_url = std::env::var("DOORIS_SERVER_URL").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_SERVER_URL environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?; - Url::parse(&server_url).context("Configured DOORIS_SERVER_URL is not a valid URL. Contact your nearest server maid to fix the dooris setup") -} - -fn fetch_doors() -> eyre::Result> { - http_client()? - .get(server_url()?.join("api/locks/")?) - .header(reqwest::header::AUTHORIZATION, api_token()?) - .send()? - .error_for_status() - .context("Could not get list of locks from the dooris API")? - .json() - .context("Could not parse list-locks response from dooris API") -} - -fn operate_lock(lock_id: &str, desired_state: api_models::DesiredLockState) -> eyre::Result<()> { - println!("Turning Lock, please stand by…"); - http_client()? - .patch(server_url()?.join(&format!("api/locks/{lock_id}"))?) - .header(reqwest::header::AUTHORIZATION, api_token()?) - .json(&LockOperationRequest { desired_state }) - .send()? - .error_for_status() - .context("Could not operate lock")?; - Ok(()) +async fn find_lock(api: &ApiClient, name: &str) -> eyre::Result { + let locks = api.fetch_locks().await?; + tracing::debug!("Comparing locks from API for one with name {name:?}"); + let lock = locks + .into_iter() + .find(|i_lock| i_lock.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| { + eyre!("No lock named {name:?} exists. See \"list-locks\" for a list of valid locks.") + })?; + tracing::debug!(lock = ?lock,"Identified lock with name matching {name:?}"); + Ok(lock) }