Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/estate-front/src/route.rs

//! **A product's repository, reached through the front's own :443** — the
//! design of gunnar's `front-product-key` lane (`gunnar-front/src/repo_route.rs`),
//! moved here with the product enum taken out: `https://<repo host>/…` is
//! carried to the PRIMARY's repository HTTP, and a gRPC call to the same name
//! (`content-type: application/grpc…`) to its gRPC, both on the primary's
//! UTILITY address ([`crate::web::serve::Routed`] does the carrying).
//!
//! **Why here and not a TCP forward** ([`crate::forward`]): the front holds the
//! estate's certificate (fed, kept or ACME) and the primary holds none; TLS
//! ends where the certificate is, and the leg to the primary is the private
//! utility network. A raw TCP forward of a plain HTTP door would put a bearer
//! on the internet. **Why the utility network and not wg0**: the front's
//! WireGuard is the relay ([`crate::relay`], userspace, no TUN) — the front
//! has no wg0 to dial through.
//!
//! | feed key | what | default |
//! |---|---|---|
//! | `repo_hosts` | the names routed to the primary; each must be one of `web_hosts` (the certificate), never the first (the console's), never a callback-only name and never monetize's host (RA1-16) | none: nothing routed |
//! | `repo_http_port` | the primary's repository HTTP | the product's |
//! | `repo_grpc_port` | the primary's gRPC | the product's |
//!
//! **Which gRPC services are carried is the product's, never the feed's** (RA5-1):
//! [`start`] and [`RepoRoute::of`] take the product's list
//! ([`crate::web::serve::Routed::grpc_services`]); a call to any other service is
//! refused at the front.
//!
//! Re-targeted LIVE when the verdict moves ([`start`]).

use std::collections::BTreeMap;
use std::net::{Ipv4Addr, SocketAddrV4};
use std::sync::Arc;
use std::time::Duration;

use crate::web::serve::{App, Routed};
use crate::web::WebConfig;

/// The feed key of the routed names.
pub const FEED_REPO_HOSTS: &str = "repo_hosts";
/// The feed key of the primary's repository HTTP port.
pub const FEED_REPO_HTTP_PORT: &str = "repo_http_port";
/// The feed key of the primary's gRPC port.
pub const FEED_REPO_GRPC_PORT: &str = "repo_grpc_port";

/// How often the product's view is looked at again.
const POLL: Duration = Duration::from_secs(2);

/// **What is routed, from the feed** — pure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoRoute {
    /// Names carried to the primary (served names only, never the first, never a
    /// callback-only name, never monetize's host).
    pub hosts: Vec<String>,
    pub http_port: u16,
    pub grpc_port: u16,
    /// The gRPC services carried (the product's, [`Routed::grpc_services`]).
    pub grpc_services: Vec<String>,
    /// Why a fed value was not taken — to be said.
    pub refused: Vec<String>,
}

fn port(feed: &BTreeMap<String, String>, key: &str, default: u16, refused: &mut Vec<String>) -> u16 {
    match feed.get(key).map(|v| v.trim()).filter(|v| !v.is_empty()) {
        None => default,
        Some(v) => v.parse::<u16>().ok().filter(|p| *p != 0).unwrap_or_else(|| {
            refused.push(format!("REFUSED [repo-port] the fed `{key} = {v:?}` is not a port; {default} is used"));
            default
        }),
    }
}

impl RepoRoute {
    /// `ports` = the product's repository doors on the primary, `(http, grpc)`;
    /// `None` = the product routes no repository through :443 (every fed name
    /// is then refused by name, `product` naming it). `grpc_services`: the
    /// product's gRPC services carried to the primary ([`Routed::grpc_services`]).
    pub fn of(feed: &BTreeMap<String, String>, web: &WebConfig, product: &str, ports: Option<(u16, u16)>, grpc_services: &[&str]) -> RepoRoute {
        let mut refused = Vec::new();
        let (http_default, grpc_default) = ports.unwrap_or((0, 0));
        let http_port = port(feed, FEED_REPO_HTTP_PORT, http_default, &mut refused);
        let grpc_port = port(feed, FEED_REPO_GRPC_PORT, grpc_default, &mut refused);
        let mut hosts = Vec::new();
        for h in feed.get(FEED_REPO_HOSTS).map(|v| v.split(',').map(|h| h.trim().to_ascii_lowercase()).filter(|h| !h.is_empty()).collect::<Vec<_>>()).unwrap_or_default() {
            if ports.is_none() {
                refused.push(format!("REFUSED [repo-route-not-this-product] `{FEED_REPO_HOSTS}` names {h:?}; {product} routes no repository through :443"));
            } else if Some(&h) == web.hosts.first() {
                refused.push(format!("REFUSED [repo-route-console-host] `{FEED_REPO_HOSTS}` names {h:?}, the console's own name (the first of `web_hosts`) — not routed"));
            } else if !web.hosts.contains(&h) {
                refused.push(format!("REFUSED [repo-route-no-certificate] `{FEED_REPO_HOSTS}` names {h:?}, which is not in `web_hosts` — the certificate would not name it; not routed"));
            } else if web.callback_only.contains(&h) {
                // RA1-16: a payment vendor's webhook name answers its callbacks and nothing else.
                refused.push(format!("REFUSED [repo-route-callback-only] `{FEED_REPO_HOSTS}` names {h:?}, a callback-only name (`web_callback_only`) — it answers a vendor's webhook only; not routed"));
            } else if web.control_host.as_ref() == Some(&h) {
                refused.push(format!("REFUSED [repo-route-control-host] `{FEED_REPO_HOSTS}` names {h:?}, monetize's own host (`monetize_control_host`) — not routed"));
            } else if !hosts.contains(&h) {
                hosts.push(h);
            }
        }
        RepoRoute { hosts, http_port, grpc_port, grpc_services: grpc_services.iter().map(|s| s.to_string()).collect(), refused }
    }

    /// **What :443 routes now** — `None` when no name is routed or no primary
    /// is elected yet.
    pub fn routed(&self, primary_utility: Option<Ipv4Addr>) -> Option<Routed> {
        let ip = primary_utility?;
        (!self.hosts.is_empty()).then(|| Routed { hosts: self.hosts.clone(), http: SocketAddrV4::new(ip, self.http_port), grpc: SocketAddrV4::new(ip, self.grpc_port), grpc_services: self.grpc_services.clone() })
    }
}

/// What the front knows now: the feed and the primary's utility address.
pub type Now = Arc<dyn Fn() -> Option<(BTreeMap<String, String>, Option<Ipv4Addr>)> + Send + Sync>;

/// **Follow the feed for the life of the process**: set (or clear) what :443
/// routes to the primary, carrying the product's `grpc_services` only. Returns at
/// once; must run inside the tokio runtime.
pub fn start(app: App, now: Now, estate: String, product: &'static str, ports: Option<(u16, u16)>, grpc_services: &'static [&'static str]) {
    // A32-14, stays a task: the feed follower that sets what :443 routes to, for the life of the process.
    tokio::spawn(async move {
        let mut said: Vec<String> = Vec::new();
        loop {
            let routed = now().and_then(|(feed, primary)| {
                let r = RepoRoute::of(&feed, &WebConfig::from(&estate, &feed), product, ports, grpc_services);
                for l in &r.refused {
                    if !said.contains(l) {
                        tracing::error!("front: {l}");
                        said.push(l.clone());
                    }
                }
                r.routed(primary)
            });
            if app.set_routed(routed.clone()) {
                tracing::info!(route = ?routed, "front: the repository route on :443 re-targeted");
            }
            tokio::time::sleep(POLL).await;
        }
    });
}

#[cfg(test)]
mod tests {
    use super::*;

    fn feed(kv: &[(&str, &str)]) -> BTreeMap<String, String> {
        kv.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
    }

    fn route(kv: &[(&str, &str)], ports: Option<(u16, u16)>) -> (RepoRoute, Option<Routed>) {
        let f = feed(kv);
        let rr = RepoRoute::of(&f, &WebConfig::from("example.test", &f), "the product", ports, &["the.v1.Repository"]);
        let routed = rr.routed(Some(Ipv4Addr::new(10, 13, 8, 2)));
        (rr, routed)
    }

    /// The named repo host goes to the PRIMARY's utility address, on the
    /// product's ports unless the feed says others.
    #[test]
    fn the_repo_host_is_routed_to_the_primarys_http_and_grpc() {
        let (_, routed) = route(&[("web_hosts", "example.test,repo.example.test"), ("repo_hosts", "repo.example.test")], Some((8443, 50051)));
        let r = routed.unwrap();
        assert_eq!(r.hosts, vec!["repo.example.test".to_string()]);
        assert_eq!((r.http, r.grpc), ("10.13.8.2:8443".parse().unwrap(), "10.13.8.2:50051".parse().unwrap()));
        assert_eq!(r.grpc_services, vec!["the.v1.Repository".to_string()], "the product's services, never the feed's");
        let (_, routed) = route(&[("web_hosts", "example.test,repo.example.test"), ("repo_hosts", "repo.example.test"), ("repo_http_port", "18444")], Some((8443, 50051)));
        assert_eq!(routed.unwrap().http, "10.13.8.2:18444".parse().unwrap());
        let f = feed(&[("web_hosts", "example.test,repo.example.test"), ("repo_hosts", "repo.example.test")]);
        assert!(RepoRoute::of(&f, &WebConfig::from("example.test", &f), "p", Some((1, 2)), &[]).routed(None).is_none(), "no primary elected: nothing routed");
    }

    /// Nothing is routed that has no certificate, that is the console's own
    /// name, or for a product that routes none — each said by name.
    #[test]
    fn a_repo_host_is_refused_by_name_when_it_cannot_be_routed() {
        let (rr, routed) = route(&[("web_hosts", "example.test"), ("repo_hosts", "example.test,repo.example.test")], Some((8443, 50051)));
        assert!(routed.is_none());
        assert!(rr.refused.iter().any(|l| l.contains("[repo-route-console-host]")) && rr.refused.iter().any(|l| l.contains("[repo-route-no-certificate]")), "{:?}", rr.refused);
        let (rr, routed) = route(&[("web_hosts", "example.test,repo.example.test"), ("repo_hosts", "repo.example.test")], None);
        assert!(routed.is_none() && rr.refused[0].contains("[repo-route-not-this-product]"), "{:?}", rr.refused);
        let (_, routed) = route(&[], Some((8443, 50051)));
        assert!(routed.is_none(), "no repo host fed: nothing routed");
        let (rr, _) = route(&[("repo_grpc_port", "x")], Some((8443, 50051)));
        assert_eq!(rr.grpc_port, 50051);
        assert!(rr.refused[0].contains("[repo-port]"));
    }

    /// **RA1-16, the face's own rules** (as gunnar's hole after RA1-1): the route
    /// answers on the product's OWN names only. A callback-only name (a payment
    /// vendor's webhook name, `web_callback_only`) and monetize's own host
    /// (`monetize_control_host`) are never carried to the primary, even when
    /// `repo_hosts` names them; each is refused by name, and the other names
    /// still route.
    #[test]
    fn a_callback_only_name_and_monetizes_host_are_never_routed() {
        let (rr, routed) = route(
            &[
                ("web_hosts", "example.test,repo.example.test,pay.example.test,monetize.example.test"),
                ("web_callback_only", "pay.example.test"),
                ("monetize_control_host", "monetize.example.test"),
                ("repo_hosts", "repo.example.test,pay.example.test,monetize.example.test"),
            ],
            Some((8443, 50051)),
        );
        assert_eq!(routed.unwrap().hosts, vec!["repo.example.test".to_string()], "{:?}", rr.refused);
        assert!(rr.refused.iter().any(|l| l.contains("[repo-route-callback-only]") && l.contains("pay.example.test")), "{:?}", rr.refused);
        assert!(rr.refused.iter().any(|l| l.contains("[repo-route-control-host]") && l.contains("monetize.example.test")), "{:?}", rr.refused);
    }
}