runewarp 0.1.0

Runewarp is an ingress tunneling tool for exposing local services without moving TLS termination to the edge. Clients connect out over QUIC, so you can publish services without putting your backend directly on the Internet or leaking your public IP.
Documentation
use std::path::{Path, PathBuf};

use crate::config_preparation::{
    PreparedDirectory, PreparedValue, resolve_default_path, resolve_path, resolve_path_with_default,
};
use crate::settings::{
    LogLevel, RawServerAcmeConfig, RawServerConfig, RawServerTunnelConfig, SettingsError,
    collect_server_unknown_field_messages, deserialize_selected_section, load_log_level_from_path,
    load_optional_selected_section_value,
};
use crate::{
    XdgPathError, default_config_path, default_server_acme_state_dir,
    default_server_cert_material_dir,
};

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PreparedServerConfig {
    pub(crate) hostname: Option<String>,
    pub(crate) log_level: LogLevel,
    pub(crate) public_bind_address: String,
    pub(crate) tunnel_bind_address: String,
    pub(crate) manual_cert_present: bool,
    pub(crate) acme_present: bool,
    pub(crate) manual_certificate_directory: Option<PreparedValue<PathBuf>>,
    pub(crate) acme: Option<PreparedServerAcmeConfig>,
    pub(crate) tunnels: Vec<PreparedServerTunnelConfig>,
    pub(crate) unknown_field_messages: Vec<String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PreparedServerAcmeConfig {
    pub(crate) email: Option<String>,
    pub(crate) state_directory: PreparedDirectory,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PreparedServerTunnelConfig {
    pub(crate) public_hostnames: Option<Vec<String>>,
    pub(crate) client_identity: Option<String>,
}

pub(crate) fn select_server_config_path(config: Option<PathBuf>) -> Result<PathBuf, XdgPathError> {
    select_server_config_path_with_default(config, default_config_path)
}

pub(crate) fn prepare_server_config_from_path(
    path: &Path,
) -> Result<PreparedServerConfig, SettingsError> {
    let Some(section_value) = load_optional_selected_section_value(path, "server")? else {
        return Err(SettingsError::Validation {
            path: path.to_path_buf(),
            section: "server",
            messages: vec!["missing [server] section".to_owned()],
        });
    };
    let unknown_field_messages = collect_server_unknown_field_messages(&section_value);
    let raw = deserialize_selected_section::<RawServerConfig>(path, "server", &section_value)?;
    let log_level = load_log_level_from_path(path)?;
    Ok(prepare_raw_server_config(
        path,
        log_level,
        raw,
        unknown_field_messages,
    ))
}

fn prepare_raw_server_config(
    path: &Path,
    log_level: LogLevel,
    raw: RawServerConfig,
    unknown_field_messages: Vec<String>,
) -> PreparedServerConfig {
    prepare_raw_server_config_with_defaults(
        path,
        log_level,
        raw,
        unknown_field_messages,
        &default_server_cert_material_dir,
        &default_server_acme_state_dir,
    )
}

fn prepare_raw_server_config_with_defaults(
    path: &Path,
    log_level: LogLevel,
    raw: RawServerConfig,
    unknown_field_messages: Vec<String>,
    default_server_cert_directory: &dyn Fn() -> Result<PathBuf, XdgPathError>,
    default_server_acme_state_dir: &dyn Fn() -> Result<PathBuf, XdgPathError>,
) -> PreparedServerConfig {
    let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
    let manual_cert_present = raw.cert_dir.is_some();
    let acme_present = raw.acme.is_some();

    PreparedServerConfig {
        hostname: raw.hostname,
        log_level,
        public_bind_address: raw
            .public_bind_address
            .unwrap_or_else(|| "0.0.0.0:443".to_owned()),
        tunnel_bind_address: raw
            .tunnel_bind_address
            .unwrap_or_else(|| "0.0.0.0:443".to_owned()),
        manual_cert_present,
        acme_present,
        manual_certificate_directory: if !acme_present {
            Some(resolve_path_with_default(
                raw.cert_dir,
                config_dir,
                default_server_cert_directory,
            ))
        } else {
            None
        },
        acme: if acme_present && !manual_cert_present {
            raw.acme.map(|acme| {
                prepare_server_acme_config(acme, config_dir, default_server_acme_state_dir)
            })
        } else {
            None
        },
        tunnels: raw.tunnels.into_iter().map(prepare_server_tunnel).collect(),
        unknown_field_messages,
    }
}

fn prepare_server_acme_config(
    raw: RawServerAcmeConfig,
    config_dir: &Path,
    default_server_acme_state_dir: &dyn Fn() -> Result<PathBuf, XdgPathError>,
) -> PreparedServerAcmeConfig {
    PreparedServerAcmeConfig {
        email: raw.email,
        state_directory: match raw.state_dir {
            Some(state_directory) => {
                PreparedDirectory::Explicit(resolve_path(config_dir, &state_directory))
            }
            None => {
                PreparedDirectory::Defaulted(resolve_default_path(default_server_acme_state_dir))
            }
        },
    }
}

fn prepare_server_tunnel(raw: RawServerTunnelConfig) -> PreparedServerTunnelConfig {
    PreparedServerTunnelConfig {
        public_hostnames: raw.public_hostnames,
        client_identity: raw.client_identity,
    }
}

fn select_server_config_path_with_default(
    config: Option<PathBuf>,
    default_config_path: impl FnOnce() -> Result<PathBuf, XdgPathError>,
) -> Result<PathBuf, XdgPathError> {
    match config {
        Some(path) => Ok(path),
        None => default_config_path(),
    }
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::PathBuf;

    use tempfile::tempdir;

    use super::{PreparedDirectory, PreparedValue, prepare_server_config_from_path};
    use crate::settings::{LogLevel, RawServerAcmeConfig, RawServerConfig, RawServerTunnelConfig};

    #[test]
    fn server_config_selection_prefers_the_explicit_path() -> Result<(), Box<dyn std::error::Error>>
    {
        let explicit = PathBuf::from("/tmp/explicit-server.toml");

        let selected =
            super::select_server_config_path_with_default(Some(explicit.clone()), || {
                Ok(PathBuf::from("/tmp/default-server.toml"))
            })?;

        assert_eq!(selected, explicit);
        Ok(())
    }

    #[test]
    fn server_config_selection_uses_the_default_path_when_omitted()
    -> Result<(), Box<dyn std::error::Error>> {
        let selected = super::select_server_config_path_with_default(None, || {
            Ok(PathBuf::from("/tmp/default-server.toml"))
        })?;

        assert_eq!(selected, PathBuf::from("/tmp/default-server.toml"));
        Ok(())
    }

    #[test]
    fn server_preparation_defaults_bind_addresses_and_resolves_manual_dir()
    -> Result<(), Box<dyn std::error::Error>> {
        let tempdir = tempdir()?;
        fs::write(
            tempdir.path().join("config.toml"),
            r#"
[server]
hostname = "tunnel.example.test"
cert-dir = "server-cert"

[[server.tunnels]]
public-hostnames = ["app.example.test"]
client-identity = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
"#,
        )?;

        let prepared = prepare_server_config_from_path(&tempdir.path().join("config.toml"))?;

        assert_eq!(prepared.hostname, Some("tunnel.example.test".to_owned()));
        assert_eq!(prepared.log_level, LogLevel::Info);
        assert_eq!(prepared.public_bind_address, "0.0.0.0:443");
        assert_eq!(prepared.tunnel_bind_address, "0.0.0.0:443");
        assert!(prepared.manual_cert_present);
        assert!(!prepared.acme_present);
        assert_eq!(
            prepared.manual_certificate_directory,
            Some(PreparedValue::Ready(tempdir.path().join("server-cert")))
        );
        assert_eq!(
            prepared.tunnels[0].public_hostnames,
            Some(vec!["app.example.test".to_owned()])
        );
        Ok(())
    }

    #[test]
    fn server_preparation_uses_injected_xdg_defaults_for_manual_material_and_acme_state()
    -> Result<(), Box<dyn std::error::Error>> {
        let tempdir = tempdir()?;
        let config_path = tempdir.path().join("config.toml");
        let default_cert_dir = tempdir.path().join("xdg-data/server/cert");
        let default_acme_state_dir = tempdir.path().join("xdg-state/server/acme");

        let manual = super::prepare_raw_server_config_with_defaults(
            &config_path,
            LogLevel::Info,
            RawServerConfig {
                hostname: Some("tunnel.example.test".to_owned()),
                cert_dir: None,
                acme: None,
                public_bind_address: None,
                tunnel_bind_address: None,
                tunnels: vec![RawServerTunnelConfig {
                    public_hostnames: Some(vec!["app.example.test".to_owned()]),
                    client_identity: Some(
                        "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
                            .to_owned(),
                    ),
                }],
            },
            Vec::new(),
            &|| Ok(default_cert_dir.clone()),
            &|| Ok(default_acme_state_dir.clone()),
        );

        assert_eq!(
            manual.manual_certificate_directory,
            Some(PreparedValue::Ready(default_cert_dir))
        );
        assert_eq!(manual.public_bind_address, "0.0.0.0:443");
        assert_eq!(manual.tunnel_bind_address, "0.0.0.0:443");

        let acme = super::prepare_raw_server_config_with_defaults(
            &config_path,
            LogLevel::Off,
            RawServerConfig {
                hostname: Some("tunnel.example.test".to_owned()),
                cert_dir: None,
                acme: Some(RawServerAcmeConfig {
                    email: Some("admin@example.test".to_owned()),
                    state_dir: None,
                }),
                public_bind_address: None,
                tunnel_bind_address: None,
                tunnels: vec![RawServerTunnelConfig {
                    public_hostnames: Some(vec!["app.example.test".to_owned()]),
                    client_identity: Some(
                        "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
                            .to_owned(),
                    ),
                }],
            },
            Vec::new(),
            &|| Ok(tempdir.path().join("unused-cert-dir")),
            &|| Ok(default_acme_state_dir.clone()),
        );

        assert_eq!(acme.log_level, LogLevel::Off);
        let prepared_acme = match acme.acme {
            Some(prepared_acme) => prepared_acme,
            None => panic!("expected prepared server acme config"),
        };
        assert_eq!(
            prepared_acme.state_directory,
            PreparedDirectory::Defaulted(PreparedValue::Ready(default_acme_state_dir))
        );
        Ok(())
    }

    #[test]
    fn server_preparation_resolves_relative_acme_state_dir_from_the_config_directory()
    -> Result<(), Box<dyn std::error::Error>> {
        let tempdir = tempdir()?;
        let config_path = tempdir.path().join("nested").join("server.toml");

        let prepared = super::prepare_raw_server_config_with_defaults(
            &config_path,
            LogLevel::Off,
            RawServerConfig {
                hostname: Some("tunnel.example.test".to_owned()),
                cert_dir: None,
                acme: Some(RawServerAcmeConfig {
                    email: Some("admin@example.test".to_owned()),
                    state_dir: Some(PathBuf::from("acme-state")),
                }),
                public_bind_address: Some("127.0.0.1:8443".to_owned()),
                tunnel_bind_address: Some("127.0.0.1:9443".to_owned()),
                tunnels: vec![RawServerTunnelConfig {
                    public_hostnames: Some(vec!["app.example.test".to_owned()]),
                    client_identity: Some(
                        "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
                            .to_owned(),
                    ),
                }],
            },
            Vec::new(),
            &|| Ok(tempdir.path().join("unused-cert-dir")),
            &|| Ok(tempdir.path().join("unused-acme-state")),
        );

        assert_eq!(prepared.log_level, LogLevel::Off);
        assert_eq!(prepared.public_bind_address, "127.0.0.1:8443");
        assert_eq!(prepared.tunnel_bind_address, "127.0.0.1:9443");
        let prepared_acme = match prepared.acme {
            Some(prepared_acme) => prepared_acme,
            None => panic!("expected prepared server acme config"),
        };
        assert_eq!(
            prepared_acme.state_directory,
            PreparedDirectory::Explicit(tempdir.path().join("nested/acme-state"))
        );
        Ok(())
    }
}