Repository navigation
Alias host names: serve the server under further hosts (the old Nexus host) with URLs rendered for that host - #720
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 whoseHostnames an alias gets the client protocols' absolute URLs on that alias. A request for any otherHostkeeps today's URLs onpublic_base_url. The cutover from Nexus is then: point the Nexus host name at Forge, list it as an alias, and clients keephttps://nexus.example.com/repository/<repo>/…unchanged.crates/server/src/base_urls.rs(new): parses the aliases and matches a request to one.BaseUrlsholds the public URL and the parsed aliases. It lives inAppState::base_urlsand is built once from the config.allowed_hostsmatches them (thenormalize_hosthelper moved here frommiddleware/principal.rs): case-insensitive, trailing dot dropped, port ignored. SoHost: old.example.commatcheshttp://old.example.com:8081even when the proxy (nginx$host) drops the port, and URLs keep the alias's own port.Hostor an HTTP/2 authority, neverX-Forwarded-Host.plugins.rs): the repository'sbase_urlis built byrepository_url_for(state, Host, name), on the alias the request named. The redirect checkwithinuses thatbase_url, so it accepts redirects on the alias too. What follows the alias:dist.tarball, and the web login'sdoneUrl. ItsloginUrlis the browser's approval page, which stays public.repository_url(public) is still used for the web UI's snippets (/browse/repos)./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.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.registry::amend_registry_rejection) follows the alias too.base_urlis{alias}/v2. ItsLocationheaders are relative already.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.GET/HEADof 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./admin,/auth,/browse,/v2,/repository, health) answer on an alias as on the public host.allowed_hostsin the principal layer, so a trusted-header realm names its users on an alias without listing it inserver.allowed_hosts. The issue's "anonymous allowlist" mode has since been removed; this guard now protects the trusted-header realms.hostmay not equal an alias:normalize_docker_hostrefuses one with a 400 that names the alias andserver.alias_base_urls;app::check_alias_hosts, called frommain), naming the repository and what to remove.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 fromforge.toml, so there is nothing to write.Docs
docs/deployment.md: a new section, Further host names (aliases). It covers:Hostkept by the proxy (X-Forwarded-Hostis not read), the certificate, and the Helm chart'shttpRoute.hostnames/certificate.dnsNames/ listener;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-apiRepository::base_url) and the NuGet protocol module doc:base_urlfollows 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 URLhttp://forge.testand aliaseshttps://nexus.testandhttp://old.test:8081; requests name a host withHost, as a proxy that keepsHostforwards them.old.testgets:8081. Onforge.test, and on an unlistedevil.test, they list only public URLs.dist.tarballis on the alias, or public for an unlisted host. The web login hasloginUrlon the public URL anddoneUrlon the alias./browse/reposasked on the alias still gives the public URL./v2/challenge per host (alias with its port, public, unlisted);NEXUS.test.,old.test) is 400 namingserver.alias_base_urls, and an update to one too; another host is fine;/admin/addresses.check_alias_hostswith that alias (in upper case) fails naming the repository. Another alias passes./,/manage/repositories?page=2and 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/meand/healthzon the alias are not redirected.allowed_hosts, the headers name the user onnexus.test,old.test:8081and the public host. Onrebind.attacker.examplethey do not (401).base_urls(normalization, refusals, matching likeallowed_hosts,Hostvs authority vsX-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).crates/e2e/tests/alias_hosts.rs(new): the server's public URL ishttp://forge.invalid:<port>, a name that never resolves (RFC 6761), and its alias ishttp://localhost:<port>. A client that followed any URL on the public host would fail, so a passing client made every request on the alias.StackBuildergainspublic_host()andalias_host();TestServer::base_urlstays the listener's address.Bearer realm="http://localhost:<port>/v2/token",service="localhost:<port>". Thendocker login --password-stdin,docker importof a one-file rootfs,push,rmi,pull(same image id) andlogout, with a freshDOCKER_CONFIG. As a check of the check, I ran it once with the alias removed:docker loginfailed withlookup forge.invalid … no such host.nuget.config. The service index on the alias names only the alias. Thendotnet pack,dotnet nuget push --source forge, anddotnet restore --no-cache --force-evaluateof a consumer.helm registry login --plain-http,helm create/package,helm push oci://localhost:<port>/<repo>/charts, andhelm pullof the private chart, which must equal the pushed bytes.dotnet_smoke. The helm test is also skipped when helm has noregistry login --plain-http.Checks run
On the shared build machine, with
SQLX_OFFLINE=true, line-tables-only debug info andCARGO_BUILD_JOBS=2. The branch is rebased onmainafter #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_uialso 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 fromsetup-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.pnpm install --frozen-lockfile,pnpm run tsc,pnpm run test(41 files, 548 passed),pnpm run build.Notes
helm repo addandhelm pullof a private chart without--pass-credentials. That was written for the Helmindex.yamlrepositories Forge 1.x had. Since 2.0.0 Forge keeps Helm charts as OCI artifacts in docker repositories and serves noindex.yaml, so there is nohelm repo addto test. The equivalent path is tested instead:helm registry login/push/pullthrough the alias. Helm fetches its token from the realm the challenge names, so it never needs the public host.curlscripts and the admin API keep working there.doneUrlfollow the alias.Location/Linkheaders are relative (/v2/…).verification_uriand its discovery document stay public, as browser surfaces.httpRoute.hostnamesandcertificate.dnsNames, and the docs say to list the alias there.oci-e2e.Generated by Claude Code