diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/docker.rs | 1 | ||||
| -rw-r--r-- | src/routes/instances.rs | 282 | ||||
| -rw-r--r-- | src/sanitize.rs | 6 |
3 files changed, 275 insertions, 14 deletions
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 { | |||
| 164 | let host_config = HostConfig { | 164 | let host_config = HostConfig { |
| 165 | binds: Some(vec![format!("{instance_data}:/data")]), | 165 | binds: Some(vec![format!("{instance_data}:/data")]), |
| 166 | port_bindings: Some(port_bindings), | 166 | port_bindings: Some(port_bindings), |
| 167 | network_mode: Some("host".into()), | ||
| 167 | ..Default::default() | 168 | ..Default::default() |
| 168 | }; | 169 | }; |
| 169 | 170 | ||
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 @@ | |||
| 1 | use askama::Template; | 1 | use askama::Template; |
| 2 | use axum::{ | 2 | use axum::{ |
| 3 | Form, Router, | 3 | Form, Router, |
| 4 | extract::{Multipart, Path, State}, | 4 | extract::{Multipart, Path, Query, State}, |
| 5 | response::{Html, Redirect}, | 5 | response::{Html, Redirect}, |
| 6 | routing::{get, post}, | 6 | routing::{get, post}, |
| 7 | }; | 7 | }; |
| @@ -95,6 +95,7 @@ pub fn router() -> Router<AppState> { | |||
| 95 | .route("/instances/{id}/world/export", get(export_world)) | 95 | .route("/instances/{id}/world/export", get(export_world)) |
| 96 | .route("/instances/{id}/world/import", post(import_world)) | 96 | .route("/instances/{id}/world/import", post(import_world)) |
| 97 | .route("/instances/{id}/world/regenerate", post(regenerate_world)) | 97 | .route("/instances/{id}/world/regenerate", post(regenerate_world)) |
| 98 | .route("/instances/{id}/config", get(config_editor).post(save_config)) | ||
| 98 | } | 99 | } |
| 99 | 100 | ||
| 100 | async fn list_instances( | 101 | async fn list_instances( |
| @@ -345,7 +346,53 @@ async fn edit_instance( | |||
| 345 | let pvp = form.pvp.as_deref() == Some("on"); | 346 | let pvp = form.pvp.as_deref() == Some("on"); |
| 346 | let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on"); | 347 | let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on"); |
| 347 | 348 | ||
| 348 | let is_active = { | 349 | // Read current values to detect what changed |
| 350 | let (_instance_name, is_active, old_whitelist, rcon_password): (String, bool, bool, String) = { | ||
| 351 | let conn = state.db.conn(); | ||
| 352 | conn.query_row( | ||
| 353 | "SELECT name, is_active, whitelist_enabled, rcon_password | ||
| 354 | FROM instances WHERE id = ?1", | ||
| 355 | params![id], | ||
| 356 | |row| Ok(( | ||
| 357 | row.get(0)?, | ||
| 358 | row.get::<_, i32>(1)? != 0, | ||
| 359 | row.get::<_, i32>(2)? != 0, | ||
| 360 | row.get(3)?, | ||
| 361 | )), | ||
| 362 | ) | ||
| 363 | .map_err(|_| Redirect::to("/instances"))? | ||
| 364 | }; | ||
| 365 | |||
| 366 | // Check if any restart-requiring field changed | ||
| 367 | let (old_difficulty, old_gamemode, old_max_players, old_pvp, old_motd, old_memory): (i32, i32, i32, bool, String, i32) = { | ||
| 368 | let conn = state.db.conn(); | ||
| 369 | conn.query_row( | ||
| 370 | "SELECT difficulty, gamemode, max_players, pvp, motd, memory_mb | ||
| 371 | FROM instances WHERE id = ?1", | ||
| 372 | params![id], | ||
| 373 | |row| Ok(( | ||
| 374 | row.get(0)?, | ||
| 375 | row.get(1)?, | ||
| 376 | row.get(2)?, | ||
| 377 | row.get::<_, i32>(3)? != 0, | ||
| 378 | row.get(4)?, | ||
| 379 | row.get(5)?, | ||
| 380 | )), | ||
| 381 | ) | ||
| 382 | .map_err(|_| Redirect::to("/instances"))? | ||
| 383 | }; | ||
| 384 | |||
| 385 | let needs_restart = form.difficulty != old_difficulty | ||
| 386 | || form.gamemode != old_gamemode | ||
| 387 | || form.max_players != old_max_players | ||
| 388 | || pvp != old_pvp | ||
| 389 | || form.motd.as_deref().unwrap_or("") != old_motd | ||
| 390 | || form.memory_mb != old_memory; | ||
| 391 | |||
| 392 | let whitelist_changed = whitelist_enabled != old_whitelist; | ||
| 393 | |||
| 394 | // Persist changes | ||
| 395 | { | ||
| 349 | let conn = state.db.conn(); | 396 | let conn = state.db.conn(); |
| 350 | conn.execute( | 397 | conn.execute( |
| 351 | "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3, | 398 | "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3, |
| @@ -363,17 +410,23 @@ async fn edit_instance( | |||
| 363 | ], | 410 | ], |
| 364 | ) | 411 | ) |
| 365 | .map_err(|_| Redirect::to("/instances"))?; | 412 | .map_err(|_| Redirect::to("/instances"))?; |
| 366 | 413 | } | |
| 367 | conn.query_row( | ||
| 368 | "SELECT is_active FROM instances WHERE id = ?1", | ||
| 369 | params![id], | ||
| 370 | |row| row.get::<_, i32>(0).map(|v| v != 0), | ||
| 371 | ) | ||
| 372 | .unwrap_or(false) | ||
| 373 | }; | ||
| 374 | 414 | ||
| 375 | if is_active { | 415 | if is_active { |
| 376 | let _ = state.docker.stop_container().await; | 416 | // Apply whitelist change live via RCON if it was the only change |
| 417 | if whitelist_changed { | ||
| 418 | let cmd = if whitelist_enabled { "whitelist on" } else { "whitelist off" }; | ||
| 419 | let rcon = crate::rcon::RconClient::new(rcon_password); | ||
| 420 | match rcon.send_command_async(cmd.into()).await { | ||
| 421 | Ok(resp) => tracing::info!("RCON {cmd}: {resp}"), | ||
| 422 | Err(e) => tracing::error!("RCON {cmd} failed: {e}"), | ||
| 423 | } | ||
| 424 | } | ||
| 425 | |||
| 426 | // If other settings changed, restart the container | ||
| 427 | if needs_restart { | ||
| 428 | let _ = state.docker.stop_container().await; | ||
| 429 | } | ||
| 377 | } | 430 | } |
| 378 | 431 | ||
| 379 | Ok(Redirect::to("/instances")) | 432 | Ok(Redirect::to("/instances")) |
| @@ -714,8 +767,8 @@ async fn import_world( | |||
| 714 | .join(&safe_name) | 767 | .join(&safe_name) |
| 715 | .join("data"); | 768 | .join("data"); |
| 716 | 769 | ||
| 717 | // Extract uploaded ZIP | 770 | // Extract uploaded ZIP (only the first file is processed) |
| 718 | while let Ok(Some(field)) = multipart.next_field().await { | 771 | if let Ok(Some(field)) = multipart.next_field().await { |
| 719 | let bytes = field | 772 | let bytes = field |
| 720 | .bytes() | 773 | .bytes() |
| 721 | .await | 774 | .await |
| @@ -754,12 +807,213 @@ async fn import_world( | |||
| 754 | std::io::copy(&mut file, &mut out).ok(); | 807 | std::io::copy(&mut file, &mut out).ok(); |
| 755 | } | 808 | } |
| 756 | } | 809 | } |
| 757 | break; // only process first file | ||
| 758 | } | 810 | } |
| 759 | 811 | ||
| 760 | Ok(Redirect::to(&format!("/instances/{id}/edit"))) | 812 | Ok(Redirect::to(&format!("/instances/{id}/edit"))) |
| 761 | } | 813 | } |
| 762 | 814 | ||
| 815 | #[derive(Template)] | ||
| 816 | #[template(path = "instances/config.html")] | ||
| 817 | struct ConfigEditorTemplate { | ||
| 818 | username: String, | ||
| 819 | is_superuser: bool, | ||
| 820 | password_change_required: bool, | ||
| 821 | instance_id: i64, | ||
| 822 | instance_name: String, | ||
| 823 | files: Vec<String>, | ||
| 824 | selected_file: Option<String>, | ||
| 825 | content: Option<String>, | ||
| 826 | error: Option<String>, | ||
| 827 | saved: bool, | ||
| 828 | } | ||
| 829 | |||
| 830 | #[derive(Debug, Deserialize)] | ||
| 831 | struct ConfigPathQuery { | ||
| 832 | path: Option<String>, | ||
| 833 | } | ||
| 834 | |||
| 835 | #[derive(Debug, Deserialize)] | ||
| 836 | struct SaveConfigForm { | ||
| 837 | path: String, | ||
| 838 | content: String, | ||
| 839 | } | ||
| 840 | |||
| 841 | /// Browse and edit config files in the instance's config/ directory. | ||
| 842 | async fn config_editor( | ||
| 843 | State(state): State<AppState>, | ||
| 844 | Path(instance_id): Path<i64>, | ||
| 845 | CurrentUser(account): CurrentUser, | ||
| 846 | Query(query): Query<ConfigPathQuery>, | ||
| 847 | ) -> Result<Html<String>, axum::response::Redirect> { | ||
| 848 | let conn = state.db.conn(); | ||
| 849 | let instance_name: String = conn | ||
| 850 | .query_row( | ||
| 851 | "SELECT name FROM instances WHERE id = ?1", | ||
| 852 | params![instance_id], | ||
| 853 | |row| row.get(0), | ||
| 854 | ) | ||
| 855 | .map_err(|_| Redirect::to("/instances"))?; | ||
| 856 | drop(conn); | ||
| 857 | |||
| 858 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 859 | Some(n) => n, | ||
| 860 | None => return Err(Redirect::to("/instances")), | ||
| 861 | }; | ||
| 862 | |||
| 863 | let config_dir = match sanitize::instance_config_dir(&state.config.server_directory, &safe_name) | ||
| 864 | { | ||
| 865 | Some(d) => d, | ||
| 866 | None => return Err(Redirect::to("/instances")), | ||
| 867 | }; | ||
| 868 | let _ = std::fs::create_dir_all(&config_dir); | ||
| 869 | |||
| 870 | // Walk the config directory for files | ||
| 871 | let mut files: Vec<String> = Vec::new(); | ||
| 872 | if let Ok(entries) = walk_dir(&config_dir, "") { | ||
| 873 | files = entries; | ||
| 874 | files.sort(); | ||
| 875 | } | ||
| 876 | |||
| 877 | // If a file is selected, validate and read it | ||
| 878 | let (selected_file, content, error) = if let Some(raw_path) = &query.path { | ||
| 879 | let cleaned = raw_path.trim_start_matches('/'); | ||
| 880 | let target = config_dir.join(cleaned); | ||
| 881 | if !sanitize::path_is_within_base(&target, &config_dir) { | ||
| 882 | (Some(cleaned.to_string()), None, Some("Invalid path".into())) | ||
| 883 | } else if !target.exists() || !target.is_file() { | ||
| 884 | (Some(cleaned.to_string()), None, Some("File not found".into())) | ||
| 885 | } else { | ||
| 886 | match std::fs::read_to_string(&target) { | ||
| 887 | Ok(c) => (Some(cleaned.to_string()), Some(c), None), | ||
| 888 | Err(e) => (Some(cleaned.to_string()), None, Some(format!("Read error: {e}"))), | ||
| 889 | } | ||
| 890 | } | ||
| 891 | } else { | ||
| 892 | (None, None, None) | ||
| 893 | }; | ||
| 894 | |||
| 895 | let template = ConfigEditorTemplate { | ||
| 896 | username: account.username.clone(), | ||
| 897 | is_superuser: account.is_superuser, | ||
| 898 | password_change_required: account.password_change_required, | ||
| 899 | instance_id, | ||
| 900 | instance_name, | ||
| 901 | files, | ||
| 902 | selected_file, | ||
| 903 | content, | ||
| 904 | error, | ||
| 905 | saved: false, | ||
| 906 | }; | ||
| 907 | |||
| 908 | match template.render() { | ||
| 909 | Ok(html) => Ok(Html(html)), | ||
| 910 | Err(e) => { | ||
| 911 | tracing::error!("Failed to render config template: {e}"); | ||
| 912 | Err(Redirect::to(&format!("/instances/{instance_id}/edit"))) | ||
| 913 | } | ||
| 914 | } | ||
| 915 | } | ||
| 916 | |||
| 917 | /// Save a config file back to disk. | ||
| 918 | async fn save_config( | ||
| 919 | State(state): State<AppState>, | ||
| 920 | Path(instance_id): Path<i64>, | ||
| 921 | CurrentUser(_user): CurrentUser, | ||
| 922 | Form(form): Form<SaveConfigForm>, | ||
| 923 | ) -> Result<Html<String>, axum::response::Redirect> { | ||
| 924 | let conn = state.db.conn(); | ||
| 925 | let instance_name: String = conn | ||
| 926 | .query_row( | ||
| 927 | "SELECT name FROM instances WHERE id = ?1", | ||
| 928 | params![instance_id], | ||
| 929 | |row| row.get(0), | ||
| 930 | ) | ||
| 931 | .map_err(|_| Redirect::to("/instances"))?; | ||
| 932 | drop(conn); | ||
| 933 | |||
| 934 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 935 | Some(n) => n, | ||
| 936 | None => return Err(Redirect::to("/instances")), | ||
| 937 | }; | ||
| 938 | |||
| 939 | let config_dir = match sanitize::instance_config_dir(&state.config.server_directory, &safe_name) | ||
| 940 | { | ||
| 941 | Some(d) => d, | ||
| 942 | None => return Err(Redirect::to("/instances")), | ||
| 943 | }; | ||
| 944 | |||
| 945 | let cleaned = form.path.trim_start_matches('/'); | ||
| 946 | let target = config_dir.join(cleaned); | ||
| 947 | |||
| 948 | let (error, saved) = if !sanitize::path_is_within_base(&target, &config_dir) { | ||
| 949 | (Some("Invalid path".into()), false) | ||
| 950 | } else if let Err(e) = std::fs::write(&target, &form.content) { | ||
| 951 | (Some(format!("Write error: {e}")), false) | ||
| 952 | } else { | ||
| 953 | tracing::info!("Saved config file: {}", target.display()); | ||
| 954 | (None, true) | ||
| 955 | }; | ||
| 956 | |||
| 957 | // Re-read the file list and selected file content | ||
| 958 | let _ = std::fs::create_dir_all(&config_dir); | ||
| 959 | let mut files: Vec<String> = Vec::new(); | ||
| 960 | if let Ok(entries) = walk_dir(&config_dir, "") { | ||
| 961 | files = entries; | ||
| 962 | files.sort(); | ||
| 963 | } | ||
| 964 | |||
| 965 | let content = if error.is_none() { | ||
| 966 | Some(form.content) | ||
| 967 | } else { | ||
| 968 | None | ||
| 969 | }; | ||
| 970 | |||
| 971 | let template = ConfigEditorTemplate { | ||
| 972 | username: String::new(), | ||
| 973 | is_superuser: false, | ||
| 974 | password_change_required: false, | ||
| 975 | instance_id, | ||
| 976 | instance_name, | ||
| 977 | files, | ||
| 978 | selected_file: Some(cleaned.to_string()), | ||
| 979 | content, | ||
| 980 | error, | ||
| 981 | saved, | ||
| 982 | }; | ||
| 983 | |||
| 984 | match template.render() { | ||
| 985 | Ok(html) => Ok(Html(html)), | ||
| 986 | Err(e) => { | ||
| 987 | tracing::error!("Failed to render config template after save: {e}"); | ||
| 988 | Err(Redirect::to(&format!("/instances/{instance_id}/config?path={cleaned}"))) | ||
| 989 | } | ||
| 990 | } | ||
| 991 | } | ||
| 992 | |||
| 993 | /// Recursively walk a directory and return relative paths of all files. | ||
| 994 | fn walk_dir(dir: &std::path::Path, prefix: &str) -> std::io::Result<Vec<String>> { | ||
| 995 | let mut files = Vec::new(); | ||
| 996 | for entry in std::fs::read_dir(dir)? { | ||
| 997 | let entry = entry?; | ||
| 998 | let path = entry.path(); | ||
| 999 | let name = entry | ||
| 1000 | .file_name() | ||
| 1001 | .to_string_lossy() | ||
| 1002 | .to_string(); | ||
| 1003 | let relative = if prefix.is_empty() { | ||
| 1004 | name | ||
| 1005 | } else { | ||
| 1006 | format!("{prefix}/{name}") | ||
| 1007 | }; | ||
| 1008 | if path.is_dir() { | ||
| 1009 | files.extend(walk_dir(&path, &relative)?); | ||
| 1010 | } else if path.is_file() { | ||
| 1011 | files.push(relative); | ||
| 1012 | } | ||
| 1013 | } | ||
| 1014 | Ok(files) | ||
| 1015 | } | ||
| 1016 | |||
| 763 | async fn regenerate_world( | 1017 | async fn regenerate_world( |
| 764 | State(state): State<AppState>, | 1018 | State(state): State<AppState>, |
| 765 | Path(id): Path<i64>, | 1019 | Path(id): Path<i64>, |
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<PathBuf> { | |||
| 136 | Some(server_dir.join("instances").join(&safe)) | 136 | Some(server_dir.join("instances").join(&safe)) |
| 137 | } | 137 | } |
| 138 | 138 | ||
| 139 | /// Build a safe path to the instance's config/ directory. | ||
| 140 | pub fn instance_config_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 141 | let data = instance_data_dir(server_dir, instance_name)?; | ||
| 142 | Some(data.join("config")) | ||
| 143 | } | ||
| 144 | |||
| 139 | /// Validate that a backup filename does not contain path traversal. | 145 | /// Validate that a backup filename does not contain path traversal. |
| 140 | pub fn sanitize_backup_filename(raw: &str) -> Option<String> { | 146 | pub fn sanitize_backup_filename(raw: &str) -> Option<String> { |
| 141 | let trimmed = raw.trim(); | 147 | let trimmed = raw.trim(); |
