This commit is contained in:
2025-07-19 08:37:01 -05:00
parent e430265c88
commit 395c004343
7 changed files with 349 additions and 328 deletions
-267
View File
@@ -1,267 +0,0 @@
use std::env;
use std::fs;
use std::fs::remove_file;
use std::path::PathBuf;
use std::process::Command;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct App {
pub repo: String,
pub branch: String,
pub install_command: String,
pub run_command: String,
}
impl App {
/// Converts URL to "user/repo"
pub fn id(&self) -> Option<String> {
let url = self.repo.trim_end_matches(".git");
if url.starts_with("git@") {
// Format: [email protected]:usr/repo
url.split_once(':').map(|(_, path)| path.to_string())
} else if url.starts_with("https://") || url.starts_with("http://") {
// Format: https://github.com/usr/repo
url.split_once("github.com/")
.map(|(_, path)| path.to_string())
} else {
None
}
}
/// Converts URL to "user-repo"
pub fn id_system(&self) -> Option<String> {
let url = self.repo.trim_end_matches(".git");
let repo_path = if url.starts_with("git@") {
url.split_once(':').map(|(_, path)| path)
} else if url.starts_with("https://") || url.starts_with("http://") {
url.split_once("github.com/").map(|(_, path)| path)
} else {
None
}?;
Some(repo_path.replace('/', "-"))
}
pub fn repo_folder_name(&self) -> String {
self.repo
.trim_end_matches(".git")
.rsplit('/')
.next()
.unwrap_or("unknown")
.to_string()
}
}
fn home_dir() -> PathBuf {
let home = env::var("HOME").expect("Failed to get HOME environment variable");
PathBuf::from(home)
}
fn apps_dir() -> PathBuf {
let home = env::var("HOME").expect("Failed to get HOME environment variable");
PathBuf::from(home).join("sentryx/apps")
}
fn apps_file_path() -> PathBuf {
apps_dir().join("apps.json")
}
fn update_systemd() {
Command::new("systemctl")
.arg("--user")
.arg("daemon-reload")
.status()
.expect("Failed to reload systemd daemon");
}
pub fn init() -> Vec<App> {
let path = apps_file_path();
if !path.exists() {
let _ = fs::create_dir_all(path.parent().unwrap());
let _ = fs::write(&path, "[]");
}
let content = fs::read_to_string(&path).unwrap_or_else(|_| "[]".to_string());
serde_json::from_str(&content).unwrap_or_else(|_| vec![])
}
pub fn save_apps(apps: &[App]) {
let path = apps_file_path();
let json = serde_json::to_string_pretty(apps).expect("Failed to serialize apps");
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
fs::write(path, json).expect("Failed to write apps.json");
}
pub fn uninstall_app(apps: &mut Vec<App>, index: usize) {
if index >= apps.len() {
eprintln!("Invalid app index: {}", index);
return;
}
let app = &apps[index];
let repo_dir = apps_dir().join(app.repo_folder_name());
if repo_dir.exists() {
if let Err(e) = fs::remove_dir_all(&repo_dir) {
eprintln!("Failed to remove directory {:?}: {}", repo_dir, e);
} else {
println!("Removed app: {}", app.repo_folder_name());
}
}
let user_systemd_dir = home_dir().join(".config/systemd/user");
let service_path = user_systemd_dir.join(format!(
"{}.service",
app.id_system().expect("Unable to obtain id")
));
println!("Removing service file: {:?}", service_path);
remove_file(service_path).unwrap_or_else(|e| {
eprintln!("Failed to remove service file: {}", e);
});
update_systemd();
apps.remove(index);
save_apps(apps);
}
pub fn install_app(apps: &mut Vec<App>, app: App) {
let path = apps_dir();
let repo_name = app.repo_folder_name();
// Ensure apps directory exists
if let Err(e) = fs::create_dir_all(&path) {
eprintln!("Failed to create apps directory: {}", e);
return;
}
if let Err(e) = env::set_current_dir(&path) {
eprintln!("Failed to switch to apps directory: {}", e);
return;
}
println!("Installing app: {}", app.id().expect("Unable to obtain id"));
println!("→ Cloning branch `{}` from `{}`", app.branch, app.repo);
let clone_status = Command::new("git")
.arg("clone")
.arg("-b")
.arg(&app.branch)
.arg(&app.repo)
.status();
if !matches!(clone_status, Ok(s) if s.success()) {
eprintln!("Failed to clone repository");
return;
}
let repo_path = path.join(&repo_name);
if let Err(e) = env::set_current_dir(&repo_path) {
eprintln!("Failed to change into cloned repo directory: {}", e);
return;
}
println!(
"→ Running install command in {:?}",
env::current_dir().unwrap()
);
let install_status = Command::new("bash")
.arg("-c")
.arg(&app.install_command)
.status();
if !matches!(install_status, Ok(s) if s.success()) {
eprintln!("Install command failed");
}
let service_contents = format!(
"[Unit]\nDescription={repo_name} (Installed with SentryX)\nAfter=network.target\n\n[Service]\nWorkingDirectory=/home/server/saturn-server\nExecStart={}\nRestart=always\nRestartSec=5\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target",
app.run_command,
);
let user_systemd_dir = home_dir().join(".config/systemd/user");
fs::create_dir_all(&user_systemd_dir).expect("Failed to create user systemd dir");
let service_path = user_systemd_dir.join(format!(
"{}.service",
app.id_system().expect("Unable to obtain id")
));
println!("→ Creating service file: {service_path:?}");
if let Err(e) = fs::write(&service_path, service_contents) {
eprintln!(
"Failed to write service file, make sure you run this server with sudo permissions: {}",
e
);
return;
}
update_systemd();
// Add to app list and save
apps.push(app);
save_apps(apps);
}
pub fn toggle_app_state(apps: &[App], index: usize, enable: bool) {
if let Some(app) = apps.get(index) {
let id = app.id_system().expect("Unable to obtain id");
Command::new("systemctl")
.arg("--user")
.arg(if enable { "enable" } else { "disable" })
.arg(format!("{}.service", id))
.status()
.expect("Failed to toggle app state");
}
}
pub fn start_app(apps: &[App], index: usize) {
if let Some(app) = apps.get(index) {
let id = app.id_system().expect("Unable to obtain id");
Command::new("systemctl")
.arg("--user")
.arg("start")
.arg(format!("{}.service", id))
.status()
.expect("Failed to start app");
}
}
pub fn stop_app(apps: &[App], index: usize) {
if let Some(app) = apps.get(index) {
let id = app.id_system().expect("Unable to obtain id");
Command::new("systemctl")
.arg("--user")
.arg("stop")
.arg(format!("{}.service", id))
.status()
.expect("Failed to stop app");
}
}
pub fn restart_app(apps: &[App], index: usize) {
if let Some(app) = apps.get(index) {
let id = app.id_system().expect("Unable to obtain id");
Command::new("systemctl")
.arg("--user")
.arg("restart")
.arg(format!("{}.service", id))
.status()
.expect("Failed to restart app");
}
}
+139 -13
View File
@@ -1,12 +1,26 @@
mod app_manager;
mod server_config;
use actix_web::{get, post, web, App, HttpServer, Responder, Result};
mod service;
use actix_web::{
get,
web::{self},
App, HttpServer, Responder, Result,
};
use serde_json::json;
use sysinfo::{Disks, Networks, System};
use crate::server_config::ServerStats;
use crate::server_config::{validate_master_key, ServerStats};
#[derive(serde::Deserialize)]
struct Auth {
auth: String,
}
#[get("/")]
async fn sys_info() -> Result<impl Responder> {
async fn sys_info(query: web::Query<Auth>) -> Result<impl Responder> {
if !validate_master_key(&query.auth) {
return Err(actix_web::error::ErrorUnauthorized("Invalid master key"));
}
let mut sys = System::new_all();
let mut disks: Disks = Disks::new_with_refreshed_list();
let mut networks: Networks = Networks::new_with_refreshed_list();
@@ -48,17 +62,124 @@ async fn sys_info() -> Result<impl Responder> {
}
#[get("/apps")]
async fn get_apps() -> Result<impl Responder> {
let apps = app_manager::init();
Ok(web::Json(apps))
async fn get_apps(query: web::Query<Auth>) -> Result<impl Responder> {
if !validate_master_key(&query.auth) {
return Err(actix_web::error::ErrorUnauthorized("Invalid master key"));
}
let systemctl = systemctl::SystemCtl::default();
match systemctl.list_units(None, None, None) {
Ok(units) => Ok(web::Json(
units
.iter()
.map(|v| match systemctl.status(v) {
Ok(unit) => serde_json::to_value(service::parse_service(unit)).unwrap(),
Err(_) => json!({ "name": v, "status": "undefined" }),
})
.collect::<Vec<_>>(),
)),
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
"Failed to list units: {}",
e
))),
}
}
#[get("/app/{app_index}")]
async fn get_app(id: web::Path<usize>) -> Result<impl Responder> {
let apps = app_manager::init();
match apps.get(*id) {
Some(app) => Ok(web::Json(app.clone())),
None => Err(actix_web::error::ErrorNotFound("App not found")),
#[get("/apps/{app}")]
async fn get_app(id: web::Path<String>, query: web::Query<Auth>) -> Result<impl Responder> {
if !validate_master_key(&query.auth) {
return Err(actix_web::error::ErrorUnauthorized("Invalid master key"));
}
let systemctl = systemctl::SystemCtl::default();
match systemctl.status(&*id) {
Ok(unit) => Ok(web::Json(
serde_json::to_value(service::parse_service(unit)).unwrap(),
)),
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
"Failed to get unit: {}",
e
))),
}
}
#[get("/enable/{app}")]
async fn enable_app(id: web::Path<String>, query: web::Query<Auth>) -> Result<impl Responder> {
if !validate_master_key(&query.auth) {
return Err(actix_web::error::ErrorUnauthorized("Invalid master key"));
}
let systemctl = systemctl::SystemCtl::default();
match systemctl.enable(&*id) {
Ok(status) => Ok(web::Json(json!({ "success": status.success() }))),
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
"Failed to enable unit: {}",
e
))),
}
}
#[get("/disable/{app}")]
async fn disable_app(id: web::Path<String>, query: web::Query<Auth>) -> Result<impl Responder> {
if !validate_master_key(&query.auth) {
return Err(actix_web::error::ErrorUnauthorized("Invalid master key"));
}
let systemctl = systemctl::SystemCtl::default();
match systemctl.disable(&*id) {
Ok(status) => Ok(web::Json(json!({ "success": status.success() }))),
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
"Failed to disable unit: {}",
e
))),
}
}
#[get("/start/{app}")]
async fn start_app(id: web::Path<String>, query: web::Query<Auth>) -> Result<impl Responder> {
if !validate_master_key(&query.auth) {
return Err(actix_web::error::ErrorUnauthorized("Invalid master key"));
}
let systemctl = systemctl::SystemCtl::default();
match systemctl.start(&*id) {
Ok(status) => Ok(web::Json(json!({ "success": status.success() }))),
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
"Failed to start unit: {}",
e
))),
}
}
#[get("/stop/{app}")]
async fn stop_app(id: web::Path<String>, query: web::Query<Auth>) -> Result<impl Responder> {
if !validate_master_key(&query.auth) {
return Err(actix_web::error::ErrorUnauthorized("Invalid master key"));
}
let systemctl = systemctl::SystemCtl::default();
match systemctl.stop(&*id) {
Ok(status) => Ok(web::Json(json!({ "success": status.success() }))),
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
"Failed to stop unit: {}",
e
))),
}
}
#[get("/restart/{app}")]
async fn restart_app(id: web::Path<String>, query: web::Query<Auth>) -> Result<impl Responder> {
if !validate_master_key(&query.auth) {
return Err(actix_web::error::ErrorUnauthorized("Invalid master key"));
}
let systemctl = systemctl::SystemCtl::default();
match systemctl.restart(&*id) {
Ok(status) => Ok(web::Json(json!({ "success": status.success() }))),
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
"Failed to restart unit: {}",
e
))),
}
}
@@ -69,6 +190,11 @@ async fn main() -> std::io::Result<()> {
.service(sys_info)
.service(get_apps)
.service(get_app)
.service(enable_app)
.service(disable_app)
.service(start_app)
.service(stop_app)
.service(restart_app)
})
.bind(("127.0.0.1", 8080))?
.run()
+5
View File
@@ -22,3 +22,8 @@ pub struct ServerStats {
pub disk: u8,
pub network: u64,
}
pub fn validate_master_key(master_key: &str) -> bool {
let config = ServerConfig::new();
master_key == config.master_key
}
+65
View File
@@ -0,0 +1,65 @@
use lazy_static::lazy_static;
use regex::Regex;
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct Service {
pub name: Option<String>,
pub description: Option<String>,
pub loaded_status: Option<String>,
pub unit_file_path: Option<String>,
pub enabled: Option<String>,
pub preset: Option<String>,
pub active: Option<String>,
pub active_state: Option<String>,
pub trigger: Option<String>,
pub triggers: Option<String>,
pub docs: Option<String>,
// Other fields can be added as needed
}
lazy_static! {
static ref SERVICE_RE: Regex = Regex::new(
r#"(?x)
^●\s(?P<name>\S+)\s-\s(?P<description>.+?)\n
\s+Loaded:\s(?P<loaded_status>\S+)\s\((?P<unit_file_path>[^;]+)(?:;\s(?P<enabled>\S+))?(?:;\s+preset:\s(?P<preset>\S+))?\)\n
\s+Active:\s(?P<active>\S+)\s\((?P<active_state>[^)]+)\).*\n
(?:\s+Trigger:\s(?P<trigger>.+)\n)?
(?:\s+Triggers:\s●\s(?P<triggers>[^\n]+)\n)?
(?:\s+Docs:\s(?P<docs>(?:.+\n(?:\s{7,}.+\n)*))?)?
"#
).unwrap();
}
pub fn parse_service(input: String) -> Service {
if let Some(cap) = SERVICE_RE.captures(&input) {
Service {
name: cap.name("name").map(|m| m.as_str().to_string()),
description: cap.name("description").map(|m| m.as_str().to_string()),
loaded_status: cap.name("loaded_status").map(|m| m.as_str().to_string()),
unit_file_path: cap.name("unit_file_path").map(|m| m.as_str().to_string()),
enabled: cap.name("enabled").map(|m| m.as_str().to_string()),
preset: cap.name("preset").map(|m| m.as_str().to_string()),
active: cap.name("active").map(|m| m.as_str().to_string()),
active_state: cap.name("active_state").map(|m| m.as_str().to_string()),
trigger: cap.name("trigger").map(|m| m.as_str().to_string()),
triggers: cap.name("triggers").map(|m| m.as_str().to_string()),
docs: cap.name("docs").map(|m| m.as_str().trim().to_string()),
}
} else {
// Return an empty Service or handle error as needed
Service {
name: None,
description: None,
loaded_status: None,
unit_file_path: None,
enabled: None,
preset: None,
active: None,
active_state: None,
trigger: None,
triggers: None,
docs: None,
}
}
}