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/src/web/serve.rs

//! **Gunnar's own layers on the front's :443** (A32-11): estate-front serves the two ports, the
//! certificate, the callback-only names and the face (`estate_front::web::serve`); these wrap
//! it ([`super::App`]'s outer layer, per accepted connection): the login pages of every other
//! served name go to the first host, git and Git LFS over HTTPS are answered with the SSH URL,
//! and every request is one redacted access line.

use std::sync::Arc;

use axum::http::{header, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::Router;

pub use estate_front::web::serve::{http_router, narrowed, placeholder, CallbackOnly};

/// **Names whose login pages are the first host's** (2026-10-07, private
/// ownership): the console's passkeys are bound to ONE relying party, the
/// first web host (`<company>.gunnar.rs` on a sold estate, whatever own
/// domain the buyer brought). A WebAuthn ceremony on any other name fails the
/// browser's origin check, so on those names a login page
/// ([`is_login_page`]) is a TEMPORARY redirect to the same path and query on
/// `to` — the invite and the account in a first-login link ride along. The
/// rest of each name is served as before (the anonymous web, git).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoginOn {
    pub hosts: Vec<String>,
    pub to: String,
}

/// The console's pages that run a passkey ceremony, or lead straight into one.
pub const LOGIN_PAGES: &[&str] = &["/login", "/welcome", "/signup", "/private"];

/// Pure: is `path` a login page ([`LOGIN_PAGES`], or a company's single
/// sign-on page `/login/sso/<account>`, whose second step is a passkey)?
pub fn is_login_page(path: &str) -> bool {
    LOGIN_PAGES.contains(&path) || path.starts_with("/login/sso/")
}

/// The request's host, lower case and without a port: `Host` (HTTP/1.1), else
/// the URI's authority (HTTP/2 `:authority`).
fn request_host(req: &axum::extract::Request) -> Option<String> {
    let h = req
        .headers()
        .get(header::HOST)
        .and_then(|h| h.to_str().ok())
        .map(str::to_string)
        .or_else(|| req.uri().host().map(str::to_string))?;
    Some(h.split(':').next().unwrap_or(&h).to_ascii_lowercase())
}

/// The forge's SSH port, which every web name of the estate reaches (the front
/// forwards it to the primary, [`crate::forward::GIT_PORT`]).
const SSH_GIT_PORT: u16 = crate::forward::GIT_PORT;

/// Pure: **the repository path of a git smart-HTTP or Git LFS URL**, or `None`
/// for every other path. `/<repo>/info/lfs/…`, `/<repo>/info/refs`,
/// `/<repo>/git-upload-pack`, `/<repo>/git-receive-pack`.
pub fn git_repo_of(path: &str) -> Option<&str> {
    if let Some(i) = path.find("/info/lfs/") {
        return Some(&path[..i]).filter(|r| r.len() > 1);
    }
    for tail in ["/info/refs", "/git-upload-pack", "/git-receive-pack"] {
        if let Some(repo) = path.strip_suffix(tail) {
            return Some(repo).filter(|r| r.len() > 1);
        }
    }
    None
}

/// **git and Git LFS over HTTPS are answered on the host that was asked**
/// (2026-10-25), before any other rule: Gunnar serves both over SSH only, so a
/// client that came here gets the SSH URL to use — in git-lfs's own JSON for
/// LFS (git-lfs prints `message` after `batch response:`), `501`. It used to
/// get a 308 to the apex from a callback-only host (git-lfs drops credentials
/// across a host change and ended in 401) or the console's "log in" 401.
pub fn git_paths_here(face: Router) -> Router {
    Router::new().fallback_service(face).layer(axum::middleware::from_fn(|req: axum::extract::Request, next: axum::middleware::Next| async move {
        let path = req.uri().path().to_string();
        let Some(repo) = git_repo_of(&path) else {
            return next.run(req).await;
        };
        let host = request_host(&req).unwrap_or_else(|| "this host".to_string());
        let url = format!("ssh://git@{host}:{SSH_GIT_PORT}{repo}");
        if path.contains("/info/lfs/") {
            let message = format!(
                "Gunnar serves Git LFS over pure SSH only (git-lfs 3.0 or newer, git-lfs-transfer), not HTTPS. \
                 Use the remote {url}; if that is your remote, its pure SSH transfer just failed or was refused: run the command again."
            );
            let body = serde_json::json!({ "message": message, "documentation_url": format!("https://{host}/") }).to_string();
            return (StatusCode::NOT_IMPLEMENTED, [(header::CONTENT_TYPE, "application/vnd.git-lfs+json")], body).into_response();
        }
        (StatusCode::NOT_IMPLEMENTED, format!("Gunnar serves git over SSH only, not HTTPS: git clone {url}\n")).into_response()
    }))
}

/// **`face` with its login pages on the first host** ([`LoginOn`]).
pub fn login_on_first(face: Router, rule: Option<LoginOn>) -> Router {
    let Some(rule) = rule else { return face };
    let rule = Arc::new(rule);
    Router::new()
        .fallback_service(face)
        .layer(axum::middleware::from_fn(
            move |req: axum::extract::Request, next: axum::middleware::Next| {
                let rule = Arc::clone(&rule);
                async move {
                    let elsewhere = request_host(&req).is_some_and(|h| rule.hosts.contains(&h));
                    if elsewhere && is_login_page(req.uri().path()) {
                        let path = req
                            .uri()
                            .path_and_query()
                            .map(|p| p.as_str())
                            .unwrap_or("/");
                        // TEMPORARY (307): a browser keeps a 308, and the relying
                        // party may yet move to the own domain once its DNS is proven.
                        return (
                            StatusCode::TEMPORARY_REDIRECT,
                            [(header::LOCATION, format!("https://{}{path}", rule.to))],
                        )
                            .into_response();
                    }
                    next.run(req).await
                }
            },
        ))
}

// ── the access log (RUNTIME LOGS, 2026-09-30) ───────────────────────────────

/// **One request on :443, as its access line says it.** No header, no query
/// value, no peer address: the method, the path with its query cut and any
/// token-like segment withheld, the status, who called (the console's
/// `AccessPrincipal` on the answer, else `anonymous`) and how long it took.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Access {
    pub method: String,
    pub path: String,
    pub status: u16,
    pub principal: String,
    pub ms: u64,
}

/// Where an access line goes (the log door in production; a test's list).
pub type AccessSink = Arc<dyn Fn(&Access) + Send + Sync>;

/// The line's text: `GET /api/repos 200 alice 12 ms`.
pub fn access_line(a: &Access) -> String {
    format!(
        "{} {} {} {} {} ms",
        a.method, a.path, a.status, a.principal, a.ms
    )
}

/// A path segment that reads like a credential: long, and either hex or
/// mixed-case letters with digits (an invite, a reset link, a session id).
fn token_like(seg: &str) -> bool {
    if seg.len() < 24
        || !seg
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=' || c == '.')
    {
        return false;
    }
    let hex = seg.chars().all(|c| c.is_ascii_hexdigit()) && seg.chars().any(|c| c.is_ascii_digit());
    let mixed = seg.chars().any(|c| c.is_ascii_uppercase())
        && seg.chars().any(|c| c.is_ascii_lowercase())
        && seg.chars().any(|c| c.is_ascii_digit());
    hex || mixed
}

/// Pure: **the path as the access line says it** — every token-like segment
/// `<withheld>`, at most 200 characters.
pub fn redacted_path(path: &str) -> String {
    let p: Vec<&str> = path
        .split('/')
        .map(|seg| if token_like(seg) { "<withheld>" } else { seg })
        .collect();
    let p = p.join("/");
    if p.chars().count() > 200 {
        format!("{}…", p.chars().take(200).collect::<String>())
    } else {
        p
    }
}

fn access_path(uri: &Uri) -> String {
    let p = redacted_path(uri.path());
    if uri.query().is_some() {
        format!("{p}?…")
    } else {
        p
    }
}

fn principal_of(r: &Response) -> String {
    #[cfg(feature = "console")]
    if let Some(p) = r
        .extensions()
        .get::<gunnar_ui_server::auth::middleware::AccessPrincipal>()
    {
        return p.0.clone();
    }
    let _ = r;
    "anonymous".to_string()
}

/// **Every request through `face` is one access line in the log door**
/// (tracing, `event="http.access"`, on the module's own target).
pub fn access_logged(face: Router) -> Router {
    access_logged_to(
        face,
        Arc::new(|a: &Access| {
            tracing::info!(event = "http.access", method = %a.method, path = %a.path, status = a.status, principal = %a.principal, ms = a.ms, "{}", access_line(a));
        }),
    )
}

/// [`access_logged`] into any sink.
pub fn access_logged_to(face: Router, sink: AccessSink) -> Router {
    Router::new()
        .fallback_service(face)
        .layer(axum::middleware::from_fn(
            move |req: axum::extract::Request, next: axum::middleware::Next| {
                let sink = sink.clone();
                async move {
                    let t = std::time::Instant::now();
                    let method = req.method().to_string();
                    let path = access_path(req.uri());
                    let r = next.run(req).await;
                    sink(&Access {
                        method,
                        path,
                        status: r.status().as_u16(),
                        principal: principal_of(&r),
                        ms: t.elapsed().as_millis() as u64,
                    });
                    r
                }
            },
        ))
}