diff --git a/.ci/build b/.ci/build new file mode 100644 index 0000000..ff30a2b --- /dev/null +++ b/.ci/build @@ -0,0 +1,10 @@ +#!/bin/bash -e +set -Eeuo pipefail + +export HOME=$(pwd) +export CARGO_HOME=$(pwd)/.cargo +cd wwp-server-repo +ln -s ../target ./target + +cargo build --release +cp ./target/release/worldwideportal-server ../wwpserver/ diff --git a/Cargo.lock b/Cargo.lock index 71190d3..f685919 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "actix-files" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0773d59061dedb49a8aed04c67291b9d8cf2fe0b60130a381aab53c6dd86e9be" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + [[package]] name = "actix-http" version = "3.9.0" @@ -814,6 +837,12 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.9.4" @@ -973,6 +1002,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1329,18 +1368,18 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.209" +version = "1.0.213" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09" +checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.209" +version = "1.0.213" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" +checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" dependencies = [ "proc-macro2", "quote", @@ -1359,6 +1398,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1436,9 +1484,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.76" +version = "2.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578e081a14e0cefc3279b0472138c513f37b41a08d5a3cca9b6e4e8ceb6cd525" +checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" dependencies = [ "proc-macro2", "quote", @@ -1544,6 +1592,40 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tracing" version = "0.1.40" @@ -1570,6 +1652,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unicase" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -1614,6 +1702,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + [[package]] name = "version_check" version = "0.9.5" @@ -1643,6 +1737,9 @@ name = "wildmatch" version = "2.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3928939971918220fed093266b809d1ee4ec6c1a2d72692ff6876898f3b16c19" +dependencies = [ + "serde", +] [[package]] name = "windows-sys" @@ -1717,10 +1814,20 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + [[package]] name = "worldwideportal-server" version = "0.1.0" dependencies = [ + "actix-files", "actix-web", "actix-ws", "anyhow", @@ -1729,8 +1836,10 @@ dependencies = [ "log", "rustls", "rustls-pemfile", + "serde", "serde_json", "tokio", + "toml", "wildmatch", ] diff --git a/Cargo.toml b/Cargo.toml index dfad925..fd4a457 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +actix-files = "0.6.6" actix-web = { version = "4.9.0", features = ["rustls-0_23"] } actix-ws = "0.3.0" anyhow = "1.0.86" @@ -14,6 +15,8 @@ futures-util = "0.3.30" log = "0.4.22" rustls = "0.23.12" rustls-pemfile = "2.1.3" +serde = { version = "1.0.213", features = ["derive"] } serde_json = "1.0.127" -tokio = { version = "1.39.3", features = ["net", "macros", "tokio-macros", "rt-multi-thread"] } -wildmatch = "2.3.4" +tokio = { version = "1.39.3", features = ["net", "macros", "tokio-macros", "rt-multi-thread", "signal"] } +toml = "0.8.19" +wildmatch = { version = "2.3.4", features = ["serde"] } diff --git a/docs/example.toml b/docs/example.toml new file mode 100644 index 0000000..a53adde --- /dev/null +++ b/docs/example.toml @@ -0,0 +1,62 @@ +# The port to listen on. Typically 443 if you want https, +# otherwise 80. However, you can use another port and include it +# in the URL. +# Cannot be changed without a complete restart. +listen_port = 443 +# :: to accept from anywhere, or select a particular IP to listen on. +# Cannot be changed without a complete restart. +bind_address = "::" + +# Where to find the static worldwideportal frontend to serve up. This +# default value works in the Docker/podman container. +# Updated on SIGHUP. +static_root = "/frontendstatic" + +# This entire section is optional. Leave it off if you want the server +# to speak HTTP and not HTTPS (e.g. because you are going to proxy it). +# Be aware most browsers don't support ws:// URLs for anything except +# localhost. +# Consider getting a certificate for free from LetsEncrypt. +# This whole section is updated on SIGHUP, except that it is not possible +# to change from HTTP to HTTPS or vice versa, only to update the key / cert. +# Sending a SIGHUP after each time the certificate is rotated by certbot is +# highly recommended, to ensure an expired certificate is not served up. +[tls] +private_key_file = "/etc/letsencrypt/live/blastmud.org/privkey.pem" +certificate_chain_file = "/etc/letsencrypt/live/blastmud.org/fullchain.pem" + +# An arbitrary number of muds are allowed. Each one runs on a different host +# name as a virtual host (but with the same binding address and certificate). +[[muds]] +# The Host (including port number if not 80/443) to match. +# Note that you currently can't send a different certificate for different +# MUDs - if you have more than one, either use a wildcard certificate, or +# use one certificate with subject alt names (SANs) for all relevant hosts. +hostname = "wstest.blastmud.org" +# The username and port for where to connect over telnet for your MUD. +upstream_mud = "localhost:4000" +# The text to send to the MUD when you first connect. Two special symbols +# are supported: %i is substituted for the client's IP address. %n is +# substituted for \r\n. Note that no newlines are sent unless you explicitly +# say they should be sent with %n. +# Consider implementing a command in your MUD to accept this banner and update +# the user's IP - but be careful not to accept it from users connected directly +# over telnet, or more than once (there is nothing stopping users from sending +# further text identical to the banner a second time if they want to). The +# banner is guaranteed to send before any user-supplied input. +banner_to_mud = "" +# The path to a Lua script file to send to the client to execute. +startup_script_file = "localdata/startup.lua" + +# Only browsers coming from an allowed Origin will be allowed to connect. +# This stops random web pages that a user is browsing from logging in to your +# MUD from the web user's IP. +# You should at a minimum allowlist the host users will use to connect to your +# MUD (hostname above) if you want them to be able to play through this +# worldwideportal frontend. +allowed_origins = [ + "https://*.blastmud.org:*", "https://*.blastmud.org", "http://localhost:*" +] + +# Repeat the section to add more MUDs... +# [[muds]] diff --git a/src/main.rs b/src/main.rs index 72f878a..6d563f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,52 +1,119 @@ +use actix_files::Files; use actix_web::{ self, - error::{ErrorForbidden, InternalError}, + body::BoxBody, + dev::{ + self, HttpServiceFactory, ResourceDef, Service, ServiceFactory, ServiceRequest, + ServiceResponse, + }, + error::{ + ErrorBadRequest, ErrorForbidden, ErrorInternalServerError, ErrorNotFound, InternalError, + }, get, - http::{header::ORIGIN, StatusCode}, + http::{ + header::{HOST, ORIGIN}, + StatusCode, + }, middleware::Logger, rt::{self, net::TcpStream}, web::{self, Data}, App, Error, HttpRequest, HttpResponse, HttpServer, Responder, }; use actix_ws::{AggregatedMessage, CloseCode, Closed}; -use anyhow::Context; -use futures_util::StreamExt; -use rustls::pki_types::CertificateDer; +use futures_util::{future::LocalBoxFuture, FutureExt, StreamExt}; +use log::{info, warn}; +use rustls::{ + crypto::{aws_lc_rs, CryptoProvider}, + pki_types::CertificateDer, + server::ResolvesServerCert, + sign::CertifiedKey, +}; +use serde::Deserialize; use serde_json::json; use std::{ + collections::BTreeMap, env, fs::{self, File}, + future::{ready, Ready}, io::{self, BufReader}, - num::ParseIntError, + sync::{Arc, RwLock}, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + select, + signal::unix::{signal, SignalKind}, + spawn, + sync::oneshot, }; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use wildmatch::WildMatch; +#[derive(Deserialize)] pub struct TlsConfig { pub private_key_file: String, pub certificate_chain_file: String, } +#[derive(Deserialize)] pub struct ServerConfig { - pub upstream_mud: String, - pub banner_to_mud: String, pub listen_port: u16, pub bind_address: String, + pub tls: Option, + pub static_root: String, + pub muds: Vec>, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + listen_port: 443, + bind_address: "::".to_owned(), + tls: None, + static_root: "/frontendstatic".to_owned(), + muds: vec![], + } + } +} + +#[derive(Deserialize, Clone, Debug)] +pub struct MudConfig { + pub hostname: String, + pub upstream_mud: String, + pub banner_to_mud: String, pub startup_script_file: String, - pub tls_config: Option, - pub allowed_origins: Vec, + pub allowed_origins: Vec, +} + +fn with_mudconfig( + req: &HttpRequest, + config_data: Data, +) -> Result, Error> { + match req.headers().get(&HOST) { + None => Err(ErrorBadRequest("Missing Host header"))?, + Some(host) => match config_data + .read() + .unwrap() + .host_map + .get(host.to_str().unwrap_or("invalid")) + { + None => Err(ErrorNotFound( + "No MUD matching your request is currently enabled", + ))?, + Some(v) => Ok(v.clone()), + }, + } } #[get("/ws")] async fn ws( - config_data: Data, + config_data: Data, req: HttpRequest, body: web::Payload, ) -> impl Responder { + let mud = with_mudconfig(&req, config_data)?; match req.headers().get(&ORIGIN) { None => Err(ErrorForbidden("Missing origin"))?, Some(origin) => { - if !config_data + if !mud .allowed_origins .iter() .any(|o| o.matches(origin.to_str().unwrap_or("invalid"))) @@ -59,8 +126,8 @@ async fn ws( let (response, mut session, stream) = actix_ws::handle(&req, body)?; let mut stream = stream.aggregate_continuations().max_continuation_size(1024); - let mut tcp_stream: TcpStream = TcpStream::connect(&config_data.upstream_mud).await?; - let subst_banner = config_data + let mut tcp_stream: TcpStream = TcpStream::connect(&mud.upstream_mud).await?; + let subst_banner = mud .banner_to_mud .replace( "%i", @@ -71,7 +138,7 @@ async fn ws( .replace("%n", "\r\n"); tcp_stream.write_all(subst_banner.as_bytes()).await?; - let script = fetch_startup_script(&config_data.startup_script_file) + let script = fetch_startup_script(&mud.startup_script_file) .map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?; rt::spawn(async move { @@ -128,99 +195,221 @@ async fn ws( Ok::(response) } -fn extract_server_config_from_environment() -> anyhow::Result { - Ok(ServerConfig { - upstream_mud: env::var("UPSTREAM_MUD").map_err(|_| { - anyhow::Error::msg( - "Expected UPSTREAM_MUD environment variable specifying where to connect.", - ) - })?, - banner_to_mud: env::var("BANNER_TO_MUD").map_err(|_| { - anyhow::Error::msg( - "Expected BANNER_TO_MUD environment variable specifying message to send to MUD.", - ) - })?, - listen_port: env::var("LISTEN_PORT") - .map_err(|_| { - anyhow::Error::msg( - "Expected LISTEN_PORT environment variable specifying port to listen on.", - ) - }) - .and_then(|v| { - v.parse::().map_err(|_e: ParseIntError| { - anyhow::Error::msg("LISTEN_PORT should be a decimal port number") - }) - })?, - bind_address: env::var("BIND_ADDRESS").unwrap_or_else(|_| "::".to_owned()), - startup_script_file: env::var("STARTUP_SCRIPT_FILE") - .map_err(|_| { - anyhow::Error::msg( - "Expected STARTUP_SCRIPT_FILE environment variable containing filename of script to send to client.", - ) - })?, - tls_config: env::var("PRIVATE_KEY_FILE").ok().map(|pk| Ok::(TlsConfig { - private_key_file: pk, - certificate_chain_file: env::var("CERTIFICATE_CHAIN_FILE").map_err(|_| { - anyhow::Error::msg( - "Expected CERTIFICATE_CHAIN_FILE when PRIVATE_KEY_FILE is set." - ) - })?, - })).transpose()?, - allowed_origins: env::var("ALLOWED_ORIGINS") - .map_err(|_| { - anyhow::Error::msg("Expected ALLOWED_ORIGINS with list of origins to accept") })? - .split(',').map(|v| WildMatch::new(v)).collect() - }) -} - // We load this on each connection so it can change. fn fetch_startup_script(filename: &str) -> anyhow::Result { Ok(fs::read_to_string(filename)?) } +fn load_config_file(filename: &str) -> anyhow::Result { + info!("Reading config file {}", filename); + Ok(toml::from_str(&fs::read_to_string(filename)?)?) +} + +fn validate_config_file(config: &ServerConfig) { + match fs::metadata(&config.static_root).map(|meta| meta.is_dir()) { + Err(e) => warn!( + "Error checking static_root {} is valid: {}", + &config.static_root, e + ), + Ok(false) => warn!("static_root {} is not a directory", &config.static_root), + Ok(true) => {} + } + for mud in &config.muds { + match fetch_startup_script(&mud.startup_script_file) { + Err(e) => warn!( + "Error reading startup script file {} for MUD {}: {}", + mud.startup_script_file, mud.hostname, e + ), + Ok(_) => {} + } + } +} + +#[derive(Debug)] +struct DynamicConfigData { + host_map: BTreeMap>, + certified_key: Option>, + static_root: String, +} +type DynamicConfigLock = Arc>; + +#[derive(Debug)] +struct DynconfigCertResolver(DynamicConfigLock); + +impl ResolvesServerCert for DynconfigCertResolver { + fn resolve( + &self, + _client_hello: rustls::server::ClientHello<'_>, + ) -> Option> { + self.0.read().unwrap().certified_key.as_ref().cloned() + } +} + +#[derive(Clone)] +struct DynStaticFiles(DynamicConfigLock); +impl HttpServiceFactory for DynStaticFiles { + fn register(self, config: &mut actix_web::dev::AppService) { + let rdef = if config.is_root() { + ResourceDef::root_prefix("/") + } else { + ResourceDef::prefix("/") + }; + + config.register_service(rdef, None, self, None) + } +} + +impl ServiceFactory for DynStaticFiles { + type Response = ServiceResponse; + type Error = Error; + type Config = (); + type Service = DynStaticFiles; + type InitError = (); + type Future = Ready>; + + fn new_service(&self, _: ()) -> Self::Future { + ready(Ok(self.clone())) + } +} + +impl Service for DynStaticFiles { + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + dev::always_ready!(); + fn call(&self, req: ServiceRequest) -> Self::Future { + let files = Files::new("/", &self.0.read().unwrap().static_root).index_file("index.html"); + Box::pin(async move { + files + .new_service(()) + .await + .map_err(|_| ErrorInternalServerError("Internal service error"))? + .call(req) + .await + }) + } +} + +fn make_dynamic_config_data(config: &ServerConfig) -> anyhow::Result { + Ok(DynamicConfigData { + host_map: config + .muds + .iter() + .map(|c| { + ( + c.hostname.clone(), + MudConfig:: { + hostname: c.hostname.clone(), + upstream_mud: c.upstream_mud.clone(), + banner_to_mud: c.banner_to_mud.clone(), + startup_script_file: c.startup_script_file.clone(), + allowed_origins: c + .allowed_origins + .iter() + .map(|ao| WildMatch::new(ao)) + .collect(), + }, + ) + }) + .collect::>>(), + certified_key: match &config.tls { + None => None, + Some(tls) => { + let certs: Vec = rustls_pemfile::certs(&mut BufReader::new( + &mut File::open(&tls.certificate_chain_file)?, + )) + .collect::, io::Error>>()?; + let key = rustls_pemfile::private_key(&mut BufReader::new(&mut File::open( + &tls.private_key_file, + )?))? + .expect("No private key found in private key file"); + let key = aws_lc_rs::default_provider() + .key_provider + .load_private_key(key)?; + CryptoProvider::get_default(); + Some(CertifiedKey::new(certs, key).into()) + } + }, + static_root: config.static_root.clone(), + }) +} + +fn rehash_config(config_file: &str, dynconfig_lock: &DynamicConfigLock) -> anyhow::Result<()> { + let config: ServerConfig = load_config_file(config_file)?; + validate_config_file(&config); + let dynconfig: DynamicConfigData = make_dynamic_config_data(&config)?; + + let dcref: &mut DynamicConfigData = &mut dynconfig_lock.write().unwrap(); + *dcref = dynconfig; + + Ok(()) +} + #[tokio::main] async fn main() -> anyhow::Result<()> { - env_logger::init(); + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .parse_default_env() + .init(); + let args: Vec = env::args().collect(); + let config_file = match args.get(1) { + None => "/etc/worldwideportal_server.toml".to_owned(), + Some(cfg) => cfg.clone(), + }; + if config_file == "-h" || config_file == "--help" { + println!("Usage: worldwideportal-server /path/to/config/file.toml"); + return Ok(()); + } - let config = extract_server_config_from_environment()?; + let config: ServerConfig = load_config_file(&config_file)?; + validate_config_file(&config); + let dynconfig: DynamicConfigLock = RwLock::new(make_dynamic_config_data(&config)?).into(); - let data = Data::new(config); - let config = data.get_ref(); - - // Do this early so we fail fast if config is wrong. - fetch_startup_script(&config.startup_script_file) - .context("While checking STARTUP_SCRIPT_FILE can be read")?; + // This is a double Arc, but actix doesn't provide an alternative. + let data = Data::new(dynconfig.clone()); let server_data = data.clone(); + let static_file_svc = DynStaticFiles(dynconfig.clone()); let server = HttpServer::new(move || { let logger = Logger::default(); App::new() .wrap(logger) .app_data(server_data.clone()) + .default_service(static_file_svc.clone()) .service(ws) }); - let server = match &config.tls_config { + let server = match &dynconfig.read().unwrap().certified_key { None => server.bind((config.bind_address.clone(), config.listen_port))?, - Some(tls_config) => { - let certs: Vec = rustls_pemfile::certs(&mut BufReader::new( - &mut File::open(&tls_config.certificate_chain_file)?, - )) - .collect::, io::Error>>()?; - let key = rustls_pemfile::private_key(&mut BufReader::new(&mut File::open( - &tls_config.private_key_file, - )?))? - .expect("No private key found in private key file"); + Some(_tls) => { let tls_config = rustls::server::ServerConfig::builder() .with_no_client_auth() - .with_single_cert(certs, key) - .expect("Bad certificate/key"); + .with_cert_resolver(Arc::new(DynconfigCertResolver(dynconfig.clone()))); server.bind_rustls_0_23( (config.bind_address.clone(), config.listen_port), tls_config, )? } }; + + let mut sighup = signal(SignalKind::hangup())?; + let (exit_tx, exit_rx) = oneshot::channel::<()>(); + let exit_rx = exit_rx.shared(); + spawn(async move { + loop { + select! { + _ = sighup.recv() => { + let _ = rehash_config(&config_file, &dynconfig); + } + _ = exit_rx.clone() => { + return; + } + } + } + }); + server.run().await?; + let _ = exit_tx.send(()); Ok(()) }