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

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