diff --git a/Cargo.lock b/Cargo.lock index 0df615346e..3ef66baa18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7301,11 +7301,13 @@ dependencies = [ "mimalloc", "num_cpus", "prometheus", + "rand 0.8.5", "ratatui", "ron", "serde", "shell-words", "signal-hook", + "specs", "tokio", "tracing", "veloren-common", diff --git a/server-cli/Cargo.toml b/server-cli/Cargo.toml index 9621edcc3f..252ef7f349 100644 --- a/server-cli/Cargo.toml +++ b/server-cli/Cargo.toml @@ -47,6 +47,9 @@ tracing = { workspace = true } ron = { workspace = true } serde = { workspace = true, features = [ "rc", "derive" ]} ratatui = { version = "0.26.0", features = ["crossterm"] } +rand = { workspace = true } +# ECS +specs = { workspace = true } #HTTP axum = { version = "0.6.20" } diff --git a/server-cli/src/cli.rs b/server-cli/src/cli.rs index ec8b6f71c1..1a88e8df00 100644 --- a/server-cli/src/cli.rs +++ b/server-cli/src/cli.rs @@ -71,6 +71,19 @@ pub enum Message { }, /// Disconnects all connected clients DisconnectAllClients, + /// returns active player names + ListPlayers, + ListLogs, + /// sends a msg to everyone on the server + SendGlobalMsg { + msg: String, + }, +} + +#[derive(Debug, Clone)] +pub enum MessageReturn { + Players(Vec), + Logs(Vec), } #[derive(Parser)] diff --git a/server-cli/src/main.rs b/server-cli/src/main.rs index a7c1229bf8..960613a978 100644 --- a/server-cli/src/main.rs +++ b/server-cli/src/main.rs @@ -18,18 +18,25 @@ mod tui_runner; mod tuilog; mod web; use crate::{ - cli::{Admin, ArgvApp, ArgvCommand, Message, SharedCommand, Shutdown}, + cli::{ + Admin, ArgvApp, ArgvCommand, BenchParams, Message, MessageReturn, SharedCommand, Shutdown, + }, + settings::Settings, shutdown_coordinator::ShutdownCoordinator, tui_runner::Tui, tuilog::TuiLog, }; -use common::{clock::Clock, consts::MIN_RECOMMENDED_TOKIO_THREADS}; +use common::{ + clock::Clock, + comp::{ChatType, Player}, + consts::MIN_RECOMMENDED_TOKIO_THREADS, +}; use common_base::span; use core::sync::atomic::{AtomicUsize, Ordering}; use server::{persistence::DatabaseSettings, settings::Protocol, Event, Input, Server}; use std::{ io, - sync::{atomic::AtomicBool, mpsc, Arc}, + sync::{atomic::AtomicBool, Arc}, time::{Duration, Instant}, }; use tokio::sync::Notify; @@ -216,12 +223,22 @@ fn main() -> io::Result<()> { let metrics_shutdown = Arc::new(Notify::new()); let metrics_shutdown_clone = Arc::clone(&metrics_shutdown); let web_chat_secret = settings.web_chat_secret.clone(); + let ui_api_secret = settings.ui_api_secret.clone().unwrap_or_else(|| { + // when no secret is provided we generate one that we distribute via the /ui + // endpoint + use rand::distributions::{Alphanumeric, DistString}; + Alphanumeric.sample_string(&mut rand::thread_rng(), 32) + }); + + let (web_ui_request_s, web_ui_request_r) = tokio::sync::mpsc::channel(1000); runtime.spawn(async move { web::run( registry, chat, web_chat_secret, + ui_api_secret, + web_ui_request_s, settings.web_address, metrics_shutdown_clone.notified(), ) @@ -246,19 +263,43 @@ fn main() -> io::Result<()> { "Server is ready to accept connections." ); - let mut shutdown_coordinator = ShutdownCoordinator::new(Arc::clone(&shutdown_signal)); - - // Set up an fps clock - let mut clock = Clock::new(Duration::from_secs_f64(1.0 / TPS as f64)); - if let Some(bench) = bench { #[cfg(feature = "worldgen")] server.create_centered_persister(bench.view_distance); } + + server_loop( + server, + bench, + settings, + tui, + web_ui_request_r, + shutdown_signal, + )?; + + metrics_shutdown.notify_one(); + + Ok(()) +} + +fn server_loop( + mut server: Server, + bench: Option, + settings: Settings, + tui: Option, + mut web_ui_request_r: tokio::sync::mpsc::Receiver<( + Message, + tokio::sync::oneshot::Sender, + )>, + shutdown_signal: Arc, +) -> io::Result<()> { + // Set up an fps clock + let mut clock = Clock::new(Duration::from_secs_f64(1.0 / TPS as f64)); + let mut shutdown_coordinator = ShutdownCoordinator::new(Arc::clone(&shutdown_signal)); let mut bench_exit_time = None; let mut tick_no = 0u64; - loop { + 'outer: loop { span!(guard, "work"); if let Some(bench) = bench { if let Some(t) = bench_exit_time { @@ -296,49 +337,96 @@ fn main() -> io::Result<()> { trace!(?tick_no, "keepalive") } - if let Some(tui) = tui.as_ref() { - match tui.msg_r.try_recv() { - Ok(msg) => match msg { - Message::Shutdown { - command: Shutdown::Cancel, - } => shutdown_coordinator.abort_shutdown(&mut server), - Message::Shutdown { - command: Shutdown::Graceful { seconds, reason }, - } => { - shutdown_coordinator.initiate_shutdown( - &mut server, - Duration::from_secs(seconds), - reason, - ); - }, - Message::Shutdown { - command: Shutdown::Immediate, - } => { - info!("Closing the server"); - break; - }, - Message::Shared(SharedCommand::Admin { - command: Admin::Add { username, role }, - }) => { - server.add_admin(&username, role); - }, - Message::Shared(SharedCommand::Admin { - command: Admin::Remove { username }, - }) => { - server.remove_admin(&username); - }, - Message::LoadArea { view_distance } => { - #[cfg(feature = "worldgen")] - server.create_centered_persister(view_distance); - }, - Message::SqlLogMode { mode } => { - server.set_sql_log_mode(mode); - }, - Message::DisconnectAllClients => { - server.disconnect_all_clients(); - }, + let mut handle_msg = |msg, response: tokio::sync::oneshot::Sender| { + use specs::{Join, WorldExt}; + match msg { + Message::Shutdown { + command: Shutdown::Cancel, + } => shutdown_coordinator.abort_shutdown(&mut server), + Message::Shutdown { + command: Shutdown::Graceful { seconds, reason }, + } => { + shutdown_coordinator.initiate_shutdown( + &mut server, + Duration::from_secs(seconds), + reason, + ); }, - Err(mpsc::TryRecvError::Empty) | Err(mpsc::TryRecvError::Disconnected) => {}, + Message::Shutdown { + command: Shutdown::Immediate, + } => { + return true; + }, + Message::Shared(SharedCommand::Admin { + command: Admin::Add { username, role }, + }) => { + server.add_admin(&username, role); + }, + Message::Shared(SharedCommand::Admin { + command: Admin::Remove { username }, + }) => { + server.remove_admin(&username); + }, + Message::LoadArea { view_distance } => { + #[cfg(feature = "worldgen")] + server.create_centered_persister(view_distance); + }, + Message::SqlLogMode { mode } => { + server.set_sql_log_mode(mode); + }, + Message::DisconnectAllClients => { + server.disconnect_all_clients(); + }, + Message::ListPlayers => { + let players: Vec = server + .state() + .ecs() + .read_storage::() + .join() + .map(|p| p.alias.clone()) + .collect(); + let _ = response.send(MessageReturn::Players(players)); + }, + Message::ListLogs => { + let log = LOG.inner.lock().unwrap(); + let lines: Vec<_> = log + .lines + .iter() + .rev() + .take(30) + .map(|l| l.to_string()) + .collect(); + let _ = response.send(MessageReturn::Logs(lines)); + }, + Message::SendGlobalMsg { msg } => { + use server::state_ext::StateExt; + let msg = ChatType::Meta.into_plain_msg(msg); + server.state().send_chat(msg); + }, + } + false + }; + + if let Some(tui) = tui.as_ref() { + while let Ok(msg) = tui.msg_r.try_recv() { + let (sender, mut recv) = tokio::sync::oneshot::channel(); + if handle_msg(msg, sender) { + info!("Closing the server"); + break 'outer; + } + if let Ok(msg_answ) = recv.try_recv() { + match msg_answ { + MessageReturn::Players(players) => info!("Players: {:?}", players), + MessageReturn::Logs(_) => info!("skipp sending logs to tui"), + }; + } + } + } + + while let Ok((msg, sender)) = web_ui_request_r.try_recv() { + if handle_msg(msg, sender) { + info!("Closing the server"); + break 'outer; } } @@ -348,7 +436,5 @@ fn main() -> io::Result<()> { #[cfg(feature = "tracy")] common_base::tracy_client::frame_mark(); } - metrics_shutdown.notify_one(); - Ok(()) } diff --git a/server-cli/src/settings.rs b/server-cli/src/settings.rs index c834047ca4..412b924305 100644 --- a/server-cli/src/settings.rs +++ b/server-cli/src/settings.rs @@ -34,6 +34,9 @@ pub struct Settings { /// SECRET API HEADER used to access the chat api, if disabled the API is /// unreachable pub web_chat_secret: Option, + /// public SECRET API HEADER used to access the /ui_api, if disabled the API + /// is reachable localhost only (by /ui) + pub ui_api_secret: Option, pub shutdown_signals: Vec, } @@ -44,6 +47,7 @@ impl Default for Settings { update_shutdown_message: "The server is restarting for an update".to_owned(), web_address: SocketAddr::from((Ipv4Addr::LOCALHOST, 14005)), web_chat_secret: None, + ui_api_secret: None, shutdown_signals: if cfg!(any(target_os = "linux", target_os = "macos")) { vec![ShutdownSignal::SIGUSR1] } else { diff --git a/server-cli/src/web/mod.rs b/server-cli/src/web/mod.rs index eac86e37c8..de1a4bd7bb 100644 --- a/server-cli/src/web/mod.rs +++ b/server-cli/src/web/mod.rs @@ -1,3 +1,4 @@ +use crate::web::ui::api::UiRequestSender; use axum::{extract::State, response::IntoResponse, routing::get, Router}; use core::{future::Future, ops::Deref}; use hyper::{body::Body, header, http, StatusCode}; @@ -6,11 +7,14 @@ use server::chat::ChatCache; use std::net::SocketAddr; mod chat; +mod ui; pub async fn run( registry: R, cache: ChatCache, chat_secret: Option, + ui_secret: String, + web_ui_request_s: UiRequestSender, addr: S, shutdown: F, ) -> Result<(), hyper::Error> @@ -25,6 +29,11 @@ where let app = Router::new() .nest("/chat/v1", chat::router(cache, chat_secret)) + .nest( + "/ui_api/v1", + ui::api::router(web_ui_request_s, ui_secret.clone()), + ) + .nest("/ui", ui::router(ui_secret)) .nest("/metrics", metrics) .route("/health", get(|| async {})); diff --git a/server-cli/src/web/ui/api.rs b/server-cli/src/web/ui/api.rs new file mode 100644 index 0000000000..845fa783a4 --- /dev/null +++ b/server-cli/src/web/ui/api.rs @@ -0,0 +1,123 @@ +use crate::cli::{Message, MessageReturn}; +use axum::{ + extract::{ConnectInfo, State}, + http::header::COOKIE, + middleware::Next, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use hyper::{Request, StatusCode}; +use serde::Deserialize; +use std::{ + collections::HashSet, + net::{IpAddr, SocketAddr}, + sync::Arc, +}; +use tokio::sync::Mutex; + +/// Keep Size small, so we dont have to Clone much for each request. +#[derive(Clone)] +struct UiApiToken { + secret_token: String, +} + +pub(crate) type UiRequestSender = + tokio::sync::mpsc::Sender<(Message, tokio::sync::oneshot::Sender)>; + +#[derive(Clone, Default)] +struct IpAddresses { + users: Arc>>, +} + +async fn validate_secret( + State(token): State, + req: Request, + next: Next, +) -> Result { + let session_cookie = req.headers().get(COOKIE).ok_or(StatusCode::UNAUTHORIZED)?; + + pub const X_SECRET_TOKEN: &str = "X-Secret-Token"; + let expected = format!("{X_SECRET_TOKEN}={}", token.secret_token); + + if session_cookie.as_bytes() != expected.as_bytes() { + return Err(StatusCode::UNAUTHORIZED); + } + + Ok(next.run(req).await) +} + +/// Logs each new IP address that accesses this API authenticated +async fn log_users( + State(ip_addresses): State, + ConnectInfo(addr): ConnectInfo, + req: Request, + next: Next, +) -> Result { + let mut ip_addresses = ip_addresses.users.lock().await; + let ip_addr = addr.ip(); + if !ip_addresses.contains(&ip_addr) { + ip_addresses.insert(ip_addr); + let users_so_far = ip_addresses.len(); + tracing::info!(?ip_addr, ?users_so_far, "Is accessing the /ui_api endpoint"); + } + Ok(next.run(req).await) +} + +//TODO: do security audit before we extend this api with more security relevant +// functionality (e.g. account management) +pub fn router(web_ui_request_s: UiRequestSender, secret_token: String) -> Router { + let token = UiApiToken { secret_token }; + let ip_addrs = IpAddresses::default(); + Router::new() + .route("/players", get(players)) + .route("/logs", get(logs)) + .route("/send_global_msg", post(send_global_msg)) + .layer(axum::middleware::from_fn_with_state(ip_addrs, log_users)) + .layer(axum::middleware::from_fn_with_state(token, validate_secret)) + .with_state(web_ui_request_s) +} + +async fn players( + State(web_ui_request_s): State, +) -> Result { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let _ = web_ui_request_s.send((Message::ListPlayers, sender)).await; + match receiver + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + MessageReturn::Players(players) => Ok(Json(players)), + _ => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +async fn logs( + State(web_ui_request_s): State, +) -> Result { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let _ = web_ui_request_s.send((Message::ListLogs, sender)).await; + match receiver + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + MessageReturn::Logs(logs) => Ok(Json(logs)), + _ => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +#[derive(Deserialize)] +struct SendWorldMsgBody { + msg: String, +} + +async fn send_global_msg( + State(web_ui_request_s): State, + Json(payload): Json, +) -> Result { + let (dummy_s, _) = tokio::sync::oneshot::channel(); + let _ = web_ui_request_s + .send((Message::SendGlobalMsg { msg: payload.msg }, dummy_s)) + .await; + Ok(()) +} diff --git a/server-cli/src/web/ui/mod.rs b/server-cli/src/web/ui/mod.rs new file mode 100644 index 0000000000..7ce1a066f7 --- /dev/null +++ b/server-cli/src/web/ui/mod.rs @@ -0,0 +1,77 @@ +use axum::{ + extract::{ConnectInfo, State}, + http::{header::SET_COOKIE, HeaderMap, HeaderValue}, + response::{Html, IntoResponse}, + routing::get, + Router, +}; +use std::net::SocketAddr; + +pub mod api; + +/// Keep Size small, so we dont have to Clone much for each request. +#[derive(Clone)] +struct UiApiToken { + secret_token: String, +} + +pub fn router(secret_token: String) -> Router { + let token = UiApiToken { secret_token }; + Router::new().route("/", get(ui)).with_state(token) +} + +async fn ui( + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + State(token): State, +) -> impl IntoResponse { + const X_FORWARDED_FOR: &'_ str = "X-Forwarded-For"; + if !addr.ip().is_loopback() + || headers.contains_key(axum::http::header::FORWARDED) + || headers.contains_key(X_FORWARDED_FOR) + { + return Html( + r#" + + +Ui is only accessible from 127.0.0.1. Usage of proxies is forbidden. + + + "# + .to_string(), + ) + .into_response(); + } + + let js = include_str!("./ui.js"); + let css = include_str!("./ui.css"); + let inner = include_str!("./ui.html"); + + let mut response = Html(format!( + r#" + + + + + + +{inner} + +"# + )) + .into_response(); + + let cookie = format!("X-Secret-Token={}; SameSite=Strict", token.secret_token); + + //Note: at this point we give a user our secret for the Api, this is only + // intended for local users, protect this route against the whole internet + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&cookie).expect("An invalid secret-token for ui was provided"), + ); + response +} diff --git a/server-cli/src/web/ui/ui.css b/server-cli/src/web/ui/ui.css new file mode 100644 index 0000000000..e6af189c72 --- /dev/null +++ b/server-cli/src/web/ui/ui.css @@ -0,0 +1,49 @@ +/* Style the tab */ +.tab { + overflow: hidden; + border: 1px solid #ccc; + background-color: #f1f1f1; +} + +/* Style the buttons that are used to open the tab content */ +.tab button { + background-color: inherit; + float: left; + border: none; + outline: none; + cursor: pointer; + padding: 14px 16px; + transition: 0.3s; +} + +/* Change background color of buttons on hover */ +.tab button:hover { + background-color: #ddd; +} + +/* Create an active/current tablink class */ +.tab button.active { + background-color: #ccc; +} + +/* Style the tab content */ +.tabcontent { + display: none; + padding: 6px 12px; + border: 1px solid #ccc; + border-top: none; +} +div#settings.tabcontent { + display: block; +} + +.flex-container { + display: flex; + margin: 4px; + padding: 4px; + background-color: #f1f1f1; +} + +.flex-container .first { + width: 300px +} \ No newline at end of file diff --git a/server-cli/src/web/ui/ui.html b/server-cli/src/web/ui/ui.html new file mode 100644 index 0000000000..4fa97e091a --- /dev/null +++ b/server-cli/src/web/ui/ui.html @@ -0,0 +1,93 @@ +
+ + + + +
+ +
+
+
+

Server Name:

+
+
+

+
+
+ +
+
+

Server Ip/Port:

+
+
+

+
+
+ +
+
+

Require Auth:

+
+
+

Enabled

+
+
+ +
+
+

Player Limit:

+
+
+

20

+

+
+
+ +
+
+

Max View Distance:

+
+
+

30

+

+
+
+ +
+
+

Global PvP:

+
+
+

Enable

+
+
+ +
+
+

Experimental Terrain Persistence:

+
+
+

Enable

+
+
+
+ +
+

Server Logs

+
+
+ +
+ + + +

Players

+
    +
+
+ +
+

Whitelist

+

Banlist

+

Admin

+
\ No newline at end of file diff --git a/server-cli/src/web/ui/ui.js b/server-cli/src/web/ui/ui.js new file mode 100644 index 0000000000..fb650f48db --- /dev/null +++ b/server-cli/src/web/ui/ui.js @@ -0,0 +1,99 @@ +function openTab(evt, cityName) { + // Declare all variables + var i, tabcontent, tablinks; + + // Get all elements with class="tabcontent" and hide them + tabcontent = document.getElementsByClassName("tabcontent"); + for (i = 0; i < tabcontent.length; i++) { + tabcontent[i].style.display = "none"; + } + + // Get all elements with class="tablinks" and remove the class "active" + tablinks = document.getElementsByClassName("tablinks"); + for (i = 0; i < tablinks.length; i++) { + tablinks[i].className = tablinks[i].className.replace(" active", ""); + } + + // Show the current tab, and add an "active" class to the button that opened the tab + document.getElementById(cityName).style.display = "block"; + evt.currentTarget.className += " active"; +} + +function changeSlider(evt, sliderId, showId) { + var slider = document.getElementById(sliderId); + var sliderNo = document.getElementById(showId); + sliderNo.innerHTML = slider.value; +} + +async function sendGlobalMsg() { + var world_msg = document.getElementById("world_msg"); + const msg_text = world_msg.value; + + const msg_response = await fetch("/ui_api/v1/send_global_msg", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + msg: msg_text + }) + }); + + if (msg_response.status == 200) { + world_msg.value = ''; + } +} + +async function update_players() { + const players_response = await fetch("/ui_api/v1/players"); + const players = await players_response.json(); + + // remove no longer existing childs + var players_list = document.getElementById("players_list"); + for (var i = players_list.children.length-1; i >= 0; i--) { + if (!players.includes(players_list.children[i].innerText)) { + console.log("remove player: " + players_list.children[i].innerText); + players_list.removeChild(players_list.children[i]); + i--; + } + } + + // add non-existing elements + addloop: for (const player of players) { + for (var i = 0; i < players_list.children.length; i++) { + if (players_list.children[i].innerText == player) { + continue addloop; + } + } + + var li = document.createElement("li"); + li.appendChild(document.createTextNode(player)); + players_list.appendChild(li); + + console.log("added player: " + player); + } +} + +async function update_logs() { + const logs_response = await fetch("/ui_api/v1/logs"); + const logs = await logs_response.json(); + + // remove no longer existing childs + var logs_list = document.getElementById("logs_list"); + while (logs_list.lastElementChild) { + logs_list.removeChild(logs_list.lastElementChild); + } + + for (const log of logs) { + var p = document.createElement("p"); + p.appendChild(document.createTextNode(log)); + logs_list.appendChild(p); + } +} + +async function loop() { + await update_players(); + await update_logs(); +} + +var loopId = window.setInterval(loop, 1000); \ No newline at end of file