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"
Diff
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(-)
@@ -0,0 +1,57 @@
use headers::{HeaderMapExt, Host};
use hyper::{header::LOCATION, Body, Request, Response, StatusCode};
use std::sync::Arc;
use crate::Result;
pub struct RedirectOpts {
pub https_hostname: String,
pub https_port: u16,
pub allowed_hosts: Vec<String>,
}
pub async fn redirect_to_https(
req: &Request<Body>,
opts: Arc<RedirectOpts>,
) -> Result<Response<Body>, StatusCode> {
if let Some(ref host) = req.headers().typed_get::<Host>() {
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)
}
@@ -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;
@@ -6,11 +6,13 @@
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};
@@ -232,6 +235,22 @@ impl Server {
let grace_period = general.grace_period;
tracing::info!("grace period before graceful shutdown: {}s", grace_period);
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
);
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(());
#[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(());
}
});
#[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");
if general.https_redirect {
let ip = general
.host
.parse::<IpAddr>()
.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();
let redirect_allowed_hosts = general
.https_redirect_from_hosts
.split(',')
.map(|s| s.trim().to_owned())
.collect::<Vec<_>>();
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;
}
});
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)
}
});
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();
@@ -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"])
)]
@@ -160,6 +160,47 @@ pub struct General {
pub http2_tls_key: Option<PathBuf>,
#[structopt(
long,
required_if("http2", "true"),
parse(try_from_str),
default_value = "false",
env = "SERVER_HTTPS_REDIRECT"
)]
#[cfg(feature = "http2")]
pub https_redirect: bool,
#[structopt(
long,
required_if("https_redirect", "true"),
default_value = "localhost",
env = "HTTPS_REDIRECT_HOST"
)]
#[cfg(feature = "http2")]
pub https_redirect_host: String,
#[structopt(
long,
required_if("https_redirect", "true"),
default_value = "80",
env = "HTTPS_REDIRECT_FROM_PORT"
)]
#[cfg(feature = "http2")]
pub https_redirect_from_port: u16,
#[structopt(
long,
required_if("https_redirect", "true"),
default_value = "localhost",
env = "HTTPS_REDIRECT_FROM_HOSTS"
)]
#[cfg(feature = "http2")]
pub https_redirect_from_hosts: String,
#[cfg(feature = "compression")]
#[cfg_attr(docsrs, doc(cfg(feature = "compression")))]
#[structopt(
@@ -141,6 +141,19 @@ pub struct General {
#[cfg(feature = "http2")]
pub http2_tls_key: Option<PathBuf>,
#[cfg(feature = "http2")]
pub https_redirect: Option<bool>,
#[cfg(feature = "http2")]
pub https_redirect_host: Option<String>,
#[cfg(feature = "http2")]
pub https_redirect_from_port: Option<u16>,
#[cfg(feature = "http2")]
pub https_redirect_from_hosts: Option<String>,
pub security_headers: Option<bool>,
@@ -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,
@@ -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)]
@@ -59,22 +59,9 @@ async fn delay_graceful_shutdown(grace_period_secs: u8) {
#[cfg(windows)]
pub async fn wait_for_ctrl_c<F>(
cancel_recv: Option<Receiver<()>>,
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<Mutex<Option<Receiver<()>>>>, grace_period_secs: u8) {
if let Some(receiver) = &mut *cancel_recv.lock().await {
receiver.changed().await.ok();
}
delay_graceful_shutdown(grace_period_secs).await;
@@ -79,7 +79,7 @@ fn run_service() -> Result {
tracing::info!("windows service: starting service setup");
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);
@@ -169,7 +169,7 @@ pub fn run_server_as_service() -> Result {
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(())
}
@@ -2,8 +2,8 @@
host = "::"
port = 8787
root = "tests/fixtures/public"
port = 4433
root = "docker/public"
log-level = "trace"
@@ -20,11 +20,15 @@ page50x = "docker/public/50x.html"
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"
security-headers = true
cors-allow-origins = ""