Left over changes
This commit is contained in:
parent
7640a235ca
commit
b748ef36d6
7 changed files with 219 additions and 10 deletions
|
|
@ -25,6 +25,7 @@ tokio = { version = "1.50.0", features = [
|
|||
"macros",
|
||||
"rt-multi-thread",
|
||||
"process",
|
||||
"time",
|
||||
] }
|
||||
tapo = "0.9.0"
|
||||
zbus = "5.14.0"
|
||||
|
|
|
|||
12
src/auth.rs
12
src/auth.rs
|
|
@ -24,15 +24,15 @@ use yescrypt::{PasswordHash, PasswordVerifier, Yescrypt};
|
|||
static JWT_SECRET: OnceLock<String> = OnceLock::new();
|
||||
|
||||
const ROTATION_DAYS: u64 = 7;
|
||||
const CREDENTIAL_DIR: &str = "/var/lib/server-dash-api/webauthn-credentials";
|
||||
pub(crate) const CREDENTIAL_DIR: &str = "/var/lib/server-dash-api/webauthn-credentials";
|
||||
const CHALLENGE_TTL: Duration = Duration::from_secs(300);
|
||||
const RP_ID: &str = "jackmechem.dev";
|
||||
const RP_ORIGIN: &str = "https://dashboard.jackmechem.dev";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct StoredCredentials {
|
||||
user_id: Uuid,
|
||||
credentials: Vec<SecurityKey>,
|
||||
pub(crate) struct StoredCredentials {
|
||||
pub(crate) user_id: Uuid,
|
||||
pub(crate) credentials: Vec<SecurityKey>,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
|
|
@ -183,13 +183,13 @@ fn verify_shadow_hash(password: &str, hash: &str) -> bool {
|
|||
.is_ok()
|
||||
}
|
||||
|
||||
fn load_credentials(username: &str) -> Option<StoredCredentials> {
|
||||
pub(crate) fn load_credentials(username: &str) -> Option<StoredCredentials> {
|
||||
let path = PathBuf::from(CREDENTIAL_DIR).join(format!("{}.json", username));
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&data).ok()
|
||||
}
|
||||
|
||||
fn save_credentials(username: &str, creds: &StoredCredentials) -> Result<(), String> {
|
||||
pub(crate) fn save_credentials(username: &str, creds: &StoredCredentials) -> Result<(), String> {
|
||||
let dir = PathBuf::from(CREDENTIAL_DIR);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.json", username));
|
||||
|
|
|
|||
24
src/main.rs
24
src/main.rs
|
|
@ -1,7 +1,9 @@
|
|||
use axum::extract::Extension;
|
||||
use axum::middleware;
|
||||
use axum::response::Redirect;
|
||||
use axum::{Router, routing::get, routing::post};
|
||||
use axum::{Router, routing::delete, routing::get, routing::post};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
|
|
@ -12,7 +14,23 @@ mod routes;
|
|||
async fn main() {
|
||||
let state = Arc::new(auth::AppState::new());
|
||||
|
||||
let power_history: routes::power::PowerHistory =
|
||||
Arc::new(Mutex::new(routes::power::load_history()));
|
||||
|
||||
let bg_history = Arc::clone(&power_history);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(300)).await;
|
||||
routes::power::record_snapshot(&bg_history).await;
|
||||
}
|
||||
});
|
||||
|
||||
let protected = Router::new()
|
||||
.route("/users", get(routes::users::list_users))
|
||||
.route(
|
||||
"/users/{username}/credentials/{cred_id}",
|
||||
delete(routes::users::delete_credential),
|
||||
)
|
||||
.route("/power/{device}/on", post(routes::power::power_on))
|
||||
.route("/power/{device}/off", post(routes::power::power_off))
|
||||
.route(
|
||||
|
|
@ -39,12 +57,14 @@ async fn main() {
|
|||
.route("/", get(|| async { Redirect::permanent("/stats") }))
|
||||
.route("/stats", get(routes::stats::get_stats))
|
||||
.route("/power", get(routes::power::get_power))
|
||||
.route("/power/history", get(routes::power::get_power_history))
|
||||
.route("/auth/login", post(auth::post_login))
|
||||
.route("/auth/verify", post(auth::post_verify))
|
||||
.route("/auth/register/start", post(auth::post_register_start))
|
||||
.route("/auth/register/finish", post(auth::post_register_finish))
|
||||
.merge(protected)
|
||||
.with_state(state);
|
||||
.with_state(state)
|
||||
.layer(Extension(power_history));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001")
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -112,3 +112,18 @@ pub struct TapoPowerResponse {
|
|||
pub timestamp: String,
|
||||
pub devices: Vec<TapoDeviceData>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct PowerDeviceReading {
|
||||
pub name: String,
|
||||
pub watts: f64,
|
||||
pub on: bool,
|
||||
pub today_wh: u64,
|
||||
pub month_wh: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct PowerHistoryEntry {
|
||||
pub ts: String,
|
||||
pub devices: Vec<PowerDeviceReading>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ pub mod power;
|
|||
pub mod services;
|
||||
pub mod stats;
|
||||
pub mod system;
|
||||
pub mod users;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,103 @@
|
|||
use axum::extract::Path;
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tapo::ApiClient;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{config, models};
|
||||
|
||||
pub type PowerHistory = Arc<Mutex<Vec<models::PowerHistoryEntry>>>;
|
||||
|
||||
const HISTORY_FILE: &str = "/var/lib/server-dash-api/power-history.json";
|
||||
const MAX_HISTORY_DAYS: i64 = 60;
|
||||
|
||||
pub fn load_history() -> Vec<models::PowerHistoryEntry> {
|
||||
let path = std::path::Path::new(HISTORY_FILE);
|
||||
if !path.exists() {
|
||||
return vec![];
|
||||
}
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn record_snapshot(history: &PowerHistory) {
|
||||
let username = std::env::var("TAPO_USERNAME").unwrap_or_default();
|
||||
let password = std::env::var("TAPO_PASSWORD").unwrap_or_default();
|
||||
if username.is_empty() || password.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let tasks: Vec<_> = config::TAPO_DEVICES
|
||||
.iter()
|
||||
.map(|(name, ip)| {
|
||||
let username = username.clone();
|
||||
let password = password.clone();
|
||||
let name = name.to_string();
|
||||
let ip = ip.to_string();
|
||||
tokio::spawn(async move { query_device(&username, &password, &name, &ip).await })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut devices = Vec::new();
|
||||
for task in tasks {
|
||||
if let Ok(Ok(data)) = task.await {
|
||||
devices.push(models::PowerDeviceReading {
|
||||
name: data.name,
|
||||
watts: data.current_power_w,
|
||||
on: data.on,
|
||||
today_wh: data.today_energy_wh,
|
||||
month_wh: data.month_energy_wh,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if devices.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entry = models::PowerHistoryEntry {
|
||||
ts: chrono::Utc::now().to_rfc3339(),
|
||||
devices,
|
||||
};
|
||||
|
||||
let cutoff = (chrono::Utc::now() - chrono::Duration::days(MAX_HISTORY_DAYS)).to_rfc3339();
|
||||
|
||||
let mut guard = history.lock().await;
|
||||
guard.push(entry);
|
||||
guard.retain(|e| e.ts >= cutoff);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&*guard) {
|
||||
let _ = std::fs::write(HISTORY_FILE, json);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HistoryQuery {
|
||||
#[serde(default = "default_hours")]
|
||||
pub hours: u32,
|
||||
}
|
||||
fn default_hours() -> u32 {
|
||||
24
|
||||
}
|
||||
|
||||
pub async fn get_power_history(
|
||||
Extension(history): Extension<PowerHistory>,
|
||||
Query(params): Query<HistoryQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let hours = params.hours.max(1).min(24 * 60) as i64;
|
||||
let cutoff = (chrono::Utc::now() - chrono::Duration::hours(hours)).to_rfc3339();
|
||||
|
||||
let guard = history.lock().await;
|
||||
let readings: Vec<&models::PowerHistoryEntry> =
|
||||
guard.iter().filter(|e| e.ts >= cutoff).collect();
|
||||
|
||||
Json(serde_json::json!({ "readings": readings, "hours": hours })).into_response()
|
||||
}
|
||||
|
||||
async fn query_device(
|
||||
username: &str,
|
||||
password: &str,
|
||||
|
|
|
|||
79
src/routes/users.rs
Normal file
79
src/routes/users.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use axum::{extract::Path, http::StatusCode, response::IntoResponse, response::Json};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth::{load_credentials, save_credentials, CREDENTIAL_DIR};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CredentialInfo {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UserInfo {
|
||||
username: String,
|
||||
credentials: Vec<CredentialInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UsersResponse {
|
||||
users: Vec<UserInfo>,
|
||||
}
|
||||
|
||||
pub async fn list_users() -> impl IntoResponse {
|
||||
let dir = std::path::Path::new(CREDENTIAL_DIR);
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Json(UsersResponse { users: vec![] }).into_response();
|
||||
};
|
||||
|
||||
let mut users: Vec<UserInfo> = entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
if path.extension()?.to_str() != Some("json") {
|
||||
return None;
|
||||
}
|
||||
let username = path.file_stem()?.to_str()?.to_string();
|
||||
let stored = load_credentials(&username)?;
|
||||
let credentials = stored
|
||||
.credentials
|
||||
.iter()
|
||||
.map(|c| CredentialInfo {
|
||||
id: general_purpose::URL_SAFE_NO_PAD.encode(c.cred_id()),
|
||||
})
|
||||
.collect();
|
||||
Some(UserInfo { username, credentials })
|
||||
})
|
||||
.collect();
|
||||
|
||||
users.sort_by(|a, b| a.username.cmp(&b.username));
|
||||
Json(UsersResponse { users }).into_response()
|
||||
}
|
||||
|
||||
pub async fn delete_credential(
|
||||
Path((username, cred_id)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(cred_id_bytes) = general_purpose::URL_SAFE_NO_PAD.decode(&cred_id) else {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid credential ID").into_response();
|
||||
};
|
||||
|
||||
let Some(mut stored) = load_credentials(&username) else {
|
||||
return (StatusCode::NOT_FOUND, "User not found").into_response();
|
||||
};
|
||||
|
||||
let original_len = stored.credentials.len();
|
||||
stored
|
||||
.credentials
|
||||
.retain(|c| c.cred_id().as_ref() != cred_id_bytes.as_slice());
|
||||
|
||||
if stored.credentials.len() == original_len {
|
||||
return (StatusCode::NOT_FOUND, "Credential not found").into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = save_credentials(&username, &stored) {
|
||||
eprintln!("Failed to save credentials for {}: {}", username, e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to save changes").into_response();
|
||||
}
|
||||
|
||||
Json(serde_json::json!({ "success": true })).into_response()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue