Rename things to ush for brevity. Add Tree system.

This commit is contained in:
Michael Mikovsky
2026-02-09 10:27:15 -07:00
parent ebeaa29d5b
commit 2a18639d84
86 changed files with 368 additions and 419 deletions
+1937
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "ush-server"
version.workspace = true
edition.workspace = true
authors.workspace = true
include.workspace = true
[features]
default = ["log_debug"]
log = ["unshell/log"]
log_debug = ["unshell/log_debug"]
[dependencies]
unshell = { path = "../" }
# ush-manager = { path = "../unshell-manager" }
chrono = { workspace = true }
toml = { workspace = true }
static_init = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sled = "0.34.7"
clap = {version = "4.5.53", features = ["derive"]}
axum = "0.8.7"
axum-extra = {version="0.12.2", features = ["typed-header"]}
tokio = {version="1.48.0", features = ["full"] }
jsonwebtoken = {version = "10.2.0", features = ["aws_lc_rs"]}
bcrypt = "0.17.1"
+3
View File
@@ -0,0 +1,3 @@
GET /api/interface/*/some_config -> Returns the interface and struct at that location
GET /api/interface/*/some_folder -> Returns the list of sub-folders and interfaces at that location
+105
View File
@@ -0,0 +1,105 @@
use axum::{
Extension, Json, Router,
extract::{Path, State},
middleware,
routing::{get, post},
};
use tokio::net::TcpListener;
use unshell::{debug, info};
// axum_extra::
use crate::{auth, auth::structs::CurrentUser, logger::Logger, server::Server};
macro_rules! route_get {
($router:expr, $path:expr, $func:expr) => {{
{
$router.route(
$path,
get($func).layer(middleware::from_fn(auth::authorize)),
)
}
}};
}
macro_rules! route_post {
($router:expr, $path:expr, $func:expr) => {{
{
$router.route(
$path,
post($func).layer(middleware::from_fn(auth::authorize)),
)
}
}};
}
pub async fn start_api(address: &str, server: Server) {
let listener = TcpListener::bind(address)
.await
.expect("Unable to start listener");
info!("Listening on {}", listener.local_addr().unwrap());
let mut router = Router::new().route("/api/auth", post(auth::sign_in));
router = route_trees(router);
router = route_get!(router, "/api/log/{*offset}", Logger::poll_logs_api);
router = route_get!(router, "/api/trees", Server::get_trees_api);
router = route_get!(router, "/api/keys/{*path}", Server::all_tree_keys_api);
router = route_get!(router, "/api/values/{*path}", Server::all_tree_values_api);
router = route_get!(router, "/api/interface/", Server::get_tree2_root);
router = route_get!(router, "/api/interface/{*path}", Server::get_tree2);
// router = router.route("/api/interface", get(Server::get_tree2_root));
// router = router.route("/api/interface/{*path}", post(Server::post_tree2));
router = route_post!(router, "/api/interface/{*path}", Server::post_tree2);
// router = route_get_log(router);
axum::serve(listener, router.with_state(server))
.await
.expect("Error serving application");
}
// Loop through all trees and add /api/<tree>/<path> POST aand GET listeners for them
fn route_trees(mut router: Router<Server>) -> Router<Server> {
for tree in crate::DATABASE_TREES.iter() {
router = router
// Route GET requests to this tree
.route(
&format!("/api/{}/{{*path}}", tree),
get(
async |State(server): State<Server>,
Path(path): Path<String>,
Extension(_): Extension<CurrentUser>| {
let result = server.get_value(tree, &path);
debug!("GET /api/{}/{}", tree.to_string(), path);
Json(serde_json::to_value(result).unwrap())
},
)
.layer(middleware::from_fn(auth::authorize)),
)
// Route POST requests to this tree
.route(
&format!("/api/{}/{{*path}}", tree),
post(
async |State(server): State<Server>,
Path(path): Path<String>,
Extension(_): Extension<CurrentUser>,
body: String| {
let result = server.put_value(tree, &path, &body);
debug!("POST /api/{}/{}", tree.to_string(), path);
Json(serde_json::to_value(result).unwrap())
},
)
.layer(middleware::from_fn(auth::authorize)),
);
}
router
}
+130
View File
@@ -0,0 +1,130 @@
pub mod structs;
use axum::{
body::Body,
extract::{Json, Request},
http::{self, Response, StatusCode},
middleware::Next,
};
use bcrypt::{DEFAULT_COST, hash, verify};
use chrono::Utc;
use jsonwebtoken::{Header, TokenData, Validation, decode, encode};
use serde_json::{Value, json};
use unshell::{debug, info};
use crate::{EXPIRE_DURATION, JWT_DECODING_KEY, JWT_ENCODING_KEY};
use structs::{AuthError, Cliams, CurrentUser, SignInData};
pub fn hash_password(password: &str) -> Result<String, bcrypt::BcryptError> {
let hash = hash(password, DEFAULT_COST)?;
Ok(hash)
}
pub fn encode_jwt(email: String) -> Result<(String, usize), StatusCode> {
let now = Utc::now();
let exp = (now + EXPIRE_DURATION).timestamp() as usize;
let iat = now.timestamp() as usize;
let claim = Cliams { iat, exp, email };
let token = encode(&Header::default(), &claim, &JWT_ENCODING_KEY)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok((token, exp))
}
pub fn decode_jwt(jwt: String) -> Result<TokenData<Cliams>, StatusCode> {
decode(&jwt, &JWT_DECODING_KEY, &Validation::default())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
pub async fn authorize(mut req: Request, next: Next) -> Result<Response<Body>, AuthError> {
let auth_header = req.headers_mut().get(http::header::AUTHORIZATION);
let auth_header = match auth_header {
Some(header) => header.to_str().map_err(|_| AuthError {
message: "Empty header is not allowed".to_string(),
status_code: StatusCode::FORBIDDEN,
})?,
None => {
return Err(AuthError {
message: "Please add the JWT token to the header".to_string(),
status_code: StatusCode::FORBIDDEN,
});
}
};
let mut header = auth_header.split_whitespace();
let (_, token) = (header.next(), header.next());
let _token_data: TokenData<Cliams> = match decode_jwt(token.unwrap().to_string()) {
Ok(data) => data,
Err(_) => {
return Err(AuthError {
message: "Invalid Session".to_string(),
status_code: StatusCode::UNAUTHORIZED,
});
}
};
// // Fetch the user details from the database
// let current_user = match retrieve_user_by_email(&token_data.claims.email) {
// Some(user) => user,
// None => {
// return Err(AuthError {
// message: "Unauthorized".to_string(),
// status_code: StatusCode::UNAUTHORIZED,
// });
// }
// };
// req.extensions_mut().insert(current_user);
Ok(next.run(req).await)
}
pub async fn sign_in(Json(user_data): Json<SignInData>) -> Result<Json<Value>, StatusCode> {
// 1. Retrieve user from the database
let user = match retrieve_user_by_email(&user_data.username) {
Some(user) => user,
None => {
debug!(
"Denied user {}: Could not find user data",
user_data.username
);
return Err(StatusCode::UNAUTHORIZED);
} // User not found
};
// 2. Compare the password
if !verify(&user_data.password, &user.password_hash)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
// Handle bcrypt errors
{
debug!("Denied user {}: Incorrect password hash", user.username);
return Err(StatusCode::UNAUTHORIZED); // Wrong password
}
info!(
"Authenticated user {} for {}",
user_data.username, EXPIRE_DURATION
);
// 3. Generate JWT
let (token, experation) =
encode_jwt(user.username).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 4. Return the token
Ok(Json(json!({
"token": token,
"expiration": experation,
})))
}
fn retrieve_user_by_email(_email: &str) -> Option<CurrentUser> {
let current_user: CurrentUser = CurrentUser {
username: "foo".to_string(),
password_hash: hash_password("bar").unwrap(),
};
Some(current_user)
}
+45
View File
@@ -0,0 +1,45 @@
use axum::{
Json,
body::Body,
http::{Response, StatusCode},
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Deserialize)]
pub struct SignInData {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone)]
pub struct CurrentUser {
pub username: String,
pub password_hash: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Cliams {
// pub exp: u128,
// pub iat: u128,
//
pub exp: usize,
pub iat: usize,
pub email: String,
}
pub struct AuthError {
pub message: String,
pub status_code: StatusCode,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response<Body> {
let body = Json(json!({
"error": self.message,
}));
(self.status_code, body).into_response()
}
}
+59
View File
@@ -0,0 +1,59 @@
mod api;
mod auth;
// mod config;
pub mod logger;
mod server;
// use math
pub use server::Server;
use static_init::dynamic;
#[static_init::dynamic]
pub static DATABASE_TREES: Vec<&'static str> = vec!["users"];
#[static_init::dynamic]
pub static DEFAULT_HOST: String = "localhost".to_string();
#[static_init::dynamic]
pub static DATABASE_NAME: String = "database".to_string();
#[static_init::dynamic]
pub static SERVER_CONFIG: unshell_manager::PayloadConfig = unshell_manager::PayloadConfig {
id: "Server",
components: Vec::new(),
runtime_config: Vec::new(),
};
// Constants for server config
pub use api::start_api;
use chrono::Duration;
use jsonwebtoken::{DecodingKey, EncodingKey};
static EXPIRE_DURATION: Duration = Duration::hours(12);
#[dynamic]
static JWT_SECRET: String = {
if let Ok(env_secret) = std::env::var("JWT_SECRET") {
env_secret
} else {
println!(
r#"
##############
# WARNING: You are using the default JWT secret, used for creating user sessions
# With this default key, anyone can login as any user.
##############"#
);
"DEFAULT_SECRET".to_string()
}
};
// std::env::var("JWT_SECRET").unwrap_or(|| -> String {
// return "TEST".to_string();
// }());
#[dynamic]
static JWT_ENCODING_KEY: EncodingKey = EncodingKey::from_secret(JWT_SECRET.as_bytes());
#[dynamic]
static JWT_DECODING_KEY: DecodingKey = DecodingKey::from_secret(JWT_SECRET.as_bytes());
+100
View File
@@ -0,0 +1,100 @@
use axum::extract::{Path, State};
use axum::{Extension, Json};
use chrono::Local;
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use unshell::debug;
use crate::Server;
use crate::auth::structs::CurrentUser;
const LOG_DIR: &str = "logs";
const LOG_COUNT: usize = 100;
/// The full path to the log file.
/// Initialized once based on the startup time.
#[static_init::dynamic]
static LOG_FILE_PATH: PathBuf = {
let log_dir_path = PathBuf::from(LOG_DIR);
if let Err(e) = fs::create_dir_all(&log_dir_path) {
eprintln!("Error creating log directory {:?}: {}", log_dir_path, e);
panic!("Failed to initialize log directory.");
}
let now = Local::now();
let filename = format!("{}.log", now.format("%Y%m%d_%H%M%S"));
log_dir_path.join(filename)
};
/// A static utility module for logging operations.
pub struct Logger;
impl Logger {
pub fn log(message: String) {
let log_line = format!("{}\n", message);
match OpenOptions::new()
.create(true)
.append(true)
.open(&*LOG_FILE_PATH)
{
Ok(mut file) => {
// 3. Write the log line to the file
if let Err(e) = file.write_all(log_line.as_bytes()) {
eprintln!("Error writing log to file {:?}: {}", *LOG_FILE_PATH, e);
}
}
Err(e) => {
eprintln!("Error opening log file {:?}: {}", *LOG_FILE_PATH, e);
}
}
}
pub fn poll_logs(offset: usize) -> Vec<String> {
match File::open(&*LOG_FILE_PATH) {
Ok(file) => {
let reader = BufReader::new(file);
let lines: Vec<String> = reader
.lines()
.filter_map(|line| line.ok()) // Ignore lines that fail to read
.collect();
let total_lines = lines.len();
if offset >= total_lines {
return Vec::new();
}
let start_index = total_lines
.checked_sub(offset)
.and_then(|i| i.checked_sub(LOG_COUNT))
.unwrap_or(0);
let end_index = total_lines.checked_sub(offset).unwrap_or(total_lines);
let slice = &lines[start_index..end_index];
slice.iter().cloned().collect()
}
Err(e) => {
eprintln!("Error reading log file {:?}: {}", *LOG_FILE_PATH, e);
Vec::new()
}
}
}
pub async fn poll_logs_api(
State(_): State<Server>,
Extension(_): Extension<CurrentUser>,
Path(offset): Path<usize>,
) -> axum::Json<serde_json::Value> {
debug!("GET /api/log/{}", offset);
let result = Self::poll_logs(offset);
Json(serde_json::to_value(result).unwrap())
}
}
+45
View File
@@ -0,0 +1,45 @@
use std::{error::Error, path::PathBuf};
use unshell_server::{Server, start_api};
use clap::Parser;
use unshell_server::{DATABASE_NAME, DEFAULT_HOST};
/// A fictional versioning CLI
#[derive(Debug, Parser)]
#[command(name = "unshell-server")]
#[command(about = "UnShell server", long_about = None)]
pub struct Args {
/// Host to listen on
#[clap(long, default_value_t = DEFAULT_HOST.clone())]
host: String,
/// Port to listen
#[arg(short, long, default_value_t = 3000)]
port: usize,
/// Name of database folder
#[clap(short, long, default_value_t = DATABASE_NAME.clone())]
database_name: String,
/// Load config from path
#[clap(short, long, value_parser)]
pub config: Vec<PathBuf>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let args = Args::parse();
unshell::logger::PrettyLogger::init_output(|message| {
if let Ok(json) = serde_json::to_string(message) {
unshell_server::logger::Logger::log(json);
}
});
let database = Server::new(args.config, args.database_name)?;
start_api(&format!("{}:{}", args.host, args.port), database).await;
Ok(())
}
+9
View File
@@ -0,0 +1,9 @@
use serde_json::Value;
use crate::Server;
impl Server {
pub fn get_blobs(&self) -> Result<Vec<Value>, String> {
Ok(Vec::new())
}
}
+100
View File
@@ -0,0 +1,100 @@
use std::collections::HashMap;
use axum::{
Extension, Json,
extract::{Path, State},
};
use serde_json::Value;
use sled::Tree;
use unshell::{debug, error};
use crate::{auth::structs::CurrentUser, server::Server};
impl Server {
fn get_tree(&self, tree_name: &str) -> Result<Tree, String> {
self.db.open_tree(tree_name).map_err(|e| {
error!("DB Failed to open tree: {}", e);
"Internal server error".to_string()
})
}
pub async fn get_trees_api(State(server): State<Server>) -> Json<Value> {
debug!("GET tree list");
let result = server
.db
.tree_names()
.iter()
.map(|n| String::from_utf8_lossy(&n.to_vec()).to_string())
.collect::<Vec<String>>();
Json(serde_json::to_value(result).unwrap())
}
pub fn put_value(&self, tree_name: &str, key: &str, value: &str) -> Result<(), String> {
match self.get_tree(tree_name)?.insert(key, value) {
Ok(_) => Ok(()),
Err(e) => {
error!("Failed to load '{}' from database: {}", key, e);
Err("Internal server error".to_string())
}
}
}
pub fn get_value(&self, tree_name: &str, key: &str) -> Result<String, String> {
match self.get_tree(tree_name)?.get(key) {
Ok(v) => match v {
Some(v) => Ok(String::from_utf8_lossy(&v.to_vec()).to_string()),
None => Err(format!("Could not find key '{}'", key)),
},
Err(e) => {
error!("Failed to load '{}' from database: {}", key, e);
Err("Internal server error".to_string())
// Err(e.to_string())
}
}
}
fn get_keys(&self, tree_name: &str) -> Result<Vec<String>, String> {
Ok(self
.get_tree(tree_name)?
.iter()
.keys()
.map(|key| {
String::from_utf8_lossy(&key.expect("This key should exist").to_vec()).to_string()
})
.collect::<Vec<String>>())
}
// Route the "keys" api for each tree
pub async fn all_tree_keys_api(
State(server): State<Server>,
Path(tree_name): Path<String>,
Extension(_): Extension<CurrentUser>,
) -> Json<Value> {
let result = server.get_keys(&tree_name);
Json(serde_json::to_value(result).unwrap())
}
// Route the "values" api to get all the values for each tree
pub async fn all_tree_values_api(
State(server): State<Server>,
Path(tree_name): Path<String>,
Extension(_): Extension<CurrentUser>,
) -> Json<Value> {
let result = || -> Result<HashMap<String, String>, String> {
Ok(server
.get_keys(&tree_name)?
.iter()
.map(|key| -> Result<(String, String), String> {
Ok((key.clone(), server.get_value(&tree_name, &key)?))
})
.collect::<Result<Vec<(String, String)>, String>>()?
.into_iter()
.collect::<HashMap<String, String>>())
}();
Json(serde_json::to_value(result).unwrap())
}
}
+81
View File
@@ -0,0 +1,81 @@
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use unshell::{
ModuleError, Result,
config::{ConfigStructField, Tree, TreeMessage, config_struct::Config},
};
use unshell_manager::Manager;
mod blobs;
mod database;
mod tree2;
#[derive(Clone)]
pub struct Server {
// pub component_configs: Vec<crate::config::ComponentState>,
// pub interface: InterfaceWrapper,
pub manager: Arc<Mutex<Manager>>,
pub db: sled::Db,
// pub tree: Tree2,
test_thing: Arc<Mutex<Config>>,
}
impl Server {
pub fn new(_config_paths: Vec<PathBuf>, database: String) -> Result<Self> {
// let mut component_configs: Vec<crate::config::ComponentState> = Vec::new(1);
// for config in &config_paths {
// component_configs.extend(crate::config::load_config(config)?);
// }
Ok(Self {
// component_configs,
manager: Manager::start(&crate::SERVER_CONFIG, Vec::new()),
db: sled::open(database).map_err(|e| ModuleError::DatabaseError(e.to_string()))?,
test_thing: Arc::new(Mutex::new(Config::new(vec![
ConfigStructField::Header("Test Heading".into()),
ConfigStructField::Text("Test Texttttttttttttttt".into()),
ConfigStructField::String {
default: "Test Texttttttttttttttt".into(),
max_length: None,
protected: true,
},
ConfigStructField::String {
default: "Test ".into(),
max_length: Some(15),
protected: false,
},
]))),
// tree: Tree2::default(),
// interface: get_test_interface(),
})
}
}
impl Tree for Server {
fn is_folder() -> bool {
true
}
fn get_children_string(&self) -> Vec<String> {
vec!["connection_count".into()]
}
fn select_child(&mut self, child: &str, message: TreeMessage) -> Result<TreeMessage> {
match child {
"connection_count" => self.test_thing.lock().unwrap().get(message),
_ => Err("No such child".into()),
}
}
}
impl Drop for Server {
fn drop(&mut self) {
self.db.flush().expect("Failed to flush database on drop");
// Manager::join(self.manager.clone());
}
}
+53
View File
@@ -0,0 +1,53 @@
use axum::{
Json,
extract::{Path, State},
};
use serde_json::Value;
use unshell::{
ModuleError,
config::{Tree, TreeMessage},
debug,
};
use crate::Server;
impl Server {
pub async fn get_tree2_root(
State(server): State<Server>,
// Extension(extension): Extension<CurrentUser>,
) -> Json<Value> {
Self::get_tree2(State(server), Path("".into())).await
}
pub async fn get_tree2(
State(mut server): State<Server>,
Path(path): Path<String>,
// Extension(_): Extension<CurrentUser>,
) -> Json<Value> {
debug!("GET /api/interface/{}", path);
let result = server
.get(&path, TreeMessage::RequestStructAndValue)
.map_err(|e| ModuleError::CryptError(e.to_string()));
Json(serde_json::to_value(result).unwrap())
}
pub async fn post_tree2(
State(mut server): State<Server>,
Path(path): Path<String>,
// Extension(_): Extension<CurrentUser>,
Json(tree_message): Json<TreeMessage>,
) -> Json<Value> {
debug!("POST /api/interface/");
// Json(Value::Null)
let result = server
.get(&path, tree_message)
.map_err(|e| ModuleError::CryptError(e.to_string()));
Json(serde_json::to_value(result).unwrap())
}
}