index : static-web-server.git

ascending towards madness

author Jose Quintana <1700322+joseluisq@users.noreply.github.com> 2023-05-31 21:09:55.0 +00:00:00
committer GitHub <noreply@github.com> 2023-05-31 21:09:55.0 +00:00:00
commit
946b4e5d690fba0a63c781e630328c8074acdebf [patch]
tree
d380f64608406280b23dfb827d6ed068ffbb065a
parent
0f66443c2f90bb6f1eccd9e7af32107364c81c72
download
946b4e5d690fba0a63c781e630328c8074acdebf.tar.gz

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(-)

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 <joseluisq.net>

//! 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<String>,
}

/// It redirects all requests from HTTP to HTTPS.
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)
}
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::<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();

                // Allowed redirect hosts
                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;
                    }
                });

                // 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<PathBuf>,

    #[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<PathBuf>,

    /// Redirect all HTTP requests to HTTPS.
    #[cfg(feature = "http2")]
    pub https_redirect: Option<bool>,
    /// HTTP host port where the redirect server will listen for requests to redirect them to HTTPS.
    #[cfg(feature = "http2")]
    pub https_redirect_host: Option<String>,
    /// Host port for redirecting HTTP requests to HTTPS.
    #[cfg(feature = "http2")]
    pub https_redirect_from_port: Option<u16>,
    /// List of host names or IPs allowed to redirect from.
    #[cfg(feature = "http2")]
    pub https_redirect_from_hosts: Option<String>,

    /// Security headers.
    pub security_headers: Option<bool>,

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<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;
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