diff --git a/Cargo.lock b/Cargo.lock index 37a67f5..6fa03a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,6 +397,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-macros" version = "0.5.0" @@ -831,6 +854,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -968,6 +1016,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -994,6 +1063,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + [[package]] name = "either" version = "1.15.0" @@ -1344,6 +1419,24 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generator" version = "0.7.5" @@ -1496,6 +1589,30 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core", + "http 1.3.1", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.3.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -1967,6 +2084,23 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.9.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "intl-memoizer" version = "0.5.2" @@ -2232,6 +2366,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.3" @@ -2279,6 +2425,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nom" version = "7.1.3" @@ -2433,6 +2588,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "overload" version = "0.1.1" @@ -2854,6 +3015,17 @@ dependencies = [ "bitflags 2.9.0", ] +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.12", +] + [[package]] name = "ref-cast" version = "1.0.24" @@ -3581,11 +3753,14 @@ dependencies = [ "argh", "axum", "axum-client-ip", + "axum-extra", "based", "chrono", "dashmap", + "directories", "hex", "http2", + "inquire", "log", "owl", "rand 0.9.1", @@ -3607,6 +3782,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" @@ -4116,7 +4312,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 1.0.3", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -4461,6 +4657,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 9b0f66c..6be1032 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,10 +3,18 @@ name = "sheepd" version = "0.1.0" edition = "2024" +[lib] +name = "sheepd" +path = "src/lib.rs" + [[bin]] name = "sheepd" path = "src/sheepd.rs" +[[bin]] +name = "sheepctl" +path = "src/sheepctl.rs" + [[bin]] name = "herd" path = "src/herd.rs" @@ -38,3 +46,6 @@ sage = { git = "https://git.hydrar.de/jmarya/sage" } dashmap = "6.1.0" ulid = { version = "1.2.1", features = ["serde"] } chrono = "0.4.41" +directories = "6.0.0" +inquire = "0.7.5" +axum-extra = { version = "0.10.1", features = ["typed-header"] } diff --git a/src/api.rs b/src/api.rs index 773401f..63c61ba 100644 --- a/src/api.rs +++ b/src/api.rs @@ -3,6 +3,20 @@ use rumqttc::{AsyncClient, Event, EventLoop, MqttOptions, Packet, Transport}; use std::time::Duration; use tokio::time::sleep; +pub fn domain(host: &str) -> String { + if host.starts_with("http") { + return host.to_string(); + } else { + format!("https://{host}") + } +} + +#[derive(Deserialize, Serialize)] +pub struct LoginParam { + pub username: String, + pub password: String, +} + #[derive(Deserialize, Serialize)] /// Join Request pub struct JoinParams { @@ -89,20 +103,57 @@ where } #[derive(Deserialize, Serialize)] -/// Generic JSON API result -pub struct Result { - pub ok: u32, +pub struct DeviceList { + pub devices: Vec, } -impl Result { +#[derive(Deserialize, Serialize)] +pub struct DeviceEntry { + pub id: String, + pub hostname: String, + pub online: bool, +} + +#[derive(Deserialize, Serialize)] +/// Generic JSON API result +pub struct Result { + pub ok: Option, + pub err: Option, +} + +impl Result { + #[allow(non_snake_case)] + pub fn OkVal(val: T) -> Self { + Self { + ok: Some(val), + err: None, + } + } + + pub fn as_result(self) -> std::result::Result { + if let Some(ok) = self.ok { + Ok(ok) + } else { + Err(self.err.unwrap()) + } + } +} + +impl Result { #[allow(non_snake_case)] pub fn Ok() -> Self { - Self { ok: 1 } + Self { + ok: Some(1), + err: None, + } } #[allow(non_snake_case)] - pub fn Err() -> Self { - Self { ok: 0 } + pub fn Err(msg: &str) -> Self { + Self { + ok: None, + err: Some(msg.to_string()), + } } } diff --git a/src/herd.rs b/src/herd.rs index e2891cb..ab14824 100644 --- a/src/herd.rs +++ b/src/herd.rs @@ -11,11 +11,11 @@ use std::{net::SocketAddr, path::PathBuf}; mod api; mod herd_core; use crate::herd_core::mqtt::{handle_mqtt, listen_to_devices}; -use herd_core::model::Machine; use herd_core::{ config::Config, route::{join_device, login_user}, }; +use herd_core::{model::Machine, route::devices_list}; use sage::Identity; use tokio::sync::OnceCell; @@ -59,7 +59,9 @@ async fn main() { .layer(ClientIpSource::ConnectInfo.into_extension()); // Direct IP // .layer(ClientIpSource::XRealIp.into_extension()) // Proxy - let user = Router::new().route("/login", post(login_user)); + let user = Router::new() + .route("/login", post(login_user)) + .route("/devices", get(devices_list)); let app = Router::new().merge(device).merge(user); diff --git a/src/herd_core/mqtt.rs b/src/herd_core/mqtt.rs index 2b6323e..b309b4e 100644 --- a/src/herd_core/mqtt.rs +++ b/src/herd_core/mqtt.rs @@ -7,7 +7,7 @@ use owl::{Serialize, get, query}; use rumqttc::AsyncClient; use sage::PersonaIdentity; -fn is_within_80_seconds(time: chrono::DateTime) -> bool { +pub fn is_within_80_seconds(time: chrono::DateTime) -> bool { let now = chrono::Utc::now(); now.signed_duration_since(time).num_seconds() <= 80 } diff --git a/src/herd_core/route.rs b/src/herd_core/route.rs index 60bd1c6..93ad334 100644 --- a/src/herd_core/route.rs +++ b/src/herd_core/route.rs @@ -1,19 +1,55 @@ +use std::ops::Deref; + use crate::api; use crate::api::JoinResponse; +use crate::api::LoginParam; use crate::herd_core::model::Machine; use axum::Json; +use axum::extract::FromRequestParts; use axum::http::StatusCode; use axum_client_ip::ClientIp; +use axum_extra::TypedHeader; +use axum_extra::headers::Authorization; +use axum_extra::headers::authorization::Bearer; use based::auth::Sessions; use based::auth::User; +use owl::prelude::Model; +use owl::query; use owl::save; use serde::Deserialize; use serde_json::json; +use sheepd::DeviceEntry; +use sheepd::DeviceList; -#[derive(Deserialize)] -pub struct LoginParam { - username: String, - password: String, +use super::mqtt::is_within_80_seconds; + +pub async fn devices_list( + session: TypedHeader>, +) -> (StatusCode, Json>) { + let machines: Vec> = query!(|_| true); + + let mut ret = vec![]; + + for mac in machines { + let id = mac.read().id.to_string().replace("-", ""); + let online_state = crate::ONLINE + .get() + .unwrap() + .get(&id) + .map(|x| is_within_80_seconds(*x.deref())) + .unwrap_or(false); + + ret.push(DeviceEntry { + id: id, + hostname: mac.read().hostname.clone(), + online: online_state, + }); + } + + ( + StatusCode::OK, + Json(api::Result::OkVal(DeviceList { devices: ret })), + ) } pub async fn login_user(Json(payload): Json) -> (StatusCode, Json) { @@ -21,9 +57,15 @@ pub async fn login_user(Json(payload): Json) -> (StatusCode, Json match device_command.command { + DeviceCommands::List(list_devices_command) => list_devices(list_devices_command), + }, + SheepctlCommand::Login(login_command) => login(login_command), + } +} diff --git a/src/sheepctl_core/args.rs b/src/sheepctl_core/args.rs new file mode 100644 index 0000000..f9affdf --- /dev/null +++ b/src/sheepctl_core/args.rs @@ -0,0 +1,51 @@ +use argh::FromArgs; + +#[derive(FromArgs)] +/// Control your herd +pub struct SheepctlArgs { + #[argh(subcommand)] + pub command: SheepctlCommand, +} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand)] +pub enum SheepctlCommand { + Login(LoginCommand), + Device(DeviceCommand), +} + +#[derive(FromArgs, PartialEq, Debug)] +/// Login to a homeserver +#[argh(subcommand, name = "login")] +pub struct LoginCommand { + #[argh(positional)] + /// homeserver + pub home: String, + + #[argh(positional)] + /// username + pub username: String, +} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "device")] +/// Commands for devices +pub struct DeviceCommand { + #[argh(subcommand)] + pub command: DeviceCommands, +} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand)] +pub enum DeviceCommands { + List(ListDevicesCommand), +} + +#[derive(FromArgs, PartialEq, Debug)] +/// List devices +#[argh(subcommand, name = "ls")] +pub struct ListDevicesCommand { + #[argh(switch)] + /// only show online devices + pub online: bool, +} diff --git a/src/sheepctl_core/cmd.rs b/src/sheepctl_core/cmd.rs new file mode 100644 index 0000000..fe4c447 --- /dev/null +++ b/src/sheepctl_core/cmd.rs @@ -0,0 +1,105 @@ +use std::path::PathBuf; + +use owl::{Deserialize, Serialize}; +use sheepd::{DeviceList, LoginParam}; + +use super::args::{ListDevicesCommand, LoginCommand}; +use crate::api::domain; + +pub fn api_call Deserialize<'a>, I: Serialize>( + server: &str, + path: &str, + data: I, +) -> crate::api::Result { + let url = format!("{}/{path}", domain(server)); + let mut res = ureq::post(url).send_json(data).unwrap(); + let res: crate::api::Result = res.body_mut().read_json().unwrap(); + res +} + +pub fn api_call_get Deserialize<'a>>( + server: &str, + path: &str, + token: &str, +) -> crate::api::Result { + let url = format!("{}/{path}", domain(server)); + let mut res = ureq::get(url) + .header("Authorization", format!("Bearer {token}")) + .force_send_body() + .send_empty() + .unwrap(); + let res: crate::api::Result = res.body_mut().read_json().unwrap(); + res +} + +fn get_config_path() -> Option { + directories::ProjectDirs::from("de", "Hydrar", "sheepd") + .map(|proj_dirs| proj_dirs.config_dir().join("config.toml")) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CtlConfig { + pub home: String, + pub token: String, +} + +impl CtlConfig { + pub fn load() -> Option { + let c = std::fs::read_to_string(get_config_path()?).ok()?; + toml::from_str(&c).ok() + } + + pub fn save(&self) { + let s = toml::to_string(self).unwrap(); + let config = get_config_path().unwrap(); + let _ = std::fs::create_dir_all(config.parent().unwrap()); + std::fs::write(get_config_path().unwrap(), s).unwrap(); + } +} + +pub fn list_devices(arg: ListDevicesCommand) { + let conf = CtlConfig::load().unwrap(); + + if let Ok(devices) = api_call_get::(&conf.home, "devices", &conf.token).as_result() + { + println!("Hosts:"); + for d in devices.devices { + println!( + "- {} [{}]{}", + d.hostname, + d.id, + if d.online { " [ONLINE]" } else { "" } + ); + } + } +} + +pub fn login(arg: LoginCommand) { + if let Some(conf) = CtlConfig::load() { + println!("You are already logged in to {}", conf.home); + std::process::exit(1); + } + + let password = inquire::prompt_secret("Password: ").unwrap(); + + // login request + if let Result::Ok(token) = api_call::( + &arg.home, + "login", + LoginParam { + username: arg.username, + password: password, + }, + ) + .as_result() + { + // save token to config + CtlConfig { + home: arg.home, + token, + } + .save(); + } else { + println!("Login failed"); + } +} diff --git a/src/sheepctl_core/mod.rs b/src/sheepctl_core/mod.rs new file mode 100644 index 0000000..a786728 --- /dev/null +++ b/src/sheepctl_core/mod.rs @@ -0,0 +1,2 @@ +pub mod args; +pub mod cmd; diff --git a/src/sheepd_core/cmd.rs b/src/sheepd_core/cmd.rs index 8b1c7ec..02f0a0f 100644 --- a/src/sheepd_core/cmd.rs +++ b/src/sheepd_core/cmd.rs @@ -8,14 +8,7 @@ use crate::{ }; use super::args::JoinCommand; - -fn domain(host: &str) -> String { - if host.starts_with("http") { - return host.to_string(); - } else { - format!("https://{host}") - } -} +use crate::api::domain; /// Join a herd as client pub fn join(conf: JoinCommand) {