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