ordinary-config 0.11.1

Config for Ordinary
Documentation
// Copyright (C) 2026 The Ordinary Authors.
//
// SPDX-License-Identifier: BSD-3-Clause

use crate::{MiddlewareMechanism, OrdinaryConfig};
use anyhow::bail;
use hashbrown::HashSet;
use regex::Regex;
use url::Url;

// todo: use DnsName to validate: https://docs.rs/rustls-pki-types/latest/src/rustls_pki_types/server_name.rs.html#177
pub static DOMAIN_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
    Regex::new(r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$")
        .expect("failed to create regex")
});

#[allow(clippy::too_many_lines)]
pub fn validate(config: &OrdinaryConfig) -> anyhow::Result<()> {
    let port = config.port.unwrap_or(433);
    let redirect_port = config.redirect_port.unwrap_or(80);

    if port == redirect_port {
        bail!("port and redirect_port cannot be same value {port}");
    }

    if !DOMAIN_REGEX.is_match(&config.domain) {
        bail!("'domain' field is not a valid domain name");
    }

    if let Some(cnames) = &config.cnames {
        if cnames.contains(&config.domain) {
            bail!("CNAMEs cannot include the primary domain");
        }

        for cname in cnames {
            if !DOMAIN_REGEX.is_match(cname) {
                bail!("cname '{cname}' is not valid domain name");
            }
        }
    }

    if let Some(http_config) = &config.http
        && let Some(redirects) = &http_config.redirects
    {
        if let Some(host_redirects) = &redirects.host {
            let mut from_set = HashSet::new();
            let mut to_set = HashSet::new();

            let mut host_set = HashSet::new();
            host_set.insert(config.domain.clone());

            if let Some(cnames) = &config.cnames {
                for cname in cnames {
                    host_set.insert(cname.clone());
                }
            }

            for host_redirect in host_redirects {
                if !DOMAIN_REGEX.is_match(&host_redirect.from) {
                    bail!(
                        "host redirect from '{}' is not valid domain name",
                        host_redirect.from
                    );
                }

                if !DOMAIN_REGEX.is_match(&host_redirect.to) {
                    bail!(
                        "host redirect to '{}' is not valid domain name",
                        host_redirect.to
                    );
                }

                if !host_set.contains(&host_redirect.from) {
                    bail!(
                        "'{}' for from is not in 'cnames' or the 'domain' field",
                        host_redirect.from
                    );
                }

                if !host_set.contains(&host_redirect.to) {
                    bail!(
                        "'{}' for to is not in 'cnames' or the 'domain' field",
                        host_redirect.from
                    );
                }

                if from_set.contains(&host_redirect.from) {
                    bail!(
                        "cannot have multiple 'from' host redirect rules for a single host {}",
                        host_redirect.from
                    );
                }
                from_set.insert(host_redirect.from.clone());

                if to_set.contains(&host_redirect.from) {
                    bail!(
                        "cannot have chained to/from redirects '{}'",
                        host_redirect.from
                    );
                }

                if from_set.contains(&host_redirect.to) {
                    bail!(
                        "cannot have chained to/from redirects '{}'",
                        host_redirect.to
                    );
                }

                to_set.insert(host_redirect.to.clone());
            }
        }

        if let Some(route_redirects) = &redirects.route {
            for route_redirect in route_redirects {
                if !route_redirect.condition.starts_with('/') {
                    bail!(
                        "route redirect condition {} must start with a leading forward slash",
                        route_redirect.condition
                    );
                }
            }
        }
    }

    let mut middlewares_set = HashSet::new();

    if let Some(http_config) = &config.http
        && let Some(middlewares) = &http_config.middlewares
    {
        for middleware in middlewares {
            middlewares_set.insert(middleware.name.clone());

            match &middleware.mechanism {
                MiddlewareMechanism::Request { endpoint } => {
                    Url::parse(endpoint)?;
                }
            }
        }
    }

    if let Some(http_config) = &config.http
        && let Some(proxies) = &http_config.proxies
    {
        for proxy in proxies {
            if proxy.path.is_none() && proxy.domain.is_none() && proxy.port.is_none() {
                bail!(
                    "neither the proxy `path` nor the proxy `domain` nor the proxy `port` is set for target '{}'",
                    proxy.target
                );
            }

            if let Some(proxy_port) = &proxy.port {
                if proxy_port == &port {
                    bail!(
                        "proxy port {proxy_port} for target {} cannot be the same as primary port",
                        proxy.target
                    )
                }

                if proxy_port == &redirect_port {
                    bail!(
                        "proxy port {proxy_port} for target {} cannot be the same as redirect_port",
                        proxy.target
                    )
                }
            }

            if proxy.path.is_some() && proxy.domain.is_some() {
                tracing::warn!(
                    "`path` {} and `domain` {} should not be set at the same time, only one or the other should be picked; if both are set, only the `path` will be evaluated",
                    proxy.path.as_ref().unwrap_or(&String::new()),
                    proxy.domain.as_ref().unwrap_or(&String::new()),
                );
            }

            if let Some(custom_domain) = &proxy.domain {
                if !DOMAIN_REGEX.is_match(custom_domain) {
                    bail!("proxy domain '{custom_domain}' is not valid domain name");
                }

                if custom_domain == &config.domain {
                    bail!(
                        "proxy domain {custom_domain} for target {} is the same as the primary app domain.",
                        proxy.target
                    );
                }

                if let Some(cnames) = &config.cnames
                    && cnames.contains(custom_domain)
                {
                    bail!(
                        "proxy domain {custom_domain} for target {} contained in CNAMEs {cnames:?}",
                        proxy.target
                    );
                }

                let port_str = format!(":{port}");
                let redirect_port_str = format!(":{redirect_port}");

                if (custom_domain.starts_with("localhost")
                    || custom_domain.starts_with("127.0.0.1"))
                    && (custom_domain.contains(&port_str)
                        || custom_domain.contains(&redirect_port_str))
                {
                    bail!(
                        "loopback domain {custom_domain} port for target {} collide with primary {port_str} or redirect {redirect_port_str} ports",
                        proxy.target
                    );
                }
            }

            if let Some(path) = &proxy.path
                && !path.ends_with("/{*path}")
            {
                bail!(
                    "path for target '{}' must end in `/{{*path}}`",
                    proxy.target
                );
            }

            Url::parse(&proxy.target)?;

            if let Some(middlewares) = &proxy.middlewares {
                for middleware in middlewares {
                    if !middlewares_set.contains(middleware) {
                        bail!(
                            "middleware '{middleware}' does not exist (for proxy {})",
                            proxy.target
                        );
                    }
                }
            }
        }
    }

    if let Some(assets) = &config.assets {
        if !assets.base_route.starts_with('/') {
            bail!("assets base route must start with leading forward slash");
        }

        if assets.append_index_html == Some(true) && assets.append_html_ext == Some(true) {
            tracing::warn!(
                "'assets.append_index_html' and 'assets.append_index_ext' will conflict at runtime with 'assets.append_index_html' taking precedence. only one or the other should be selected."
            );
        }

        if let Some(middlewares) = &assets.middlewares {
            for middleware in middlewares {
                if !middlewares_set.contains(middleware) {
                    bail!("middleware '{middleware}' does not exist (for assets)");
                }
            }
        }
    }

    if let Some(database_config) = &config.database {
        for model_config in &database_config.models {
            for field in &model_config.fields {
                if field.idx == 0 {
                    bail!(
                        "model {} {} field {}: database model fields cannot use 0th idx as it is reserved for its UUID",
                        model_config.name,
                        model_config.idx,
                        field.name
                    );
                }
            }
        }
    }

    if let Some(function_configs) = &config.functions {
        for function_config in function_configs {
            let Some(function_name) = &function_config.name else {
                bail!("function must have a name");
            };

            if function_config.ffi.is_none() {
                bail!("function '{function_name}' must have an 'ffi'");
            }

            if function_config.input.is_none() {
                bail!("function '{function_name}' must have an 'input'");
            }

            if function_config.output.is_none() {
                bail!("function '{function_name}' must have an 'output'");
            }
        }
    }

    tracing::info!("valid config");

    Ok(())
}