summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-12 19:38:03 +0200
committersalmonik <salmonik@tuta.io>2026-07-12 19:38:03 +0200
commitae7ef10b67737f08a86743f687bca30a12e9e17a (patch)
tree056539ab76b801559dc8d07ee6599220e2bd8e07 /src
parentarchitecture (diff)
downloadminecraft-ui-ae7ef10b67737f08a86743f687bca30a12e9e17a.tar.gz
minecraft-ui-ae7ef10b67737f08a86743f687bca30a12e9e17a.zip
first stable
Diffstat (limited to '')
-rw-r--r--src/auth.rs194
-rw-r--r--src/config.rs23
-rw-r--r--src/db.rs107
-rw-r--r--src/docker.rs213
-rw-r--r--src/main.rs99
-rw-r--r--src/models/mod.rs44
-rw-r--r--src/rcon.rs133
-rw-r--r--src/routes/accounts.rs236
-rw-r--r--src/routes/backups.rs256
-rw-r--r--src/routes/dashboard.rs68
-rw-r--r--src/routes/instances.rs484
-rw-r--r--src/routes/mod.rs75
-rw-r--r--src/routes/players.rs131
13 files changed, 2061 insertions, 2 deletions
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 @@
1use argon2::{
2 Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
3 password_hash::{SaltString, rand_core::OsRng},
4};
5use axum::{
6 extract::{FromRequestParts, Request, State},
7 http::{StatusCode, request::Parts},
8 middleware::Next,
9 response::{IntoResponse, Redirect, Response},
10};
11use rusqlite::params;
12use serde::Deserialize;
13use uuid::Uuid;
14
15use crate::models::Account;
16use crate::routes::AppState;
17
18#[derive(Debug, Deserialize)]
19pub struct LoginForm {
20 pub username: String,
21 pub password: String,
22}
23
24const SESSION_COOKIE: &str = "mcui_session";
25
26pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
27 let salt = SaltString::generate(&mut OsRng);
28 let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?;
29 Ok(hash.to_string())
30}
31
32pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::password_hash::Error> {
33 let parsed = PasswordHash::new(hash)?;
34 Ok(Argon2::default()
35 .verify_password(password.as_bytes(), &parsed)
36 .is_ok())
37}
38
39pub async fn authenticate(
40 State(state): State<AppState>,
41 axum::Form(form): axum::Form<LoginForm>,
42) -> Result<impl IntoResponse, (StatusCode, String)> {
43 let conn = state.db.conn();
44
45 let account = conn
46 .query_row(
47 "SELECT id, username, is_superuser, password_change_required FROM accounts WHERE username = ?1",
48 params![form.username],
49 |row| {
50 Ok(Account {
51 id: row.get(0)?,
52 username: row.get(1)?,
53 is_superuser: row.get::<_, i32>(2)? != 0,
54 password_change_required: row.get::<_, i32>(3)? != 0,
55 })
56 },
57 )
58 .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid username or password".into()))?;
59
60 let hash: String = conn
61 .query_row(
62 "SELECT password FROM accounts WHERE id = ?1",
63 params![account.id],
64 |row| row.get(0),
65 )
66 .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error".into()))?;
67
68 if !verify_password(&form.password, &hash).unwrap_or(false) {
69 return Err((
70 StatusCode::UNAUTHORIZED,
71 "Invalid username or password".into(),
72 ));
73 }
74
75 let session_id = Uuid::new_v4().to_string();
76 conn.execute(
77 "INSERT INTO sessions (id, account_id) VALUES (?1, ?2)",
78 params![session_id, account.id],
79 )
80 .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Session error".into()))?;
81 drop(conn);
82
83 let cookie_value =
84 format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Lax");
85
86 let redirect = if account.password_change_required {
87 Redirect::to("/change-password")
88 } else {
89 Redirect::to("/")
90 };
91
92 Ok(([(axum::http::header::SET_COOKIE, cookie_value)], redirect))
93}
94
95pub async fn require_auth(
96 State(state): State<AppState>,
97 mut request: Request,
98 next: Next,
99) -> Result<Response, Response> {
100 let cookies = request
101 .headers()
102 .get(axum::http::header::COOKIE)
103 .and_then(|v| v.to_str().ok())
104 .unwrap_or("");
105
106 let session_id = cookies.split(';').map(|s| s.trim()).find_map(|cookie| {
107 if let Some((name, value)) = cookie.split_once('=') {
108 if name.trim() == SESSION_COOKIE {
109 Some(value.trim().to_string())
110 } else {
111 None
112 }
113 } else {
114 None
115 }
116 });
117
118 match session_id {
119 Some(id) => {
120 let account = state
121 .db
122 .conn()
123 .query_row(
124 "SELECT a.id, a.username, a.is_superuser, a.password_change_required
125 FROM sessions s JOIN accounts a ON s.account_id = a.id
126 WHERE s.id = ?1",
127 params![id],
128 |row| {
129 Ok(Account {
130 id: row.get(0)?,
131 username: row.get(1)?,
132 is_superuser: row.get::<_, i32>(2)? != 0,
133 password_change_required: row.get::<_, i32>(3)? != 0,
134 })
135 },
136 )
137 .ok();
138
139 if let Some(account) = account {
140 request.extensions_mut().insert(account);
141 Ok(next.run(request).await)
142 } else {
143 Err(Redirect::to("/login").into_response())
144 }
145 }
146 None => Err(Redirect::to("/login").into_response()),
147 }
148}
149
150#[derive(Clone)]
151pub struct CurrentUser(pub Account);
152
153impl<S> FromRequestParts<S> for CurrentUser
154where
155 S: Send + Sync,
156{
157 type Rejection = (StatusCode, &'static str);
158
159 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
160 parts
161 .extensions
162 .get::<Account>()
163 .cloned()
164 .map(CurrentUser)
165 .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))
166 }
167}
168
169pub async fn logout(
170 State(state): State<AppState>,
171 headers: axum::http::HeaderMap,
172) -> impl IntoResponse {
173 if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) {
174 if let Ok(cookie_str) = cookie_header.to_str() {
175 for part in cookie_str.split(';') {
176 let part = part.trim();
177 if let Some((name, value)) = part.split_once('=') {
178 if name.trim() == SESSION_COOKIE {
179 let _ = state
180 .db
181 .conn()
182 .execute("DELETE FROM sessions WHERE id = ?1", params![value.trim()]);
183 }
184 }
185 }
186 }
187 }
188
189 let clear_cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0");
190 (
191 [(axum::http::header::SET_COOKIE, clear_cookie)],
192 Redirect::to("/login"),
193 )
194}
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 @@
1use serde::Deserialize;
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, Deserialize)]
5pub struct Config {
6 pub server_directory: PathBuf,
7 pub webui_port: u16,
8 pub max_memory_mb: u32,
9 pub initial_superuser: String,
10 pub initial_superuser_password: String,
11}
12
13impl Config {
14 pub fn load() -> Result<Self, String> {
15 let content = std::fs::read_to_string("config.toml")
16 .map_err(|e| format!("Failed to read config.toml: {e}"))?;
17
18 let config: Config =
19 toml::from_str(&content).map_err(|e| format!("Failed to parse config.toml: {e}"))?;
20
21 Ok(config)
22 }
23}
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 @@
1use rusqlite::Connection;
2use std::path::Path;
3use std::sync::{Arc, Mutex};
4
5#[derive(Clone)]
6pub struct Database {
7 conn: Arc<Mutex<Connection>>,
8}
9
10// Safe because all access to Connection goes through Mutex::lock()
11unsafe impl Sync for Database {}
12
13impl Database {
14 pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
15 let conn = Connection::open(path)?;
16 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
17 let db = Self {
18 conn: Arc::new(Mutex::new(conn)),
19 };
20 db.migrate()?;
21 Ok(db)
22 }
23
24 fn migrate(&self) -> Result<(), rusqlite::Error> {
25 let conn = self.conn.lock().unwrap();
26 conn.execute_batch(
27 "
28 CREATE TABLE IF NOT EXISTS accounts (
29 id INTEGER PRIMARY KEY AUTOINCREMENT,
30 username TEXT NOT NULL UNIQUE,
31 password TEXT NOT NULL,
32 is_superuser INTEGER NOT NULL DEFAULT 0,
33 password_change_required INTEGER NOT NULL DEFAULT 0
34 );
35
36 CREATE TABLE IF NOT EXISTS sessions (
37 id TEXT PRIMARY KEY,
38 account_id INTEGER NOT NULL,
39 created_at TEXT NOT NULL DEFAULT (datetime('now')),
40 FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
41 );
42
43 CREATE TABLE IF NOT EXISTS instances (
44 id INTEGER PRIMARY KEY AUTOINCREMENT,
45 name TEXT NOT NULL UNIQUE,
46 version TEXT NOT NULL,
47 server_type TEXT NOT NULL CHECK(server_type IN ('VANILLA', 'FORGE', 'FABRIC')),
48 world_source TEXT NOT NULL CHECK(world_source IN ('generate', 'upload')),
49 world_seed TEXT,
50 level_type TEXT NOT NULL DEFAULT 'DEFAULT',
51 has_modpack INTEGER NOT NULL DEFAULT 0,
52 memory_mb INTEGER NOT NULL,
53 difficulty INTEGER NOT NULL DEFAULT 1,
54 gamemode INTEGER NOT NULL DEFAULT 0,
55 max_players INTEGER NOT NULL DEFAULT 20,
56 pvp INTEGER NOT NULL DEFAULT 1,
57 motd TEXT NOT NULL DEFAULT 'A Minecraft Server',
58 rcon_password TEXT NOT NULL,
59 is_active INTEGER NOT NULL DEFAULT 0,
60 container_state TEXT NOT NULL DEFAULT 'stopped',
61 created_at TEXT NOT NULL DEFAULT (datetime('now')),
62 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
63 );
64
65 CREATE TABLE IF NOT EXISTS backups (
66 id INTEGER PRIMARY KEY AUTOINCREMENT,
67 instance_id INTEGER NOT NULL,
68 filename TEXT NOT NULL,
69 created_at TEXT NOT NULL DEFAULT (datetime('now')),
70 FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
71 );
72
73 CREATE TABLE IF NOT EXISTS whitelist (
74 instance_id INTEGER NOT NULL,
75 username TEXT NOT NULL,
76 PRIMARY KEY (instance_id, username),
77 FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
78 );
79
80 CREATE TABLE IF NOT EXISTS ops (
81 instance_id INTEGER NOT NULL,
82 username TEXT NOT NULL,
83 PRIMARY KEY (instance_id, username),
84 FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
85 );
86
87 CREATE TABLE IF NOT EXISTS bans (
88 instance_id INTEGER NOT NULL,
89 username TEXT NOT NULL,
90 ban_type TEXT NOT NULL CHECK(ban_type IN ('user', 'ip')),
91 PRIMARY KEY (instance_id, username),
92 FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
93 );
94 ",
95 )?;
96
97 // Migration: add container_state column if missing (for older DBs)
98 let _ = conn.execute_batch(
99 "ALTER TABLE instances ADD COLUMN container_state TEXT NOT NULL DEFAULT 'stopped'",
100 );
101 Ok(())
102 }
103
104 pub fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
105 self.conn.lock().unwrap()
106 }
107}
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 @@
1use bollard::Docker;
2use bollard::container::{
3 Config as ContainerConfig, CreateContainerOptions, ListContainersOptions,
4 RemoveContainerOptions, StartContainerOptions, StopContainerOptions,
5};
6use bollard::image::CreateImageOptions;
7use bollard::models::HostConfig;
8use futures_util::StreamExt;
9use std::collections::HashMap;
10
11use crate::config::Config;
12use crate::models::Instance;
13
14#[derive(Clone)]
15pub struct DockerManager {
16 client: Docker,
17 config: Config,
18}
19
20#[derive(Debug, Clone)]
21pub enum ContainerStatus {
22 Running,
23 Stopped,
24 Starting,
25 NotFound,
26 Error(String),
27}
28
29impl DockerManager {
30 pub fn new(config: Config) -> Result<Self, bollard::errors::Error> {
31 let client = Docker::connect_with_local_defaults()?;
32 Ok(Self { client, config })
33 }
34
35 pub async fn container_status(&self) -> ContainerStatus {
36 let mut filters = HashMap::new();
37 filters.insert("name".to_string(), vec!["mc".to_string()]);
38
39 let options = ListContainersOptions {
40 all: true,
41 filters,
42 ..Default::default()
43 };
44
45 match self.client.list_containers(Some(options)).await {
46 Ok(containers) => {
47 if containers.is_empty() {
48 ContainerStatus::NotFound
49 } else {
50 match containers[0].state.as_deref() {
51 Some("running") => ContainerStatus::Running,
52 Some("exited") | Some("stopped") => ContainerStatus::Stopped,
53 _ => ContainerStatus::Starting,
54 }
55 }
56 }
57 Err(e) => ContainerStatus::Error(e.to_string()),
58 }
59 }
60
61 pub async fn start_container(&self, instance: &Instance) -> Result<(), String> {
62 tracing::info!("Pulling itzg/minecraft-server:latest image...");
63 self.pull_image().await?;
64 tracing::info!("Creating container for instance '{}'", instance.name);
65 self.recreate_container(instance).await?;
66 tracing::info!("Starting container...");
67 self.start_existing().await
68 }
69
70 async fn pull_image(&self) -> Result<(), String> {
71 let options = CreateImageOptions {
72 from_image: "itzg/minecraft-server",
73 tag: "latest",
74 ..Default::default()
75 };
76
77 let mut stream = self.client.create_image(Some(options), None, None);
78
79 while let Some(result) = stream.next().await {
80 match result {
81 Ok(_info) => {}
82 Err(e) => return Err(format!("Image pull failed: {e}")),
83 }
84 }
85 Ok(())
86 }
87
88 async fn recreate_container(&self, instance: &Instance) -> Result<(), String> {
89 // Remove existing container if present
90 let remove_options = RemoveContainerOptions {
91 force: true,
92 ..Default::default()
93 };
94 match self
95 .client
96 .remove_container("mc", Some(remove_options))
97 .await
98 {
99 Ok(_) => tracing::info!("Removed existing container"),
100 Err(e) => tracing::debug!("No existing container to remove: {e}"),
101 }
102
103 let server_dir = self
104 .config
105 .server_directory
106 .canonicalize()
107 .unwrap_or_else(|_| self.config.server_directory.clone())
108 .display()
109 .to_string();
110 let instance_data = format!("{server_dir}/instances/{}/data", instance.name);
111
112 let mut env_strings: Vec<String> = vec![
113 "EULA=TRUE".into(),
114 format!("VERSION={}", instance.version),
115 format!("TYPE={}", instance.server_type),
116 format!("MEMORY={}M", instance.memory_mb),
117 format!("MAX_PLAYERS={}", instance.max_players),
118 format!("DIFFICULTY={}", instance.difficulty),
119 "FORCE_GAMEMODE=true".into(),
120 "SPAWN_PROTECTION=0".into(),
121 "ENABLE_WHITELIST=true".into(),
122 "PAUSE_WHEN_EMPTY_SECONDS=30".into(),
123 "ONLINE_MODE=false".into(),
124 "TZ=Europe/Warsaw".into(),
125 "USE_AIKAR_FLAGS=true".into(),
126 "ENABLE_RCON=true".into(),
127 format!("RCON_PASSWORD={}", instance.rcon_password),
128 "RCON_PORT=25575".into(),
129 format!("MOTD={}", instance.motd),
130 format!("GAMEMODE={}", instance.gamemode),
131 format!("LEVEL_TYPE={}", instance.level_type),
132 ];
133
134 if instance.pvp {
135 env_strings.push("PVP=true".into());
136 } else {
137 env_strings.push("PVP=false".into());
138 }
139
140 if let Some(ref seed) = instance.world_seed {
141 if !seed.is_empty() {
142 env_strings.push(format!("LEVEL_SEED={seed}"));
143 }
144 }
145
146 let env_refs: Vec<&str> = env_strings.iter().map(|s| s.as_str()).collect();
147
148 let mut exposed_ports: HashMap<&str, HashMap<(), ()>> = HashMap::new();
149 exposed_ports.insert("25565/tcp", HashMap::new());
150 exposed_ports.insert("25575/tcp", HashMap::new());
151
152 let mut port_bindings = HashMap::new();
153 port_bindings.insert(
154 "25565/tcp".to_string(),
155 Some(vec![bollard::models::PortBinding {
156 host_ip: None,
157 host_port: Some("25565".to_string()),
158 }]),
159 );
160 port_bindings.insert(
161 "25575/tcp".to_string(),
162 Some(vec![bollard::models::PortBinding {
163 host_ip: None,
164 host_port: Some("25575".to_string()),
165 }]),
166 );
167
168 let host_config = HostConfig {
169 binds: Some(vec![format!("{instance_data}:/data")]),
170 port_bindings: Some(port_bindings),
171 ..Default::default()
172 };
173
174 let container_config = ContainerConfig {
175 image: Some("itzg/minecraft-server:latest"),
176 tty: Some(true),
177 env: Some(env_refs),
178 exposed_ports: Some(exposed_ports),
179 host_config: Some(host_config),
180 ..Default::default()
181 };
182
183 let options = CreateContainerOptions {
184 name: "mc",
185 ..Default::default()
186 };
187
188 self.client
189 .create_container(Some(options), container_config)
190 .await
191 .map_err(|e| format!("Failed to create container: {e}"))?;
192
193 tracing::info!("Container 'mc' created successfully");
194 Ok(())
195 }
196
197 async fn start_existing(&self) -> Result<(), String> {
198 self.client
199 .start_container("mc", None::<StartContainerOptions<&str>>)
200 .await
201 .map_err(|e| format!("Failed to start container: {e}"))?;
202 Ok(())
203 }
204
205 pub async fn stop_container(&self) -> Result<(), String> {
206 let options = StopContainerOptions { t: 30 };
207 self.client
208 .stop_container("mc", Some(options))
209 .await
210 .map_err(|e| format!("Failed to stop container: {e}"))?;
211 Ok(())
212 }
213}
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 @@
1fn main() { 1mod auth;
2 println!("Hello, world!"); 2mod config;
3mod db;
4mod docker;
5mod models;
6mod rcon;
7mod routes;
8
9use tracing_subscriber::EnvFilter;
10
11use crate::db::Database;
12use crate::docker::DockerManager;
13use crate::routes::AppState;
14
15#[tokio::main]
16async fn main() {
17 tracing_subscriber::fmt()
18 .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
19 .init();
20
21 let config = config::Config::load().unwrap_or_else(|e| {
22 eprintln!("Configuration error: {e}");
23 eprintln!("Please create a config.toml file based on the example.");
24 std::process::exit(1);
25 });
26
27 if !config.server_directory.exists() {
28 eprintln!(
29 "Server directory does not exist: {}",
30 config.server_directory.display()
31 );
32 eprintln!("Please create it or update config.toml.");
33 std::process::exit(1);
34 }
35
36 let db_path = config.server_directory.join("mcui.db");
37 let db = Database::open(&db_path).unwrap_or_else(|e| {
38 eprintln!("Failed to open database: {e}");
39 std::process::exit(1);
40 });
41
42 bootstrap_superuser(&db, &config);
43
44 let docker = DockerManager::new(config.clone()).unwrap_or_else(|e| {
45 eprintln!("Failed to connect to Docker: {e}");
46 eprintln!("Is the Docker daemon running and accessible?");
47 std::process::exit(1);
48 });
49
50 let state = AppState {
51 config: config.clone(),
52 db: db.clone(),
53 docker,
54 };
55
56 let router = routes::build_router(state);
57
58 let addr = format!("127.0.0.1:{}", config.webui_port);
59 tracing::info!("Starting Minecraft UI on http://{addr}");
60
61 let listener = tokio::net::TcpListener::bind(&addr)
62 .await
63 .unwrap_or_else(|e| {
64 eprintln!("Failed to bind to {addr}: {e}");
65 std::process::exit(1);
66 });
67
68 axum::serve(listener, router).await.unwrap_or_else(|e| {
69 eprintln!("Server error: {e}");
70 });
71}
72
73fn bootstrap_superuser(db: &Database, config: &config::Config) {
74 let conn = db.conn();
75 let count: i32 = conn
76 .query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get(0))
77 .unwrap_or(0);
78 drop(conn);
79
80 if count == 0 {
81 let hash = auth::hash_password(&config.initial_superuser_password)
82 .expect("Failed to hash initial password");
83
84 let conn = db.conn();
85 conn.execute(
86 "INSERT INTO accounts (username, password, is_superuser, password_change_required)
87 VALUES (?1, ?2, 1, 1)",
88 rusqlite::params![config.initial_superuser, hash],
89 )
90 .expect("Failed to create initial superuser");
91 drop(conn);
92
93 tracing::info!(
94 "Created initial superuser '{}' (password change required on first login)",
95 config.initial_superuser
96 );
97 }
3} 98}
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 @@
1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4pub struct Account {
5 pub id: i64,
6 pub username: String,
7 pub is_superuser: bool,
8 pub password_change_required: bool,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Instance {
13 pub id: i64,
14 pub name: String,
15 pub version: String,
16 pub server_type: String,
17 pub world_source: String,
18 pub world_seed: Option<String>,
19 pub level_type: String,
20 pub has_modpack: bool,
21 pub memory_mb: i32,
22 pub difficulty: i32,
23 pub gamemode: i32,
24 pub max_players: i32,
25 pub pvp: bool,
26 pub motd: String,
27 pub rcon_password: String,
28 pub is_active: bool,
29 pub created_at: String,
30 pub updated_at: String,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Backup {
35 pub id: i64,
36 pub instance_id: i64,
37 pub filename: String,
38 pub created_at: String,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct PlayerEntry {
43 pub username: String,
44}
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 @@
1use std::io::{Read, Write};
2use std::net::TcpStream;
3
4#[derive(Debug, Clone)]
5pub struct RconClient {
6 host: String,
7 port: u16,
8 password: String,
9}
10
11#[derive(Debug)]
12pub enum RconError {
13 Connection(String),
14 Auth(String),
15 Response(String),
16}
17
18impl std::fmt::Display for RconError {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 RconError::Connection(e) => write!(f, "RCON connection error: {e}"),
22 RconError::Auth(e) => write!(f, "RCON auth error: {e}"),
23 RconError::Response(e) => write!(f, "RCON response error: {e}"),
24 }
25 }
26}
27
28const SERVERDATA_AUTH: i32 = 3;
29const SERVERDATA_EXECCOMMAND: i32 = 2;
30
31impl RconClient {
32 pub fn new(password: String) -> Self {
33 Self {
34 host: "127.0.0.1".to_string(),
35 port: 25575,
36 password,
37 }
38 }
39
40 /// Non-blocking async wrapper — runs the blocking RCON call on a thread pool.
41 pub async fn send_command_async(&self, command: String) -> Result<String, RconError> {
42 let client = self.clone();
43 tokio::task::spawn_blocking(move || client.send_command(&command))
44 .await
45 .map_err(|e| RconError::Connection(e.to_string()))?
46 }
47
48 fn send_command(&self, command: &str) -> Result<String, RconError> {
49 let mut stream = TcpStream::connect(format!("{}:{}", self.host, self.port))
50 .map_err(|e| RconError::Connection(e.to_string()))?;
51
52 stream
53 .set_read_timeout(Some(std::time::Duration::from_secs(1)))
54 .ok();
55
56 let request_id = 1;
57 self.write_packet(&mut stream, request_id, SERVERDATA_AUTH, &self.password)?;
58 let response = self.read_packet(&mut stream)?;
59
60 if response.request_id == -1 {
61 return Err(RconError::Auth("Invalid RCON password".into()));
62 }
63
64 let cmd_id = 2;
65 self.write_packet(&mut stream, cmd_id, SERVERDATA_EXECCOMMAND, command)?;
66
67 let response = self.read_packet(&mut stream)?;
68 // Second packet is an end marker — some servers send it, some don't; ignore errors
69 let _ = self.read_packet(&mut stream);
70
71 Ok(response.body)
72 }
73
74 fn write_packet(
75 &self,
76 stream: &mut TcpStream,
77 request_id: i32,
78 packet_type: i32,
79 body: &str,
80 ) -> Result<(), RconError> {
81 let body_bytes = body.as_bytes();
82 let length = 10 + body_bytes.len() as i32;
83
84 let mut packet = Vec::with_capacity(4 + length as usize);
85 packet.extend_from_slice(&length.to_le_bytes());
86 packet.extend_from_slice(&request_id.to_le_bytes());
87 packet.extend_from_slice(&packet_type.to_le_bytes());
88 packet.extend_from_slice(body_bytes);
89 packet.extend_from_slice(&[0u8, 0u8]);
90
91 stream
92 .write_all(&packet)
93 .map_err(|e| RconError::Connection(e.to_string()))?;
94
95 Ok(())
96 }
97
98 fn read_packet(&self, stream: &mut TcpStream) -> Result<RconPacket, RconError> {
99 let mut length_buf = [0u8; 4];
100 stream
101 .read_exact(&mut length_buf)
102 .map_err(|e| RconError::Response(format!("Failed to read length: {e}")))?;
103
104 let length = i32::from_le_bytes(length_buf);
105 if length < 10 {
106 return Err(RconError::Response("Packet too short".into()));
107 }
108
109 let mut payload = vec![0u8; length as usize];
110 stream
111 .read_exact(&mut payload)
112 .map_err(|e| RconError::Response(format!("Failed to read payload: {e}")))?;
113
114 let request_id = i32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]);
115 let packet_type = i32::from_le_bytes([payload[4], payload[5], payload[6], payload[7]]);
116
117 let body_bytes = &payload[8..payload.len() - 2];
118 let body = String::from_utf8_lossy(body_bytes).to_string();
119
120 Ok(RconPacket {
121 request_id,
122 packet_type,
123 body,
124 })
125 }
126}
127
128#[derive(Debug)]
129struct RconPacket {
130 request_id: i32,
131 packet_type: i32,
132 body: String,
133}
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 @@
1use askama::Template;
2use axum::{
3 Form, Router,
4 extract::State,
5 response::{Html, Redirect},
6 routing::{get, post},
7};
8use rusqlite::params;
9use serde::Deserialize;
10
11use crate::auth::{CurrentUser, hash_password};
12use crate::routes::AppState;
13
14#[derive(Template)]
15#[template(path = "accounts/manage.html")]
16struct AccountManagementTemplate {
17 username: String,
18 is_superuser: bool,
19 password_change_required: bool,
20 accounts: Vec<AccountRow>,
21 message: Option<String>,
22}
23
24struct AccountRow {
25 id: i64,
26 username: String,
27 is_superuser: bool,
28}
29
30#[derive(Debug, Deserialize)]
31pub struct CreateAccountForm {
32 pub username: String,
33 pub password: String,
34 pub is_superuser: Option<String>,
35}
36
37#[derive(Debug, Deserialize)]
38pub struct DeleteAccountForm {
39 pub id: i64,
40}
41
42#[derive(Debug, Deserialize)]
43pub struct ChangePasswordForm {
44 pub current_password: String,
45 pub new_password: String,
46}
47
48#[derive(Debug, Deserialize)]
49pub struct SetPasswordForm {
50 pub id: i64,
51 pub new_password: String,
52}
53
54pub fn router() -> Router<AppState> {
55 Router::new()
56 .route("/accounts", get(manage_accounts))
57 .route("/accounts/create", post(create_account))
58 .route("/accounts/delete", post(delete_account))
59 .route("/accounts/set-password", post(set_password))
60 .route(
61 "/accounts/change-own-password",
62 post(change_own_password_from_accounts),
63 )
64}
65
66pub async fn change_password(
67 State(state): State<AppState>,
68 CurrentUser(account): CurrentUser,
69 Form(form): Form<ChangePasswordForm>,
70) -> Result<Redirect, Redirect> {
71 let conn = state.db.conn();
72
73 let hash: String = conn
74 .query_row(
75 "SELECT password FROM accounts WHERE id = ?1",
76 params![account.id],
77 |row| row.get(0),
78 )
79 .map_err(|_| Redirect::to("/login"))?;
80
81 if !crate::auth::verify_password(&form.current_password, &hash).unwrap_or(false) {
82 drop(conn);
83 return Ok(Redirect::to("/change-password"));
84 }
85
86 let new_hash = hash_password(&form.new_password).map_err(|_| Redirect::to("/login"))?;
87
88 conn.execute(
89 "UPDATE accounts SET password = ?1, password_change_required = 0 WHERE id = ?2",
90 params![new_hash, account.id],
91 )
92 .map_err(|_| Redirect::to("/login"))?;
93
94 conn.execute(
95 "DELETE FROM sessions WHERE account_id = ?1",
96 params![account.id],
97 )
98 .ok();
99 drop(conn);
100
101 Ok(Redirect::to("/login"))
102}
103
104async fn manage_accounts(
105 State(state): State<AppState>,
106 CurrentUser(account): CurrentUser,
107) -> Result<Html<String>, Redirect> {
108 if !account.is_superuser {
109 return Err(Redirect::to("/"));
110 }
111
112 let conn = state.db.conn();
113 let mut stmt = conn
114 .prepare("SELECT id, username, is_superuser FROM accounts ORDER BY username")
115 .map_err(|_| Redirect::to("/login"))?;
116
117 let accounts: Vec<AccountRow> = stmt
118 .query_map([], |row| {
119 Ok(AccountRow {
120 id: row.get(0)?,
121 username: row.get(1)?,
122 is_superuser: row.get::<_, i32>(2)? != 0,
123 })
124 })
125 .map_err(|_| Redirect::to("/login"))?
126 .filter_map(|r| r.ok())
127 .collect();
128 drop(stmt);
129 drop(conn);
130
131 let template = AccountManagementTemplate {
132 username: account.username,
133 is_superuser: account.is_superuser,
134 password_change_required: account.password_change_required,
135 accounts,
136 message: None,
137 };
138
139 Ok(Html(template.render().unwrap_or_default()))
140}
141
142async fn create_account(
143 State(state): State<AppState>,
144 CurrentUser(account): CurrentUser,
145 Form(form): Form<CreateAccountForm>,
146) -> Result<Redirect, Redirect> {
147 if !account.is_superuser {
148 return Ok(Redirect::to("/"));
149 }
150
151 let hash = hash_password(&form.password).map_err(|_| Redirect::to("/accounts"))?;
152 let is_superuser = form.is_superuser.as_deref() == Some("on");
153
154 let conn = state.db.conn();
155 let _ = conn.execute(
156 "INSERT INTO accounts (username, password, is_superuser) VALUES (?1, ?2, ?3)",
157 params![form.username, hash, is_superuser],
158 );
159 drop(conn);
160
161 Ok(Redirect::to("/accounts"))
162}
163
164async fn delete_account(
165 State(state): State<AppState>,
166 CurrentUser(account): CurrentUser,
167 Form(form): Form<DeleteAccountForm>,
168) -> Result<Redirect, Redirect> {
169 if !account.is_superuser || account.id == form.id {
170 return Ok(Redirect::to("/accounts"));
171 }
172
173 let conn = state.db.conn();
174 let superuser_count: i32 = conn
175 .query_row(
176 "SELECT COUNT(*) FROM accounts WHERE is_superuser = 1 AND id != ?1",
177 params![form.id],
178 |row| row.get(0),
179 )
180 .unwrap_or(0);
181
182 let is_superuser: bool = conn
183 .query_row(
184 "SELECT is_superuser FROM accounts WHERE id = ?1",
185 params![form.id],
186 |row| row.get::<_, i32>(0).map(|v| v != 0),
187 )
188 .unwrap_or(false);
189
190 if is_superuser && superuser_count == 0 {
191 drop(conn);
192 return Ok(Redirect::to("/accounts"));
193 }
194
195 let _ = conn.execute("DELETE FROM accounts WHERE id = ?1", params![form.id]);
196 drop(conn);
197
198 Ok(Redirect::to("/accounts"))
199}
200
201async fn set_password(
202 State(state): State<AppState>,
203 CurrentUser(account): CurrentUser,
204 Form(form): Form<SetPasswordForm>,
205) -> Result<Redirect, Redirect> {
206 if !account.is_superuser {
207 return Ok(Redirect::to("/"));
208 }
209
210 let new_hash = hash_password(&form.new_password).map_err(|_| Redirect::to("/accounts"))?;
211
212 let conn = state.db.conn();
213 conn.execute(
214 "UPDATE accounts SET password = ?1, password_change_required = 0 WHERE id = ?2",
215 params![new_hash, form.id],
216 )
217 .ok();
218
219 // Invalidate sessions for the target account
220 conn.execute(
221 "DELETE FROM sessions WHERE account_id = ?1",
222 params![form.id],
223 )
224 .ok();
225 drop(conn);
226
227 Ok(Redirect::to("/accounts"))
228}
229
230async fn change_own_password_from_accounts(
231 State(state): State<AppState>,
232 CurrentUser(account): CurrentUser,
233 Form(form): Form<ChangePasswordForm>,
234) -> Result<Redirect, Redirect> {
235 change_password(State(state), CurrentUser(account), Form(form)).await
236}
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 @@
1use askama::Template;
2use axum::{
3 Router,
4 body::Body,
5 extract::{Path, State},
6 http::{StatusCode, header},
7 response::{Html, Redirect, Response},
8 routing::{get, post},
9};
10use rusqlite::params;
11use zip::write::FileOptions;
12
13use crate::auth::CurrentUser;
14use crate::routes::AppState;
15
16#[derive(Template)]
17#[template(path = "backups/list.html")]
18struct BackupListTemplate {
19 username: String,
20 is_superuser: bool,
21 password_change_required: bool,
22 instance_name: String,
23 instance_id: i64,
24 backups: Vec<BackupRow>,
25 message: Option<String>,
26}
27
28struct BackupRow {
29 id: i64,
30 filename: String,
31 created_at: String,
32}
33
34pub fn router() -> Router<AppState> {
35 Router::new()
36 .route("/instances/{id}/backups", get(list_backups))
37 .route("/instances/{id}/backups/create", post(create_backup))
38 .route("/backups/{id}/restore", post(restore_backup))
39 .route("/backups/{id}/download", get(download_backup))
40 .route("/instances/{id}/world/export", get(export_world))
41}
42
43async fn list_backups(
44 State(state): State<AppState>,
45 Path(instance_id): Path<i64>,
46 CurrentUser(account): CurrentUser,
47) -> Result<Html<String>, axum::response::Redirect> {
48 let conn = state.db.conn();
49 let instance_name: String = conn
50 .query_row(
51 "SELECT name FROM instances WHERE id = ?1",
52 params![instance_id],
53 |row| row.get(0),
54 )
55 .map_err(|_| Redirect::to("/instances"))?;
56
57 let mut stmt = conn
58 .prepare("SELECT id, filename, created_at FROM backups WHERE instance_id = ?1 ORDER BY created_at DESC")
59 .map_err(|_| Redirect::to("/instances"))?;
60
61 let backups: Vec<BackupRow> = stmt
62 .query_map(params![instance_id], |row| {
63 Ok(BackupRow {
64 id: row.get(0)?,
65 filename: row.get(1)?,
66 created_at: row.get(2)?,
67 })
68 })
69 .map_err(|_| Redirect::to("/instances"))?
70 .filter_map(|r| r.ok())
71 .collect();
72 drop(stmt);
73 drop(conn);
74
75 let template = BackupListTemplate {
76 username: account.username.clone(),
77 is_superuser: account.is_superuser,
78 password_change_required: account.password_change_required,
79 instance_name,
80 instance_id,
81 backups,
82 message: None,
83 };
84
85 Ok(Html(template.render().unwrap_or_default()))
86}
87
88async fn create_backup(
89 State(state): State<AppState>,
90 Path(instance_id): Path<i64>,
91 CurrentUser(_user): CurrentUser,
92) -> Result<Redirect, axum::response::Redirect> {
93 let conn = state.db.conn();
94 let instance_name: String = conn
95 .query_row(
96 "SELECT name FROM instances WHERE id = ?1",
97 params![instance_id],
98 |row| row.get(0),
99 )
100 .map_err(|_| Redirect::to("/instances"))?;
101 drop(conn);
102
103 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
104 let filename = format!("{instance_name}_{timestamp}.zip");
105
106 let data_dir = state
107 .config
108 .server_directory
109 .join("instances")
110 .join(&instance_name)
111 .join("data");
112
113 let backup_dir = state.config.server_directory.join("backups");
114 let _ = std::fs::create_dir_all(&backup_dir);
115 let backup_path = backup_dir.join(&filename);
116
117 if let Ok(file) = std::fs::File::create(&backup_path) {
118 let mut zip = zip::ZipWriter::new(file);
119 let options: FileOptions<'_, ()> =
120 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
121
122 if let Ok(entries) = std::fs::read_dir(&data_dir) {
123 for entry in entries.flatten() {
124 let path = entry.path();
125 if path.is_file() {
126 if let Some(name) = path.file_name() {
127 if let Ok(contents) = std::fs::read(&path) {
128 let _ = zip.start_file(&*name.to_string_lossy(), options);
129 let _ = std::io::Write::write_all(&mut zip, &contents);
130 }
131 }
132 }
133 }
134 }
135 let _ = zip.finish();
136 }
137
138 let conn = state.db.conn();
139 conn.execute(
140 "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)",
141 params![instance_id, filename],
142 )
143 .ok();
144 drop(conn);
145
146 Ok(Redirect::to(&format!("/instances/{instance_id}/backups")))
147}
148
149async fn restore_backup(
150 State(state): State<AppState>,
151 Path(backup_id): Path<i64>,
152 CurrentUser(_user): CurrentUser,
153) -> Result<Redirect, axum::response::Redirect> {
154 let conn = state.db.conn();
155 let (instance_id, _filename): (i64, String) = conn
156 .query_row(
157 "SELECT instance_id, filename FROM backups WHERE id = ?1",
158 params![backup_id],
159 |row| Ok((row.get(0)?, row.get(1)?)),
160 )
161 .map_err(|_| Redirect::to("/instances"))?;
162 drop(conn);
163
164 Ok(Redirect::to(&format!("/instances/{instance_id}/backups")))
165}
166
167async fn download_backup(
168 State(state): State<AppState>,
169 Path(backup_id): Path<i64>,
170 CurrentUser(_user): CurrentUser,
171) -> Result<Response, (StatusCode, &'static str)> {
172 let conn = state.db.conn();
173 let (_, filename): (i64, String) = conn
174 .query_row(
175 "SELECT instance_id, filename FROM backups WHERE id = ?1",
176 params![backup_id],
177 |row| Ok((row.get(0)?, row.get(1)?)),
178 )
179 .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?;
180 drop(conn);
181
182 let backup_path = state
183 .config
184 .server_directory
185 .join("backups")
186 .join(&filename);
187
188 let content =
189 std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?;
190
191 Ok(Response::builder()
192 .header(header::CONTENT_TYPE, "application/zip")
193 .header(
194 header::CONTENT_DISPOSITION,
195 format!("attachment; filename=\"{filename}\""),
196 )
197 .body(Body::from(content))
198 .unwrap())
199}
200
201async fn export_world(
202 State(state): State<AppState>,
203 Path(instance_id): Path<i64>,
204 CurrentUser(_user): CurrentUser,
205) -> Result<Response, (StatusCode, &'static str)> {
206 let conn = state.db.conn();
207 let instance_name: String = conn
208 .query_row(
209 "SELECT name FROM instances WHERE id = ?1",
210 params![instance_id],
211 |row| row.get(0),
212 )
213 .map_err(|_| (StatusCode::NOT_FOUND, "Instance not found"))?;
214 drop(conn);
215
216 let world_dir = state
217 .config
218 .server_directory
219 .join("instances")
220 .join(&instance_name)
221 .join("data")
222 .join("world");
223
224 let mut zip_buf = Vec::new();
225 {
226 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
227 let options: FileOptions<'_, ()> =
228 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
229
230 if let Ok(entries) = std::fs::read_dir(&world_dir) {
231 for entry in entries.flatten() {
232 let path = entry.path();
233 if path.is_file() {
234 if let Some(name) = path.file_name() {
235 if let Ok(contents) = std::fs::read(&path) {
236 let _ = zip.start_file(&*name.to_string_lossy(), options);
237 let _ = std::io::Write::write_all(&mut zip, &contents);
238 }
239 }
240 }
241 }
242 }
243 let _ = zip.finish();
244 }
245
246 let filename = format!("{instance_name}_world.zip");
247
248 Ok(Response::builder()
249 .header(header::CONTENT_TYPE, "application/zip")
250 .header(
251 header::CONTENT_DISPOSITION,
252 format!("attachment; filename=\"{filename}\""),
253 )
254 .body(Body::from(zip_buf))
255 .unwrap())
256}
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 @@
1use askama::Template;
2use axum::{
3 Router,
4 extract::State,
5 response::{Html, IntoResponse},
6 routing::get,
7};
8
9use crate::auth::CurrentUser;
10use crate::routes::AppState;
11
12#[derive(Template)]
13#[template(path = "dashboard.html")]
14struct DashboardTemplate {
15 username: String,
16 is_superuser: bool,
17 password_change_required: bool,
18 active_instance: Option<ActiveInstanceInfo>,
19 container_state: String,
20}
21
22#[derive(Clone)]
23struct ActiveInstanceInfo {
24 name: String,
25 version: String,
26 server_type: String,
27 max_players: i32,
28}
29
30pub fn router() -> Router<AppState> {
31 Router::new().route("/", get(dashboard))
32}
33
34async fn dashboard(
35 State(state): State<AppState>,
36 CurrentUser(account): CurrentUser,
37) -> Result<impl IntoResponse, axum::response::Redirect> {
38 let (active_info, container_state) = {
39 let conn = state.db.conn();
40 let result = conn.query_row(
41 "SELECT name, version, server_type, max_players, container_state
42 FROM instances WHERE is_active = 1",
43 [],
44 |row| {
45 Ok((
46 Some(ActiveInstanceInfo {
47 name: row.get(0)?,
48 version: row.get(1)?,
49 server_type: row.get(2)?,
50 max_players: row.get(3)?,
51 }),
52 row.get::<_, String>(4)?,
53 ))
54 },
55 );
56 result.unwrap_or((None, "stopped".into()))
57 };
58
59 let template = DashboardTemplate {
60 username: account.username,
61 is_superuser: account.is_superuser,
62 password_change_required: account.password_change_required,
63 active_instance: active_info,
64 container_state,
65 };
66
67 Ok(Html(template.render().unwrap_or_default()))
68}
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 @@
1use askama::Template;
2use axum::{
3 Form, Router,
4 extract::{Path, State},
5 response::{Html, Redirect},
6 routing::{get, post},
7};
8use rusqlite::params;
9use serde::Deserialize;
10
11use crate::auth::CurrentUser;
12use crate::models::Instance;
13use crate::routes::AppState;
14
15#[derive(Template)]
16#[template(path = "instances/list.html")]
17struct InstanceListTemplate {
18 username: String,
19 is_superuser: bool,
20 password_change_required: bool,
21 instances: Vec<InstanceInfo>,
22 has_active: bool,
23}
24
25#[derive(Template)]
26#[template(path = "instances/create.html")]
27struct CreateInstanceTemplate {
28 username: String,
29 is_superuser: bool,
30 password_change_required: bool,
31 versions: Vec<String>,
32 max_memory_mb: u32,
33 error: Option<String>,
34}
35
36#[derive(Template)]
37#[template(path = "instances/edit.html")]
38struct EditInstanceTemplate {
39 username: String,
40 is_superuser: bool,
41 password_change_required: bool,
42 instance: Instance,
43 max_memory_mb: u32,
44 error: Option<String>,
45}
46
47struct InstanceInfo {
48 id: i64,
49 name: String,
50 version: String,
51 server_type: String,
52 is_active: bool,
53 has_modpack: bool,
54}
55
56#[derive(Debug, Deserialize)]
57pub struct CreateInstanceForm {
58 pub name: String,
59 pub version: String,
60 pub server_type: String,
61 pub world_source: String,
62 pub seed: Option<String>,
63 pub level_type: Option<String>,
64 pub memory_mb: i32,
65 pub difficulty: i32,
66 pub gamemode: i32,
67 pub max_players: i32,
68 pub pvp: Option<String>,
69 pub motd: Option<String>,
70}
71
72#[derive(Debug, Deserialize)]
73pub struct EditInstanceForm {
74 pub difficulty: i32,
75 pub gamemode: i32,
76 pub max_players: i32,
77 pub pvp: Option<String>,
78 pub motd: Option<String>,
79 pub memory_mb: i32,
80}
81
82pub fn router() -> Router<AppState> {
83 Router::new()
84 .route("/instances", get(list_instances))
85 .route("/instances/new", get(create_form))
86 .route("/instances/new", post(create_instance))
87 .route("/instances/{id}/edit", get(edit_form))
88 .route("/instances/{id}/edit", post(edit_instance))
89 .route("/instances/{id}/delete", post(delete_instance))
90 .route("/instances/{id}/activate", post(activate_instance))
91 .route("/instances/deactivate", post(deactivate_instance))
92}
93
94async fn list_instances(
95 State(state): State<AppState>,
96 CurrentUser(account): CurrentUser,
97) -> Result<Html<String>, axum::response::Redirect> {
98 let conn = state.db.conn();
99 let mut stmt = conn
100 .prepare("SELECT id, name, version, server_type, is_active, has_modpack FROM instances ORDER BY name")
101 .map_err(|_| Redirect::to("/login"))?;
102
103 let instances: Vec<InstanceInfo> = stmt
104 .query_map([], |row| {
105 Ok(InstanceInfo {
106 id: row.get(0)?,
107 name: row.get(1)?,
108 version: row.get(2)?,
109 server_type: row.get(3)?,
110 is_active: row.get::<_, i32>(4)? != 0,
111 has_modpack: row.get::<_, i32>(5)? != 0,
112 })
113 })
114 .map_err(|_| Redirect::to("/login"))?
115 .filter_map(|r| r.ok())
116 .collect();
117
118 let has_active = instances.iter().any(|i| i.is_active);
119 drop(stmt);
120 drop(conn);
121
122 let template = InstanceListTemplate {
123 username: account.username.clone(),
124 is_superuser: account.is_superuser,
125 password_change_required: account.password_change_required,
126 instances,
127 has_active,
128 };
129
130 Ok(Html(template.render().unwrap_or_default()))
131}
132
133async fn create_form(
134 State(state): State<AppState>,
135 CurrentUser(account): CurrentUser,
136) -> Result<Html<String>, axum::response::Redirect> {
137 let versions = fetch_minecraft_versions().await.unwrap_or_else(|_| {
138 vec![
139 "1.21".into(),
140 "1.20.6".into(),
141 "1.20.1".into(),
142 "1.19.4".into(),
143 "1.12.2".into(),
144 ]
145 });
146
147 let template = CreateInstanceTemplate {
148 username: account.username.clone(),
149 is_superuser: account.is_superuser,
150 password_change_required: account.password_change_required,
151 versions,
152 max_memory_mb: state.config.max_memory_mb,
153 error: None,
154 };
155
156 Ok(Html(template.render().unwrap_or_default()))
157}
158
159async fn create_instance(
160 State(state): State<AppState>,
161 CurrentUser(_user): CurrentUser,
162 Form(form): Form<CreateInstanceForm>,
163) -> Result<Redirect, axum::response::Redirect> {
164 if form.memory_mb > state.config.max_memory_mb as i32 || form.memory_mb < 512 {
165 return Ok(Redirect::to("/instances/new"));
166 }
167
168 let rcon_password = generate_rcon_password();
169 let pvp = form.pvp.as_deref() == Some("on");
170 let motd = form.motd.unwrap_or_else(|| "A Minecraft Server".into());
171 let level_type = form.level_type.unwrap_or_else(|| "DEFAULT".into());
172
173 let conn = state.db.conn();
174 conn.execute(
175 "INSERT INTO instances (name, version, server_type, world_source, world_seed, level_type,
176 memory_mb, difficulty, gamemode, max_players, pvp, motd, rcon_password, has_modpack)
177 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)",
178 params![
179 form.name,
180 form.version,
181 form.server_type,
182 form.world_source,
183 form.seed,
184 level_type,
185 form.memory_mb,
186 form.difficulty,
187 form.gamemode,
188 form.max_players,
189 pvp,
190 motd,
191 rcon_password,
192 ],
193 )
194 .ok();
195 drop(conn);
196
197 let instance_dir = state
198 .config
199 .server_directory
200 .join("instances")
201 .join(&form.name)
202 .join("data");
203 let _ = std::fs::create_dir_all(&instance_dir);
204
205 Ok(Redirect::to("/instances"))
206}
207
208async fn edit_form(
209 State(state): State<AppState>,
210 Path(id): Path<i64>,
211 CurrentUser(account): CurrentUser,
212) -> Result<Html<String>, axum::response::Redirect> {
213 let conn = state.db.conn();
214 let instance = conn
215 .query_row(
216 "SELECT id, name, version, server_type, world_source, world_seed, level_type,
217 has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd,
218 rcon_password, is_active, created_at, updated_at
219 FROM instances WHERE id = ?1",
220 params![id],
221 |row| {
222 Ok(Instance {
223 id: row.get(0)?,
224 name: row.get(1)?,
225 version: row.get(2)?,
226 server_type: row.get(3)?,
227 world_source: row.get(4)?,
228 world_seed: row.get(5)?,
229 level_type: row.get(6)?,
230 has_modpack: row.get::<_, i32>(7)? != 0,
231 memory_mb: row.get(8)?,
232 difficulty: row.get(9)?,
233 gamemode: row.get(10)?,
234 max_players: row.get(11)?,
235 pvp: row.get::<_, i32>(12)? != 0,
236 motd: row.get(13)?,
237 rcon_password: row.get(14)?,
238 is_active: row.get::<_, i32>(15)? != 0,
239 created_at: row.get(16)?,
240 updated_at: row.get(17)?,
241 })
242 },
243 )
244 .map_err(|_| Redirect::to("/instances"))?;
245 drop(conn);
246
247 let template = EditInstanceTemplate {
248 username: account.username.clone(),
249 is_superuser: account.is_superuser,
250 password_change_required: account.password_change_required,
251 instance,
252 max_memory_mb: state.config.max_memory_mb,
253 error: None,
254 };
255
256 Ok(Html(template.render().unwrap_or_default()))
257}
258
259#[axum::debug_handler]
260async fn edit_instance(
261 State(state): State<AppState>,
262 Path(id): Path<i64>,
263 CurrentUser(_user): CurrentUser,
264 Form(form): Form<EditInstanceForm>,
265) -> Result<Redirect, axum::response::Redirect> {
266 if form.memory_mb > state.config.max_memory_mb as i32 || form.memory_mb < 512 {
267 return Ok(Redirect::to(&format!("/instances/{id}/edit")));
268 }
269
270 let pvp = form.pvp.as_deref() == Some("on");
271
272 let is_active = {
273 let conn = state.db.conn();
274 conn.execute(
275 "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3,
276 pvp = ?4, motd = ?5, memory_mb = ?6, updated_at = datetime('now')
277 WHERE id = ?7",
278 params![
279 form.difficulty,
280 form.gamemode,
281 form.max_players,
282 pvp,
283 form.motd,
284 form.memory_mb,
285 id,
286 ],
287 )
288 .map_err(|_| Redirect::to("/instances"))?;
289
290 conn.query_row(
291 "SELECT is_active FROM instances WHERE id = ?1",
292 params![id],
293 |row| row.get::<_, i32>(0).map(|v| v != 0),
294 )
295 .unwrap_or(false)
296 };
297
298 if is_active {
299 let _ = state.docker.stop_container().await;
300 }
301
302 Ok(Redirect::to("/instances"))
303}
304
305async fn delete_instance(
306 State(state): State<AppState>,
307 Path(id): Path<i64>,
308 CurrentUser(_user): CurrentUser,
309) -> Result<Redirect, axum::response::Redirect> {
310 let conn = state.db.conn();
311 let is_active: bool = conn
312 .query_row(
313 "SELECT is_active FROM instances WHERE id = ?1",
314 params![id],
315 |row| row.get::<_, i32>(0).map(|v| v != 0),
316 )
317 .unwrap_or(false);
318
319 if is_active {
320 drop(conn);
321 return Ok(Redirect::to("/instances"));
322 }
323
324 let name: Option<String> = conn
325 .query_row(
326 "SELECT name FROM instances WHERE id = ?1",
327 params![id],
328 |row| row.get(0),
329 )
330 .ok();
331
332 conn.execute("DELETE FROM instances WHERE id = ?1", params![id])
333 .ok();
334 drop(conn);
335
336 if let Some(name) = name {
337 let instance_dir = state.config.server_directory.join("instances").join(&name);
338 let _ = std::fs::remove_dir_all(&instance_dir);
339 }
340
341 Ok(Redirect::to("/instances"))
342}
343
344#[axum::debug_handler]
345async fn activate_instance(
346 State(state): State<AppState>,
347 Path(id): Path<i64>,
348 CurrentUser(_user): CurrentUser,
349) -> Result<Redirect, axum::response::Redirect> {
350 // Stop any running container first (this is usually fast)
351 let _ = state.docker.stop_container().await;
352
353 // Update DB: deactivate all, activate the new one
354 let instance = {
355 let conn = state.db.conn();
356 conn.execute("UPDATE instances SET is_active = 0 WHERE is_active = 1", [])
357 .ok();
358 conn.execute(
359 "UPDATE instances SET is_active = 1, updated_at = datetime('now') WHERE id = ?1",
360 params![id],
361 )
362 .ok();
363
364 conn.query_row(
365 "SELECT id, name, version, server_type, world_source, world_seed, level_type,
366 has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd,
367 rcon_password, is_active, created_at, updated_at
368 FROM instances WHERE id = ?1",
369 params![id],
370 |row| {
371 Ok(Instance {
372 id: row.get(0)?,
373 name: row.get(1)?,
374 version: row.get(2)?,
375 server_type: row.get(3)?,
376 world_source: row.get(4)?,
377 world_seed: row.get(5)?,
378 level_type: row.get(6)?,
379 has_modpack: row.get::<_, i32>(7)? != 0,
380 memory_mb: row.get(8)?,
381 difficulty: row.get(9)?,
382 gamemode: row.get(10)?,
383 max_players: row.get(11)?,
384 pvp: row.get::<_, i32>(12)? != 0,
385 motd: row.get(13)?,
386 rcon_password: row.get(14)?,
387 is_active: row.get::<_, i32>(15)? != 0,
388 created_at: row.get(16)?,
389 updated_at: row.get(17)?,
390 })
391 },
392 )
393 .map_err(|_| Redirect::to("/instances"))?
394 };
395
396 // Pull image + start container in background so the UI stays responsive
397 let docker = state.docker.clone();
398 let db = state.db.clone();
399 tokio::spawn(async move {
400 {
401 let conn = db.conn();
402 conn.execute(
403 "UPDATE instances SET container_state = 'pulling' WHERE is_active = 1",
404 [],
405 )
406 .ok();
407 }
408 match docker.start_container(&instance).await {
409 Ok(()) => {
410 let conn = db.conn();
411 conn.execute(
412 "UPDATE instances SET container_state = 'running' WHERE is_active = 1",
413 [],
414 )
415 .ok();
416 }
417 Err(e) => {
418 tracing::error!("Failed to start container: {e}");
419 let conn = db.conn();
420 conn.execute(
421 "UPDATE instances SET container_state = 'error' WHERE is_active = 1",
422 [],
423 )
424 .ok();
425 }
426 }
427 });
428
429 Ok(Redirect::to("/"))
430}
431
432async fn deactivate_instance(
433 State(state): State<AppState>,
434 CurrentUser(_user): CurrentUser,
435) -> Result<Redirect, axum::response::Redirect> {
436 let _ = state.docker.stop_container().await;
437
438 let conn = state.db.conn();
439 conn.execute(
440 "UPDATE instances SET is_active = 0, container_state = 'stopped' WHERE is_active = 1",
441 [],
442 )
443 .ok();
444 drop(conn);
445
446 Ok(Redirect::to("/"))
447}
448
449fn generate_rcon_password() -> String {
450 use rand::Rng;
451 let mut rng = rand::thread_rng();
452 let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz0123456789".chars().collect();
453 (0..16)
454 .map(|_| chars[rng.gen_range(0..chars.len())])
455 .collect()
456}
457
458async fn fetch_minecraft_versions() -> Result<Vec<String>, String> {
459 let client = reqwest::Client::new();
460 let manifest: serde_json::Value = client
461 .get("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")
462 .send()
463 .await
464 .map_err(|e| e.to_string())?
465 .json()
466 .await
467 .map_err(|e| e.to_string())?;
468
469 let versions: Vec<String> = manifest["versions"]
470 .as_array()
471 .ok_or("Invalid version manifest")?
472 .iter()
473 .filter_map(|v| {
474 let t = v["type"].as_str()?;
475 if t == "release" {
476 v["id"].as_str().map(String::from)
477 } else {
478 None
479 }
480 })
481 .collect();
482
483 Ok(versions)
484}
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 @@
1pub mod accounts;
2pub mod backups;
3pub mod dashboard;
4pub mod instances;
5pub mod players;
6
7use askama::Template;
8use axum::{Router, response::Html};
9use tower_http::services::ServeDir;
10
11use crate::auth::CurrentUser;
12use crate::config::Config;
13use crate::db::Database;
14use crate::docker::DockerManager;
15
16#[derive(Clone)]
17pub struct AppState {
18 pub config: Config,
19 pub db: Database,
20 pub docker: DockerManager,
21}
22
23pub fn build_router(state: AppState) -> Router {
24 let auth_required =
25 axum::middleware::from_fn_with_state(state.clone(), crate::auth::require_auth);
26
27 let protected = Router::new()
28 .route("/change-password", axum::routing::get(change_password_page))
29 .route(
30 "/change-password",
31 axum::routing::post(accounts::change_password),
32 )
33 .merge(dashboard::router())
34 .merge(instances::router())
35 .merge(players::router())
36 .merge(accounts::router())
37 .merge(backups::router())
38 .layer(auth_required);
39
40 Router::new()
41 .route("/login", axum::routing::get(login_page))
42 .route("/login", axum::routing::post(crate::auth::authenticate))
43 .route("/logout", axum::routing::post(crate::auth::logout))
44 .merge(protected)
45 .nest_service("/static", ServeDir::new("static"))
46 .with_state(state)
47}
48
49#[derive(Template)]
50#[template(path = "login.html")]
51struct LoginPageTemplate;
52
53async fn login_page() -> Html<String> {
54 let template = LoginPageTemplate;
55 Html(template.render().unwrap_or_default())
56}
57
58#[derive(Template)]
59#[template(path = "change_password.html")]
60struct ChangePasswordPageTemplate {
61 username: String,
62 is_superuser: bool,
63 password_change_required: bool,
64 error: String,
65}
66
67async fn change_password_page(CurrentUser(account): CurrentUser) -> Html<String> {
68 let template = ChangePasswordPageTemplate {
69 username: account.username,
70 is_superuser: account.is_superuser,
71 password_change_required: account.password_change_required,
72 error: String::new(),
73 };
74 Html(template.render().unwrap_or_default())
75}
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 @@
1use askama::Template;
2use axum::{
3 Form, Router,
4 extract::State,
5 response::{Html, Redirect},
6 routing::{get, post},
7};
8use serde::Deserialize;
9
10use crate::auth::CurrentUser;
11use crate::routes::AppState;
12
13#[derive(Template)]
14#[template(path = "players/manage.html")]
15struct PlayerManagementTemplate {
16 username: String,
17 is_superuser: bool,
18 password_change_required: bool,
19 active_instance_name: Option<String>,
20 whitelist: Vec<String>,
21 ops: Vec<String>,
22 bans: Vec<String>,
23 message: Option<String>,
24}
25
26#[derive(Debug, Deserialize)]
27pub struct PlayerAction {
28 pub username: String,
29}
30
31pub fn router() -> Router<AppState> {
32 Router::new()
33 .route("/players", get(manage_players))
34 .route("/players/whitelist/add", post(add_whitelist))
35 .route("/players/whitelist/remove", post(remove_whitelist))
36 .route("/players/op/add", post(add_op))
37 .route("/players/op/remove", post(remove_op))
38 .route("/players/ban/add", post(add_ban))
39 .route("/players/ban/remove", post(remove_ban))
40}
41
42async fn manage_players(
43 State(state): State<AppState>,
44 CurrentUser(account): CurrentUser,
45) -> Result<Html<String>, axum::response::Redirect> {
46 let active = get_active_instance(&state.db);
47
48 let (instance_name, whitelist, ops, bans) =
49 if let Some((_id, inst_name, _rcon_pw, _container_state)) = active {
50 let data_dir = state
51 .config
52 .server_directory
53 .join("instances")
54 .join(&inst_name)
55 .join("data");
56
57 let w = read_json_list(&data_dir.join("whitelist.json"));
58 let o = read_json_list(&data_dir.join("ops.json"));
59 let b = read_json_list(&data_dir.join("banned-players.json"));
60 (Some(inst_name), w, o, b)
61 } else {
62 (None, vec![], vec![], vec![])
63 };
64
65 let template = PlayerManagementTemplate {
66 username: account.username,
67 is_superuser: account.is_superuser,
68 password_change_required: account.password_change_required,
69 active_instance_name: instance_name,
70 whitelist,
71 ops,
72 bans,
73 message: None,
74 };
75
76 Ok(Html(template.render().unwrap_or_default()))
77}
78
79fn read_json_list(path: &std::path::Path) -> Vec<String> {
80 if let Ok(contents) = std::fs::read_to_string(path) {
81 if let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(&contents) {
82 return entries
83 .iter()
84 .filter_map(|e| e["name"].as_str().map(String::from))
85 .collect();
86 }
87 }
88 vec![]
89}
90
91macro_rules! player_action {
92 ($name:ident, $command:expr) => {
93 async fn $name(
94 State(state): State<AppState>,
95 CurrentUser(_user): CurrentUser,
96 Form(form): Form<PlayerAction>,
97 ) -> Result<Redirect, axum::response::Redirect> {
98 if let Some((_, _, rcon_pw, _)) = get_active_instance(&state.db) {
99 let client = crate::rcon::RconClient::new(rcon_pw);
100 let cmd = format!($command, form.username);
101 match client.send_command_async(cmd.clone()).await {
102 Ok(resp) => tracing::info!("RCON '{}' -> {}", cmd, resp.trim()),
103 Err(e) => tracing::error!("RCON '{}' failed: {}", cmd, e),
104 }
105 } else {
106 tracing::warn!(
107 "Player action '{}' called but no active instance",
108 stringify!($name)
109 );
110 }
111 Ok(Redirect::to("/players"))
112 }
113 };
114}
115
116player_action!(add_whitelist, "whitelist add {}");
117player_action!(remove_whitelist, "whitelist remove {}");
118player_action!(add_op, "op {}");
119player_action!(remove_op, "deop {}");
120player_action!(add_ban, "ban {}");
121player_action!(remove_ban, "pardon {}");
122
123fn get_active_instance(db: &crate::db::Database) -> Option<(i64, String, String, String)> {
124 db.conn()
125 .query_row(
126 "SELECT id, name, rcon_password, container_state FROM instances WHERE is_active = 1",
127 [],
128 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
129 )
130 .ok()
131}