From ae7ef10b67737f08a86743f687bca30a12e9e17a Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 12 Jul 2026 19:38:03 +0200 Subject: first stable --- src/auth.rs | 194 +++++++++++++++++++ src/config.rs | 23 +++ src/db.rs | 107 +++++++++++ src/docker.rs | 213 +++++++++++++++++++++ src/main.rs | 99 +++++++++- src/models/mod.rs | 44 +++++ src/rcon.rs | 133 +++++++++++++ src/routes/accounts.rs | 236 +++++++++++++++++++++++ src/routes/backups.rs | 256 +++++++++++++++++++++++++ src/routes/dashboard.rs | 68 +++++++ src/routes/instances.rs | 484 ++++++++++++++++++++++++++++++++++++++++++++++++ src/routes/mod.rs | 75 ++++++++ src/routes/players.rs | 131 +++++++++++++ 13 files changed, 2061 insertions(+), 2 deletions(-) create mode 100644 src/auth.rs create mode 100644 src/config.rs create mode 100644 src/db.rs create mode 100644 src/docker.rs create mode 100644 src/models/mod.rs create mode 100644 src/rcon.rs create mode 100644 src/routes/accounts.rs create mode 100644 src/routes/backups.rs create mode 100644 src/routes/dashboard.rs create mode 100644 src/routes/instances.rs create mode 100644 src/routes/mod.rs create mode 100644 src/routes/players.rs (limited to 'src') diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..8fa62ec --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,194 @@ +use argon2::{ + Argon2, PasswordHash, PasswordHasher, PasswordVerifier, + password_hash::{SaltString, rand_core::OsRng}, +}; +use axum::{ + extract::{FromRequestParts, Request, State}, + http::{StatusCode, request::Parts}, + middleware::Next, + response::{IntoResponse, Redirect, Response}, +}; +use rusqlite::params; +use serde::Deserialize; +use uuid::Uuid; + +use crate::models::Account; +use crate::routes::AppState; + +#[derive(Debug, Deserialize)] +pub struct LoginForm { + pub username: String, + pub password: String, +} + +const SESSION_COOKIE: &str = "mcui_session"; + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; + Ok(hash.to_string()) +} + +pub fn verify_password(password: &str, hash: &str) -> Result { + let parsed = PasswordHash::new(hash)?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok()) +} + +pub async fn authenticate( + State(state): State, + axum::Form(form): axum::Form, +) -> Result { + let conn = state.db.conn(); + + let account = conn + .query_row( + "SELECT id, username, is_superuser, password_change_required FROM accounts WHERE username = ?1", + params![form.username], + |row| { + Ok(Account { + id: row.get(0)?, + username: row.get(1)?, + is_superuser: row.get::<_, i32>(2)? != 0, + password_change_required: row.get::<_, i32>(3)? != 0, + }) + }, + ) + .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid username or password".into()))?; + + let hash: String = conn + .query_row( + "SELECT password FROM accounts WHERE id = ?1", + params![account.id], + |row| row.get(0), + ) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error".into()))?; + + if !verify_password(&form.password, &hash).unwrap_or(false) { + return Err(( + StatusCode::UNAUTHORIZED, + "Invalid username or password".into(), + )); + } + + let session_id = Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO sessions (id, account_id) VALUES (?1, ?2)", + params![session_id, account.id], + ) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Session error".into()))?; + drop(conn); + + let cookie_value = + format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Lax"); + + let redirect = if account.password_change_required { + Redirect::to("/change-password") + } else { + Redirect::to("/") + }; + + Ok(([(axum::http::header::SET_COOKIE, cookie_value)], redirect)) +} + +pub async fn require_auth( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let cookies = request + .headers() + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let session_id = cookies.split(';').map(|s| s.trim()).find_map(|cookie| { + if let Some((name, value)) = cookie.split_once('=') { + if name.trim() == SESSION_COOKIE { + Some(value.trim().to_string()) + } else { + None + } + } else { + None + } + }); + + match session_id { + Some(id) => { + let account = state + .db + .conn() + .query_row( + "SELECT a.id, a.username, a.is_superuser, a.password_change_required + FROM sessions s JOIN accounts a ON s.account_id = a.id + WHERE s.id = ?1", + params![id], + |row| { + Ok(Account { + id: row.get(0)?, + username: row.get(1)?, + is_superuser: row.get::<_, i32>(2)? != 0, + password_change_required: row.get::<_, i32>(3)? != 0, + }) + }, + ) + .ok(); + + if let Some(account) = account { + request.extensions_mut().insert(account); + Ok(next.run(request).await) + } else { + Err(Redirect::to("/login").into_response()) + } + } + None => Err(Redirect::to("/login").into_response()), + } +} + +#[derive(Clone)] +pub struct CurrentUser(pub Account); + +impl FromRequestParts for CurrentUser +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parts + .extensions + .get::() + .cloned() + .map(CurrentUser) + .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated")) + } +} + +pub async fn logout( + State(state): State, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) { + if let Ok(cookie_str) = cookie_header.to_str() { + for part in cookie_str.split(';') { + let part = part.trim(); + if let Some((name, value)) = part.split_once('=') { + if name.trim() == SESSION_COOKIE { + let _ = state + .db + .conn() + .execute("DELETE FROM sessions WHERE id = ?1", params![value.trim()]); + } + } + } + } + } + + let clear_cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0"); + ( + [(axum::http::header::SET_COOKIE, clear_cookie)], + Redirect::to("/login"), + ) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d9f6103 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + pub server_directory: PathBuf, + pub webui_port: u16, + pub max_memory_mb: u32, + pub initial_superuser: String, + pub initial_superuser_password: String, +} + +impl Config { + pub fn load() -> Result { + let content = std::fs::read_to_string("config.toml") + .map_err(|e| format!("Failed to read config.toml: {e}"))?; + + let config: Config = + toml::from_str(&content).map_err(|e| format!("Failed to parse config.toml: {e}"))?; + + Ok(config) + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..7868e3e --- /dev/null +++ b/src/db.rs @@ -0,0 +1,107 @@ +use rusqlite::Connection; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +pub struct Database { + conn: Arc>, +} + +// Safe because all access to Connection goes through Mutex::lock() +unsafe impl Sync for Database {} + +impl Database { + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path)?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; + let db = Self { + conn: Arc::new(Mutex::new(conn)), + }; + db.migrate()?; + Ok(db) + } + + fn migrate(&self) -> Result<(), rusqlite::Error> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + is_superuser INTEGER NOT NULL DEFAULT 0, + password_change_required INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + account_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + version TEXT NOT NULL, + server_type TEXT NOT NULL CHECK(server_type IN ('VANILLA', 'FORGE', 'FABRIC')), + world_source TEXT NOT NULL CHECK(world_source IN ('generate', 'upload')), + world_seed TEXT, + level_type TEXT NOT NULL DEFAULT 'DEFAULT', + has_modpack INTEGER NOT NULL DEFAULT 0, + memory_mb INTEGER NOT NULL, + difficulty INTEGER NOT NULL DEFAULT 1, + gamemode INTEGER NOT NULL DEFAULT 0, + max_players INTEGER NOT NULL DEFAULT 20, + pvp INTEGER NOT NULL DEFAULT 1, + motd TEXT NOT NULL DEFAULT 'A Minecraft Server', + rcon_password TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 0, + container_state TEXT NOT NULL DEFAULT 'stopped', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS backups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL, + filename TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS whitelist ( + instance_id INTEGER NOT NULL, + username TEXT NOT NULL, + PRIMARY KEY (instance_id, username), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS ops ( + instance_id INTEGER NOT NULL, + username TEXT NOT NULL, + PRIMARY KEY (instance_id, username), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS bans ( + instance_id INTEGER NOT NULL, + username TEXT NOT NULL, + ban_type TEXT NOT NULL CHECK(ban_type IN ('user', 'ip')), + PRIMARY KEY (instance_id, username), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE + ); + ", + )?; + + // Migration: add container_state column if missing (for older DBs) + let _ = conn.execute_batch( + "ALTER TABLE instances ADD COLUMN container_state TEXT NOT NULL DEFAULT 'stopped'", + ); + Ok(()) + } + + pub fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { + self.conn.lock().unwrap() + } +} diff --git a/src/docker.rs b/src/docker.rs new file mode 100644 index 0000000..b18bb47 --- /dev/null +++ b/src/docker.rs @@ -0,0 +1,213 @@ +use bollard::Docker; +use bollard::container::{ + Config as ContainerConfig, CreateContainerOptions, ListContainersOptions, + RemoveContainerOptions, StartContainerOptions, StopContainerOptions, +}; +use bollard::image::CreateImageOptions; +use bollard::models::HostConfig; +use futures_util::StreamExt; +use std::collections::HashMap; + +use crate::config::Config; +use crate::models::Instance; + +#[derive(Clone)] +pub struct DockerManager { + client: Docker, + config: Config, +} + +#[derive(Debug, Clone)] +pub enum ContainerStatus { + Running, + Stopped, + Starting, + NotFound, + Error(String), +} + +impl DockerManager { + pub fn new(config: Config) -> Result { + let client = Docker::connect_with_local_defaults()?; + Ok(Self { client, config }) + } + + pub async fn container_status(&self) -> ContainerStatus { + let mut filters = HashMap::new(); + filters.insert("name".to_string(), vec!["mc".to_string()]); + + let options = ListContainersOptions { + all: true, + filters, + ..Default::default() + }; + + match self.client.list_containers(Some(options)).await { + Ok(containers) => { + if containers.is_empty() { + ContainerStatus::NotFound + } else { + match containers[0].state.as_deref() { + Some("running") => ContainerStatus::Running, + Some("exited") | Some("stopped") => ContainerStatus::Stopped, + _ => ContainerStatus::Starting, + } + } + } + Err(e) => ContainerStatus::Error(e.to_string()), + } + } + + pub async fn start_container(&self, instance: &Instance) -> Result<(), String> { + tracing::info!("Pulling itzg/minecraft-server:latest image..."); + self.pull_image().await?; + tracing::info!("Creating container for instance '{}'", instance.name); + self.recreate_container(instance).await?; + tracing::info!("Starting container..."); + self.start_existing().await + } + + async fn pull_image(&self) -> Result<(), String> { + let options = CreateImageOptions { + from_image: "itzg/minecraft-server", + tag: "latest", + ..Default::default() + }; + + let mut stream = self.client.create_image(Some(options), None, None); + + while let Some(result) = stream.next().await { + match result { + Ok(_info) => {} + Err(e) => return Err(format!("Image pull failed: {e}")), + } + } + Ok(()) + } + + async fn recreate_container(&self, instance: &Instance) -> Result<(), String> { + // Remove existing container if present + let remove_options = RemoveContainerOptions { + force: true, + ..Default::default() + }; + match self + .client + .remove_container("mc", Some(remove_options)) + .await + { + Ok(_) => tracing::info!("Removed existing container"), + Err(e) => tracing::debug!("No existing container to remove: {e}"), + } + + let server_dir = self + .config + .server_directory + .canonicalize() + .unwrap_or_else(|_| self.config.server_directory.clone()) + .display() + .to_string(); + let instance_data = format!("{server_dir}/instances/{}/data", instance.name); + + let mut env_strings: Vec = vec![ + "EULA=TRUE".into(), + format!("VERSION={}", instance.version), + format!("TYPE={}", instance.server_type), + format!("MEMORY={}M", instance.memory_mb), + format!("MAX_PLAYERS={}", instance.max_players), + format!("DIFFICULTY={}", instance.difficulty), + "FORCE_GAMEMODE=true".into(), + "SPAWN_PROTECTION=0".into(), + "ENABLE_WHITELIST=true".into(), + "PAUSE_WHEN_EMPTY_SECONDS=30".into(), + "ONLINE_MODE=false".into(), + "TZ=Europe/Warsaw".into(), + "USE_AIKAR_FLAGS=true".into(), + "ENABLE_RCON=true".into(), + format!("RCON_PASSWORD={}", instance.rcon_password), + "RCON_PORT=25575".into(), + format!("MOTD={}", instance.motd), + format!("GAMEMODE={}", instance.gamemode), + format!("LEVEL_TYPE={}", instance.level_type), + ]; + + if instance.pvp { + env_strings.push("PVP=true".into()); + } else { + env_strings.push("PVP=false".into()); + } + + if let Some(ref seed) = instance.world_seed { + if !seed.is_empty() { + env_strings.push(format!("LEVEL_SEED={seed}")); + } + } + + let env_refs: Vec<&str> = env_strings.iter().map(|s| s.as_str()).collect(); + + let mut exposed_ports: HashMap<&str, HashMap<(), ()>> = HashMap::new(); + exposed_ports.insert("25565/tcp", HashMap::new()); + exposed_ports.insert("25575/tcp", HashMap::new()); + + let mut port_bindings = HashMap::new(); + port_bindings.insert( + "25565/tcp".to_string(), + Some(vec![bollard::models::PortBinding { + host_ip: None, + host_port: Some("25565".to_string()), + }]), + ); + port_bindings.insert( + "25575/tcp".to_string(), + Some(vec![bollard::models::PortBinding { + host_ip: None, + host_port: Some("25575".to_string()), + }]), + ); + + let host_config = HostConfig { + binds: Some(vec![format!("{instance_data}:/data")]), + port_bindings: Some(port_bindings), + ..Default::default() + }; + + let container_config = ContainerConfig { + image: Some("itzg/minecraft-server:latest"), + tty: Some(true), + env: Some(env_refs), + exposed_ports: Some(exposed_ports), + host_config: Some(host_config), + ..Default::default() + }; + + let options = CreateContainerOptions { + name: "mc", + ..Default::default() + }; + + self.client + .create_container(Some(options), container_config) + .await + .map_err(|e| format!("Failed to create container: {e}"))?; + + tracing::info!("Container 'mc' created successfully"); + Ok(()) + } + + async fn start_existing(&self) -> Result<(), String> { + self.client + .start_container("mc", None::>) + .await + .map_err(|e| format!("Failed to start container: {e}"))?; + Ok(()) + } + + pub async fn stop_container(&self) -> Result<(), String> { + let options = StopContainerOptions { t: 30 }; + self.client + .stop_container("mc", Some(options)) + .await + .map_err(|e| format!("Failed to stop container: {e}"))?; + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..a96b41b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,98 @@ -fn main() { - println!("Hello, world!"); +mod auth; +mod config; +mod db; +mod docker; +mod models; +mod rcon; +mod routes; + +use tracing_subscriber::EnvFilter; + +use crate::db::Database; +use crate::docker::DockerManager; +use crate::routes::AppState; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + + let config = config::Config::load().unwrap_or_else(|e| { + eprintln!("Configuration error: {e}"); + eprintln!("Please create a config.toml file based on the example."); + std::process::exit(1); + }); + + if !config.server_directory.exists() { + eprintln!( + "Server directory does not exist: {}", + config.server_directory.display() + ); + eprintln!("Please create it or update config.toml."); + std::process::exit(1); + } + + let db_path = config.server_directory.join("mcui.db"); + let db = Database::open(&db_path).unwrap_or_else(|e| { + eprintln!("Failed to open database: {e}"); + std::process::exit(1); + }); + + bootstrap_superuser(&db, &config); + + let docker = DockerManager::new(config.clone()).unwrap_or_else(|e| { + eprintln!("Failed to connect to Docker: {e}"); + eprintln!("Is the Docker daemon running and accessible?"); + std::process::exit(1); + }); + + let state = AppState { + config: config.clone(), + db: db.clone(), + docker, + }; + + let router = routes::build_router(state); + + let addr = format!("127.0.0.1:{}", config.webui_port); + tracing::info!("Starting Minecraft UI on http://{addr}"); + + let listener = tokio::net::TcpListener::bind(&addr) + .await + .unwrap_or_else(|e| { + eprintln!("Failed to bind to {addr}: {e}"); + std::process::exit(1); + }); + + axum::serve(listener, router).await.unwrap_or_else(|e| { + eprintln!("Server error: {e}"); + }); +} + +fn bootstrap_superuser(db: &Database, config: &config::Config) { + let conn = db.conn(); + let count: i32 = conn + .query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get(0)) + .unwrap_or(0); + drop(conn); + + if count == 0 { + let hash = auth::hash_password(&config.initial_superuser_password) + .expect("Failed to hash initial password"); + + let conn = db.conn(); + conn.execute( + "INSERT INTO accounts (username, password, is_superuser, password_change_required) + VALUES (?1, ?2, 1, 1)", + rusqlite::params![config.initial_superuser, hash], + ) + .expect("Failed to create initial superuser"); + drop(conn); + + tracing::info!( + "Created initial superuser '{}' (password change required on first login)", + config.initial_superuser + ); + } } diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..c847ea3 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + pub id: i64, + pub username: String, + pub is_superuser: bool, + pub password_change_required: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Instance { + pub id: i64, + pub name: String, + pub version: String, + pub server_type: String, + pub world_source: String, + pub world_seed: Option, + pub level_type: String, + pub has_modpack: bool, + pub memory_mb: i32, + pub difficulty: i32, + pub gamemode: i32, + pub max_players: i32, + pub pvp: bool, + pub motd: String, + pub rcon_password: String, + pub is_active: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Backup { + pub id: i64, + pub instance_id: i64, + pub filename: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlayerEntry { + pub username: String, +} diff --git a/src/rcon.rs b/src/rcon.rs new file mode 100644 index 0000000..b91fe9b --- /dev/null +++ b/src/rcon.rs @@ -0,0 +1,133 @@ +use std::io::{Read, Write}; +use std::net::TcpStream; + +#[derive(Debug, Clone)] +pub struct RconClient { + host: String, + port: u16, + password: String, +} + +#[derive(Debug)] +pub enum RconError { + Connection(String), + Auth(String), + Response(String), +} + +impl std::fmt::Display for RconError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RconError::Connection(e) => write!(f, "RCON connection error: {e}"), + RconError::Auth(e) => write!(f, "RCON auth error: {e}"), + RconError::Response(e) => write!(f, "RCON response error: {e}"), + } + } +} + +const SERVERDATA_AUTH: i32 = 3; +const SERVERDATA_EXECCOMMAND: i32 = 2; + +impl RconClient { + pub fn new(password: String) -> Self { + Self { + host: "127.0.0.1".to_string(), + port: 25575, + password, + } + } + + /// Non-blocking async wrapper — runs the blocking RCON call on a thread pool. + pub async fn send_command_async(&self, command: String) -> Result { + let client = self.clone(); + tokio::task::spawn_blocking(move || client.send_command(&command)) + .await + .map_err(|e| RconError::Connection(e.to_string()))? + } + + fn send_command(&self, command: &str) -> Result { + let mut stream = TcpStream::connect(format!("{}:{}", self.host, self.port)) + .map_err(|e| RconError::Connection(e.to_string()))?; + + stream + .set_read_timeout(Some(std::time::Duration::from_secs(1))) + .ok(); + + let request_id = 1; + self.write_packet(&mut stream, request_id, SERVERDATA_AUTH, &self.password)?; + let response = self.read_packet(&mut stream)?; + + if response.request_id == -1 { + return Err(RconError::Auth("Invalid RCON password".into())); + } + + let cmd_id = 2; + self.write_packet(&mut stream, cmd_id, SERVERDATA_EXECCOMMAND, command)?; + + let response = self.read_packet(&mut stream)?; + // Second packet is an end marker — some servers send it, some don't; ignore errors + let _ = self.read_packet(&mut stream); + + Ok(response.body) + } + + fn write_packet( + &self, + stream: &mut TcpStream, + request_id: i32, + packet_type: i32, + body: &str, + ) -> Result<(), RconError> { + let body_bytes = body.as_bytes(); + let length = 10 + body_bytes.len() as i32; + + let mut packet = Vec::with_capacity(4 + length as usize); + packet.extend_from_slice(&length.to_le_bytes()); + packet.extend_from_slice(&request_id.to_le_bytes()); + packet.extend_from_slice(&packet_type.to_le_bytes()); + packet.extend_from_slice(body_bytes); + packet.extend_from_slice(&[0u8, 0u8]); + + stream + .write_all(&packet) + .map_err(|e| RconError::Connection(e.to_string()))?; + + Ok(()) + } + + fn read_packet(&self, stream: &mut TcpStream) -> Result { + let mut length_buf = [0u8; 4]; + stream + .read_exact(&mut length_buf) + .map_err(|e| RconError::Response(format!("Failed to read length: {e}")))?; + + let length = i32::from_le_bytes(length_buf); + if length < 10 { + return Err(RconError::Response("Packet too short".into())); + } + + let mut payload = vec![0u8; length as usize]; + stream + .read_exact(&mut payload) + .map_err(|e| RconError::Response(format!("Failed to read payload: {e}")))?; + + let request_id = i32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]); + let packet_type = i32::from_le_bytes([payload[4], payload[5], payload[6], payload[7]]); + + let body_bytes = &payload[8..payload.len() - 2]; + let body = String::from_utf8_lossy(body_bytes).to_string(); + + Ok(RconPacket { + request_id, + packet_type, + body, + }) + } +} + +#[derive(Debug)] +struct RconPacket { + request_id: i32, + packet_type: i32, + body: String, +} diff --git a/src/routes/accounts.rs b/src/routes/accounts.rs new file mode 100644 index 0000000..89774f8 --- /dev/null +++ b/src/routes/accounts.rs @@ -0,0 +1,236 @@ +use askama::Template; +use axum::{ + Form, Router, + extract::State, + response::{Html, Redirect}, + routing::{get, post}, +}; +use rusqlite::params; +use serde::Deserialize; + +use crate::auth::{CurrentUser, hash_password}; +use crate::routes::AppState; + +#[derive(Template)] +#[template(path = "accounts/manage.html")] +struct AccountManagementTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + accounts: Vec, + message: Option, +} + +struct AccountRow { + id: i64, + username: String, + is_superuser: bool, +} + +#[derive(Debug, Deserialize)] +pub struct CreateAccountForm { + pub username: String, + pub password: String, + pub is_superuser: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DeleteAccountForm { + pub id: i64, +} + +#[derive(Debug, Deserialize)] +pub struct ChangePasswordForm { + pub current_password: String, + pub new_password: String, +} + +#[derive(Debug, Deserialize)] +pub struct SetPasswordForm { + pub id: i64, + pub new_password: String, +} + +pub fn router() -> Router { + Router::new() + .route("/accounts", get(manage_accounts)) + .route("/accounts/create", post(create_account)) + .route("/accounts/delete", post(delete_account)) + .route("/accounts/set-password", post(set_password)) + .route( + "/accounts/change-own-password", + post(change_own_password_from_accounts), + ) +} + +pub async fn change_password( + State(state): State, + CurrentUser(account): CurrentUser, + Form(form): Form, +) -> Result { + let conn = state.db.conn(); + + let hash: String = conn + .query_row( + "SELECT password FROM accounts WHERE id = ?1", + params![account.id], + |row| row.get(0), + ) + .map_err(|_| Redirect::to("/login"))?; + + if !crate::auth::verify_password(&form.current_password, &hash).unwrap_or(false) { + drop(conn); + return Ok(Redirect::to("/change-password")); + } + + let new_hash = hash_password(&form.new_password).map_err(|_| Redirect::to("/login"))?; + + conn.execute( + "UPDATE accounts SET password = ?1, password_change_required = 0 WHERE id = ?2", + params![new_hash, account.id], + ) + .map_err(|_| Redirect::to("/login"))?; + + conn.execute( + "DELETE FROM sessions WHERE account_id = ?1", + params![account.id], + ) + .ok(); + drop(conn); + + Ok(Redirect::to("/login")) +} + +async fn manage_accounts( + State(state): State, + CurrentUser(account): CurrentUser, +) -> Result, Redirect> { + if !account.is_superuser { + return Err(Redirect::to("/")); + } + + let conn = state.db.conn(); + let mut stmt = conn + .prepare("SELECT id, username, is_superuser FROM accounts ORDER BY username") + .map_err(|_| Redirect::to("/login"))?; + + let accounts: Vec = stmt + .query_map([], |row| { + Ok(AccountRow { + id: row.get(0)?, + username: row.get(1)?, + is_superuser: row.get::<_, i32>(2)? != 0, + }) + }) + .map_err(|_| Redirect::to("/login"))? + .filter_map(|r| r.ok()) + .collect(); + drop(stmt); + drop(conn); + + let template = AccountManagementTemplate { + username: account.username, + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + accounts, + message: None, + }; + + Ok(Html(template.render().unwrap_or_default())) +} + +async fn create_account( + State(state): State, + CurrentUser(account): CurrentUser, + Form(form): Form, +) -> Result { + if !account.is_superuser { + return Ok(Redirect::to("/")); + } + + let hash = hash_password(&form.password).map_err(|_| Redirect::to("/accounts"))?; + let is_superuser = form.is_superuser.as_deref() == Some("on"); + + let conn = state.db.conn(); + let _ = conn.execute( + "INSERT INTO accounts (username, password, is_superuser) VALUES (?1, ?2, ?3)", + params![form.username, hash, is_superuser], + ); + drop(conn); + + Ok(Redirect::to("/accounts")) +} + +async fn delete_account( + State(state): State, + CurrentUser(account): CurrentUser, + Form(form): Form, +) -> Result { + if !account.is_superuser || account.id == form.id { + return Ok(Redirect::to("/accounts")); + } + + let conn = state.db.conn(); + let superuser_count: i32 = conn + .query_row( + "SELECT COUNT(*) FROM accounts WHERE is_superuser = 1 AND id != ?1", + params![form.id], + |row| row.get(0), + ) + .unwrap_or(0); + + let is_superuser: bool = conn + .query_row( + "SELECT is_superuser FROM accounts WHERE id = ?1", + params![form.id], + |row| row.get::<_, i32>(0).map(|v| v != 0), + ) + .unwrap_or(false); + + if is_superuser && superuser_count == 0 { + drop(conn); + return Ok(Redirect::to("/accounts")); + } + + let _ = conn.execute("DELETE FROM accounts WHERE id = ?1", params![form.id]); + drop(conn); + + Ok(Redirect::to("/accounts")) +} + +async fn set_password( + State(state): State, + CurrentUser(account): CurrentUser, + Form(form): Form, +) -> Result { + if !account.is_superuser { + return Ok(Redirect::to("/")); + } + + let new_hash = hash_password(&form.new_password).map_err(|_| Redirect::to("/accounts"))?; + + let conn = state.db.conn(); + conn.execute( + "UPDATE accounts SET password = ?1, password_change_required = 0 WHERE id = ?2", + params![new_hash, form.id], + ) + .ok(); + + // Invalidate sessions for the target account + conn.execute( + "DELETE FROM sessions WHERE account_id = ?1", + params![form.id], + ) + .ok(); + drop(conn); + + Ok(Redirect::to("/accounts")) +} + +async fn change_own_password_from_accounts( + State(state): State, + CurrentUser(account): CurrentUser, + Form(form): Form, +) -> Result { + change_password(State(state), CurrentUser(account), Form(form)).await +} diff --git a/src/routes/backups.rs b/src/routes/backups.rs new file mode 100644 index 0000000..09298d2 --- /dev/null +++ b/src/routes/backups.rs @@ -0,0 +1,256 @@ +use askama::Template; +use axum::{ + Router, + body::Body, + extract::{Path, State}, + http::{StatusCode, header}, + response::{Html, Redirect, Response}, + routing::{get, post}, +}; +use rusqlite::params; +use zip::write::FileOptions; + +use crate::auth::CurrentUser; +use crate::routes::AppState; + +#[derive(Template)] +#[template(path = "backups/list.html")] +struct BackupListTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + instance_name: String, + instance_id: i64, + backups: Vec, + message: Option, +} + +struct BackupRow { + id: i64, + filename: String, + created_at: String, +} + +pub fn router() -> Router { + Router::new() + .route("/instances/{id}/backups", get(list_backups)) + .route("/instances/{id}/backups/create", post(create_backup)) + .route("/backups/{id}/restore", post(restore_backup)) + .route("/backups/{id}/download", get(download_backup)) + .route("/instances/{id}/world/export", get(export_world)) +} + +async fn list_backups( + State(state): State, + Path(instance_id): Path, + CurrentUser(account): CurrentUser, +) -> Result, axum::response::Redirect> { + let conn = state.db.conn(); + let instance_name: String = conn + .query_row( + "SELECT name FROM instances WHERE id = ?1", + params![instance_id], + |row| row.get(0), + ) + .map_err(|_| Redirect::to("/instances"))?; + + let mut stmt = conn + .prepare("SELECT id, filename, created_at FROM backups WHERE instance_id = ?1 ORDER BY created_at DESC") + .map_err(|_| Redirect::to("/instances"))?; + + let backups: Vec = stmt + .query_map(params![instance_id], |row| { + Ok(BackupRow { + id: row.get(0)?, + filename: row.get(1)?, + created_at: row.get(2)?, + }) + }) + .map_err(|_| Redirect::to("/instances"))? + .filter_map(|r| r.ok()) + .collect(); + drop(stmt); + drop(conn); + + let template = BackupListTemplate { + username: account.username.clone(), + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + instance_name, + instance_id, + backups, + message: None, + }; + + Ok(Html(template.render().unwrap_or_default())) +} + +async fn create_backup( + State(state): State, + Path(instance_id): Path, + CurrentUser(_user): CurrentUser, +) -> Result { + let conn = state.db.conn(); + let instance_name: String = conn + .query_row( + "SELECT name FROM instances WHERE id = ?1", + params![instance_id], + |row| row.get(0), + ) + .map_err(|_| Redirect::to("/instances"))?; + drop(conn); + + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + let filename = format!("{instance_name}_{timestamp}.zip"); + + let data_dir = state + .config + .server_directory + .join("instances") + .join(&instance_name) + .join("data"); + + let backup_dir = state.config.server_directory.join("backups"); + let _ = std::fs::create_dir_all(&backup_dir); + let backup_path = backup_dir.join(&filename); + + if let Ok(file) = std::fs::File::create(&backup_path) { + let mut zip = zip::ZipWriter::new(file); + let options: FileOptions<'_, ()> = + FileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + if let Ok(entries) = std::fs::read_dir(&data_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(name) = path.file_name() { + if let Ok(contents) = std::fs::read(&path) { + let _ = zip.start_file(&*name.to_string_lossy(), options); + let _ = std::io::Write::write_all(&mut zip, &contents); + } + } + } + } + } + let _ = zip.finish(); + } + + let conn = state.db.conn(); + conn.execute( + "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)", + params![instance_id, filename], + ) + .ok(); + drop(conn); + + Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) +} + +async fn restore_backup( + State(state): State, + Path(backup_id): Path, + CurrentUser(_user): CurrentUser, +) -> Result { + let conn = state.db.conn(); + let (instance_id, _filename): (i64, String) = conn + .query_row( + "SELECT instance_id, filename FROM backups WHERE id = ?1", + params![backup_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|_| Redirect::to("/instances"))?; + drop(conn); + + Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) +} + +async fn download_backup( + State(state): State, + Path(backup_id): Path, + CurrentUser(_user): CurrentUser, +) -> Result { + let conn = state.db.conn(); + let (_, filename): (i64, String) = conn + .query_row( + "SELECT instance_id, filename FROM backups WHERE id = ?1", + params![backup_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?; + drop(conn); + + let backup_path = state + .config + .server_directory + .join("backups") + .join(&filename); + + let content = + std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?; + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "application/zip") + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{filename}\""), + ) + .body(Body::from(content)) + .unwrap()) +} + +async fn export_world( + State(state): State, + Path(instance_id): Path, + CurrentUser(_user): CurrentUser, +) -> Result { + let conn = state.db.conn(); + let instance_name: String = conn + .query_row( + "SELECT name FROM instances WHERE id = ?1", + params![instance_id], + |row| row.get(0), + ) + .map_err(|_| (StatusCode::NOT_FOUND, "Instance not found"))?; + drop(conn); + + let world_dir = state + .config + .server_directory + .join("instances") + .join(&instance_name) + .join("data") + .join("world"); + + let mut zip_buf = Vec::new(); + { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf)); + let options: FileOptions<'_, ()> = + FileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + if let Ok(entries) = std::fs::read_dir(&world_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(name) = path.file_name() { + if let Ok(contents) = std::fs::read(&path) { + let _ = zip.start_file(&*name.to_string_lossy(), options); + let _ = std::io::Write::write_all(&mut zip, &contents); + } + } + } + } + } + let _ = zip.finish(); + } + + let filename = format!("{instance_name}_world.zip"); + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "application/zip") + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{filename}\""), + ) + .body(Body::from(zip_buf)) + .unwrap()) +} diff --git a/src/routes/dashboard.rs b/src/routes/dashboard.rs new file mode 100644 index 0000000..7d5d3e4 --- /dev/null +++ b/src/routes/dashboard.rs @@ -0,0 +1,68 @@ +use askama::Template; +use axum::{ + Router, + extract::State, + response::{Html, IntoResponse}, + routing::get, +}; + +use crate::auth::CurrentUser; +use crate::routes::AppState; + +#[derive(Template)] +#[template(path = "dashboard.html")] +struct DashboardTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + active_instance: Option, + container_state: String, +} + +#[derive(Clone)] +struct ActiveInstanceInfo { + name: String, + version: String, + server_type: String, + max_players: i32, +} + +pub fn router() -> Router { + Router::new().route("/", get(dashboard)) +} + +async fn dashboard( + State(state): State, + CurrentUser(account): CurrentUser, +) -> Result { + let (active_info, container_state) = { + let conn = state.db.conn(); + let result = conn.query_row( + "SELECT name, version, server_type, max_players, container_state + FROM instances WHERE is_active = 1", + [], + |row| { + Ok(( + Some(ActiveInstanceInfo { + name: row.get(0)?, + version: row.get(1)?, + server_type: row.get(2)?, + max_players: row.get(3)?, + }), + row.get::<_, String>(4)?, + )) + }, + ); + result.unwrap_or((None, "stopped".into())) + }; + + let template = DashboardTemplate { + username: account.username, + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + active_instance: active_info, + container_state, + }; + + Ok(Html(template.render().unwrap_or_default())) +} diff --git a/src/routes/instances.rs b/src/routes/instances.rs new file mode 100644 index 0000000..2da1319 --- /dev/null +++ b/src/routes/instances.rs @@ -0,0 +1,484 @@ +use askama::Template; +use axum::{ + Form, Router, + extract::{Path, State}, + response::{Html, Redirect}, + routing::{get, post}, +}; +use rusqlite::params; +use serde::Deserialize; + +use crate::auth::CurrentUser; +use crate::models::Instance; +use crate::routes::AppState; + +#[derive(Template)] +#[template(path = "instances/list.html")] +struct InstanceListTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + instances: Vec, + has_active: bool, +} + +#[derive(Template)] +#[template(path = "instances/create.html")] +struct CreateInstanceTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + versions: Vec, + max_memory_mb: u32, + error: Option, +} + +#[derive(Template)] +#[template(path = "instances/edit.html")] +struct EditInstanceTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + instance: Instance, + max_memory_mb: u32, + error: Option, +} + +struct InstanceInfo { + id: i64, + name: String, + version: String, + server_type: String, + is_active: bool, + has_modpack: bool, +} + +#[derive(Debug, Deserialize)] +pub struct CreateInstanceForm { + pub name: String, + pub version: String, + pub server_type: String, + pub world_source: String, + pub seed: Option, + pub level_type: Option, + pub memory_mb: i32, + pub difficulty: i32, + pub gamemode: i32, + pub max_players: i32, + pub pvp: Option, + pub motd: Option, +} + +#[derive(Debug, Deserialize)] +pub struct EditInstanceForm { + pub difficulty: i32, + pub gamemode: i32, + pub max_players: i32, + pub pvp: Option, + pub motd: Option, + pub memory_mb: i32, +} + +pub fn router() -> Router { + Router::new() + .route("/instances", get(list_instances)) + .route("/instances/new", get(create_form)) + .route("/instances/new", post(create_instance)) + .route("/instances/{id}/edit", get(edit_form)) + .route("/instances/{id}/edit", post(edit_instance)) + .route("/instances/{id}/delete", post(delete_instance)) + .route("/instances/{id}/activate", post(activate_instance)) + .route("/instances/deactivate", post(deactivate_instance)) +} + +async fn list_instances( + State(state): State, + CurrentUser(account): CurrentUser, +) -> Result, axum::response::Redirect> { + let conn = state.db.conn(); + let mut stmt = conn + .prepare("SELECT id, name, version, server_type, is_active, has_modpack FROM instances ORDER BY name") + .map_err(|_| Redirect::to("/login"))?; + + let instances: Vec = stmt + .query_map([], |row| { + Ok(InstanceInfo { + id: row.get(0)?, + name: row.get(1)?, + version: row.get(2)?, + server_type: row.get(3)?, + is_active: row.get::<_, i32>(4)? != 0, + has_modpack: row.get::<_, i32>(5)? != 0, + }) + }) + .map_err(|_| Redirect::to("/login"))? + .filter_map(|r| r.ok()) + .collect(); + + let has_active = instances.iter().any(|i| i.is_active); + drop(stmt); + drop(conn); + + let template = InstanceListTemplate { + username: account.username.clone(), + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + instances, + has_active, + }; + + Ok(Html(template.render().unwrap_or_default())) +} + +async fn create_form( + State(state): State, + CurrentUser(account): CurrentUser, +) -> Result, axum::response::Redirect> { + let versions = fetch_minecraft_versions().await.unwrap_or_else(|_| { + vec![ + "1.21".into(), + "1.20.6".into(), + "1.20.1".into(), + "1.19.4".into(), + "1.12.2".into(), + ] + }); + + let template = CreateInstanceTemplate { + username: account.username.clone(), + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + versions, + max_memory_mb: state.config.max_memory_mb, + error: None, + }; + + Ok(Html(template.render().unwrap_or_default())) +} + +async fn create_instance( + State(state): State, + CurrentUser(_user): CurrentUser, + Form(form): Form, +) -> Result { + if form.memory_mb > state.config.max_memory_mb as i32 || form.memory_mb < 512 { + return Ok(Redirect::to("/instances/new")); + } + + let rcon_password = generate_rcon_password(); + let pvp = form.pvp.as_deref() == Some("on"); + let motd = form.motd.unwrap_or_else(|| "A Minecraft Server".into()); + let level_type = form.level_type.unwrap_or_else(|| "DEFAULT".into()); + + let conn = state.db.conn(); + conn.execute( + "INSERT INTO instances (name, version, server_type, world_source, world_seed, level_type, + memory_mb, difficulty, gamemode, max_players, pvp, motd, rcon_password, has_modpack) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)", + params![ + form.name, + form.version, + form.server_type, + form.world_source, + form.seed, + level_type, + form.memory_mb, + form.difficulty, + form.gamemode, + form.max_players, + pvp, + motd, + rcon_password, + ], + ) + .ok(); + drop(conn); + + let instance_dir = state + .config + .server_directory + .join("instances") + .join(&form.name) + .join("data"); + let _ = std::fs::create_dir_all(&instance_dir); + + Ok(Redirect::to("/instances")) +} + +async fn edit_form( + State(state): State, + Path(id): Path, + CurrentUser(account): CurrentUser, +) -> Result, axum::response::Redirect> { + let conn = state.db.conn(); + let instance = conn + .query_row( + "SELECT id, name, version, server_type, world_source, world_seed, level_type, + has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd, + rcon_password, is_active, created_at, updated_at + FROM instances WHERE id = ?1", + params![id], + |row| { + Ok(Instance { + id: row.get(0)?, + name: row.get(1)?, + version: row.get(2)?, + server_type: row.get(3)?, + world_source: row.get(4)?, + world_seed: row.get(5)?, + level_type: row.get(6)?, + has_modpack: row.get::<_, i32>(7)? != 0, + memory_mb: row.get(8)?, + difficulty: row.get(9)?, + gamemode: row.get(10)?, + max_players: row.get(11)?, + pvp: row.get::<_, i32>(12)? != 0, + motd: row.get(13)?, + rcon_password: row.get(14)?, + is_active: row.get::<_, i32>(15)? != 0, + created_at: row.get(16)?, + updated_at: row.get(17)?, + }) + }, + ) + .map_err(|_| Redirect::to("/instances"))?; + drop(conn); + + let template = EditInstanceTemplate { + username: account.username.clone(), + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + instance, + max_memory_mb: state.config.max_memory_mb, + error: None, + }; + + Ok(Html(template.render().unwrap_or_default())) +} + +#[axum::debug_handler] +async fn edit_instance( + State(state): State, + Path(id): Path, + CurrentUser(_user): CurrentUser, + Form(form): Form, +) -> Result { + if form.memory_mb > state.config.max_memory_mb as i32 || form.memory_mb < 512 { + return Ok(Redirect::to(&format!("/instances/{id}/edit"))); + } + + let pvp = form.pvp.as_deref() == Some("on"); + + let is_active = { + let conn = state.db.conn(); + conn.execute( + "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3, + pvp = ?4, motd = ?5, memory_mb = ?6, updated_at = datetime('now') + WHERE id = ?7", + params![ + form.difficulty, + form.gamemode, + form.max_players, + pvp, + form.motd, + form.memory_mb, + id, + ], + ) + .map_err(|_| Redirect::to("/instances"))?; + + conn.query_row( + "SELECT is_active FROM instances WHERE id = ?1", + params![id], + |row| row.get::<_, i32>(0).map(|v| v != 0), + ) + .unwrap_or(false) + }; + + if is_active { + let _ = state.docker.stop_container().await; + } + + Ok(Redirect::to("/instances")) +} + +async fn delete_instance( + State(state): State, + Path(id): Path, + CurrentUser(_user): CurrentUser, +) -> Result { + let conn = state.db.conn(); + let is_active: bool = conn + .query_row( + "SELECT is_active FROM instances WHERE id = ?1", + params![id], + |row| row.get::<_, i32>(0).map(|v| v != 0), + ) + .unwrap_or(false); + + if is_active { + drop(conn); + return Ok(Redirect::to("/instances")); + } + + let name: Option = conn + .query_row( + "SELECT name FROM instances WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .ok(); + + conn.execute("DELETE FROM instances WHERE id = ?1", params![id]) + .ok(); + drop(conn); + + if let Some(name) = name { + let instance_dir = state.config.server_directory.join("instances").join(&name); + let _ = std::fs::remove_dir_all(&instance_dir); + } + + Ok(Redirect::to("/instances")) +} + +#[axum::debug_handler] +async fn activate_instance( + State(state): State, + Path(id): Path, + CurrentUser(_user): CurrentUser, +) -> Result { + // Stop any running container first (this is usually fast) + let _ = state.docker.stop_container().await; + + // Update DB: deactivate all, activate the new one + let instance = { + let conn = state.db.conn(); + conn.execute("UPDATE instances SET is_active = 0 WHERE is_active = 1", []) + .ok(); + conn.execute( + "UPDATE instances SET is_active = 1, updated_at = datetime('now') WHERE id = ?1", + params![id], + ) + .ok(); + + conn.query_row( + "SELECT id, name, version, server_type, world_source, world_seed, level_type, + has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd, + rcon_password, is_active, created_at, updated_at + FROM instances WHERE id = ?1", + params![id], + |row| { + Ok(Instance { + id: row.get(0)?, + name: row.get(1)?, + version: row.get(2)?, + server_type: row.get(3)?, + world_source: row.get(4)?, + world_seed: row.get(5)?, + level_type: row.get(6)?, + has_modpack: row.get::<_, i32>(7)? != 0, + memory_mb: row.get(8)?, + difficulty: row.get(9)?, + gamemode: row.get(10)?, + max_players: row.get(11)?, + pvp: row.get::<_, i32>(12)? != 0, + motd: row.get(13)?, + rcon_password: row.get(14)?, + is_active: row.get::<_, i32>(15)? != 0, + created_at: row.get(16)?, + updated_at: row.get(17)?, + }) + }, + ) + .map_err(|_| Redirect::to("/instances"))? + }; + + // Pull image + start container in background so the UI stays responsive + let docker = state.docker.clone(); + let db = state.db.clone(); + tokio::spawn(async move { + { + let conn = db.conn(); + conn.execute( + "UPDATE instances SET container_state = 'pulling' WHERE is_active = 1", + [], + ) + .ok(); + } + match docker.start_container(&instance).await { + Ok(()) => { + let conn = db.conn(); + conn.execute( + "UPDATE instances SET container_state = 'running' WHERE is_active = 1", + [], + ) + .ok(); + } + Err(e) => { + tracing::error!("Failed to start container: {e}"); + let conn = db.conn(); + conn.execute( + "UPDATE instances SET container_state = 'error' WHERE is_active = 1", + [], + ) + .ok(); + } + } + }); + + Ok(Redirect::to("/")) +} + +async fn deactivate_instance( + State(state): State, + CurrentUser(_user): CurrentUser, +) -> Result { + let _ = state.docker.stop_container().await; + + let conn = state.db.conn(); + conn.execute( + "UPDATE instances SET is_active = 0, container_state = 'stopped' WHERE is_active = 1", + [], + ) + .ok(); + drop(conn); + + Ok(Redirect::to("/")) +} + +fn generate_rcon_password() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + let chars: Vec = "abcdefghijklmnopqrstuvwxyz0123456789".chars().collect(); + (0..16) + .map(|_| chars[rng.gen_range(0..chars.len())]) + .collect() +} + +async fn fetch_minecraft_versions() -> Result, String> { + let client = reqwest::Client::new(); + let manifest: serde_json::Value = client + .get("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json") + .send() + .await + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + + let versions: Vec = manifest["versions"] + .as_array() + .ok_or("Invalid version manifest")? + .iter() + .filter_map(|v| { + let t = v["type"].as_str()?; + if t == "release" { + v["id"].as_str().map(String::from) + } else { + None + } + }) + .collect(); + + Ok(versions) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..1cb0e54 --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,75 @@ +pub mod accounts; +pub mod backups; +pub mod dashboard; +pub mod instances; +pub mod players; + +use askama::Template; +use axum::{Router, response::Html}; +use tower_http::services::ServeDir; + +use crate::auth::CurrentUser; +use crate::config::Config; +use crate::db::Database; +use crate::docker::DockerManager; + +#[derive(Clone)] +pub struct AppState { + pub config: Config, + pub db: Database, + pub docker: DockerManager, +} + +pub fn build_router(state: AppState) -> Router { + let auth_required = + axum::middleware::from_fn_with_state(state.clone(), crate::auth::require_auth); + + let protected = Router::new() + .route("/change-password", axum::routing::get(change_password_page)) + .route( + "/change-password", + axum::routing::post(accounts::change_password), + ) + .merge(dashboard::router()) + .merge(instances::router()) + .merge(players::router()) + .merge(accounts::router()) + .merge(backups::router()) + .layer(auth_required); + + Router::new() + .route("/login", axum::routing::get(login_page)) + .route("/login", axum::routing::post(crate::auth::authenticate)) + .route("/logout", axum::routing::post(crate::auth::logout)) + .merge(protected) + .nest_service("/static", ServeDir::new("static")) + .with_state(state) +} + +#[derive(Template)] +#[template(path = "login.html")] +struct LoginPageTemplate; + +async fn login_page() -> Html { + let template = LoginPageTemplate; + Html(template.render().unwrap_or_default()) +} + +#[derive(Template)] +#[template(path = "change_password.html")] +struct ChangePasswordPageTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + error: String, +} + +async fn change_password_page(CurrentUser(account): CurrentUser) -> Html { + let template = ChangePasswordPageTemplate { + username: account.username, + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + error: String::new(), + }; + Html(template.render().unwrap_or_default()) +} diff --git a/src/routes/players.rs b/src/routes/players.rs new file mode 100644 index 0000000..b98a39d --- /dev/null +++ b/src/routes/players.rs @@ -0,0 +1,131 @@ +use askama::Template; +use axum::{ + Form, Router, + extract::State, + response::{Html, Redirect}, + routing::{get, post}, +}; +use serde::Deserialize; + +use crate::auth::CurrentUser; +use crate::routes::AppState; + +#[derive(Template)] +#[template(path = "players/manage.html")] +struct PlayerManagementTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + active_instance_name: Option, + whitelist: Vec, + ops: Vec, + bans: Vec, + message: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PlayerAction { + pub username: String, +} + +pub fn router() -> Router { + Router::new() + .route("/players", get(manage_players)) + .route("/players/whitelist/add", post(add_whitelist)) + .route("/players/whitelist/remove", post(remove_whitelist)) + .route("/players/op/add", post(add_op)) + .route("/players/op/remove", post(remove_op)) + .route("/players/ban/add", post(add_ban)) + .route("/players/ban/remove", post(remove_ban)) +} + +async fn manage_players( + State(state): State, + CurrentUser(account): CurrentUser, +) -> Result, axum::response::Redirect> { + let active = get_active_instance(&state.db); + + let (instance_name, whitelist, ops, bans) = + if let Some((_id, inst_name, _rcon_pw, _container_state)) = active { + let data_dir = state + .config + .server_directory + .join("instances") + .join(&inst_name) + .join("data"); + + let w = read_json_list(&data_dir.join("whitelist.json")); + let o = read_json_list(&data_dir.join("ops.json")); + let b = read_json_list(&data_dir.join("banned-players.json")); + (Some(inst_name), w, o, b) + } else { + (None, vec![], vec![], vec![]) + }; + + let template = PlayerManagementTemplate { + username: account.username, + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + active_instance_name: instance_name, + whitelist, + ops, + bans, + message: None, + }; + + Ok(Html(template.render().unwrap_or_default())) +} + +fn read_json_list(path: &std::path::Path) -> Vec { + if let Ok(contents) = std::fs::read_to_string(path) { + if let Ok(entries) = serde_json::from_str::>(&contents) { + return entries + .iter() + .filter_map(|e| e["name"].as_str().map(String::from)) + .collect(); + } + } + vec![] +} + +macro_rules! player_action { + ($name:ident, $command:expr) => { + async fn $name( + State(state): State, + CurrentUser(_user): CurrentUser, + Form(form): Form, + ) -> Result { + if let Some((_, _, rcon_pw, _)) = get_active_instance(&state.db) { + let client = crate::rcon::RconClient::new(rcon_pw); + let cmd = format!($command, form.username); + match client.send_command_async(cmd.clone()).await { + Ok(resp) => tracing::info!("RCON '{}' -> {}", cmd, resp.trim()), + Err(e) => tracing::error!("RCON '{}' failed: {}", cmd, e), + } + } else { + tracing::warn!( + "Player action '{}' called but no active instance", + stringify!($name) + ); + } + Ok(Redirect::to("/players")) + } + }; +} + +player_action!(add_whitelist, "whitelist add {}"); +player_action!(remove_whitelist, "whitelist remove {}"); +player_action!(add_op, "op {}"); +player_action!(remove_op, "deop {}"); +player_action!(add_ban, "ban {}"); +player_action!(remove_ban, "pardon {}"); + +fn get_active_instance(db: &crate::db::Database) -> Option<(i64, String, String, String)> { + db.conn() + .query_row( + "SELECT id, name, rcon_password, container_state FROM instances WHERE is_active = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .ok() +} -- cgit v1.2.3