From bc0d3d2f324ee613424ee31103207222b4b19640 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 29 Jul 2026 12:21:32 +0200 Subject: Config editor, host networking, whitelist RCON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Config file editor at /instances/{id}/config (browse & edit config/ files) - Host networking for Docker container (fixes WireGuard MTU DNS issues) - Whitelist changes applied live via RCON instead of stopping the server - Config edit handler detects which fields changed: whitelist → RCON, other settings → restart container - walk_dir simplified (removed unused root param) - import_world uses if-let instead of never-looping while-let - docker-compose.yml updated as reference template --- src/docker.rs | 1 + src/routes/instances.rs | 282 +++++++++++++++++++++++++++++++++++++++++++++--- src/sanitize.rs | 6 ++ 3 files changed, 275 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/docker.rs b/src/docker.rs index d66767e..f171d43 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -164,6 +164,7 @@ impl DockerManager { let host_config = HostConfig { binds: Some(vec![format!("{instance_data}:/data")]), port_bindings: Some(port_bindings), + network_mode: Some("host".into()), ..Default::default() }; diff --git a/src/routes/instances.rs b/src/routes/instances.rs index d3b8294..a3c4baa 100644 --- a/src/routes/instances.rs +++ b/src/routes/instances.rs @@ -1,7 +1,7 @@ use askama::Template; use axum::{ Form, Router, - extract::{Multipart, Path, State}, + extract::{Multipart, Path, Query, State}, response::{Html, Redirect}, routing::{get, post}, }; @@ -95,6 +95,7 @@ pub fn router() -> Router { .route("/instances/{id}/world/export", get(export_world)) .route("/instances/{id}/world/import", post(import_world)) .route("/instances/{id}/world/regenerate", post(regenerate_world)) + .route("/instances/{id}/config", get(config_editor).post(save_config)) } async fn list_instances( @@ -345,7 +346,53 @@ async fn edit_instance( let pvp = form.pvp.as_deref() == Some("on"); let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on"); - let is_active = { + // Read current values to detect what changed + let (_instance_name, is_active, old_whitelist, rcon_password): (String, bool, bool, String) = { + let conn = state.db.conn(); + conn.query_row( + "SELECT name, is_active, whitelist_enabled, rcon_password + FROM instances WHERE id = ?1", + params![id], + |row| Ok(( + row.get(0)?, + row.get::<_, i32>(1)? != 0, + row.get::<_, i32>(2)? != 0, + row.get(3)?, + )), + ) + .map_err(|_| Redirect::to("/instances"))? + }; + + // Check if any restart-requiring field changed + let (old_difficulty, old_gamemode, old_max_players, old_pvp, old_motd, old_memory): (i32, i32, i32, bool, String, i32) = { + let conn = state.db.conn(); + conn.query_row( + "SELECT difficulty, gamemode, max_players, pvp, motd, memory_mb + FROM instances WHERE id = ?1", + params![id], + |row| Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get::<_, i32>(3)? != 0, + row.get(4)?, + row.get(5)?, + )), + ) + .map_err(|_| Redirect::to("/instances"))? + }; + + let needs_restart = form.difficulty != old_difficulty + || form.gamemode != old_gamemode + || form.max_players != old_max_players + || pvp != old_pvp + || form.motd.as_deref().unwrap_or("") != old_motd + || form.memory_mb != old_memory; + + let whitelist_changed = whitelist_enabled != old_whitelist; + + // Persist changes + { let conn = state.db.conn(); conn.execute( "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3, @@ -363,17 +410,23 @@ async fn edit_instance( ], ) .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; + // Apply whitelist change live via RCON if it was the only change + if whitelist_changed { + let cmd = if whitelist_enabled { "whitelist on" } else { "whitelist off" }; + let rcon = crate::rcon::RconClient::new(rcon_password); + match rcon.send_command_async(cmd.into()).await { + Ok(resp) => tracing::info!("RCON {cmd}: {resp}"), + Err(e) => tracing::error!("RCON {cmd} failed: {e}"), + } + } + + // If other settings changed, restart the container + if needs_restart { + let _ = state.docker.stop_container().await; + } } Ok(Redirect::to("/instances")) @@ -714,8 +767,8 @@ async fn import_world( .join(&safe_name) .join("data"); - // Extract uploaded ZIP - while let Ok(Some(field)) = multipart.next_field().await { + // Extract uploaded ZIP (only the first file is processed) + if let Ok(Some(field)) = multipart.next_field().await { let bytes = field .bytes() .await @@ -754,12 +807,213 @@ async fn import_world( std::io::copy(&mut file, &mut out).ok(); } } - break; // only process first file } Ok(Redirect::to(&format!("/instances/{id}/edit"))) } +#[derive(Template)] +#[template(path = "instances/config.html")] +struct ConfigEditorTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + instance_id: i64, + instance_name: String, + files: Vec, + selected_file: Option, + content: Option, + error: Option, + saved: bool, +} + +#[derive(Debug, Deserialize)] +struct ConfigPathQuery { + path: Option, +} + +#[derive(Debug, Deserialize)] +struct SaveConfigForm { + path: String, + content: String, +} + +/// Browse and edit config files in the instance's config/ directory. +async fn config_editor( + State(state): State, + Path(instance_id): Path, + CurrentUser(account): CurrentUser, + Query(query): Query, +) -> 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"))?; + drop(conn); + + let safe_name = match sanitize::sanitize_instance_name(&instance_name) { + Some(n) => n, + None => return Err(Redirect::to("/instances")), + }; + + let config_dir = match sanitize::instance_config_dir(&state.config.server_directory, &safe_name) + { + Some(d) => d, + None => return Err(Redirect::to("/instances")), + }; + let _ = std::fs::create_dir_all(&config_dir); + + // Walk the config directory for files + let mut files: Vec = Vec::new(); + if let Ok(entries) = walk_dir(&config_dir, "") { + files = entries; + files.sort(); + } + + // If a file is selected, validate and read it + let (selected_file, content, error) = if let Some(raw_path) = &query.path { + let cleaned = raw_path.trim_start_matches('/'); + let target = config_dir.join(cleaned); + if !sanitize::path_is_within_base(&target, &config_dir) { + (Some(cleaned.to_string()), None, Some("Invalid path".into())) + } else if !target.exists() || !target.is_file() { + (Some(cleaned.to_string()), None, Some("File not found".into())) + } else { + match std::fs::read_to_string(&target) { + Ok(c) => (Some(cleaned.to_string()), Some(c), None), + Err(e) => (Some(cleaned.to_string()), None, Some(format!("Read error: {e}"))), + } + } + } else { + (None, None, None) + }; + + let template = ConfigEditorTemplate { + username: account.username.clone(), + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + instance_id, + instance_name, + files, + selected_file, + content, + error, + saved: false, + }; + + match template.render() { + Ok(html) => Ok(Html(html)), + Err(e) => { + tracing::error!("Failed to render config template: {e}"); + Err(Redirect::to(&format!("/instances/{instance_id}/edit"))) + } + } +} + +/// Save a config file back to disk. +async fn save_config( + State(state): State, + Path(instance_id): Path, + CurrentUser(_user): CurrentUser, + Form(form): Form, +) -> 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"))?; + drop(conn); + + let safe_name = match sanitize::sanitize_instance_name(&instance_name) { + Some(n) => n, + None => return Err(Redirect::to("/instances")), + }; + + let config_dir = match sanitize::instance_config_dir(&state.config.server_directory, &safe_name) + { + Some(d) => d, + None => return Err(Redirect::to("/instances")), + }; + + let cleaned = form.path.trim_start_matches('/'); + let target = config_dir.join(cleaned); + + let (error, saved) = if !sanitize::path_is_within_base(&target, &config_dir) { + (Some("Invalid path".into()), false) + } else if let Err(e) = std::fs::write(&target, &form.content) { + (Some(format!("Write error: {e}")), false) + } else { + tracing::info!("Saved config file: {}", target.display()); + (None, true) + }; + + // Re-read the file list and selected file content + let _ = std::fs::create_dir_all(&config_dir); + let mut files: Vec = Vec::new(); + if let Ok(entries) = walk_dir(&config_dir, "") { + files = entries; + files.sort(); + } + + let content = if error.is_none() { + Some(form.content) + } else { + None + }; + + let template = ConfigEditorTemplate { + username: String::new(), + is_superuser: false, + password_change_required: false, + instance_id, + instance_name, + files, + selected_file: Some(cleaned.to_string()), + content, + error, + saved, + }; + + match template.render() { + Ok(html) => Ok(Html(html)), + Err(e) => { + tracing::error!("Failed to render config template after save: {e}"); + Err(Redirect::to(&format!("/instances/{instance_id}/config?path={cleaned}"))) + } + } +} + +/// Recursively walk a directory and return relative paths of all files. +fn walk_dir(dir: &std::path::Path, prefix: &str) -> std::io::Result> { + let mut files = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let name = entry + .file_name() + .to_string_lossy() + .to_string(); + let relative = if prefix.is_empty() { + name + } else { + format!("{prefix}/{name}") + }; + if path.is_dir() { + files.extend(walk_dir(&path, &relative)?); + } else if path.is_file() { + files.push(relative); + } + } + Ok(files) +} + async fn regenerate_world( State(state): State, Path(id): Path, diff --git a/src/sanitize.rs b/src/sanitize.rs index f251131..19c7045 100644 --- a/src/sanitize.rs +++ b/src/sanitize.rs @@ -136,6 +136,12 @@ pub fn instance_dir(server_dir: &Path, instance_name: &str) -> Option { Some(server_dir.join("instances").join(&safe)) } +/// Build a safe path to the instance's config/ directory. +pub fn instance_config_dir(server_dir: &Path, instance_name: &str) -> Option { + let data = instance_data_dir(server_dir, instance_name)?; + Some(data.join("config")) +} + /// Validate that a backup filename does not contain path traversal. pub fn sanitize_backup_filename(raw: &str) -> Option { let trimmed = raw.trim(); -- cgit v1.2.3