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

nordisk/monetize

crates/monetize-admin/src/edge.rs

//! **The edge** — the box's :80/:443 face (edda `estate-front::web` holds the
//! certificates and the ACME challenge), carrying each request to the process
//! that answers it:
//!
//! | request | carried to |
//! |---|---|
//! | `/callback/<kind>/<instance>` (a vendor's webhook) | monetize-server, HTTP/1.1 |
//! | [`RETIRED_PAGES`]: `/kop…`, `/shop` and every other `/shop/…` such as `/shop/private…` (the ONE box's own order pages, deleted: OD6, 2026-10-26) | nobody: 404 |
//! | `POST /attach/enrol` (an estate joining, G7) | answered HERE ([`monetize_front::attach::enrol_answer`]): recorded where monetize-server's guardian takes it |
//! | `POST /attach/installed` (an attached estate's owner naming the boxes that will call) | answered HERE ([`monetize_front::attach::installed_answer`]): the guardian opens each role's pin window |
//! | gRPC of an estate-facing service ([`ESTATE_FACING`]: the pull channel `Fleet` among them) | monetize-server, h2c |
//! | any other `monetize.*` service (the operator's: Ledger, Accounts, …) | nobody: 404 |
//! | everything else | the console, HTTP/1.1 |
//!
//! The name the client asked for travels as `Host`, the client's address as
//! `X-Forwarded-For`, and `X-Forwarded-Proto: https`. A bearer the caller
//! presents to an estate-facing service is carried as it is: monetize's own
//! interceptor judges it (product-scoped credentials are G8).

use std::net::{SocketAddr, SocketAddrV4};
use std::sync::Arc;

use axum::extract::ConnectInfo;
use axum::http::{header, StatusCode, Uri};
use axum::response::{IntoResponse as _, Response};
use axum::Router;

/// The gRPC services an ESTATE (or its web shop) calls on monetize from
/// outside — `Fleet` is the pull channel every estate's boxes call (Report,
/// Orders, Ask; signed per box). Every other service is the operator's, on
/// loopback only.
pub const ESTATE_FACING: [&str; 5] = ["monetize.v1.Fleet", "monetize.v1.Signup", "monetize.v1.EstateOrder", "monetize.v1.Purchase", "monetize.v1.Products"];

/// Where an estate joins (`plugin-door-wire::attach::ENROL_PATH`).
pub const ENROL_PATH: &str = "/attach/enrol";
/// Where an enrolled estate's owner names the boxes that will call (`plugin-door-wire::attach::INSTALLED_PATH`).
pub const INSTALLED_PATH: &str = "/attach/installed";

const HOP_BY_HOP: &[&str] = &["connection", "keep-alive", "proxy-connection", "transfer-encoding", "trailer", "upgrade"];

type Client = hyper_util::client::legacy::Client<hyper_util::client::legacy::connect::HttpConnector, axum::body::Body>;

struct Edge {
    server: SocketAddrV4,
    ui: SocketAddrV4,
    h1: Client,
    h2: Client,
}

/// Where a request goes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dest {
    Server,
    Ui,
    Nobody,
}

/// **Where `path` goes** — pure.
pub fn dest(path: &str, grpc: bool) -> Dest {
    if path.starts_with("/callback/") {
        return Dest::Server;
    }
    if !grpc && RETIRED_PAGES.iter().any(|p| path == *p || path.strip_prefix(p).is_some_and(|rest| rest.starts_with('/'))) {
        return Dest::Nobody;
    }
    let service = path.trim_start_matches('/').split('/').next().unwrap_or("");
    if ESTATE_FACING.contains(&service) {
        return if grpc { Dest::Server } else { Dest::Nobody };
    }
    if grpc || service.starts_with("monetize.") || service.starts_with("grpc.") {
        return Dest::Nobody;
    }
    Dest::Ui
}

/// **The ONE box's own order pages, deleted** (owner decision OD6, 2026-10-26: buying happens on
/// each product site's own sign-up): the purchase page, the shop's intake that only linked to
/// it, and the private-estate order page; A33-12: the ONE box's own sign-up page too. A stale
/// link answers 404 here, never the console. (`/shop` covers `/shop/private…` and `/shop/signup…`.)
pub const RETIRED_PAGES: &[&str] = &["/kop", "/shop"];

/// **The edge's router**: `server` and `ui` are the children's loopback addresses; `root` is
/// the data root.
pub fn router(server: SocketAddrV4, ui: SocketAddrV4, root: std::path::PathBuf) -> Router {
    let exec = hyper_util::rt::TokioExecutor::new();
    let h1 = hyper_util::client::legacy::Client::builder(exec.clone()).pool_idle_timeout(std::time::Duration::from_secs(30)).pool_timer(hyper_util::rt::TokioTimer::new()).build_http();
    let h2 = hyper_util::client::legacy::Client::builder(exec)
        .http2_only(true)
        .timer(hyper_util::rt::TokioTimer::new())
        .pool_timer(hyper_util::rt::TokioTimer::new())
        .pool_idle_timeout(std::time::Duration::from_secs(30))
        .http2_keep_alive_interval(std::time::Duration::from_secs(10))
        .http2_keep_alive_timeout(std::time::Duration::from_secs(5))
        .http2_keep_alive_while_idle(true)
        .build_http();
    let edge = Arc::new(Edge { server, ui, h1, h2 });
    let root_installed = root.clone();
    let enrol = move |body: axum::body::Bytes| enrol_at(root.clone(), body);
    let installed = move |body: axum::body::Bytes| {
        let c = monetize_front::attach::Central { root: root_installed.clone(), plugin_door: String::new() };
        monetize_front::attach::installed_answer(c, body)
    };
    Router::new().route(ENROL_PATH, axum::routing::post(enrol)).route(INSTALLED_PATH, axum::routing::post(installed)).fallback(move |req: axum::extract::Request| {
        let edge = Arc::clone(&edge);
        async move { carry(&edge, req).await }
    })
}

/// **The door an enrolled estate is told to call** (STINKY ST-14): the box's CONFIGURED
/// name — the console's settings, from the fed `web_hosts` — on :443; never a request's
/// `Host` header. `None`: no name with a dot is configured yet.
pub fn plugin_door_of(root: &std::path::Path) -> Option<String> {
    let name = crate::solo::UiSettings::read(root)?.rp_id;
    let name = name.trim();
    (name.contains('.') && !name.contains(':') && !name.contains('/')).then(|| format!("{name}:443"))
}

/// **`POST /attach/enrol`, on the edge and on the door alike**: answered with the configured
/// door ([`plugin_door_of`]), or refused by name while the box has none.
pub async fn enrol_at(root: std::path::PathBuf, body: axum::body::Bytes) -> (StatusCode, axum::Json<serde_json::Value>) {
    let Some(plugin_door) = plugin_door_of(&root) else {
        tracing::warn!("attach: an enrolment REFUSED — this box has no configured name (web_hosts) to hand the estate");
        return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(serde_json::json!({ "refused": "attach-no-host", "why": "this monetize has no configured name (the fed `web_hosts`) to tell the estate where to call; it never takes one from the request" })));
    };
    monetize_front::attach::enrol_answer(monetize_front::attach::Central { root, plugin_door }, body).await
}

async fn carry(edge: &Edge, req: axum::extract::Request) -> Response {
    let grpc = req.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).is_some_and(|v| v.starts_with("application/grpc"));
    let to = match dest(req.uri().path(), grpc) {
        Dest::Server => edge.server,
        Dest::Ui => edge.ui,
        Dest::Nobody => return (StatusCode::NOT_FOUND, "not served here\n").into_response(),
    };
    let peer = req.extensions().get::<ConnectInfo<SocketAddr>>().map(|c| c.0.ip());
    let (mut parts, body) = req.into_parts();
    let path = parts.uri.path_and_query().map(|p| p.as_str()).unwrap_or("/").to_string();
    let Ok(uri) = format!("http://{to}{path}").parse::<Uri>() else {
        return (StatusCode::BAD_REQUEST, "the path cannot be carried\n").into_response();
    };
    let host = parts.headers.get(header::HOST).and_then(|h| h.to_str().ok()).map(str::to_string).or_else(|| parts.uri.authority().map(|a| a.to_string()));
    for h in HOP_BY_HOP {
        parts.headers.remove(*h);
    }
    parts.headers.remove(header::HOST);
    if !grpc {
        if let Some(v) = host.and_then(|h| h.parse().ok()) {
            parts.headers.insert(header::HOST, v);
        }
    }
    parts.headers.insert("x-forwarded-proto", axum::http::HeaderValue::from_static("https"));
    match peer.and_then(|ip| ip.to_string().parse().ok()) {
        Some(v) => {
            parts.headers.insert("x-forwarded-for", v);
        }
        None => {
            parts.headers.remove("x-forwarded-for");
        }
    }
    parts.uri = uri;
    parts.version = if grpc { axum::http::Version::HTTP_2 } else { axum::http::Version::HTTP_11 };
    let req = axum::http::Request::from_parts(parts, body);
    let answer = if grpc { edge.h2.request(req).await } else { edge.h1.request(req).await };
    match answer {
        Ok(resp) => {
            let (parts, body) = resp.into_parts();
            Response::from_parts(parts, axum::body::Body::new(body))
        }
        Err(e) => {
            let who = if to == edge.server { "monetize-server" } else { "the console" };
            tracing::warn!(%to, error = %e, "edge: {who} did not answer");
            (StatusCode::BAD_GATEWAY, format!("{who} did not answer\n")).into_response()
        }
    }
}

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

    #[test]
    fn webhooks_and_estate_grpc_go_to_the_server_the_operators_grpc_nowhere_the_rest_to_the_console() {
        assert_eq!(dest("/callback/stripe/gunnar", false), Dest::Server);
        assert_eq!(dest("/monetize.v1.Fleet/Ask", true), Dest::Server);
        assert_eq!(dest("/monetize.v1.Fleet/Ask", false), Dest::Nobody);
        // ★ H16: the old SoldEstate door is gone (the fronts ride Fleet.Ask since P3-FLEET).
        assert_eq!(dest("/monetize.v1.SoldEstate/Upgrade", true), Dest::Nobody);
        assert_eq!(dest("/monetize.v1.Ledger/Export", true), Dest::Nobody);
        assert_eq!(dest("/monetize.v1.Accounts/List", false), Dest::Nobody);
        assert_eq!(dest("/grpc.reflection.v1.ServerReflection/X", true), Dest::Nobody);
        assert_eq!(dest("/api/accounts", false), Dest::Ui);
        assert_eq!(dest("/", false), Dest::Ui);
    }

    /// ★ OD6 (2026-10-26): the ONE box's own order pages are gone; a stale link answers 404 at
    /// the edge, never the console. The account sign-up page is still monetize-server's.
    #[test]
    fn the_retired_order_pages_answer_404_and_the_sign_up_page_is_the_servers() {
        for page in ["/kop", "/kop/start", "/kop/klar", "/shop", "/shop/private", "/shop/private/start", "/shop/private/change", "/shop/private/klar", "/shop/signupx"] {
            assert_eq!(dest(page, false), Dest::Nobody, "retired: {page}");
        }
        // ★ A33-12: the ONE box's own sign-up page is retired too.
        for page in ["/shop/signup", "/shop/signup/start", "/shop/signup/welcome", "/shop/signup/again", "/shop/signup/rename"] {
            assert_eq!(dest(page, false), Dest::Nobody, "retired: {page}");
        }
        for console in ["/shopping", "/kopia", "/", "/accounts"] {
            assert_eq!(dest(console, false), Dest::Ui, "the console's: {console}");
        }
    }

    /// ★ OD6: a product's old buy link at the root (`/?product=holger&repository=…`) is no longer
    /// redirected to the deleted intake; it is the console's root, as any other query is.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn an_old_buy_link_at_the_root_is_not_redirected_to_the_deleted_intake() {
        let (status, head) = get(router_at_nowhere(), "/?product=holger&repository=acme").await;
        assert_ne!(status, 303, "no redirect: {head}");
        assert!(!head.to_ascii_lowercase().contains("location: /shop"), "{head}");
        let (status, _) = get(router_at_nowhere(), "/kop?product=holger").await;
        assert_eq!(status, 404, "a retired page answers 404 at the edge");
    }

    /// G1: an estate's box calls the pull channel (Report, Orders, Ask) on the ONE monetize.
    #[test]
    fn the_pull_channel_is_estate_facing() {
        for call in ["Report", "Orders", "Ask"] {
            assert_eq!(dest(&format!("/monetize.v1.Fleet/{call}"), true), Dest::Server, "{call}");
        }
        assert_eq!(dest("/monetize.v1.Guardian/Cases", true), Dest::Nobody, "the guardian's console is the operator's");
    }

    /// The edge with no child listening (port 9), on a scratch root.
    fn router_at_nowhere() -> Router {
        let nobody = SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 9);
        router(nobody, nobody, std::env::temp_dir().join(format!("monetize-admin-edge-od6-{}", std::process::id())))
    }

    async fn get(app: Router, path: &str) -> (u16, String) {
        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
        let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = l.local_addr().unwrap();
        tokio::spawn(async move { axum::serve(l, app.into_make_service_with_connect_info::<SocketAddr>()).await });
        let mut tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
        tcp.write_all(format!("GET {path} HTTP/1.1\r\nHost: monetize.ignalina.dk\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap();
        let mut out = Vec::new();
        let _ = tcp.read_to_end(&mut out).await;
        let t = String::from_utf8_lossy(&out).to_string();
        (t.split_whitespace().nth(1).and_then(|s| s.parse().ok()).unwrap_or(0), t)
    }

    async fn post(app: Router, path: &str, body: &str) -> (u16, String) {
        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
        let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = l.local_addr().unwrap();
        tokio::spawn(async move { axum::serve(l, app.into_make_service_with_connect_info::<SocketAddr>()).await });
        let mut tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
        let req = format!("POST {path} HTTP/1.1\r\nHost: monetize.ignalina.dk\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
        tcp.write_all(req.as_bytes()).await.unwrap();
        let mut out = Vec::new();
        let _ = tcp.read_to_end(&mut out).await;
        let t = String::from_utf8_lossy(&out).to_string();
        (t.split_whitespace().nth(1).and_then(|s| s.parse().ok()).unwrap_or(0), t)
    }

    /// G7: an estate joins at the ONE monetize's edge (`POST /attach/enrol`, public): the
    /// edge answers it itself — no child is asked (none listens here).
    #[tokio::test]
    async fn the_edge_answers_an_enrolment_itself() {
        let root = std::env::temp_dir().join(format!("monetize-admin-edge-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        let nobody = SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 9);
        crate::solo::UiSettings { rp_id: "monetize.ignalina.dk".into(), admin_token: None }.write(&root).unwrap();
        let (st, text) = post(router(nobody, nobody, root.clone()), "/attach/enrol", "{").await;
        assert_eq!(st, 400, "{text}");
        assert!(text.contains("attach-not-a-request"), "{text}");
        // An attached estate's install notice is the edge's too (i54a run13): never the console's.
        let (st, text) = post(router(nobody, nobody, root.clone()), "/attach/installed", "{").await;
        assert_eq!(st, 400, "{text}");
        assert!(text.contains("attach-not-a-notice"), "{text}");
        let _ = std::fs::remove_dir_all(&root);
    }

    /// ★ **STINKY ST-14: the door an estate is told to call is the box's CONFIGURED name**
    /// (the console's settings, from the fed `web_hosts`), never the request's `Host` header:
    /// a caller that names another host cannot steer where an enrolled estate pulls. A box
    /// with no configured name refuses the enrolment by name.
    #[tokio::test]
    async fn the_enrol_names_the_configured_host_never_the_requests() {
        let root = std::env::temp_dir().join(format!("monetize-admin-edge-host-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        let nobody = SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 9);
        assert_eq!(plugin_door_of(&root), None, "nothing configured");
        let (st, text) = post(router(nobody, nobody, root.clone()), "/attach/enrol", "{").await;
        assert_eq!(st, 503, "{text}");
        assert!(text.contains("attach-no-host"), "{text}");
        crate::solo::UiSettings { rp_id: "monetize.ignalina.dk".into(), admin_token: None }.write(&root).unwrap();
        assert_eq!(plugin_door_of(&root).as_deref(), Some("monetize.ignalina.dk:443"));
        crate::solo::UiSettings { rp_id: "monetize-admin".into(), admin_token: None }.write(&root).unwrap();
        assert_eq!(plugin_door_of(&root), None, "an estate's bare name is not a host");
        let _ = std::fs::remove_dir_all(&root);
    }
}