Repository navigation
v1.1.0
The Docker / OCI registry, format plugins (Helm built in, NuGet ported to
one), the trusted-publishing token exchange, short-lived S3 credentials and a
local blob cache, and the fixes for the findings of the 1.0.0 release review.
The 1.0.0 known limitations still apply, except where an entry below says
otherwise.
Upgrade notes
- Startup refuses a
server.staging_dirthat does not belong to Forge's
user, or that group or others can write unless it is sticky like/tmp.
The container image and the Kubernetes manifests are not affected.
(#127) - Durations above 100 years (
outbox.grace_seconds,
retention.default_run_interval_seconds,retention.lease_seconds,
crypto.cache.unwrapped_key_ttl_secs,
crypto.rotation.dek_max_age_days,crypto.rotation.kek_max_age_days,
and the newdocker.upload_ttl_seconds,
docker.unreferenced_blob_ttl_secondsanddocker.sweep_interval_seconds)
fail validation instead of panicking later.
(#128) - Migration 0032 deletes grants of the
adminaction on a single
repository, which never had an effect, and the grants API refuses them
with 400. Administration stays global.
(#124) - While a credential-forwarding realm is enabled, an account that an OIDC
realm provisions is named<claim>@<realm>. Existing accounts keep their
names. (#123) - Proxy search now calls the upstream's search service, which may live on
another host than the feed (on nuget.org,azuresearch-*.nuget.org).
Where outbound traffic is restricted, allow that host, or proxy search
keeps answering from the cache only.
(#112) - The production kustomize overlay adds a NetworkPolicy that admits only
the ingress controller's namespace (set it in the overlay), and the Helm
chart renders one as soon asnetworkPolicy.fromlists a peer.
(#129) - Helm: the first upgrade rolls the Forge pods (when
secretEnvis set)
and restarts the in-cluster Redis once, because the checksum annotations
are now salted. (#133) - The published
cosign verifycommands also pin the tagged commit and the
push trigger; use the commands of the release you verify.
(#130) - Migration 0033 adds the Docker / OCI registry's tables; it changes no
existing data. To push images through a reverse proxy, allow request
bodies of a docker repository's upload limit (1 GiB by default: docker
sends a whole layer in one request) on/v2, and serve/v2at the root
of the host. The shipped Ingress now allows 1 GiB. The default
server.max_staging_bytes(four times the largest item) grows with it to
4 GiB; size the staging volume for it.
(#40) - An explicit
server.max_staging_byteskeeps working as it is. When it is
below the new 1 GiB docker defaults, they shrink to fit it (less
nuget.max_nuspec_bytes): docker repositories without an upload limit of
their own accept layers up to that size, and so do docker proxy
downloads unlessdocker.max_blob_bytesis set; startup logs a warning.
Raise the budget for larger layers. A budget too small for the package
limits is refused as before, with an error that names the setting that
decides the minimum. (#198) - The container image's baked
forge.tomlno longer holds the placeholder
minioadminS3 keys. An image started withoutFORGE_S3__ACCESS_KEY/
FORGE_S3__SECRET_KEY(and without short-lived credentials) still
refuses to start, now with an error that names the missing keys.
(#60) server.max_upload_bytesis deprecated: upload limits are now set per
hosted repository, in the web UI or the admin API. The setting still
works as the default of package repositories without a limit of their own,
and startup logs a warning while it is set; give the repositories that
need it their own limit, then remove it. A repository's limit may be at
mostserver.max_staging_bytes(lessnuget.max_nuspec_bytes); raise the
staging budget first if a repository needs more.- A workload identity realm's
max_token_age_secondsdefaults to 39600
(11 hours) instead of 3600, so GitLab jobs with a timeout over an hour
can publish. Existing realms keep the value they were saved with; to
use the new default, clear the field in the realm form (or drop the key
from the config) and save. - The username
oidcis reserved for workload identity (trusted
publishing), and 1.0.0 allowed it. An account namedoidccan no longer
sign in with its password, in the browser or over Basic, and its API
tokens work only asX-NuGet-ApiKey, no longer as the Basic password;
startup logs a warning that names it. A config with
auth.bootstrap_admin.username = "oidc"refuses to start. Rename the
account before upgrading; there is no API for it, so use SQL:
UPDATE app_user SET username = 'oidc-admin' WHERE username = 'oidc';
(the account keeps its id, password, roles and tokens). Then set
auth.bootstrap_admin.usernameto the new name if it wasoidc.
(#199) - Migration 0034 adds the
workload_api_keytable for the trusted-publishing
token exchange; it changes no existing data./api/v2/tokenis a new
route, so a reverse proxy that only forwards known prefixes needs/api.
(#169) cache.l1_max_capacitycounts an entry over 16 KiB once per started
16 KiB, so it bounds the in-process cache's memory as well as its entry
count (about 160 MiB of large entries at the default 10000). Small
entries count once, as before.
Features
-
Format plugins (preview): further package formats, served under
/repository/{repo}/(or a mount the plugin names) with the same
storage, RBAC and package selectors, hosted, proxy and group
repositories, retention, ranged downloads and audit log as NuGet. A
plugin is a crate implementing one contract (forge-plugin-api's
FormatPlugin, mirrored by theforge:format@0.3.0WIT package; a
plugin'supstreamside tells the host what to fetch for its proxies) and
runs either built into the server or as a WebAssembly component run by
Wasmtime. Components are loaded from[plugins]only when pinned by
SHA-256, and each call runs with fuel, memory and time limits and no WASI
capabilities. Helm chart repositories (plugins/helm) are built in:
index.yaml, chart downloads and ChartMuseum-compatible push, delete and
listing, and proxies of other chart repositories. The index lists chart
metadata with the types Helm reads, so a Chart.yaml withappVersion: 2
or an annotationfalsedoes not break it
(#206). A Helm proxy
parses its upstream's index once while the upstream serves the same
document, one parse at a time, instead of once per operation
(#207). Parsed indexes
are kept up to 128 MiB per instance, shared by all Helm proxies: about
60 MB ofindex.yaml, enough for every upstream of a typical group. Past
that, the indexes in use stay kept and only the others are parsed again
for each operation
(#279,
#284). NuGet is a built-in plugin too (plugins/nuget), served at
/v3/{repo}/by the same dispatcher with the same paths, responses and
proxy behaviour as before; its proxies read the upstream feed through the
plugin's upstream side.GET /admin/formatslists the installed
formats; the web UI offers them when creating a repository. See
docs/format-plugins.md. -
Docker / OCI registry under
/v2/(OCI Distribution Spec v1.1,
passing its conformance suite), for container images, Helm charts and
other OCI artifacts, as a native format next to NuGet ("format": "docker", listed byGET /admin/formats) with all three repository
kinds:- hosted: monolithic, chunked and cross-repository-mount pushes,
deletes, the referrers API for cosign signatures and attestations, and
optional immutable tags (with alatestexception); - proxy: a pull-through cache of Docker Hub (with its
library/
names), GHCR, Quay or any distribution-spec registry, with upstream
credentials, tag re-validation byHEADaftermetadata_ttl_seconds,
and stale serving while the upstream is down or rate limits; - group: members by priority, pushes to a
writable_member, and the
prefer_hosted/reserved_prefixes/ owner guards of NuGet groups.
docker logingoes through a Bearer token service (API tokens, passwords
where allowed, or a workload OIDC token), anonymous pulls follow the
repository's anonymous read, and repositories are addressed by path or by
an optional host name of their own. Image retention rules (keep the last N
tags, stale tags, untagged manifests; aHEADof a manifest counts as a
pull, #197) and a sweeper
release layers only when no image references them. The web UI lists images, tags, manifests,
platforms and referrers. New[docker]settings; see
docs/docker.md.
(#40) - hosted: monolithic, chunked and cross-repository-mount pushes,
-
Per-repository upload limits: every hosted repository (NuGet, Helm,
docker or a WebAssembly plugin format) has its own upload limit, the
largest push it accepts (docker: per blob), editable as Maximum upload
size in the web UI and asmax_upload_bytesin the admin API. Without
one, a repository accepts 512 MiB (docker 1 GiB per blob).
docker.max_blob_bytesnow bounds proxy downloads only. -
Browse by format:
GET /browse/repostakesformatand names each
repository's client URL; the web UI's Browse screen filters by format,
and NuGet and Helm repositories show theirdotnet nugetandhelm
commands (other plugin formats, their URL) like docker ones show theirs.
(#38) -
Short-lived S3 credentials from the platform's workload identity, so
no long-lived S3 key has to be stored or rotated.s3.credentials
(FORGE_S3__CREDENTIALS) isstatic(the default,s3.access_key/
s3.secret_keyas before),default_chain(the AWS SDK chain: IRSA, EKS
Pod Identity, ECS task roles, EC2 instance profiles,AWS_*variables),
orweb_identity: a token file exchanged with STS
AssumeRoleWithWebIdentityagainst AWS or MinIO / Ceph RGW
([s3.web_identity]:token_file,role_arn,sts_endpoint,
session_name,duration_seconds). Credentials are refreshed before they
expire and the token file is read again on each refresh. A failed fetch
names the credential source and the STS error code, never the token. The
short-lived modes refuse S3 keys, and startup warns about a plain-http
sts_endpoint(the token and the temporary keys would cross the network
in clear text). The Helm chart hass3.credentialsand
s3.webIdentity(with an optional projected service-account token), and
the kustomize components3-workload-identitymounts one. See
docs/deployment.md.
(#60) -
Trusted-publishing token exchange:
POST /api/v2/token(nuget.org's
path and request/response shape, soNuGet/loginworks with
token-service-url) exchanges a CI job's OIDC token, validated by a
workload_identityrealm, for a short-lived workload API key
(forge_wif.…). Each token is redeemed once (itsjtiis required), the
key lives for the realm's newapi_key_ttl_seconds(default an hour)
whatever the token's lifetime, it authorizes and is audited as the
token's workload, and any edit, disabling or deletion of the realm
revokes its keys for good.
dotnet nuget push --source <feed>/v3/<repo>/package --no-service-endpoint --api-key <key>publishes with the key alone; restore takes it as the
Basic password of the usernameoidc.
(#169) -
Reserved account names:
oidcis the workload-identity Basic
username. Basicoidc:<secret>is only ever checked as a workload
credential (an OIDC token or a workload key), never as a user token or a
password and never forwarded to a credential-forwarding upstream, and no
account can be created, bootstrapped or provisioned under the name (an
OIDC login whose username claim isoidcgetsoidc-2).
(#169) -
Proxy cache invalidation:
POST /admin/repositories/{name}/proxy-cache/invalidate, and Invalidate
cache on a proxy's page in the web UI, make a NuGet, Helm or docker proxy
ask its upstream again before its TTLs run out, for example once a package
it answered "not found" for is published upstream. The proxy forgets the
upstream's "not found" answers (of one package with{"package": "<id>"},
or of all) and the listings and documents it cached from the upstream, on
every instance; invalidating the whole repository also refreshes the
upstream's service index, and a docker proxy re-validates its tags (of one
image, or of all) on their next pull. Downloaded packages, blobs and
manifests stay cached, and so does the package-delay clock. -
Local blob cache (optional,
[blob_cache]): a copy of downloaded and
pushed blobs on a local disk, sized from the disk (max_bytes = "auto")
or fixed, with FIFO eviction that survives a restart. Hot downloads skip
S3, concurrent misses of one blob share a single S3 read, and cached
packages keep resolving while S3 is unreachable: uncached downloads and
pushes then answer 503 withRetry-Afterat once (a circuit breaker),
/readyzstays 200 and reports the object storedegraded, and an
instance starts without S3. With the cache, package downloads also answer
Rangerequests. Statistics atGET /admin/blob-cache; Kubernetes
componentslocal-cache(emptyDir) andlocal-cache-persistent(a
PersistentVolume). The cache directory must belong to Forge's user and be
writable by no one else (startup refuses it otherwise; a directory a
KubernetesfsGrouppass made group-writable is restored instead), and
its entries are readable by that user alone. The kustomize base and the
Helm chart setfsGroupChangePolicy: OnRootMismatch
(#195). See
docs/deployment.md.
Fixed
- Proxy and group search find packages nobody has restored through Forge
yet: a proxy forwards the search to its upstream's search service,
applies the caller's package filter, not-found markers and the package
delay, and answers from its cache when the upstream search fails. The
browse UI keeps listing what Forge holds.
(#112) - A proxy pointed back to its previous upstream within the metadata TTL
served empty version listings.
(#105) - A proxied package id's casing could flip between reads; it is the
newest version's, as on nuget.org.
(#108) - The package delay held back old unlisted upstream versions, such as ones
a lockfile pins, for the whole delay. They now count from when the
upstream created them, and old versions of large packages are dated at
their first sighting too.
(#111) - A push with a version longer than 64 characters answered 500; it is
refused with 400. (#134) - An offline nested group was still served through its outer group; it now
fails like an offline hosted member.
(#106) - When a group was reachable along two paths, a guard on one path hid
packages the other path admits.
(#107) - Downloads are counted in memory and written in batches every few
seconds. A burst of downloads of one version no longer queues on its row
and holds every database connection.
(#119) - Creating a missing S3 bucket failed on AWS outside us-east-1, and a
bucket name owned by another account counted as created.
(#113) - Unknown paths under
/admin,/auth,/browseand/v3answered 200
with the web UI's page; they answer 404.
(#121) PUT /admin/realms/orderanswered 500 when it raced another realm
change; it answers 409. (#117)- The browse API served offline hosted and group repositories; it answers
503 like the feed, and the web UI says the repository is offline.
(#120) - A failed OIDC sign-in ended on a plain-text page; it returns to the
login page with a message and a way to try again.
(#109) - Web UI: a proxy's version times are labelled "Cached", not "Published"
(#110); disabling a
sign-in realm asks first
(#104); unflagging a
group's last owner and removing a member ask first
(#122); the user list
shows how each user signs in
(#123).
Security
- Behind an outbound
HTTP(S)_PROXY, a proxy refuses an upstream host
name that resolves to a link-local or cloud-metadata address, and checks
every redirect hop the same way.
(#125) - Forged Redis invalidations can no longer grow every instance's memory:
the invalidation bookkeeping is bounded.
(#126) - The staging directory is private to Forge's user, and a staged package
is parsed and uploaded from the file that was hashed, not reopened by
name. (#127) - An OIDC-provisioned account can no longer take a credential-forwarding
user's username. (#123) - Only the ingress proxies can reach Forge when it trusts
X-Forwarded-Forfrom a pod network (NetworkPolicy, see above).
(#129) - Release: every
cosign verifypins the tagged commit
(#130); re-running a
release reuses what it already pushed instead of moving the tags to a
new build (#131); the
build helpers and the dependency images are pinned by digest
(#132); the Helm
checksum annotations are salted
(#133). - Docker registry tokens are HS256 JWTs bound to
public_base_url, live
fordocker.token_ttl_seconds(5 minutes), grant only the scopes the
caller held when they were issued, and are accepted under/v2only. A
token request names at most 32 repositories
(#200).
Every registry request is authorized again against current permissions
and content selectors, the user and API token behind a token must still
be active, and a browser session never pushes or deletes. The token
endpoint takes the same Basic credentials as the feeds, so it adds no new
password path. The signing key isdocker.token_secret, or a generated
key kept in the database, sealed when[crypto]is enabled and re-sealed
by DEK rotation; an instance replaces it only when it can never be opened,
and the others pick the new key up within 30 seconds
(#196). - Registry digests are verified on every upload and every proxied blob and
manifest. Blobs are linked per image: knowing a layer digest grants no
read on another image's copy, and a mount needs read on the source image.
A docker proxy sends upstream credentials only to itsremote_urlorigin
and the token realm the upstream's challenge names (over https when the
upstream is https), and dropsAuthorizationon a redirect to another
host. Registry responses carry the feeds' hardening headers and
Vary: Authorization, Cookie, Accept.
Documentation
- The deployment guide lists the S3 permissions and the PostgreSQL role
Forge needs, and an S3 delete that is denied names the missing
s3:DeleteObject. (#114)
Known limitations
- With a package delay, proxy search shows a version only once the proxy
has recorded it and the delay has passed, because a search answer names
no publish times. Totals of a proxy search are the upstream's, less the
ids dropped from the returned page; for a caller whose content selectors
hide packages, they count what it sees, plus one while more may follow. - Behind an outbound proxy, a host name that resolves differently for the
proxy than for Forge, or that only the proxy can resolve, still gets
past the metadata guard; block those addresses on the proxy as well. - Format plugins receive no request bodies other than the published
package (which must be the whole body or one multipart part), accept only
Basic credentials, and treat a version with a-as a prerelease; a
response of a plugin may load at most 2,048 query answers. Helm refuses a
Chart.yamlorindex.yamlwhose YAML aliases would add more than
100,000 nodes, or that nests deeper than 64 levels. Their proxies
read the upstream with plain GETs of whole documents (Basic credentials
only, no token exchange). - A docker proxy downloads a blob completely and verifies it before it
answers, so a client waits for a large layer's first fetch; concurrent
pulls of one layer share a download per instance only. A registry upload
of several chunks is assembled into a new blob when it finishes (one-chunk
uploads, what docker, podman and containerd send, are stored once). The
registry has no catalog API (/v2/_catalog) and no per-repository
connector ports.
Image
ghcr.io/schmitch/forge on GitHub Packages.
Built from commit cc1fbfd, which v1.1.0 points to. The cosign verify commands below accept only a signature by this repository's release workflow for that tag and that commit.
docker pull ghcr.io/schmitch/forge:1.1.0
# digest: sha256:8d3e49aee2c3af589efe2da8670a1f08569baf06e3ec58bec985c3cb245386c7
cosign verify ghcr.io/schmitch/forge@sha256:8d3e49aee2c3af589efe2da8670a1f08569baf06e3ec58bec985c3cb245386c7 \
--certificate-identity https://github.com/schmitch/forge/.github/workflows/release.yml@refs/tags/v1.1.0 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-github-workflow-sha cc1fbfd88e0670b22d7568e901e65ab01b3f0397 \
--certificate-github-workflow-trigger push
Helm chart
oci://ghcr.io/schmitch/charts/forge on GitHub Packages.
Deploys exactly the image above (by digest).
helm install forge oci://ghcr.io/schmitch/charts/forge --version 1.1.0
# digest: sha256:9d0b4afccd3ff6948e0dfa5cf930a50cac43b502eeddc5e427bf7f117cbf4336
cosign verify ghcr.io/schmitch/charts/forge@sha256:9d0b4afccd3ff6948e0dfa5cf930a50cac43b502eeddc5e427bf7f117cbf4336 \
--certificate-identity https://github.com/schmitch/forge/.github/workflows/release.yml@refs/tags/v1.1.0 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-github-workflow-sha cc1fbfd88e0670b22d7568e901e65ab01b3f0397 \
--certificate-github-workflow-trigger push