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