cli + user api

This commit is contained in:
JMARyA 2025-05-02 12:53:28 +02:00
parent 46cf2f4572
commit b010027549
12 changed files with 504 additions and 25 deletions

204
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -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<DeviceEntry>,
}
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<T: Serialize> {
pub ok: Option<T>,
pub err: Option<String>,
}
impl<T: Serialize> Result<T> {
#[allow(non_snake_case)]
pub fn OkVal(val: T) -> Self {
Self {
ok: Some(val),
err: None,
}
}
pub fn as_result(self) -> std::result::Result<T, String> {
if let Some(ok) = self.ok {
Ok(ok)
} else {
Err(self.err.unwrap())
}
}
}
impl Result<i32> {
#[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()),
}
}
}

View file

@ -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);

View file

@ -7,7 +7,7 @@ use owl::{Serialize, get, query};
use rumqttc::AsyncClient;
use sage::PersonaIdentity;
fn is_within_80_seconds(time: chrono::DateTime<chrono::Utc>) -> bool {
pub fn is_within_80_seconds(time: chrono::DateTime<chrono::Utc>) -> bool {
let now = chrono::Utc::now();
now.signed_duration_since(time).num_seconds() <= 80
}

View file

@ -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<Authorization<Bearer>>,
) -> (StatusCode, Json<api::Result<DeviceList>>) {
let machines: Vec<Model<Machine>> = 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<LoginParam>) -> (StatusCode, Json<serde_json::Value>) {
@ -21,9 +57,15 @@ pub async fn login_user(Json(payload): Json<LoginParam>) -> (StatusCode, Json<se
let u = User::find(&payload.username).await.unwrap();
if u.read().verify_pw(&payload.password) {
let ses = u.read().session().await;
(StatusCode::OK, Json(json!({"token": ses.read().token})))
(
StatusCode::OK,
Json(json!(api::Result::OkVal(ses.read().token.as_str()))),
)
} else {
(StatusCode::FORBIDDEN, Json(json!({"error": "invalid"})))
(
StatusCode::FORBIDDEN,
Json(json!(api::Result::Err("invalid"))),
)
}
}

2
src/lib.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod api;
pub use api::*;

18
src/sheepctl.rs Normal file
View file

@ -0,0 +1,18 @@
use sheepctl_core::{
args::{DeviceCommands, SheepctlArgs, SheepctlCommand},
cmd::{list_devices, login},
};
mod api;
mod sheepctl_core;
fn main() {
let args: SheepctlArgs = argh::from_env();
match args.command {
SheepctlCommand::Device(device_command) => match device_command.command {
DeviceCommands::List(list_devices_command) => list_devices(list_devices_command),
},
SheepctlCommand::Login(login_command) => login(login_command),
}
}

51
src/sheepctl_core/args.rs Normal file
View file

@ -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,
}

105
src/sheepctl_core/cmd.rs Normal file
View file

@ -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<T: Serialize + for<'a> Deserialize<'a>, I: Serialize>(
server: &str,
path: &str,
data: I,
) -> crate::api::Result<T> {
let url = format!("{}/{path}", domain(server));
let mut res = ureq::post(url).send_json(data).unwrap();
let res: crate::api::Result<T> = res.body_mut().read_json().unwrap();
res
}
pub fn api_call_get<T: Serialize + for<'a> Deserialize<'a>>(
server: &str,
path: &str,
token: &str,
) -> crate::api::Result<T> {
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<T> = res.body_mut().read_json().unwrap();
res
}
fn get_config_path() -> Option<PathBuf> {
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<Self> {
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::<DeviceList>(&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::<String, _>(
&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");
}
}

2
src/sheepctl_core/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod args;
pub mod cmd;

View file

@ -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) {