Skip to content

Alias host names: serve the server under further hosts (the old Nexus host) with URLs rendered for that host - #720

Merged
schmitch merged 1 commit into
mainfrom
schmitch/alias-base-urls-312
Oct 31, 2026
Merged

schmitch merged 1 commit into
mainfrom
schmitch/alias-base-urls-312

Conversation

@schmitch

Copy link
Copy Markdown
Member

Fixes #312

The change

A new setting, server.alias_base_urls, lists further base URLs the server is reached under, for example ["https://nexus.example.com"]. A request whose Host names an alias gets the client protocols' absolute URLs on that alias. A request for any other Host keeps today's URLs on public_base_url. The cutover from Nexus is then: point the Nexus host name at Forge, list it as an alias, and clients keep https://nexus.example.com/repository/<repo>/… unchanged.

  • crates/server/src/base_urls.rs (new): parses the aliases and matches a request to one.
    • BaseUrls holds the public URL and the parsed aliases. It lives in AppState::base_urls and is built once from the config.
    • Hosts are matched the way allowed_hosts matches them (the normalize_host helper moved here from middleware/principal.rs): case-insensitive, trailing dot dropped, port ignored. So Host: old.example.com matches http://old.example.com:8081 even when the proxy (nginx $host) drops the port, and URLs keep the alias's own port.
    • The host comes from Host or an HTTP/2 authority, never X-Forwarded-Host.
  • Format plugins (plugins.rs): the repository's base_url is built by repository_url_for(state, Host, name), on the alias the request named. The redirect check within uses that base_url, so it accepts redirects on the alias too. What follows the alias:
    • NuGet: the service index and registrations.
    • npm: packument dist.tarball, and the web login's doneUrl. Its loginUrl is the browser's approval page, which stays public.
    • Raw: its links and redirects are already relative.
    • repository_url (public) is still used for the web UI's snippets (/browse/repos).
  • Nexus-compatible repository URLs: serve every format's repositories under /repository/<repo>/ #311 / Serve every repository at /repository/{name}/, with no format in the URL (breaking) #438: Nexus-compatible repository URLs: serve every format's repositories under /repository/<repo>/ #311 was closed as superseded by Serve every repository at /repository/{name}/, with no format in the URL (breaking) #438, which made /repository/{repo}/ the only prefix of every non-Docker repository. The alias base URL is therefore {alias}/repository/{repo}: the prefix the request used, since there is only one.
  • Registry (images/dispatch.rs): on an alias host, site() makes the alias the token realm and service (https://nexus.example.com/v2/token, service="nexus.example.com"), as on a repository's own host. Path routing works as on the public host.
    • The token's audience is the service, so a token issued on one host is refused on another, as for repository hosts today.
    • The alias is checked before the repository-host lookup.
    • The middleware's 401 challenge (registry::amend_registry_rejection) follows the alias too.
    • The format's base_url is {alias}/v2. Its Location headers are relative already.
  • Browser surfaces stay on public_base_url: the web UI, sign-in, OIDC and device-login URLs, cookies, the CSRF origin, email links, the npm approval page and the connect snippets.
    • A GET/HEAD of a web UI path on an alias host is redirected (302, no-cache) to the same path and query on the public URL (web_ui.rs), which covers bookmarks to the old Nexus UI.
    • The APIs (/admin, /auth, /browse, /v2, /repository, health) answer on an alias as on the public host.
  • DNS-rebinding guard: the alias hosts join allowed_hosts in the principal layer, so a trusted-header realm names its users on an alias without listing it in server.allowed_hosts. The issue's "anonymous allowlist" mode has since been removed; this guard now protects the trusted-header realms.
  • A docker repository host may not equal an alias:
    • normalize_docker_host refuses one with a 400 that names the alias and server.alias_base_urls;
    • startup fails while a stored repository host equals an alias (app::check_alias_hosts, called from main), naming the repository and what to remove.
  • Config validation: startup refuses a malformed alias, a non-http(s) scheme, a path, query, fragment or credentials, a host listed twice (ports ignored), and the host of public_base_url. A plain-http alias is a deployment warning, like a plain-http public URL. FORGE_SERVER__ALIAS_BASE_URLS=[https://nexus.example.com] works (tested).

API and Web UI

  • GET /admin/addresses (new, read-only): {"public_base_url": …, "alias_base_urls": [...]}. The values come from forge.toml, so there is nothing to write.
  • Admin → System gains an Addresses panel. It shows the public URL and the aliases (or "None", with where to set them), and in a sentence what follows an alias and what stays on the public URL.
  • Admin → Repositories:
    • The registry host's help text says a host may not be one of the server's own addresses (Admin → System).
    • The refusal of an alias host shows inline like the existing host conflict. The existing web test is parameterized over both refusals.

Docs

  • docs/deployment.md: a new section, Further host names (aliases). It covers:

    • why an alias is needed;
    • the setting and its env form;
    • matching, and what follows the alias;
    • Host kept by the proxy (X-Forwarded-Host is not read), the certificate, and the Helm chart's httpRoute.hostnames / certificate.dnsNames / listener;
    • the trusted-header guard, the docker host rule, and the browser surfaces.

    The trusted-header and reverse-proxy bullets mention aliases.

  • docs/docker.md: Names gains On an alias, and a paragraph on token audiences.

  • docs/nexus-import.md: a cutover paragraph.

  • docs/format-plugins.md, the plugin API docs (plugin-api Repository::base_url) and the NuGet protocol module doc: base_url follows the alias.

  • forge.toml.example, DEVELOPMENT.md's config table: the setting.

  • Changelog: docs/changes/2.2.0/312.feature.md.

Tests

  • crates/server/tests/alias_base_urls.rs (new, 7 tests). The server has public URL http://forge.test and aliases https://nexus.test and http://old.test:8081; requests name a host with Host, as a proxy that keeps Host forwards them.
    • NuGet: a push through the alias. The service index and the registration index list only URLs on the alias, whatever the case, trailing dot or port. old.test gets :8081. On forge.test, and on an unlisted evil.test, they list only public URLs.
    • npm: a publish through the alias. dist.tarball is on the alias, or public for an unlisted host. The web login has loginUrl on the public URL and doneUrl on the alias. /browse/repos asked on the alias still gives the public URL.
    • Registry:
      • the /v2/ challenge per host (alias with its port, public, unlisted);
      • the middleware's 401 on the alias;
      • token, monolithic blob pushes, manifest put/get by path through the alias;
      • the alias's token refused on the public host and on the other alias, the public token refused on the alias, the public token working on the public host.
    • Admin API:
      • create with an alias host (also NEXUS.test., old.test) is 400 naming server.alias_base_urls, and an update to one too; another host is fine;
      • /admin/addresses.
    • Startup: a repository host stored before the alias, then check_alias_hosts with that alias (in upper case) fails naming the repository. Another alias passes.
    • Web UI: /, /manage/repositories?page=2 and a deep link on the alias give 302 to the public URL with path and query. They are 200 on the public host and on an unlisted host. /admin/me and /healthz on the alias are not redirected.
    • Trusted-header realm: with no allowed_hosts, the headers name the user on nexus.test, old.test:8081 and the public host. On rebind.attacker.example they do not (401).
  • Unit tests:
    • base_urls (normalization, refusals, matching like allowed_hosts, Host vs authority vs X-Forwarded-Host);
    • config (validation messages, the http warning, the env var);
    • web_ui (the redirect with a public URL that has a path, the API 404, other hosts served);
    • admin::repositories (the alias refusal).
  • e2e, crates/e2e/tests/alias_hosts.rs (new): the server's public URL is http://forge.invalid:<port>, a name that never resolves (RFC 6761), and its alias is http://localhost:<port>. A client that followed any URL on the public host would fail, so a passing client made every request on the alias. StackBuilder gains public_host() and alias_host(); TestServer::base_url stays the listener's address.
    • docker: the challenge is exactly Bearer realm="http://localhost:<port>/v2/token",service="localhost:<port>". Then docker login --password-stdin, docker import of a one-file rootfs, push, rmi, pull (same image id) and logout, with a fresh DOCKER_CONFIG. As a check of the check, I ran it once with the alias removed: docker login failed with lookup forge.invalid … no such host.
    • dotnet: a private feed with source credentials in nuget.config. The service index on the alias names only the alias. Then dotnet pack, dotnet nuget push --source forge, and dotnet restore --no-cache --force-evaluate of a consumer.
    • helm: helm registry login --plain-http, helm create/package, helm push oci://localhost:<port>/<repo>/charts, and helm pull of the private chart, which must equal the pushed bytes.
    • Each test is skipped (and passes) when its client is missing, like dotnet_smoke. The helm test is also skipped when helm has no registry login --plain-http.

Checks run

On the shared build machine, with SQLX_OFFLINE=true, line-tables-only debug info and CARGO_BUILD_JOBS=2. The branch is rebased on main after #716/#717.

  • cargo test -p forge-server --test alias_base_urls: 7 passed (before and after the rebase).
  • cargo test -p forge-server --test registry --test trusted_header --test web_ui --test npm: 47 + 11 + 2 + 16 passed (web_ui also after the rebase).
  • cargo test -p forge-server --lib: 267 passed after the rebase (262 before).
  • cargo test -p forge-e2e --features forge-e2e --test alias_hosts: 3 passed. The docker test ran for real (Docker 29.9.2). The dotnet and helm tests were skipped: neither client is installed on this machine, and I was not permitted to install them. They are written against the documented CLI and run in CI's e2e job, whose runner has docker and helm, plus dotnet from setup-dotnet.
  • cargo clippy -p forge-server --all-targets -- -D warnings, cargo clippy -p forge-e2e --features forge-e2e --all-targets -- -D warnings, cargo clippy -p forge-plugin-api -p forge-format-nuget --all-targets -- -D warnings: clean.
  • cargo fmt --all -- --check, cargo xtask changelog --check, cargo xtask docs --check: clean.
  • Web: pnpm install --frozen-lockfile, pnpm run tsc, pnpm run test (41 files, 548 passed), pnpm run build.

Notes

  • Helm acceptance criterion: the issue asks for helm repo add and helm pull of a private chart without --pass-credentials. That was written for the Helm index.yaml repositories Forge 1.x had. Since 2.0.0 Forge keeps Helm charts as OCI artifacts in docker repositories and serves no index.yaml, so there is no helm repo add to test. The equivalent path is tested instead: helm registry login/push/pull through the alias. Helm fetches its token from the realm the challenge names, so it never needs the public host.
  • Decisions:
    • Port-insensitive matching: an alias's host must be unique among the aliases and differ from the public host, ports aside. Two aliases on one host with different ports, or an alias on the public host with another port, are refused as duplicates.
    • Startup fails rather than warns on a repository host equal to an alias. A deterministic error beats a silent precedence rule. The registry still checks aliases first, should a host slip in (for example through an instance without the alias).
    • Only the web UI is redirected. The APIs answer on an alias, so curl scripts and the admin API keep working there.
  • Other formats checked:
  • Not done here:
    • The Helm chart's templates are unchanged: they already take extra httpRoute.hostnames and certificate.dnsNames, and the docs say to list the alias there.
    • Not run here: the rest of the server integration suite, the other e2e scenarios, Playwright, and CI's oci-e2e.

Generated by Claude Code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6DJc3No8xu6rxoMc3pFJ
@schmitch
schmitch added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit f096978 Oct 31, 2026
19 checks passed
@schmitch
schmitch deleted the schmitch/alias-base-urls-312 branch October 31, 2026 15:18
schmitch pushed a commit that referenced this pull request Oct 31, 2026
Resolves the conflict in docs/nexus-import.md with #720: the alias host
cutover paragraph stays in the introduction, the new "Raw" section follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6DJc3No8xu6rxoMc3pFJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serve the server under additional host names (e.g. the old Nexus host) with URLs rendered for that host

2 participants