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

nordisk/gunnar

crates/gunnar-front/tests/public_site_refeed.rs

//! ★ **gunnar.rs serves its public site to anonymous readers** (GRS-PUBLIC, the live bug of
//! 2026-10-28 ~19:40 CET: https://gunnar.rs/ answered `/login?next=/`, `/llms.txt` and
//! `/sitemap.xml` 401 "log in to use the console", robots.txt `Disallow: /`).
//!
//! **Why it broke.** The public site is built into gunnar.rs's front (`public-site`), and its
//! switch is gunnar's generic setting `console.public-site`; where nothing is stored, the
//! position the front was FED at lay (`console_public_site = on`, from the site's declaration
//! `[estates.gunnar-rs] public_site = true`) is the initial one. gunnar.rs was laid by its kit
//! (2026-10-13) before that declaration existed (TASKS-12, 2026-10-25), the ONE box only adopted
//! it, and a keep boot never feeds a front again. So its console was never told: OFF, for good.
//!
//! **The fix.** The ONE box feeds the declaration again (`site refeed gunnar-rs --only
//! public_site`): a signed `refeed` order carrying `console_public_site`, pulled by the main,
//! which stores it as the console's switch. The console reads the stored position every
//! recheck, and the stored position wins over the feed.
//!
//! This test is gunnar.rs as it runs: gunnar's REAL control plane (`gunnar_grpc::router` over
//! real stores on loopback) and the front's console (gunnar-ui-server, `metered,public-site`,
//! gunnar.rs's row) built from a feed that never said `console_public_site`. It holds that the
//! main takes the refeed order through the executor its pull channel runs, and that the site
//! then answers `/`, `/llms.txt`, `/sitemap.xml` and a public repository's page anonymously
//! with 200, while a private repository and the console's own paths still need a login.
//! Loopback only; the site is `gunnar.mock.invalid`.

use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};

use axum::body::Body;
use gix_object::Kind;
use gunnar_core::{HashKind, NewRepo, RepoRef};
use gunnar_grpc::ControlPlane as _;
use gunnar_server::stores::ServerStores;
use gunnar_server::CoreControlPlane;
use gunnar_store::conformance::pack::PackBuilder;
use http::{Request, StatusCode};
use tower::ServiceExt as _;

const RP_ID: &str = "gunnar.mock.invalid";
const ORIGIN: &str = "https://gunnar.mock.invalid";
/// The front's feed key and gunnar's setting name the switch lives under.
const FEED_KEY: &str = "console_public_site";
const SETTING: &str = "console.public-site";

type Oid = gix_hash::ObjectId;

fn mint() -> (String, String) {
    let pem = gunnar_core::mint_ed25519().expect("mint");
    let text = std::str::from_utf8(&pem).expect("utf8").to_owned();
    let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&text).expect("parse");
    let line = key.public_key().to_openssh().expect("line");
    (text, line)
}

fn compute(hash: gix_hash::Kind, kind: Kind, content: &[u8]) -> Oid {
    gix_object::compute_hash(hash, kind, content).expect("hash")
}

fn tree_bytes(entries: &[(&str, &str, Oid)]) -> Vec<u8> {
    let mut sorted = entries.to_vec();
    sorted.sort_by(|a, b| a.1.as_bytes().cmp(b.1.as_bytes()));
    let mut body = Vec::new();
    for (mode, name, oid) in sorted {
        body.extend_from_slice(mode.as_bytes());
        body.push(b' ');
        body.extend_from_slice(name.as_bytes());
        body.push(0);
        body.extend_from_slice(oid.as_bytes());
    }
    body
}

/// One commit on `refs/heads/main` holding a README with `words`.
fn plant(stores: &ServerStores, ns: &str, name: &str, words: &str) {
    let path = RepoRef::new(ns, name).expect("a repo path");
    if stores
        .catalog()
        .namespaces()
        .unwrap()
        .iter()
        .all(|n| n.name != ns)
    {
        stores.catalog().create_namespace(ns).expect("namespace");
    }
    let entry = stores
        .create_repo(NewRepo::new(path).hash_kind(HashKind::Sha256), false)
        .expect("repo");
    let store = stores.repo_store(&entry).expect("store");
    let hash = store.hash_kind();
    let readme_text = format!("# {name}\n\n{words}\n").into_bytes();
    let readme = compute(hash, Kind::Blob, &readme_text);
    let root = tree_bytes(&[("100644", "README.md", readme)]);
    let tree = compute(hash, Kind::Tree, &root);
    let commit = format!("tree {}\nauthor Kim <kim@mock.invalid> 1700000000 +0000\ncommitter Kim <kim@mock.invalid> 1700000000 +0000\n\nfirst\n", tree.to_hex()).into_bytes();
    let commit_id = compute(hash, Kind::Commit, &commit);
    let mut pb = PackBuilder::new(hash);
    for (kind, content) in [
        (Kind::Blob, readme_text),
        (Kind::Tree, root),
        (Kind::Commit, commit),
    ] {
        pb.object(kind, content);
    }
    store
        .serve()
        .put_pack(&pb.build().expect("pack").bytes)
        .expect("pack");
    store
        .serve()
        .update_ref("refs/heads/main", None, Some(commit_id.as_bytes()))
        .expect("ref");
}

fn free_addr() -> SocketAddr {
    std::net::TcpListener::bind("127.0.0.1:0")
        .expect("bind")
        .local_addr()
        .expect("addr")
}

struct Site {
    _dir: tempfile::TempDir,
    _state_dir: tempfile::TempDir,
    stores: Arc<ServerStores>,
    console_pem: String,
    control: String,
    app: axum::Router,
}

/// gunnar's main, for real, and gunnar.rs's console in front of it, built from a feed that
/// never named `console_public_site` (the kit's, as gunnar.rs's front keeps it).
async fn gunnar_rs_as_it_runs() -> Site {
    // The front sets this only where its feed says `console_public_site = on`; gunnar.rs's
    // kept feed does not, so the console is built with it unset.
    std::env::remove_var("GUNNAR_UI_PUBLIC_SITE");
    gunnar_ui_server::console_setting::recheck_every(Duration::from_millis(50));
    let dir = tempfile::tempdir().expect("tempdir");
    let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
    let (console_pem, console_line) = mint();
    stores
        .auth()
        .bootstrap_admin(&console_line)
        .expect("the console's key is the first admin");
    let (guest_pem, guest_line) = mint();
    let plane = CoreControlPlane::new(Arc::clone(&stores));
    stores
        .auth()
        .add_principal(gunnar_core::Principal {
            id: "gunnar-guest".into(),
            name: "gunnar-guest".into(),
            kind: gunnar_core::PrincipalKind::Machine,
            created: 0,
        })
        .expect("the guest");
    stores
        .auth()
        .register_key("gunnar-guest", &guest_line, None)
        .expect("the guest's key");
    plant(
        &stores,
        "nordisk",
        "gunnar",
        "the forge itself, read by anyone",
    );
    plant(&stores, "kim", "secret", "kims private words");
    plane
        .set_visibility(
            &gunnar_proto::RepoPath::new("nordisk", "gunnar"),
            gunnar_proto::Visibility::Public,
        )
        .expect("public");
    plane
        .set_visibility(
            &gunnar_proto::RepoPath::new("kim", "secret"),
            gunnar_proto::Visibility::Private,
        )
        .expect("private");

    let addr = free_addr();
    let served: Arc<dyn gunnar_grpc::ControlPlane> =
        Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
    let ctx = gunnar_grpc::ServiceContext::new(
        served,
        Arc::new(gunnar_grpc::EventBus::new(16)),
        gunnar_grpc::ServerConfig::default(),
    );
    tokio::spawn(async move {
        let _ = gunnar_grpc::router(ctx, || 0).serve(addr).await;
    });
    let t0 = Instant::now();
    while std::net::TcpStream::connect(addr).is_err() {
        assert!(t0.elapsed() < Duration::from_secs(10), "gunnar listens");
        tokio::time::sleep(Duration::from_millis(25)).await;
    }

    use gunnar_ui_server::{app::App, auth, grpc::Gunnar, signer::Signer};
    let state_dir = tempfile::tempdir().expect("state dir");
    let store = gunnar_ui_server::state::StateStore::open(state_dir.path().join("state.redb"))
        .expect("state");
    let control = addr.to_string();
    let gunnar = Gunnar::connect_lazy(
        &control,
        Arc::new(Signer::from_openssh(&console_pem).expect("console key")),
    )
    .expect("channel");
    let guest = Gunnar::connect_lazy(
        &control,
        Arc::new(Signer::from_openssh(&guest_pem).expect("guest key")),
    )
    .expect("channel");
    let rp = auth::RelyingParty {
        rp_id: RP_ID.to_string(),
        origin: ORIGIN.to_string(),
        rp_name: "gunnar".to_string(),
    };
    let keys = Arc::new(auth::Webauthn::new(rp, store.clone()).expect("webauthn"));
    let app = App {
        webauthn: Some(keys),
        admin_token: None,
        events: gunnar_ui_server::sse::EventHub::spawn(gunnar.clone()),
        reviewers: Arc::new(gunnar_ui_server::reviewer::ReviewerKeys::new(
            control.clone(),
            store.clone(),
        )),
        tenants: Arc::new(gunnar_ui_server::tenant::TenantKeys::new(
            control.clone(),
            store.clone(),
        )),
        gunnar,
        monetize: None,
        twin: None,
        guest: Some(guest),
        forges: Arc::new(gunnar_ui_server::forges::ForgeSessions::new()),
        forge_apps: Arc::new(gunnar_ui_server::forges::ForgeApps::from_env()),
        imports: Arc::new(gunnar_ui_server::api::mirror_gate::Imports::from_env()),
        ui_dir: None,
        caches: Default::default(),
        objects: Default::default(),
        readmes: Arc::new(gunnar_ui_server::readme::Cache::new(Some(store.clone()))),
    };
    let app = gunnar_ui_server::serve::router(Arc::new(app));
    Site {
        _dir: dir,
        _state_dir: state_dir,
        stores,
        console_pem,
        control,
        app,
    }
}

impl Site {
    /// An anonymous reader's GET: (status, Location, body).
    async fn anon(&self, uri: &str, html: bool) -> (StatusCode, String, String) {
        let mut r = Request::get(uri).header(http::header::USER_AGENT, "nordisk-dep-audit");
        if html {
            r = r.header(http::header::ACCEPT, "text/html");
        }
        let answer = self
            .app
            .clone()
            .oneshot(r.body(Body::empty()).unwrap())
            .await
            .expect("an answer");
        let status = answer.status();
        let location = answer
            .headers()
            .get(http::header::LOCATION)
            .and_then(|v| v.to_str().ok())
            .unwrap_or_default()
            .to_owned();
        let bytes = axum::body::to_bytes(answer.into_body(), 1 << 22)
            .await
            .expect("a body");
        (
            status,
            location,
            String::from_utf8_lossy(&bytes).into_owned(),
        )
    }

    /// Asks `uri` until it answers 200 with `needle` in it (the switch is read every recheck,
    /// the pages are built from gunnar once the first visitor asks): its body.
    async fn until_served(&self, uri: &str, needle: &str) -> String {
        let t0 = Instant::now();
        loop {
            let (status, _, body) = self.anon(uri, false).await;
            if status == StatusCode::OK && body.contains(needle) {
                return body;
            }
            assert!(t0.elapsed() < Duration::from_secs(30), "{uri} is never served to an anonymous reader with {needle:?} in it: {status} {body}");
            // One reader, asking as a person would: the console's one throttle (GUN-GUEST,
            // gunnar-ui) answers a client past ~4 requests a second 429, and blocks one that
            // keeps on, so the poll stays under it.
            tokio::time::sleep(Duration::from_millis(300)).await;
        }
    }

    /// **The main takes the ONE box's `refeed` order** through the executor its pull channel
    /// runs (gunnar-repair `order::decode` + `exec::execute` over the server's own estate).
    async fn takes_refeed(
        &self,
        seq: u64,
        keys: serde_json::Value,
    ) -> gunnar_repair::order::OrderAck {
        let body = serde_json::json!({ "keys": keys }).to_string();
        let order =
            gunnar_repair::order::decode(seq, gunnar_repair::order::kind::REFEED, body.as_bytes())
                .expect("the main takes the refeed of the public site's switch");
        let estate = gunnar_server::repair::ServerEstate {
            key: gunnar_core::ssh_key::PrivateKey::from_openssh(&self.console_pem).expect("a key"),
            own: self.control.clone(),
            entitlements: gunnar_server::repair::Entitlements::of(Arc::clone(&self.stores)),
        };
        gunnar_repair::exec::execute(&estate, seq, &order, &|| false).await
    }
}

/// ★ The public site of a site declared `public_site = true`, laid before the declaration,
/// is served to anonymous readers once its main took the ONE box's refeed — and its console
/// still asks everything else to log in.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn gunnar_rs_serves_its_public_site_anonymously_once_its_main_takes_the_refeed() {
    let site = gunnar_rs_as_it_runs().await;
    // gunnar.rs as the owner found it: the console was never told, so the home asks for a login.
    tokio::time::sleep(Duration::from_millis(300)).await;
    let (status, location, _) = site.anon("/", true).await;
    assert!(
        status.is_redirection() && location.starts_with("/login?next="),
        "before the refeed, as on gunnar.rs: {status} {location}"
    );
    assert_eq!(
        site.anon("/llms.txt", false).await.0,
        StatusCode::UNAUTHORIZED,
        "before the refeed, as on gunnar.rs"
    );

    let ack = site
        .takes_refeed(41, serde_json::json!({ FEED_KEY: "on" }))
        .await;
    assert!(ack.ok, "the main keeps the switch: {}", ack.detail);
    assert_eq!(
        site.stores.settings().get(SETTING).unwrap().as_deref(),
        Some(&b"on"[..]),
        "stored as the console's switch at gunnar"
    );

    // The site, to anyone, with no login.
    // The home is the console's landing with the site's title (PUB-HOME, 2026-10-29); llms.txt
    // and the sitemap name the public repository; its page (the console's document, its README
    // pre-rendered in it, GUN-GUEST) shows its README.
    site.until_served("/", "Gunnar — a European git server in Rust")
        .await;
    site.until_served("/llms.txt", "nordisk/gunnar").await;
    site.until_served("/sitemap.xml", "/nordisk/gunnar").await;
    site.until_served("/nordisk/gunnar", "the forge itself, read by anyone")
        .await;
    let (status, _, robots) = site.anon("/robots.txt", false).await;
    assert!(
        status == StatusCode::OK && robots.contains("Sitemap:"),
        "robots.txt points readers at the sitemap: {robots}"
    );

    // Everything else still needs a login.
    let (status, _, body) = site.anon("/kim/secret", false).await;
    assert_ne!(
        status,
        StatusCode::OK,
        "a private repository is never a public page: {body}"
    );
    assert!(!body.contains("kims private words"), "{body}");
    // (`/api/repos` is the anonymous viewer's own read of public repositories, by design.)
    for path in [
        "/api/public-site",
        "/api/principals",
        "/api/keys",
        "/api/machine",
    ] {
        assert_eq!(
            site.anon(path, false).await.0,
            StatusCode::UNAUTHORIZED,
            "{path} is the console's"
        );
    }
    let (status, _, body) = site.anon("/api/repos/kim/secret", false).await;
    assert!(
        status != StatusCode::OK && !body.contains("kims private words"),
        "a private repository is not read anonymously: {status} {body}"
    );
    let (status, location, _) = site.anon("/admin", true).await;
    assert!(
        status.is_redirection() && location.starts_with("/login?next="),
        "/admin asks for a login: {status} {location}"
    );

    // The ONE box's OFF is taken the same way, and the site closes again.
    let ack = site
        .takes_refeed(42, serde_json::json!({ FEED_KEY: "off" }))
        .await;
    assert!(ack.ok, "{}", ack.detail);
    let t0 = Instant::now();
    while site.anon("/llms.txt", false).await.0 == StatusCode::OK {
        assert!(
            t0.elapsed() < Duration::from_secs(20),
            "the site never closes after an OFF"
        );
        tokio::time::sleep(Duration::from_millis(300)).await;
    }
}