From 946b4e5d690fba0a63c781e630328c8074acdebf Mon Sep 17 00:00:00 2001 From: Jose Quintana <1700322+joseluisq@users.noreply.github.com> Date: Wed, 31 May 2023 23:09:55 +0200 Subject: [PATCH] feat: http to https redirect support (#203) This PR provides support for redirecting HTTP requests to HTTPS (301 Moved Permanently) Redirect options: --https-redirect = false --https-redirect-host = "localhost" --https-redirect-from-port = 80 --https-redirect-from-hosts = "localhost" --- src/https_redirect.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 ++ src/server.rs | 218 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------- src/settings/cli.rs | 43 ++++++++++++++++++++++++++++++++++++++++++- src/settings/file.rs | 13 +++++++++++++ src/settings/mod.rs | 32 ++++++++++++++++++++++++++++++++ src/signals.rs | 21 ++++----------------- src/winservice.rs | 4 ++-- tests/toml/config.toml | 14 +++++++++----- 9 files changed, 354 insertions(+), 50 deletions(-) create mode 100644 src/https_redirect.rs diff --git a/src/https_redirect.rs b/src/https_redirect.rs new file mode 100644 index 0000000..02f804c --- /dev/null +++ b/src/https_redirect.rs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// This file is part of Static Web Server. +// See https://static-web-server.net/ for more information +// Copyright (C) 2019-present Jose Quintana + +//! Module to redirect HTTP requests to HTTPS. +//! + +use headers::{HeaderMapExt, Host}; +use hyper::{header::LOCATION, Body, Request, Response, StatusCode}; +use std::sync::Arc; + +use crate::Result; + +/// HTTPS redirect options. +pub struct RedirectOpts { + /// HTTPS hostname to redirect to. + pub https_hostname: String, + /// HTTPS hostname port to redirect to. + pub https_port: u16, + /// Hostnames or IPS to redirect from. + pub allowed_hosts: Vec, +} + +/// It redirects all requests from HTTP to HTTPS. +pub async fn redirect_to_https( + req: &Request, + opts: Arc, +) -> Result, StatusCode> { + if let Some(ref host) = req.headers().typed_get::() { + let from_hostname = host.hostname(); + if !opts + .allowed_hosts + .iter() + .any(|s| s.as_str() == from_hostname) + { + tracing::debug!("redirect host is not allowed!"); + return Err(StatusCode::BAD_REQUEST); + } + + let url = format!( + "https://{}:{}{}", + opts.https_hostname, + opts.https_port, + req.uri() + ); + tracing::debug!("https redirect to {}", url); + + let mut resp = Response::new(Body::empty()); + *resp.status_mut() = StatusCode::MOVED_PERMANENTLY; + resp.headers_mut().insert(LOCATION, url.parse().unwrap()); + return Ok(resp); + } + + tracing::debug!("redirect host was not determined!"); + Err(StatusCode::BAD_REQUEST) +} diff --git a/src/lib.rs b/src/lib.rs index d8c59d6..3fe8c44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -115,6 +115,8 @@ pub mod error_page; pub mod exts; pub mod fallback_page; pub mod handler; +#[cfg(feature = "http2")] +pub mod https_redirect; pub mod logger; pub mod redirects; pub mod rewrites; diff --git a/src/server.rs b/src/server.rs index a2a9915..f96b71a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -6,11 +6,13 @@ //! Server module intended to construct a multi-thread HTTP or HTTP/2 web server. //! +use hyper::server::conn::AddrStream; use hyper::server::Server as HyperServer; +use hyper::service::{make_service_fn, service_fn}; use listenfd::ListenFd; use std::net::{IpAddr, SocketAddr, TcpListener}; use std::sync::Arc; -use tokio::sync::oneshot::Receiver; +use tokio::sync::watch::Receiver; use crate::handler::{RequestHandler, RequestHandlerOpts}; #[cfg(any(unix, windows))] @@ -22,7 +24,8 @@ use { hyper::server::conn::AddrIncoming, }; -use crate::{cors, helpers, logger, Settings}; +use crate::https_redirect; +use crate::{cors, error, error_page, helpers, logger, Settings}; use crate::{service::RouterService, Context, Result}; /// Define a multi-thread HTTP or HTTP/2 web server. @@ -232,6 +235,22 @@ impl Server { let grace_period = general.grace_period; tracing::info!("grace period before graceful shutdown: {}s", grace_period); + // HTTP to HTTPS redirect option + let https_redirect = general.https_redirect; + tracing::info!("http to https redirect: enabled={}", https_redirect); + tracing::info!( + "http to https redirect host: {}", + general.https_redirect_host + ); + tracing::info!( + "http to https redirect from port: {}", + general.https_redirect_from_port + ); + tracing::info!( + "http to https redirect from hosts: {}", + general.https_redirect_from_hosts + ); + // Create a service router for Hyper let router_service = RouterService::new(RequestHandler { opts: Arc::from(RequestHandlerOpts { @@ -244,8 +263,8 @@ impl Server { cors, security_headers, cache_control_headers, - page404, - page50x, + page404: page404.clone(), + page50x: page50x.clone(), page_fallback, basic_auth, log_remote_address, @@ -255,6 +274,21 @@ impl Server { }), }); + #[cfg(windows)] + let (sender, receiver) = tokio::sync::watch::channel(()); + // ctrl+c listening + #[cfg(windows)] + let ctrlc_task = tokio::spawn(async move { + if !general.windows_service { + tracing::info!("installing graceful shutdown ctrl+c signal handler"); + tokio::signal::ctrl_c() + .await + .expect("failed to install ctrl+c signal handler"); + tracing::info!("installing graceful shutdown ctrl+c signal handler"); + let _ = sender.send(()); + } + }); + // Run the corresponding HTTP Server asynchronously with its given options #[cfg(feature = "http2")] if general.http2 { @@ -293,32 +327,155 @@ impl Server { #[cfg(unix)] let handle = signals.handle(); - let server = + let http2_server = HyperServer::builder(TlsAcceptor::new(tls, incoming)).serve(router_service); #[cfg(unix)] - let server = - server.with_graceful_shutdown(signals::wait_for_signals(signals, grace_period)); + let http2_server = http2_server + .with_graceful_shutdown(signals::wait_for_signals(signals, grace_period)); + + #[cfg(windows)] + let http2_cancel_recv = Arc::new(tokio::sync::Mutex::new(_cancel_recv)); + #[cfg(windows)] + let redirect_cancel_recv = http2_cancel_recv.clone(); + #[cfg(windows)] - let server = server.with_graceful_shutdown(signals::wait_for_ctrl_c( - _cancel_recv, - _cancel_fn, - grace_period, - )); + let http2_ctrlc_recv = Arc::new(tokio::sync::Mutex::new(Some(receiver))); + #[cfg(windows)] + let redirect_ctrlc_recv = http2_ctrlc_recv.clone(); + + #[cfg(windows)] + let http2_server = http2_server.with_graceful_shutdown(async move { + if general.windows_service { + signals::wait_for_ctrl_c(http2_cancel_recv, grace_period).await; + } else { + signals::wait_for_ctrl_c(http2_ctrlc_recv, grace_period).await; + } + }); tracing::info!( parent: tracing::info_span!("Server::start_server", ?addr_str, ?threads), - "listening on https://{}", + "http2 server is listening on https://{}", addr_str ); - tracing::info!("press ctrl+c to shut down the server"); + // HTTP to HTTPS redirect server + if general.https_redirect { + let ip = general + .host + .parse::() + .with_context(|| format!("failed to parse {} address", general.host))?; + let addr = SocketAddr::from((ip, general.https_redirect_from_port)); + let tcp_listener = TcpListener::bind(addr) + .with_context(|| format!("failed to bind to {addr} address"))?; + tracing::info!( + parent: tracing::info_span!("Server::start_server", ?addr, ?threads), + "http1 redirect server is listening on http://{}", + addr + ); + tcp_listener + .set_nonblocking(true) + .with_context(|| "failed to set TCP non-blocking mode")?; + + #[cfg(unix)] + let redirect_signals = signals::create_signals() + .with_context(|| "failed to register termination signals")?; + #[cfg(unix)] + let redirect_handle = redirect_signals.handle(); + + // Allowed redirect hosts + let redirect_allowed_hosts = general + .https_redirect_from_hosts + .split(',') + .map(|s| s.trim().to_owned()) + .collect::>(); + if redirect_allowed_hosts.is_empty() { + bail!("https redirect allowed hosts is empty, provide at least one host or IP") + } - server.await?; + let redirect_opts = Arc::new(https_redirect::RedirectOpts { + https_hostname: general.https_redirect_host, + https_port: general.port, + allowed_hosts: redirect_allowed_hosts, + }); + + let server_redirect = HyperServer::from_tcp(tcp_listener) + .unwrap() + .tcp_nodelay(true) + .serve(make_service_fn(move |_: &AddrStream| { + let redirect_opts = redirect_opts.clone(); + let page404 = page404.clone(); + let page50x = page50x.clone(); + async move { + Ok::<_, error::Error>(service_fn(move |req| { + let redirect_opts = redirect_opts.clone(); + let page404 = page404.clone(); + let page50x = page50x.clone(); + async move { + let uri = req.uri(); + let method = req.method(); + match https_redirect::redirect_to_https(&req, redirect_opts) + .await + { + Ok(resp) => Ok(resp), + Err(status) => error_page::error_response( + uri, method, &status, &page404, &page50x, + ), + } + } + })) + } + })); + + #[cfg(unix)] + let server_redirect = server_redirect.with_graceful_shutdown( + signals::wait_for_signals(redirect_signals, grace_period), + ); + #[cfg(windows)] + let server_redirect = server_redirect.with_graceful_shutdown(async move { + if general.windows_service { + signals::wait_for_ctrl_c(redirect_cancel_recv, grace_period).await; + } else { + signals::wait_for_ctrl_c(redirect_ctrlc_recv, grace_period).await; + } + }); + + // HTTP/2 server task + let server_task = tokio::spawn(async move { + if let Err(err) = http2_server.await { + tracing::error!("http2 server failed to start up: {:?}", err); + std::process::exit(1) + } + }); + + // HTTP/1 redirect server task + let redirect_server_task = tokio::spawn(async move { + if let Err(err) = server_redirect.await { + tracing::error!("http1 redirect server failed to start up: {:?}", err); + std::process::exit(1) + } + }); + + tracing::info!("press ctrl+c to shut down the servers"); + + #[cfg(windows)] + tokio::try_join!(ctrlc_task, server_task, redirect_server_task)?; + #[cfg(unix)] + tokio::try_join!(server_task, redirect_server_task)?; + + #[cfg(unix)] + redirect_handle.close(); + } else { + tracing::info!("press ctrl+c to shut down the server"); + http2_server.await?; + } #[cfg(unix)] handle.close(); + #[cfg(windows)] + _cancel_fn(); + tracing::warn!("termination signal caught, shutting down the server execution"); return Ok(()); } @@ -335,30 +492,41 @@ impl Server { .set_nonblocking(true) .with_context(|| "failed to set TCP non-blocking mode")?; - let server = HyperServer::from_tcp(tcp_listener) + let http1_server = HyperServer::from_tcp(tcp_listener) .unwrap() .tcp_nodelay(true) .serve(router_service); #[cfg(unix)] - let server = - server.with_graceful_shutdown(signals::wait_for_signals(signals, grace_period)); + let http1_server = + http1_server.with_graceful_shutdown(signals::wait_for_signals(signals, grace_period)); + + #[cfg(windows)] + let http1_cancel_recv = Arc::new(tokio::sync::Mutex::new(_cancel_recv)); + #[cfg(windows)] + let http1_ctrlc_recv = Arc::new(tokio::sync::Mutex::new(Some(receiver))); + #[cfg(windows)] - let server = server.with_graceful_shutdown(signals::wait_for_ctrl_c( - _cancel_recv, - _cancel_fn, - grace_period, - )); + let http1_server = http1_server.with_graceful_shutdown(async move { + if general.windows_service { + signals::wait_for_ctrl_c(http1_cancel_recv, grace_period).await; + } else { + signals::wait_for_ctrl_c(http1_ctrlc_recv, grace_period).await; + } + }); tracing::info!( parent: tracing::info_span!("Server::start_server", ?addr_str, ?threads), - "listening on http://{}", + "http1 server is listening on http://{}", addr_str ); tracing::info!("press ctrl+c to shut down the server"); - server.await?; + http1_server.await?; + + #[cfg(windows)] + _cancel_fn(); #[cfg(unix)] handle.close(); diff --git a/src/settings/cli.rs b/src/settings/cli.rs index a50ac5c..2c686d9 100644 --- a/src/settings/cli.rs +++ b/src/settings/cli.rs @@ -26,7 +26,7 @@ pub struct General { long, short = "f", env = "SERVER_LISTEN_FD", - conflicts_with_all(&["host", "port"]) + conflicts_with_all(&["host", "port", "https_redirect"]) )] /// Instead of binding to a TCP port, accept incoming connections to an already-bound TCP /// socket listener on the specified file descriptor number (usually zero). Requires that the @@ -160,6 +160,47 @@ pub struct General { /// Specify the file path to read the private key. pub http2_tls_key: Option, + #[structopt( + long, + required_if("http2", "true"), + parse(try_from_str), + default_value = "false", + env = "SERVER_HTTPS_REDIRECT" + )] + #[cfg(feature = "http2")] + /// Redirect all requests with scheme "http" to "https" for the current server instance. It depends on "http2" to be enabled. + pub https_redirect: bool, + + #[structopt( + long, + required_if("https_redirect", "true"), + default_value = "localhost", + env = "HTTPS_REDIRECT_HOST" + )] + #[cfg(feature = "http2")] + /// Canonical host name or IP of the HTTPS (HTTPS/2) server. It depends on "https_redirect" to be enabled. + pub https_redirect_host: String, + + #[structopt( + long, + required_if("https_redirect", "true"), + default_value = "80", + env = "HTTPS_REDIRECT_FROM_PORT" + )] + #[cfg(feature = "http2")] + /// HTTP host port where the redirect server will listen for requests to redirect them to HTTPS. It depends on "https_redirect" to be enabled. + pub https_redirect_from_port: u16, + + #[structopt( + long, + required_if("https_redirect", "true"), + default_value = "localhost", + env = "HTTPS_REDIRECT_FROM_HOSTS" + )] + #[cfg(feature = "http2")] + /// List of host names or IPs allowed to redirect from. HTTP requests must contain the HTTP 'Host' header and match against this list. It depends on "https_redirect" to be enabled. + pub https_redirect_from_hosts: String, + #[cfg(feature = "compression")] #[cfg_attr(docsrs, doc(cfg(feature = "compression")))] #[structopt( diff --git a/src/settings/file.rs b/src/settings/file.rs index 97e11a7..5c23403 100644 --- a/src/settings/file.rs +++ b/src/settings/file.rs @@ -141,6 +141,19 @@ pub struct General { #[cfg(feature = "http2")] pub http2_tls_key: Option, + /// Redirect all HTTP requests to HTTPS. + #[cfg(feature = "http2")] + pub https_redirect: Option, + /// HTTP host port where the redirect server will listen for requests to redirect them to HTTPS. + #[cfg(feature = "http2")] + pub https_redirect_host: Option, + /// Host port for redirecting HTTP requests to HTTPS. + #[cfg(feature = "http2")] + pub https_redirect_from_port: Option, + /// List of host names or IPs allowed to redirect from. + #[cfg(feature = "http2")] + pub https_redirect_from_hosts: Option, + /// Security headers. pub security_headers: Option, diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 1d60d2e..51cacaf 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -91,6 +91,14 @@ impl Settings { let mut http2_tls_cert = opts.http2_tls_cert; #[cfg(feature = "http2")] let mut http2_tls_key = opts.http2_tls_key; + #[cfg(feature = "http2")] + let mut https_redirect = opts.https_redirect; + #[cfg(feature = "http2")] + let mut https_redirect_host = opts.https_redirect_host; + #[cfg(feature = "http2")] + let mut https_redirect_from_port = opts.https_redirect_from_port; + #[cfg(feature = "http2")] + let mut https_redirect_from_hosts = opts.https_redirect_from_hosts; let mut security_headers = opts.security_headers; let mut cors_allow_origins = opts.cors_allow_origins; let mut cors_allow_headers = opts.cors_allow_headers; @@ -173,6 +181,22 @@ impl Settings { if let Some(v) = general.http2_tls_key { http2_tls_key = Some(v) } + #[cfg(feature = "http2")] + if let Some(v) = general.https_redirect { + https_redirect = v + } + #[cfg(feature = "http2")] + if let Some(v) = general.https_redirect_host { + https_redirect_host = v + } + #[cfg(feature = "http2")] + if let Some(v) = general.https_redirect_from_port { + https_redirect_from_port = v + } + #[cfg(feature = "http2")] + if let Some(v) = general.https_redirect_from_hosts { + https_redirect_from_hosts = v + } if let Some(v) = general.security_headers { security_headers = v } @@ -342,6 +366,14 @@ impl Settings { http2_tls_cert, #[cfg(feature = "http2")] http2_tls_key, + #[cfg(feature = "http2")] + https_redirect, + #[cfg(feature = "http2")] + https_redirect_host, + #[cfg(feature = "http2")] + https_redirect_from_port, + #[cfg(feature = "http2")] + https_redirect_from_hosts, security_headers, cors_allow_origins, cors_allow_headers, diff --git a/src/signals.rs b/src/signals.rs index 9679a26..3252ba4 100644 --- a/src/signals.rs +++ b/src/signals.rs @@ -15,7 +15,7 @@ use { }; #[cfg(windows)] -use tokio::sync::oneshot::Receiver; +use {std::sync::Arc, tokio::sync::watch::Receiver, tokio::sync::Mutex}; #[cfg(unix)] /// It creates a common list of signals stream for `SIGTERM`, `SIGINT` and `SIGQUIT` to be observed. @@ -59,22 +59,9 @@ async fn delay_graceful_shutdown(grace_period_secs: u8) { #[cfg(windows)] /// It waits for an incoming `ctrl+c` signal on Windows. -pub async fn wait_for_ctrl_c( - cancel_recv: Option>, - cancel_fn: F, - grace_period_secs: u8, -) where - F: FnOnce(), -{ - if let Some(recv) = cancel_recv { - if let Err(err) = recv.await { - tracing::error!("error during cancel recv: {:?}", err) - } - cancel_fn() - } else { - tokio::signal::ctrl_c() - .await - .expect("failed to install ctrl+c signal handler"); +pub async fn wait_for_ctrl_c(cancel_recv: Arc>>>, grace_period_secs: u8) { + if let Some(receiver) = &mut *cancel_recv.lock().await { + receiver.changed().await.ok(); } delay_graceful_shutdown(grace_period_secs).await; diff --git a/src/winservice.rs b/src/winservice.rs index 0898385..ff7a81f 100644 --- a/src/winservice.rs +++ b/src/winservice.rs @@ -79,7 +79,7 @@ fn run_service() -> Result { tracing::info!("windows service: starting service setup"); // Create a channel to be able to poll a stop event from the service worker loop. - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(()); let mut shutdown_tx = Some(shutdown_tx); // Define system service event handler that will be receiving service events. @@ -169,7 +169,7 @@ pub fn run_server_as_service() -> Result { // Register generated `ffi_service_main` with the system and start the // service, blocking this thread until the service is stopped - service_dispatcher::start(&SERVICE_NAME, ffi_service_main) + service_dispatcher::start(SERVICE_NAME, ffi_service_main) .with_context(|| "error registering generated `ffi_service_main` with the system")?; Ok(()) } diff --git a/tests/toml/config.toml b/tests/toml/config.toml index db3e0e5..70befff 100644 --- a/tests/toml/config.toml +++ b/tests/toml/config.toml @@ -2,8 +2,8 @@ #### Address & Root dir host = "::" -port = 8787 -root = "tests/fixtures/public" +port = 4433 +root = "docker/public" #### Logging log-level = "trace" @@ -20,11 +20,15 @@ page50x = "docker/public/50x.html" #### HTTP/2 + TLS http2 = false -http2-tls-cert = "" -http2-tls-key = "" +http2-tls-cert = "tests/tls/local.dev_cert.ecc.pem" +http2-tls-key = "tests/tls/local.dev_key.ecc.pem" +https-redirect = false +https-redirect-host = "localhost" +https-redirect-from-port = 80 +https-redirect-from-hosts = "localhost, 127.0.0.1" #### CORS & Security headers -security-headers = true +# security-headers = true cors-allow-origins = "" #### Directory listing -- libgit2 1.7.2