From 6a3a5f4354f843dd4d784d4b3a0e1c14968cf1c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:16:59 +0000 Subject: [PATCH] Serve the server under alias host names with URLs rendered for that host server.alias_base_urls lists further base URLs the server is reached under, such as the old Nexus host pointed at Forge. A request whose Host names an alias (matched like allowed_hosts: case-insensitive, trailing dot dropped, port ignored) is answered with the client protocols' URLs on that alias; any other Host keeps URLs on public_base_url, so a forged Host cannot make Forge render links to a host of its choosing. - Format plugins get their repository's base_url on the alias the request named: the NuGet service index and registrations, npm packuments' tarballs and the npm web login's doneUrl, and the redirect check follow it. Raw renders relative links already. - The registry treats an alias host as one of the server's own: repositories by path, the alias as token realm and service (a token's audience), also in the challenge of credentials the middleware refused. - Browser surfaces stay on public_base_url: the web UI (a GET of a UI path on an alias is redirected there), sign-in and OIDC, cookies, the CSRF origin check, emails, the npm login approval page and the connect snippets. - An alias is one of the server's names for the trusted-header realms' DNS-rebinding guard. The admin API refuses a docker repository host equal to an alias, and startup fails while a repository has one. - Config validation refuses a malformed alias, one with a path, query or credentials, a duplicate host and the public host; a plain-http alias is a deployment warning. - GET /admin/addresses and an Addresses panel on Admin -> System show the public URL and the aliases. Tests: crates/server/tests/alias_base_urls.rs (NuGet, npm, registry, admin refusal, startup check, web UI redirect, trusted headers), unit tests of the parsing, matching, redirect and config, and e2e tests with real docker, dotnet and helm against a server whose public host never resolves (crates/e2e/tests/alias_hosts.rs). Fixes #312 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012e6DJc3No8xu6rxoMc3pFJ --- DEVELOPMENT.md | 1 + crates/e2e/src/stack.rs | 44 +- crates/e2e/tests/alias_hosts.rs | 437 ++++++++++++ crates/formats/nuget/src/protocol.rs | 3 +- crates/plugin-api/src/types.rs | 8 +- crates/server/src/admin/addresses.rs | 28 + crates/server/src/admin/mod.rs | 5 + crates/server/src/admin/repositories.rs | 41 +- crates/server/src/app.rs | 70 +- crates/server/src/base_urls.rs | 303 +++++++++ crates/server/src/config.rs | 122 ++++ crates/server/src/images/dispatch.rs | 23 +- crates/server/src/images/mod.rs | 3 + crates/server/src/lib.rs | 1 + crates/server/src/main.rs | 2 + crates/server/src/middleware/principal.rs | 33 +- crates/server/src/plugins.rs | 29 +- crates/server/src/registry.rs | 8 +- crates/server/src/state.rs | 5 + crates/server/src/web_ui.rs | 95 ++- crates/server/tests/alias_base_urls.rs | 630 ++++++++++++++++++ docs/changes/2.2.0/312.feature.md | 14 + docs/deployment.md | 68 +- docs/docker.md | 14 + docs/format-plugins.md | 4 +- docs/nexus-import.md | 8 + forge.toml.example | 12 + web/src/api/system.ts | 19 + web/src/components/RepositoryForm.tsx | 2 + web/src/queries/useSystem.ts | 11 + .../admin/repositories/repositories.test.tsx | 19 +- web/src/routes/admin/system/System.tsx | 92 ++- web/src/routes/admin/system/system.test.tsx | 36 +- 33 files changed, 2109 insertions(+), 81 deletions(-) create mode 100644 crates/e2e/tests/alias_hosts.rs create mode 100644 crates/server/src/admin/addresses.rs create mode 100644 crates/server/src/base_urls.rs create mode 100644 crates/server/tests/alias_base_urls.rs create mode 100644 docs/changes/2.2.0/312.feature.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4468298b..4aff1d16 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -289,6 +289,7 @@ with `FORGE_*` env vars using **double-underscore** nesting, e.g. | --- | --- | | `auth.bootstrap_admin` | First-run admin username (a UPN, default `admin@example.com`) / password. No or a blank password means no bootstrap admin. | | `server.allow_insecure_defaults` | Default `false`: startup fails while the bootstrap-admin password is `changeme` or the S3 keys are `minioadmin`. `true` only in dev configs (`forge.toml`, `.env.example`, `docker-compose.app.yml`). | +| `server.alias_base_urls` | Further base URLs, such as the old Nexus host pointed at Forge (`["https://nexus.example.com"]`): a request for an alias host is handed the client protocols' URLs on that alias instead of `public_base_url`. See [docs/deployment.md](./docs/deployment.md#further-host-names-aliases). | | `[redis]` | Optional shared L2 cache + cross-instance invalidation. Omit for in-process cache only. `connect_timeout_ms` / `response_timeout_ms` (default 2000) bound every Redis call. | | `[blob_cache]` | Optional local disk cache for blobs (`path`, `max_bytes` = `"auto"` or bytes, `min_free_bytes`): hot downloads skip S3 and cached packages keep resolving while S3 is down. See [docs/deployment.md](./docs/deployment.md#local-blob-cache). | | `[telemetry]` | OpenTelemetry export over OTLP/HTTP: `metrics`, `traces`, `logs` (each off by default), sent where the `OTEL_*` variables say (`OTEL_EXPORTER_OTLP_ENDPOINT`, default `http://localhost:4318`). See [docs/deployment.md](./docs/deployment.md#telemetry). | diff --git a/crates/e2e/src/stack.rs b/crates/e2e/src/stack.rs index b9907cac..d5d7a81c 100644 --- a/crates/e2e/src/stack.rs +++ b/crates/e2e/src/stack.rs @@ -217,6 +217,7 @@ fn config_toml( trusted_proxies: &[String], cache_ttl_seconds: u64, public_base_url: &str, + alias_base_urls: &[String], oidc_block: &str, auth_block: &str, blob_cache: Option<&BlobCacheSetup>, @@ -250,12 +251,17 @@ fn config_toml( let quoted: Vec = trusted_proxies.iter().map(|t| format!("{t:?}")).collect(); format!("trusted_proxy_cidrs = [{}]\n", quoted.join(", ")) }; + let aliases = { + let quoted: Vec = alias_base_urls.iter().map(|a| format!("{a:?}")).collect(); + format!("alias_base_urls = [{}]", quoted.join(", ")) + }; std::env::set_var(ROOT_KEY_VAR, ROOT_KEY); format!( r#" [server] bind = "127.0.0.1:0" public_base_url = "{public_base_url}" +{aliases} [database] url = "{db_url}" @@ -328,6 +334,11 @@ pub struct StackBuilder { blob_cache: Option, /// `None` ⇒ the static test keys. s3_credentials: Option, + /// The host of `server.public_base_url`; `None` ⇒ the listener's + /// address (`127.0.0.1`). + public_host: Option, + /// Hosts of `server.alias_base_urls` (`http://:`). + alias_hosts: Vec, } impl Default for StackBuilder { @@ -347,6 +358,8 @@ impl Default for StackBuilder { s3_endpoint: None, blob_cache: None, s3_credentials: None, + public_host: None, + alias_hosts: Vec::new(), } } } @@ -426,6 +439,23 @@ impl StackBuilder { self } + /// Name the server `http://:` in `server.public_base_url` + /// instead of its listener's address. [`TestServer::base_url`] stays the + /// listener's address, so the harness reaches the server whether `host` + /// resolves or not (`forge.invalid` never does: a client handed a URL on + /// it cannot follow it). + pub fn public_host(mut self, host: impl Into) -> Self { + self.public_host = Some(host.into()); + self + } + + /// Add `http://:` to `server.alias_base_urls` (#312), the + /// port the server listens on. + pub fn alias_host(mut self, host: impl Into) -> Self { + self.alias_hosts.push(host.into()); + self + } + /// Enable the local blob cache at `path`, `max_bytes` bytes large /// (`None`: `"auto"`). The server then starts even while S3 is down. pub fn blob_cache( @@ -478,6 +508,15 @@ impl StackBuilder { .expect("bind ephemeral"); let addr = listener.local_addr().expect("local addr"); let base_url = format!("http://{addr}"); + let public_base_url = match &self.public_host { + Some(host) => format!("http://{host}:{}", addr.port()), + None => base_url.clone(), + }; + let alias_base_urls: Vec = self + .alias_hosts + .iter() + .map(|host| format!("http://{host}:{}", addr.port())) + .collect(); let admin_ref = self .bootstrap_admin @@ -488,7 +527,7 @@ impl StackBuilder { let oidc_block = self .oidc_block_fn .as_ref() - .map(|f| f(&base_url)) + .map(|f| f(&public_base_url)) .unwrap_or_default(); let toml = config_toml( &db_url, @@ -497,7 +536,8 @@ impl StackBuilder { redis.as_deref(), &self.trusted_proxies, self.cache_ttl_seconds, - &base_url, + &public_base_url, + &alias_base_urls, &oidc_block, self.auth_block.as_deref().unwrap_or_default(), self.blob_cache.as_ref(), diff --git a/crates/e2e/tests/alias_hosts.rs b/crates/e2e/tests/alias_hosts.rs new file mode 100644 index 00000000..52e47040 --- /dev/null +++ b/crates/e2e/tests/alias_hosts.rs @@ -0,0 +1,437 @@ +//! E2E (optional clients): real clients reach a server under an alias host +//! name (`server.alias_base_urls`, #312). +//! +//! The servers' public base URL is `http://forge.invalid:`, a name that +//! never resolves (RFC 6761), and their alias `http://localhost:`. The +//! clients are configured with the alias alone, so a client that followed a +//! URL on the public host (a NuGet resource, a token realm) would fail: a +//! passing client made every request on the alias. +//! +//! * `dotnet nuget push` and `dotnet restore` of a private feed; +//! * `docker login`, `push` and `pull`, the challenge naming +//! `http://localhost:/v2/token`; +//! * `helm registry login`, `push` and `pull` of a private chart. Helm keeps +//! its charts in docker repositories (OCI): Forge serves no `index.yaml` +//! chart repositories, so `helm repo add` has nothing to add, and helm +//! fetches the registry token from the realm the challenge names. +//! +//! Each test is skipped (passes trivially) when its client is not on PATH, +//! like `dotnet_smoke`. Gated behind `forge-e2e`. + +#![cfg(feature = "forge-e2e")] + +use std::path::Path; +use std::process::{Command, Stdio}; + +use forge_e2e::{StackBuilder, TestServer}; + +/// The public host of the servers: never resolves. +const PUBLIC_HOST: &str = "forge.invalid"; + +/// The alias the clients use. +const ALIAS_HOST: &str = "localhost"; + +/// A server on `http://forge.invalid:` with the alias +/// `http://localhost:`. +async fn aliased_server() -> TestServer { + StackBuilder::default() + .public_host(PUBLIC_HOST) + .alias_host(ALIAS_HOST) + .start() + .await +} + +/// `localhost:` of `srv`. +fn alias_authority(srv: &TestServer) -> String { + let port = srv.base_url.rsplit(':').next().expect("a port"); + format!("{ALIAS_HOST}:{port}") +} + +/// Whether `program` runs (` ` succeeds). +fn available(program: &str, args: &[&str]) -> bool { + Command::new(program) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +/// Run `cmd` on a blocking thread (the CLI calls back into the in-process +/// server, which must keep running), feeding `stdin`; panics with its output +/// unless it succeeds. Returns its stdout. +async fn run(mut cmd: Command, what: &str, stdin: Option) -> String { + let what = what.to_string(); + tokio::task::spawn_blocking(move || { + use std::io::Write as _; + cmd.stdin(if stdin.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().unwrap_or_else(|e| panic!("spawn {what}: {e}")); + if let Some(input) = stdin { + child + .stdin + .take() + .expect("piped stdin") + .write_all(input.as_bytes()) + .expect("write stdin"); + } + let out = child.wait_with_output().expect("wait"); + assert!( + out.status.success(), + "{what} failed (status {:?})\nstdout:\n{}\nstderr:\n{}", + out.status.code(), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + String::from_utf8_lossy(&out.stdout).into_owned() + }) + .await + .expect("join blocking") +} + +fn path_str(p: &Path) -> &str { + p.to_str().expect("utf-8 path") +} + +fn write_file(path: &Path, contents: &str) { + std::fs::write(path, contents).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); +} + +/// `dotnet --version`'s major version as a target framework (`net8.0`). +fn dotnet_tfm() -> Option { + let out = Command::new("dotnet").arg("--version").output().ok()?; + if !out.status.success() { + return None; + } + let version = String::from_utf8(out.stdout).ok()?; + let major = version.trim().split('.').next()?.parse::().ok()?; + Some(format!("net{major}.0")) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dotnet_pushes_and_restores_through_an_alias_alone() { + let Some(tfm) = dotnet_tfm() else { + eprintln!("dotnet SDK not present; skipping the alias dotnet test"); + return; + }; + let srv = aliased_server().await; + let repo = "alias-nuget"; + assert_eq!( + srv.create_repo(serde_json::json!({"name": repo, "kind": "hosted"})) + .await + .status(), + 201 + ); + let alias = alias_authority(&srv); + let source = format!("http://{alias}/repository/{repo}/index.json"); + // The service index fetched on the alias names only the alias. + let index: serde_json::Value = srv + .client() + .get(format!("{}/repository/{repo}/index.json", srv.base_url)) + .header("host", &alias) + .basic_auth(srv.admin_user.as_ref().unwrap(), Some(&srv.admin_token)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + for resource in index["resources"].as_array().unwrap() { + let id = resource["@id"].as_str().unwrap(); + assert!( + id.starts_with(&format!("http://{alias}/repository/{repo}/")), + "{id}" + ); + } + + // A private feed: the client sends its source credentials (an API token + // as the password) to the host of the source, the alias. + let tmp = tempfile::tempdir().unwrap(); + let (lib, app) = (tmp.path().join("Alias.Lib"), tmp.path().join("Consumer")); + std::fs::create_dir_all(&lib).unwrap(); + std::fs::create_dir_all(&app).unwrap(); + write_file( + &tmp.path().join("nuget.config"), + &format!( + r#" + + + + + + + + + + + + +"#, + user = srv.admin_user.as_ref().unwrap(), + token = srv.admin_token, + ), + ); + write_file( + &lib.join("Alias.Lib.csproj"), + &format!( + r#" + + {tfm} + Alias.Lib + 1.0.0 + e2e + forge e2e alias package + + +"# + ), + ); + write_file( + &lib.join("Class1.cs"), + "namespace Alias.Lib; public static class Greeter { public static string Hi() => \"hi\"; }\n", + ); + let mut pack = Command::new("dotnet"); + pack.args(["pack", "-c", "Release", "-o", path_str(tmp.path())]) + .current_dir(&lib); + run(pack, "dotnet pack", None).await; + let nupkg = tmp.path().join("Alias.Lib.1.0.0.nupkg"); + let mut push = Command::new("dotnet"); + push.args([ + "nuget", + "push", + path_str(&nupkg), + "--source", + "forge", + "--api-key", + &srv.admin_token, + ]) + .current_dir(tmp.path()); + run(push, "dotnet nuget push", None).await; + + write_file( + &app.join("Consumer.csproj"), + &format!( + r#" + + Exe + {tfm} + + + + + +"# + ), + ); + write_file( + &app.join("Program.cs"), + "System.Console.WriteLine(Alias.Lib.Greeter.Hi());\n", + ); + let mut restore = Command::new("dotnet"); + restore + .args(["restore", "--no-cache", "--force-evaluate"]) + .current_dir(&app); + run(restore, "dotnet restore", None).await; + assert!( + app.join("obj").join("project.assets.json").exists(), + "restore wrote its assets file" + ); +} + +/// A tar archive of one small file, for `docker import`. +fn rootfs_tar(dir: &Path) -> std::path::PathBuf { + let root = dir.join("rootfs"); + std::fs::create_dir_all(&root).unwrap(); + write_file(&root.join("hello.txt"), "served through an alias\n"); + let tar = dir.join("rootfs.tar"); + let status = Command::new("tar") + .args(["-cf", path_str(&tar), "-C", path_str(&root), "hello.txt"]) + .status() + .expect("run tar"); + assert!(status.success(), "tar"); + tar +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docker_logs_in_pushes_and_pulls_through_an_alias_alone() { + if !available("docker", &["version"]) { + eprintln!("docker (client and daemon) not present; skipping the alias docker test"); + return; + } + let srv = aliased_server().await; + // Each stack has a database of its own. + let repo = "alias-docker"; + assert_eq!( + srv.create_repo(serde_json::json!({"name": repo, "kind": "hosted", "format": "docker"})) + .await + .status(), + 201 + ); + let alias = alias_authority(&srv); + + // The challenge on the alias names the alias's token service. + let resp = srv + .client() + .get(format!("{}/v2/", srv.base_url)) + .header("host", &alias) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); + assert_eq!( + resp.headers()["www-authenticate"], + format!("Bearer realm=\"http://{alias}/v2/token\",service=\"{alias}\"").as_str() + ); + + // docker keeps its login here, not in ~/.docker. + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("docker-config"); + std::fs::create_dir_all(&config).unwrap(); + let docker = |args: &[&str]| { + let mut cmd = Command::new("docker"); + cmd.env("DOCKER_CONFIG", &config).args(args); + cmd + }; + let image = format!("{alias}/{repo}/alias/app:1"); + run( + docker(&[ + "login", + &alias, + "--username", + srv.admin_user.as_ref().unwrap(), + "--password-stdin", + ]), + "docker login", + Some(srv.admin_token.clone()), + ) + .await; + run( + docker(&["import", path_str(&rootfs_tar(tmp.path())), &image]), + "docker import", + None, + ) + .await; + let id = run( + docker(&["image", "inspect", "--format", "{{.Id}}", &image]), + "docker image inspect", + None, + ) + .await; + run(docker(&["push", &image]), "docker push", None).await; + run(docker(&["image", "rm", &image]), "docker image rm", None).await; + run(docker(&["pull", &image]), "docker pull", None).await; + let pulled = run( + docker(&["image", "inspect", "--format", "{{.Id}}", &image]), + "docker image inspect", + None, + ) + .await; + assert_eq!(pulled.trim(), id.trim(), "pulled the pushed image"); + run(docker(&["image", "rm", &image]), "docker image rm", None).await; + run(docker(&["logout", &alias]), "docker logout", None).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn helm_pushes_and_pulls_a_private_chart_through_an_alias_alone() { + if !available("helm", &["version"]) { + eprintln!("helm not present; skipping the alias helm test"); + return; + } + // The test server speaks plain HTTP: a helm that cannot log in over it + // cannot run this test. + let login_help = Command::new("helm") + .args(["registry", "login", "--help"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default(); + if !login_help.contains("--plain-http") { + eprintln!("helm has no `registry login --plain-http`; skipping the alias helm test"); + return; + } + let srv = aliased_server().await; + let repo = "alias-charts"; + assert_eq!( + srv.create_repo(serde_json::json!({"name": repo, "kind": "hosted", "format": "docker"})) + .await + .status(), + 201 + ); + let alias = alias_authority(&srv); + + // Helm keeps its registry login and cache here. + let tmp = tempfile::tempdir().unwrap(); + let helm = |args: &[&str]| { + let mut cmd = Command::new("helm"); + for (var, dir) in [ + ("HELM_CONFIG_HOME", "config"), + ("HELM_CACHE_HOME", "cache"), + ("HELM_DATA_HOME", "data"), + ] { + cmd.env(var, tmp.path().join("helm").join(dir)); + } + cmd.args(args); + cmd + }; + run( + helm(&[ + "registry", + "login", + &alias, + "--username", + srv.admin_user.as_ref().unwrap(), + "--password-stdin", + "--plain-http", + ]), + "helm registry login", + Some(srv.admin_token.clone()), + ) + .await; + let charts = tmp.path().join("charts"); + std::fs::create_dir_all(&charts).unwrap(); + let chart_dir = charts.join("alias-chart"); + run(helm(&["create", path_str(&chart_dir)]), "helm create", None).await; + run( + helm(&["package", path_str(&chart_dir), "-d", path_str(&charts)]), + "helm package", + None, + ) + .await; + let packaged = charts.join("alias-chart-0.1.0.tgz"); + run( + helm(&[ + "push", + path_str(&packaged), + &format!("oci://{alias}/{repo}/charts"), + "--plain-http", + ]), + "helm push", + None, + ) + .await; + let pulled = tmp.path().join("pulled"); + std::fs::create_dir_all(&pulled).unwrap(); + run( + helm(&[ + "pull", + &format!("oci://{alias}/{repo}/charts/alias-chart"), + "--version", + "0.1.0", + "--plain-http", + "-d", + path_str(&pulled), + ]), + "helm pull", + None, + ) + .await; + assert_eq!( + std::fs::read(pulled.join("alias-chart-0.1.0.tgz")).unwrap(), + std::fs::read(&packaged).unwrap(), + "pulled the pushed chart" + ); +} diff --git a/crates/formats/nuget/src/protocol.rs b/crates/formats/nuget/src/protocol.rs index fc7a8197..b73dc760 100644 --- a/crates/formats/nuget/src/protocol.rs +++ b/crates/formats/nuget/src/protocol.rs @@ -2,7 +2,8 @@ //! with. //! //! Every `@id` is absolute, built from the repository's base URL -//! (`{public_base_url}/repository/{repo}`). The host has authorized the request +//! (`{public_base_url}/repository/{repo}`, or on the alias base URL the +//! request named). The host has authorized the request //! before [`handle`] sees it; publishing, unlisting and relisting are host //! actions, offered for hosted repositories only. Paths match literal //! segments as sent and percent-decode parameters; an unknown path answers diff --git a/crates/plugin-api/src/types.rs b/crates/plugin-api/src/types.rs index e22248fa..ea6f908e 100644 --- a/crates/plugin-api/src/types.rs +++ b/crates/plugin-api/src/types.rs @@ -215,8 +215,12 @@ impl Default for Assets { pub struct Repository { pub name: String, pub kind: RepositoryKind, - /// The absolute URL the repository is served at, without a trailing - /// slash (e.g. `https://forge.example.com/repository/charts`). + /// The absolute URL the repository is served at for this request, + /// without a trailing slash (e.g. + /// `https://forge.example.com/repository/charts`): on the server's public + /// base URL, or on the alias base URL the request's `Host` named + /// (`server.alias_base_urls`). Build every absolute URL from it, so a + /// client stays on the host it asked. pub base_url: String, /// Files formats: whether the repository's files are displayed by a /// browser (its `inline` content disposition) rather than downloaded, as diff --git a/crates/server/src/admin/addresses.rs b/crates/server/src/admin/addresses.rs new file mode 100644 index 00000000..007a4c9e --- /dev/null +++ b/crates/server/src/admin/addresses.rs @@ -0,0 +1,28 @@ +//! The addresses the server is reached under (#312), as **Admin → System** +//! shows them: +//! +//! * `GET /admin/addresses` → `{"public_base_url": "https://forge.example.com", +//! "alias_base_urls": ["https://nexus.example.com"]}`: `server.public_base_url` +//! and `server.alias_base_urls`, normalized ([`crate::base_urls`]). They are +//! set in `forge.toml` (or `FORGE_SERVER__*`), so this is read-only. + +use axum::extract::State; +use axum::response::{IntoResponse, Json, Response}; +use serde_json::json; + +use crate::state::AppState; + +/// `GET /admin/addresses` +pub async fn get(State(state): State) -> Response { + let aliases: Vec<&str> = state + .base_urls + .aliases() + .iter() + .map(|alias| alias.url.as_str()) + .collect(); + Json(json!({ + "public_base_url": state.base_urls.public(), + "alias_base_urls": aliases, + })) + .into_response() +} diff --git a/crates/server/src/admin/mod.rs b/crates/server/src/admin/mod.rs index 95014a1f..b0c24ebb 100644 --- a/crates/server/src/admin/mod.rs +++ b/crates/server/src/admin/mod.rs @@ -23,6 +23,8 @@ //! * [`crypto`] — secret-encryption key **metadata** + per-layer rotation //! triggers (never returns key bytes or decrypted secrets). //! * [`blob_cache`] — the local blob cache's statistics. +//! * [`addresses`] — the public and alias base URLs the server is reached +//! under (read-only: `forge.toml`). //! * [`proxy_cache`] — invalidating what a proxy repository cached from its //! upstream (its "not found" answers, listings and documents). //! * [`imports`] — importing a repository from another server (a NuGet feed, @@ -52,6 +54,7 @@ //! refused here too (403): the proxy adds them to every request of the //! clients it admits, and they never make anyone an administrator. +pub mod addresses; pub mod blob_cache; pub mod content_selectors; pub mod crypto; @@ -357,6 +360,8 @@ pub fn admin_router(state: AppState) -> Router { .route("/crypto/keys", get(crypto::list_keys)) // --- local blob cache ---------------------------------------------- .route("/blob-cache", get(blob_cache::stats)) + // --- the server's addresses ---------------------------------------- + .route("/addresses", get(addresses::get)) // --- traffic statistics -------------------------------------------- .route("/traffic", get(traffic::overview)) .route( diff --git a/crates/server/src/admin/repositories.rs b/crates/server/src/admin/repositories.rs index f9e83117..1848af8a 100644 --- a/crates/server/src/admin/repositories.rs +++ b/crates/server/src/admin/repositories.rs @@ -34,6 +34,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use super::{db_error, double_option}; +use crate::base_urls::BaseUrls; use crate::state::AppState; /// Public, password-free view of a repository (REDACTED secrets). @@ -445,8 +446,10 @@ fn deployment_policy_of(r: &Repository) -> DeploymentPolicy { } /// Validate and normalize a docker repository's `host`: a lowercase DNS name -/// (no port, no scheme) other than the server's own public host. -fn normalize_docker_host(raw: &str, public_base_url: &str) -> Result { +/// (no port, no scheme) other than the server's own host names: the public +/// host and the alias hosts (`server.alias_base_urls`), where every +/// repository is reachable by path. +fn normalize_docker_host(raw: &str, base_urls: &BaseUrls) -> Result { let host = raw.trim().trim_end_matches('.').to_ascii_lowercase(); let label_ok = |l: &str| { !l.is_empty() @@ -460,7 +463,7 @@ fn normalize_docker_host(raw: &str, public_base_url: &str) -> Result Result { - let host = normalize_docker_host(&host, &state.config.public_base_url()) - .map_err(bad_request)?; + let host = normalize_docker_host(&host, &state.base_urls).map_err(bad_request)?; config.insert(DOCKER_HOST_KEY.into(), json!(host)); } Some(None) => { @@ -1652,4 +1661,26 @@ mod tests { assert!(msg.contains("characters from A-Z"), "{msg}"); assert!(validate_repo_name("nuget-hosted.v3_x").is_ok()); } + + #[test] + fn a_docker_host_is_none_of_the_servers_own_names() { + let urls = BaseUrls::new( + "https://forge.example.com", + &["https://nexus.example.com".to_string()], + ) + .unwrap(); + assert_eq!( + normalize_docker_host(" Docker.Example.com. ", &urls).as_deref(), + Ok("docker.example.com") + ); + let err = normalize_docker_host("forge.example.com", &urls).unwrap_err(); + assert!(err.contains("the server's own host"), "{err}"); + for alias in ["nexus.example.com", "NEXUS.example.com."] { + let err = normalize_docker_host(alias, &urls).unwrap_err(); + assert!( + err.contains("alias") && err.contains("https://nexus.example.com"), + "{alias}: {err}" + ); + } + } } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 01539c7a..ff517820 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -93,14 +93,24 @@ pub fn principal_layer(state: &AppState) -> anyhow::Result { sessions: Some(state.sessions.clone()), cookie_secure: state.cookie_secure, public_origin: crate::middleware::principal::origin_of(&state.config.public_base_url()), - // DNS-rebinding guard for the trusted-header realms. - allowed_hosts: Arc::new( - crate::middleware::principal::allowed_hosts( + // DNS-rebinding guard for the trusted-header realms: the public + // host, `server.allowed_hosts`, and the alias hosts, which are the + // server's own names too. + allowed_hosts: Arc::new({ + let mut hosts = crate::middleware::principal::allowed_hosts( &state.config.public_base_url(), &state.config.server.allowed_hosts, ) - .map_err(anyhow::Error::msg)?, - ), + .map_err(anyhow::Error::msg)?; + hosts.extend( + state + .base_urls + .aliases() + .iter() + .map(|alias| alias.host.clone()), + ); + hosts + }), }) } @@ -175,11 +185,11 @@ pub fn build_router(state: AppState, layer: PrincipalLayer) -> Router { // Static SPA fallback, installed LAST so the API routers above always win // and only genuine UI paths fall through to `index.html`. The admin UI // lives under `/manage/*`; `/admin/*` is the admin API's. - let app = app.fallback_service(spa_service(&state.config.web)); + let app = app.fallback_service(spa_service(&state.config.web, state.base_urls.clone())); let hsts = state.cookie_secure; let recorder = state.traffic.recorder.clone(); - let public_base_url = state.config.public_base_url(); + let base_urls = state.base_urls.clone(); let images = images.map(|p| p.implementation().clone()); let server = state.config.server.clone(); let app = app @@ -193,7 +203,7 @@ pub fn build_router(state: AppState, layer: PrincipalLayer) -> Router { response_policy( hsts, feeds.clone(), - public_base_url.clone(), + base_urls.clone(), images.clone(), req, next, @@ -276,14 +286,16 @@ const REGISTRY_VARY: &str = "Authorization, Cookie, Accept"; /// credentials: [`crate::middleware::principal::NamedByTrustedHeaders`]); /// registry responses (`/v2/...`) likewise, with /// [`REGISTRY_VARY`], and a 401 the authentication middleware answered -/// there gets the registry's Bearer challenge; Anonymous responses carry no `Cache-Control`; +/// there gets the registry's Bearer challenge (on the alias host the +/// request addressed, [`crate::base_urls`]); Anonymous responses carry no +/// `Cache-Control`; /// * with an `https` public URL every response carries HSTS. /// /// The SPA keeps its own cache headers (hashed assets are immutable). async fn response_policy( hsts: bool, feeds: std::sync::Arc>, - public_base_url: String, + base_urls: Arc, images: Option>, req: axum::extract::Request, next: axum::middleware::Next, @@ -295,6 +307,13 @@ async fn response_policy( let registry = images .as_ref() .filter(|_| under(forge_plugin::api::IMAGES_MOUNT)); + // The registry's challenge names the host the request addressed when + // it is an alias, as the registry's own do. + let registry_base = registry.map(|_| { + base_urls + .for_authority(crate::base_urls::request_authority(&req)) + .to_string() + }); let headers = req.headers(); let credentials = headers.contains_key(header::AUTHORIZATION) || headers.contains_key("x-nuget-apikey") @@ -305,8 +324,8 @@ async fn response_policy( .extensions() .get::() .is_some(); - if let Some(images) = registry { - resp = crate::registry::amend_registry_rejection(&public_base_url, images.as_ref(), resp); + if let (Some(images), Some(base)) = (registry, registry_base.as_deref()) { + resp = crate::registry::amend_registry_rejection(base, images.as_ref(), resp); } let registry = registry.is_some(); let out = resp.headers_mut(); @@ -331,6 +350,33 @@ async fn response_policy( resp } +/// Refuse to start while a docker repository's `host` is the host of an +/// alias (`server.alias_base_urls`). The registry serves an alias host as one +/// of the server's own, every repository by path, so that repository would +/// silently lose its host. The admin API refuses such a host; this catches +/// one stored before the alias was configured (or by an instance without +/// it). +pub async fn check_alias_hosts(state: &AppState) -> anyhow::Result<()> { + let Some(images) = state.formats.images_plugin() else { + return Ok(()); + }; + for alias in state.base_urls.aliases() { + let taken = + forge_db::oci_dao::find_repository_by_host(&state.pool, &alias.host, images.id()) + .await + .context("look up the docker repository hosts")?; + if let Some(repo) = taken { + anyhow::bail!( + "server.alias_base_urls: the alias {} has the host of the docker repository \ + {:?}; remove the repository's host (Admin -> Repositories) or the alias", + alias.url, + repo.name + ); + } + } + Ok(()) +} + /// `forge_setting` key prefix (`:`) recording that the /// bootstrap admin was created, so a later deletion sticks across restarts. pub const BOOTSTRAP_ADMIN_CREATED_KEY: &str = "bootstrap_admin_created"; diff --git a/crates/server/src/base_urls.rs b/crates/server/src/base_urls.rs new file mode 100644 index 00000000..cf57740f --- /dev/null +++ b/crates/server/src/base_urls.rs @@ -0,0 +1,303 @@ +//! The base URLs the absolute URLs of the client protocols are rendered on +//! (#312): `server.public_base_url`, and for a request that addressed one of +//! `server.alias_base_urls`, that alias. +//! +//! An alias keeps a client on the host name it came in on, such as the old +//! Nexus host pointed at Forge: a NuGet service index fetched there names its +//! resources there, an npm packument its tarballs, and the registry its token +//! realm and service. +//! +//! * Only listed aliases are honoured. A request for any other host is +//! answered with URLs on the public base URL, so a forged `Host` cannot +//! make Forge render links to a host of its choosing (cache poisoning). +//! * Hosts are matched the way `server.allowed_hosts` matches them: by name, +//! case-insensitive, without a trailing dot, the port ignored. The name is +//! the `Host` header (or an HTTP/2 request's authority), never +//! `X-Forwarded-Host`: the reverse proxy must pass `Host` through. +//! * Browser surfaces stay on the public base URL: the web UI (a page +//! requested on an alias is redirected there, [`crate::web_ui`]), sign-in +//! and OIDC redirects, session cookies, the CSRF origin check, links in +//! emails, and the connect snippets the web UI shows. + +use axum::http::{header, Request}; + +/// One of `server.alias_base_urls`, parsed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AliasBaseUrl { + /// `scheme://host[:port]`, lowercase, without a trailing `/` (and + /// without the scheme's default port). + pub url: String, + /// The host name requests address it by: lowercase, without port or + /// trailing dot ([`normalize_host`]). + pub host: String, +} + +/// The public base URL and the aliases, as requests are served on them. +#[derive(Debug, Clone)] +pub struct BaseUrls { + /// `server.public_base_url` without a trailing `/`. + public: String, + aliases: Vec, +} + +impl BaseUrls { + /// Parse `aliases` (`server.alias_base_urls`) next to `public_base_url`: + /// each an absolute `http`/`https` URL of a host and an optional port, + /// without path, query, fragment or credentials, of a host no other entry + /// and not the public base URL has. + pub fn new(public_base_url: &str, aliases: &[String]) -> Result { + let public = public_base_url.trim().trim_end_matches('/').to_string(); + let public_host = url::Url::parse(&public) + .ok() + .and_then(|url| authority_of(&url)) + .and_then(|authority| normalize_host(&authority)); + let mut parsed: Vec = Vec::with_capacity(aliases.len()); + for raw in aliases { + let alias = parse_alias(raw)?; + if public_host.as_deref() == Some(alias.host.as_str()) { + return Err(format!( + "{raw:?} names the host of server.public_base_url; an alias is another \ + host name" + )); + } + if parsed.iter().any(|other| other.host == alias.host) { + return Err(format!( + "{raw:?} names the host {} twice (hosts are matched without their port)", + alias.host + )); + } + parsed.push(alias); + } + Ok(BaseUrls { + public, + aliases: parsed, + }) + } + + /// `server.public_base_url`, without a trailing `/`. + pub fn public(&self) -> &str { + &self.public + } + + /// The aliases, in their configured order. + pub fn aliases(&self) -> &[AliasBaseUrl] { + &self.aliases + } + + /// The alias whose host `authority` (a `Host` value, `host[:port]`) + /// names, if any. + pub fn alias(&self, authority: Option<&str>) -> Option<&AliasBaseUrl> { + if self.aliases.is_empty() { + return None; + } + let host = normalize_host(authority?)?; + self.aliases.iter().find(|alias| alias.host == host) + } + + /// The alias `req` addressed ([`request_authority`]), if any. + pub fn alias_of(&self, req: &Request) -> Option<&AliasBaseUrl> { + self.alias(request_authority(req)) + } + + /// The base URL of a request that addressed `authority`: its alias, or + /// the public base URL. + pub fn for_authority(&self, authority: Option<&str>) -> &str { + self.alias(authority) + .map_or(self.public.as_str(), |alias| alias.url.as_str()) + } +} + +/// Parse one alias base URL ([`BaseUrls::new`]). +fn parse_alias(raw: &str) -> Result { + let invalid = |why: &str| format!("{raw:?} {why}, e.g. https://nexus.example.com"); + let trimmed = raw.trim(); + let url = + url::Url::parse(trimmed).map_err(|e| invalid(&format!("is not an absolute URL ({e})")))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(invalid("must use http or https")); + } + if url.host_str().is_none_or(str::is_empty) { + return Err(invalid("must name a host")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(invalid("must not carry credentials")); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(invalid("must not have a query or fragment")); + } + // `Url` gives `https://host` the path `/`; `https://host/` is the same + // URL, anything longer is a path. + if url.path() != "/" { + return Err(invalid( + "must not have a path: an alias serves the server's paths at its root", + )); + } + let authority = authority_of(&url).ok_or_else(|| invalid("must name a host"))?; + let host = normalize_host(&authority).ok_or_else(|| invalid("must name a valid host"))?; + Ok(AliasBaseUrl { + url: format!("{}://{}", url.scheme(), authority.to_ascii_lowercase()), + host, + }) +} + +/// `host[:port]` of `url` (the port only when it is not the scheme's +/// default). +fn authority_of(url: &url::Url) -> Option { + let host = url.host_str().filter(|h| !h.is_empty())?; + Some(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }) +} + +/// The lowercase host of an authority (`host[:port]`, IPv6 in brackets), +/// without port or trailing dot; `None` if it does not parse. +pub fn normalize_host(authority: &str) -> Option { + let authority: http::uri::Authority = authority.trim().parse().ok()?; + let host = authority.host().trim_end_matches('.').to_ascii_lowercase(); + (!host.is_empty()).then_some(host) +} + +/// The authority a request addressed: its `Host` header, or the URI +/// authority of an HTTP/2 request. Never `X-Forwarded-Host`. +pub fn request_authority(req: &Request) -> Option<&str> { + match req.headers().get(header::HOST) { + Some(value) => value.to_str().ok(), + None => req.uri().authority().map(|a| a.as_str()), + } + .filter(|authority| !authority.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn urls(aliases: &[&str]) -> Result { + BaseUrls::new( + "https://forge.example.com/", + &aliases.iter().map(|a| a.to_string()).collect::>(), + ) + } + + #[test] + fn aliases_are_normalized() { + let urls = urls(&[ + " HTTPS://Nexus.Example.com/ ", + "http://old.example.com:8081", + "https://other.example.com:443", + ]) + .unwrap(); + assert_eq!(urls.public(), "https://forge.example.com"); + assert_eq!( + urls.aliases(), + [ + AliasBaseUrl { + url: "https://nexus.example.com".into(), + host: "nexus.example.com".into() + }, + AliasBaseUrl { + url: "http://old.example.com:8081".into(), + host: "old.example.com".into() + }, + AliasBaseUrl { + url: "https://other.example.com".into(), + host: "other.example.com".into() + }, + ] + ); + } + + #[test] + fn malformed_aliases_are_refused() { + for (bad, why) in [ + ("nexus.example.com", "absolute URL"), + ("ftp://nexus.example.com", "http or https"), + ("https://nexus.example.com/repository", "path"), + ("https://nexus.example.com/repository/", "path"), + ("https://nexus.example.com/?x=1", "query"), + ("https://nexus.example.com/#top", "fragment"), + ("https://user:pw@nexus.example.com", "credentials"), + ("", "absolute URL"), + ] { + let err = urls(&[bad]).unwrap_err(); + assert!(err.contains(why), "{bad:?}: {err}"); + } + } + + #[test] + fn duplicates_and_the_public_host_are_refused() { + let err = urls(&[ + "https://nexus.example.com", + "http://NEXUS.example.com.:8081", + ]) + .unwrap_err(); + assert!(err.contains("twice"), "{err}"); + for public in [ + "https://forge.example.com", + "http://forge.example.com:8421", + "https://FORGE.example.com./", + ] { + let err = urls(&[public]).unwrap_err(); + assert!(err.contains("server.public_base_url"), "{public}: {err}"); + } + } + + #[test] + fn hosts_match_like_allowed_hosts() { + let urls = urls(&["https://nexus.example.com", "http://old.example.com:8081"]).unwrap(); + for host in [ + "nexus.example.com", + "NEXUS.example.com", + "nexus.example.com.", + "nexus.example.com:443", + "nexus.example.com:8443", + ] { + assert_eq!( + urls.for_authority(Some(host)), + "https://nexus.example.com", + "{host}" + ); + } + // The port is the alias's own, whatever the request names. + assert_eq!( + urls.for_authority(Some("old.example.com")), + "http://old.example.com:8081" + ); + // Any other host, or none, is served on the public base URL. + for host in [ + Some("forge.example.com"), + Some("evil.example.com"), + Some("nexus.example.com.evil.example"), + Some("not a host"), + None, + ] { + assert_eq!( + urls.for_authority(host), + "https://forge.example.com", + "{host:?}" + ); + } + } + + #[test] + fn the_request_host_is_the_host_header_or_the_authority() { + let req = Request::builder() + .uri("/repository/x") + .header(header::HOST, "nexus.example.com") + .header("x-forwarded-host", "forge.example.com") + .body(()) + .unwrap(); + assert_eq!(request_authority(&req), Some("nexus.example.com")); + let req = Request::builder() + .uri("https://nexus.example.com/repository/x") + .body(()) + .unwrap(); + assert_eq!(request_authority(&req), Some("nexus.example.com")); + let req = Request::builder() + .uri("/repository/x") + .header("x-forwarded-host", "nexus.example.com") + .body(()) + .unwrap(); + assert_eq!(request_authority(&req), None); + } +} diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 65cfbfa8..37970d48 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -154,6 +154,21 @@ pub struct ServerConfig { /// the guard is off. Default empty. #[serde(default)] pub allowed_hosts: Vec, + /// Further base URLs the server is reached under, such as the old Nexus + /// host pointed at Forge (`https://nexus.example.com`): each a scheme, + /// a host and an optional port, without a path. A request whose `Host` + /// names an alias (matched like [`Self::allowed_hosts`]: case-insensitive, + /// without a trailing dot, the port ignored) is answered with the + /// client protocols' absolute URLs on that alias: a NuGet service + /// index's resources, an npm packument's tarballs, the registry's token + /// realm and service. Browser surfaces (the web UI, sign-in, cookies, + /// emails, connect snippets) stay on [`Self::public_base_url`], and a web + /// UI page requested on an alias is redirected there. An alias is one of + /// the server's own names for the DNS-rebinding guard, and no docker + /// repository may have its host. See [`crate::base_urls`]. Default + /// empty. + #[serde(default)] + pub alias_base_urls: Vec, /// Seconds a client may take to send a request's headers before the /// connection is closed (slow-header "slowloris" defence). The same timer /// closes a keep-alive connection that stays idle this long, so keep it @@ -2434,6 +2449,8 @@ impl Config { &self.server.allowed_hosts, ) .map_err(|e| ConfigError::Invalid(format!("server.allowed_hosts: {e}")))?; + crate::base_urls::BaseUrls::new(&self.public_base_url(), &self.server.alias_base_urls) + .map_err(|e| ConfigError::Invalid(format!("server.alias_base_urls: {e}")))?; self.validate_removed_auth_keys()?; self.validate_removed_retention_keys()?; // Eagerly validate the trusted CIDRs so a bad value fails at startup @@ -2789,6 +2806,16 @@ impl Config { without Secure; terminate TLS in front of Forge and use an https:// URL" )); } + for alias in self.base_urls().aliases() { + if !alias.url.starts_with("https://") { + warnings.push(format!( + "server.alias_base_urls entry {} is not https: clients that reach Forge \ + under it send their credentials in clear text; terminate TLS for it in \ + front of Forge and use an https:// URL", + alias.url + )); + } + } let plain_sts = self.s3.credentials == S3CredentialsMode::WebIdentity && self .s3 @@ -3213,6 +3240,19 @@ impl Config { .trim_end_matches('/') .to_string() } + + /// The public base URL and `server.alias_base_urls`, parsed + /// ([`crate::base_urls`]). [`Self::validate`] refused a configuration + /// whose aliases do not parse; one that skipped it is served on the + /// public base URL alone. + pub fn base_urls(&self) -> crate::base_urls::BaseUrls { + crate::base_urls::BaseUrls::new(&self.public_base_url(), &self.server.alias_base_urls) + .unwrap_or_else(|error| { + tracing::error!(%error, "server.alias_base_urls ignored"); + crate::base_urls::BaseUrls::new(&self.public_base_url(), &[]) + .expect("no aliases always parse") + }) + } } #[cfg(test)] @@ -4171,6 +4211,88 @@ mod tests { assert!(err.to_string().contains("server.allowed_hosts"), "{err}"); } + /// `MINIMAL` with `server.alias_base_urls = `. + fn with_aliases(list: &str) -> String { + MINIMAL.replace( + "public_base_url = \"https://forge.example.com/\"", + &format!("public_base_url = \"https://forge.example.com/\"\nalias_base_urls = {list}"), + ) + } + + #[test] + fn alias_base_urls_are_checked_at_startup() { + let cfg = Config::from_figment(fig_from_toml(MINIMAL)).expect("valid"); + assert!(cfg.server.alias_base_urls.is_empty()); + assert!(cfg.base_urls().aliases().is_empty()); + + let cfg = Config::from_figment(fig_from_toml(&with_aliases( + r#"["HTTPS://Nexus.Example.com/", "https://old.example.com:8443"]"#, + ))) + .expect("valid"); + let urls: Vec<_> = cfg + .base_urls() + .aliases() + .iter() + .map(|a| a.url.clone()) + .collect(); + assert_eq!( + urls, + ["https://nexus.example.com", "https://old.example.com:8443"] + ); + assert!(cfg.deployment_warnings().is_empty()); + + for (bad, why) in [ + (r#"["nexus.example.com"]"#, "absolute URL"), + (r#"["https://nexus.example.com/repository"]"#, "path"), + (r#"["https://nexus.example.com?x"]"#, "query"), + ( + r#"["https://nexus.example.com", "https://NEXUS.example.com:8443"]"#, + "twice", + ), + (r#"["https://forge.example.com"]"#, "server.public_base_url"), + ( + r#"["http://forge.example.com:8421/"]"#, + "server.public_base_url", + ), + ] { + let err = Config::from_figment(fig_from_toml(&with_aliases(bad))).unwrap_err(); + let err = err.to_string(); + assert!( + err.contains("server.alias_base_urls") && err.contains(why), + "{bad}: {err}" + ); + } + } + + #[test] + fn a_plain_http_alias_is_a_deployment_warning() { + let cfg = Config::from_figment(fig_from_toml(&with_aliases( + r#"["http://nexus.example.com:8081"]"#, + ))) + .expect("valid"); + let w = cfg.deployment_warnings(); + assert_eq!(w.len(), 1, "{w:?}"); + assert!(w[0].contains("http://nexus.example.com:8081"), "{w:?}"); + } + + #[test] + #[allow(clippy::result_large_err)] // figment::Jail's closure returns figment::Error. + fn alias_base_urls_are_set_by_env() { + figment::Jail::expect_with(|jail| { + jail.create_file("forge.toml", MINIMAL)?; + jail.set_env( + "FORGE_SERVER__ALIAS_BASE_URLS", + "[https://nexus.example.com, http://old.example.com:8081]", + ); + let cfg = Config::load(Some(std::path::Path::new("forge.toml"))).expect("valid"); + assert_eq!( + cfg.server.alias_base_urls, + ["https://nexus.example.com", "http://old.example.com:8081"] + ); + Ok(()) + }); + } + #[test] fn oidc_non_https_issuer_rejected() { let toml = minimal_with_oidc("").replace( diff --git a/crates/server/src/images/dispatch.rs b/crates/server/src/images/dispatch.rs index 80c77607..6ad430bc 100644 --- a/crates/server/src/images/dispatch.rs +++ b/crates/server/src/images/dispatch.rs @@ -228,9 +228,24 @@ impl Registry { /// Which host the request reached and the token realm and service that /// go with it. A host that names a repository of the images format - /// (`config.host`) serves that repository at the registry root. + /// (`config.host`) serves that repository at the registry root. An + /// alias host (`server.alias_base_urls`) is one of the server's own: + /// repositories by path, with the alias as token realm and service, as + /// on a repository's own host. It is checked first, so a repository + /// host equal to an alias (which the admin API and startup refuse) + /// never takes it over. pub(crate) async fn site(&self, host: Option) -> ImgResult { let public = &self.inner.config.public_base_url; + if let Some(alias) = self.inner.config.base_urls.alias(host.as_deref()) { + let (scheme, alias_host) = registry_origin(&alias.url); + return Ok(Site { + realm: format!("{scheme}://{alias_host}/v2/token"), + service: alias_host, + issuer: token::issuer(public), + base_url: alias.url.clone(), + host_repo: None, + }); + } let (scheme, public_host) = registry_origin(public); let host = host.map(|h| h.to_ascii_lowercase()); let host_repo = match &host { @@ -251,6 +266,7 @@ impl Registry { service, realm, issuer: token::issuer(public), + base_url: public.clone(), host_repo, }) } @@ -718,7 +734,7 @@ impl Conversation<'_> { }, base_url: format!( "{}{}", - self.reg.inner.config.public_base_url, + self.ctx.site.base_url, forge_plugin::api::IMAGES_MOUNT ), inline: false, @@ -1122,6 +1138,9 @@ pub(crate) struct Site { /// The token endpoint URL. pub realm: String, pub issuer: String, + /// The base URL the request is served on: the alias it addressed, else + /// `server.public_base_url`. + pub base_url: String, /// The repository this host serves (host-based routing). pub host_repo: Option, } diff --git a/crates/server/src/images/mod.rs b/crates/server/src/images/mod.rs index 174a7bd4..81a9b424 100644 --- a/crates/server/src/images/mod.rs +++ b/crates/server/src/images/mod.rs @@ -58,6 +58,9 @@ use token::TokenKeys; pub struct RegistryConfig { /// `server.public_base_url`, without a trailing `/`. pub public_base_url: String, + /// The public base URL and the aliases: a request on an alias host is + /// served with the alias as its token realm and service. + pub base_urls: Arc, /// Largest blob (layer) a proxy downloads from its upstream. pub max_blob_bytes: u64, /// Largest blob a push to a hosted repository may carry when the diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index b6d077e4..b0f7d594 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -16,6 +16,7 @@ pub mod admin; pub mod app; pub mod auth; +pub mod base_urls; pub mod browse; pub mod browse_images; pub mod cli_auth; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index ada10c33..f58b138a 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -172,6 +172,8 @@ async fn main() -> anyhow::Result<()> { .context("load the registry token key")?; let state = AppState::new(pool, blob_store, cache, config, envelope).with_registry_keys(registry_keys); + // No docker repository may have the host of an alias base URL. + forge_server::app::check_alias_hosts(&state).await?; let layer = principal_layer(&state)?; telemetry::install_live_source(&state); let serve_options = forge_server::serve::ServeOptions::from_config(&state.config); diff --git a/crates/server/src/middleware/principal.rs b/crates/server/src/middleware/principal.rs index 26222acf..06c1fbd1 100644 --- a/crates/server/src/middleware/principal.rs +++ b/crates/server/src/middleware/principal.rs @@ -57,12 +57,13 @@ //! `X-Forwarded-For` line behind the trusted proxies, with IPv4-mapped IPv6 //! addresses canonicalized ([`forge_core::auth::client_ip`]). The //! trusted-header realms additionally require the request's `Host` to name -//! this server (the host of `server.public_base_url` or an entry of -//! `server.allowed_hosts`): a DNS-rebinding page in an internal user's browser -//! reaches the server through the proxy that adds the headers, but under its -//! own host name, and the headers of such a request are ignored. Requests with -//! credentials of their own, the anonymous policy, and handlers that ignore -//! the principal (health probes), do not depend on the host. +//! this server (the host of `server.public_base_url`, of an entry of +//! `server.alias_base_urls`, or an entry of `server.allowed_hosts`): a +//! DNS-rebinding page in an internal user's browser reaches the server +//! through the proxy that adds the headers, but under its own host name, and +//! the headers of such a request are ignored. Requests with credentials of +//! their own, the anonymous policy, and handlers that ignore the principal +//! (health probes), do not depend on the host. //! //! ## Stale session cookies //! A session cookie that no longer names a live session is treated as absent @@ -86,6 +87,7 @@ use forge_core::session::{csrf_token_matches, SessionService}; use ipnet::IpNet; use crate::auth::AuthSettingsCache; +use crate::base_urls::{normalize_host, request_authority}; /// The header carrying the per-session double-submit CSRF token. pub const CSRF_HEADER: &str = "x-csrf-token"; @@ -152,14 +154,6 @@ impl From for AnonSource { } } -/// The lowercase host of an authority (`host[:port]`, IPv6 in brackets), -/// without port or trailing dot; `None` if it does not parse. -fn normalize_host(authority: &str) -> Option { - let authority: http::uri::Authority = authority.trim().parse().ok()?; - let host = authority.host().trim_end_matches('.').to_ascii_lowercase(); - (!host.is_empty()).then_some(host) -} - /// The host names a request may address for the trusted-header realms: /// the host of `public_base_url` plus each `extra` entry (`host` or /// `host:port`; the port is ignored — a rebinding page cannot present this @@ -185,14 +179,9 @@ pub fn allowed_hosts(public_base_url: &str, extra: &[String]) -> Result(req: &Request, allowed: &[String]) -> bool { - let host = match req.headers().get(header::HOST) { - Some(value) => value.to_str().ok().and_then(normalize_host), - None => req - .uri() - .authority() - .and_then(|a| normalize_host(a.as_str())), - }; - host.is_some_and(|host| allowed.contains(&host)) + request_authority(req) + .and_then(normalize_host) + .is_some_and(|host| allowed.contains(&host)) } /// Refuse a state-changing request a **browser** marked as cross-site. diff --git a/crates/server/src/plugins.rs b/crates/server/src/plugins.rs index 7bea6684..4b2d4bee 100644 --- a/crates/server/src/plugins.rs +++ b/crates/server/src/plugins.rs @@ -34,6 +34,9 @@ //! * **Responses** the plugin renders keep only the headers in //! [`PASSED_RESPONSE_HEADERS`] (no cookies, no CORS, no redirects or links //! off this repository) and get the feed's hardening headers. +//! * **URLs** the plugin renders start with the repository's absolute URL +//! ([`repository_url_for`]): on the alias base URL the request's `Host` +//! names (`server.alias_base_urls`), else on `server.public_base_url`. use std::collections::HashMap; use std::io::{Read, Write}; @@ -362,7 +365,9 @@ async fn serve( RepoKind::Proxy => protocol::RepositoryKind::Proxy, RepoKind::Group => protocol::RepositoryKind::Group, }, - base_url: repository_url(state, &repo.name), + // On the alias host the request addressed, if any: every URL the + // plugin renders, and the redirects it may return, stay there. + base_url: repository_url_for(state, crate::base_urls::request_authority(&req), &repo.name), inline: stores_files && repo.inline_content(), }; let request = protocol::Request { @@ -2104,12 +2109,24 @@ fn directory_page(page: crate::files::listing::Page) -> protocol::DirectoryPage } } -/// The absolute URL of repository `name`, the base its plugin builds URLs on. +/// The absolute URL of repository `name` on `server.public_base_url`, as the +/// web UI shows it (its connect snippets are a browser surface, which stays on +/// the public base URL). pub fn repository_url(state: &AppState, name: &str) -> String { - format!( - "{}/repository/{name}", - state.config.public_base_url().trim_end_matches('/') - ) + repository_url_on(state.base_urls.public(), name) +} + +/// The absolute URL of repository `name` as a request that addressed +/// `authority` is served it, the base its plugin builds URLs on: on the alias +/// the request's `Host` names, else on `server.public_base_url` +/// ([`crate::base_urls`]). +pub fn repository_url_for(state: &AppState, authority: Option<&str>, name: &str) -> String { + repository_url_on(state.base_urls.for_authority(authority), name) +} + +/// The absolute URL of repository `name` on `base` (no trailing `/`). +fn repository_url_on(base: &str, name: &str) -> String { + format!("{}/repository/{name}", base.trim_end_matches('/')) } /// See [`Ctx::within_repository`]. A path may also start with the path of diff --git a/crates/server/src/registry.rs b/crates/server/src/registry.rs index e5067ac6..85fa965b 100644 --- a/crates/server/src/registry.rs +++ b/crates/server/src/registry.rs @@ -92,9 +92,11 @@ impl crate::images::WorkloadCheck for WorkloadLiveness { /// A 401 the authentication middleware answered itself (wrong credentials) /// on a `/v2` path, made a registry error: the Bearer challenge clients -/// expect, and the images format's answer to invalid credentials. +/// expect, naming the token service of `base_url` (the public base URL, or +/// the alias the request addressed), and the images format's answer to +/// invalid credentials. pub(crate) fn amend_registry_rejection( - public_base_url: &str, + base_url: &str, images: &dyn FormatPlugin, response: Response, ) -> Response { @@ -103,7 +105,7 @@ pub(crate) fn amend_registry_rejection( { return response; } - let (scheme, service) = crate::images::registry_origin(public_base_url); + let (scheme, service) = crate::images::registry_origin(base_url); let challenge = format!("Bearer realm=\"{scheme}://{service}/v2/token\",service=\"{service}\""); let mut amended = crate::images::dispatch::rendered(images.image_error(&ImageError::InvalidCredentials)); diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 4dbaf4b4..86bef87b 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -59,6 +59,9 @@ pub struct AppState { pub formats: Arc, /// Parsed server configuration. pub config: Arc, + /// The base URLs client-protocol URLs are rendered on: the public one, + /// and the alias a request's `Host` names ([`crate::base_urls`]). + pub base_urls: Arc, /// DB-backed auth realms: the snapshot cache the password chain, the OIDC /// login runtime and the login UX endpoints are built from. pub realms: Arc, @@ -382,6 +385,7 @@ impl AppState { config.nuget.limits(), config.npm.limits(), )), + base_urls: Arc::new(config.base_urls()), config, realms, sessions, @@ -434,6 +438,7 @@ impl AppState { envelope: self.envelope.clone(), config: crate::images::RegistryConfig { public_base_url: self.config.public_base_url(), + base_urls: self.base_urls.clone(), max_blob_bytes: self.config.docker_max_blob_bytes(), default_max_upload_bytes: self .config diff --git a/crates/server/src/web_ui.rs b/crates/server/src/web_ui.rs index 1c5d2e6e..d3a4b259 100644 --- a/crates/server/src/web_ui.rs +++ b/crates/server/src/web_ui.rs @@ -44,11 +44,19 @@ //! The admin **UI** therefore lives under [`UI_ADMIN_PREFIX`] (`/manage/*`), //! not `/admin/*`: a UI route sharing the API's paths would answer a refresh, //! bookmark or deep link with raw JSON (or a 404/405). +//! +//! ## Alias hosts +//! The web UI is served on `server.public_base_url` alone: its session cookie +//! is bound to that host, and its sign-in and OIDC redirects go there. A +//! `GET` (or `HEAD`) of a UI path on an alias host (`server.alias_base_urls`, +//! [`crate::base_urls`]) is redirected (`302`) to the same path and query on +//! the public base URL, which covers bookmarks to the old Nexus UI. use std::path::PathBuf; +use std::sync::Arc; use axum::extract::{Request, State}; -use axum::http::{header, HeaderValue, StatusCode}; +use axum::http::{header, HeaderValue, Method, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Router; use base64::Engine as _; @@ -56,6 +64,7 @@ use sha2::{Digest, Sha256}; use tower::ServiceExt; use tower_http::services::{ServeDir, ServeFile}; +use crate::base_urls::BaseUrls; use crate::config::WebConfig; /// Path prefix of the admin UI in the SPA (the admin REST API keeps `/admin`). @@ -92,6 +101,8 @@ struct Spa { shell: ServeDir, /// `Content-Security-Policy` for every SPA response. csp: HeaderValue, + /// The public base URL a UI request on an alias host is redirected to. + base_urls: Arc, } /// Build the static SPA file service from [`WebConfig`]. @@ -108,6 +119,8 @@ struct Spa { /// links. /// * `/` serves `index.html` (ServeDir appends the directory index by default). /// * `Cache-Control` is set as described in the [module docs](self). +/// * A `GET` or `HEAD` of a UI path on an alias host of `base_urls` is +/// redirected to the public base URL ([module docs](self#alias-hosts)). /// /// We use [`ServeDir::fallback`] (not `not_found_service`): `fallback` serves /// the fallback's response **verbatim** (so a found `index.html` returns `200`), @@ -116,7 +129,7 @@ struct Spa { /// /// If `index.html` is missing (UI not built), the fallback yields a 404 — the /// API still works. -pub fn spa_service(cfg: &WebConfig) -> Router { +pub fn spa_service(cfg: &WebConfig, base_urls: Arc) -> Router { let assets_dir = PathBuf::from(&cfg.assets_dir); let index = assets_dir.join("index.html"); // A missing index.html (UI not built) only drops the inline-script hashes; @@ -128,6 +141,7 @@ pub fn spa_service(cfg: &WebConfig) -> Router { files: ServeDir::new(&assets_dir), shell: ServeDir::new(&assets_dir).fallback(ServeFile::new(index)), csp, + base_urls, }; Router::new().fallback(serve_spa).with_state(spa) } @@ -139,6 +153,10 @@ async fn serve_spa(State(spa): State, req: Request) -> Response { if API_PREFIXES.iter().any(|prefix| is_under(path, prefix)) { return (StatusCode::NOT_FOUND, "not found").into_response(); } + if matches!(*req.method(), Method::GET | Method::HEAD) && spa.base_urls.alias_of(&req).is_some() + { + return to_public(spa.base_urls.public(), &req); + } if path.starts_with(ASSETS_PREFIX) { let mut res = match spa.files.oneshot(req).await { Ok(res) => res.into_response(), @@ -167,6 +185,24 @@ async fn serve_spa(State(spa): State, req: Request) -> Response { } } +/// A `302` from a UI request on an alias host to the same path and query on +/// `public` (the public base URL, which may have a path of its own). +fn to_public(public: &str, req: &Request) -> Response { + let path_and_query = req.uri().path_and_query().map_or("/", |pq| pq.as_str()); + let target = format!("{}{path_and_query}", public.trim_end_matches('/')); + match HeaderValue::from_str(&target) { + Ok(location) => ( + StatusCode::FOUND, + [ + (header::LOCATION, location), + (header::CACHE_CONTROL, NO_CACHE), + ], + ) + .into_response(), + Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(), + } +} + /// The SPA's `Content-Security-Policy`, allowing the inline (`src`-less) /// scripts of `index_html` by their SHA-256 hashes and nothing else inline. /// @@ -232,7 +268,12 @@ mod tests { fs::write(dir.path().join("assets").join("a.js"), "ASSET").unwrap(); let mut cfg = WebConfig::default(); cfg.assets_dir = dir.path().to_string_lossy().to_string(); - let svc = spa_service(&cfg); + let base_urls = BaseUrls::new( + "https://forge.example.com/ui", + &["http://nexus.example.com:8081".to_string()], + ) + .unwrap(); + let svc = spa_service(&cfg, Arc::new(base_urls)); (dir, svc) } @@ -336,6 +377,54 @@ mod tests { assert!(inline_scripts("