github getnora-io/nora v1.4.0

3 hours ago

[1.4.0] - 2026-10-19

Added

  • Hugging Face Hub pull-through proxy (#579) — opt-in with NORA_HF_ENABLED=true ([hf] enabled = true); NORA_REGISTRIES_ENABLE=all leaves it off. With HF_ENDPOINT=http://nora:4000/hf, hf download, hf_hub_download and snapshot_download fetch public models through NORA: model info, revisions and tree listings on every request, and files through the Hub's redirects, which NORA follows itself so no client request leaves for the Hub or its CDN — only to the upstream's origin and, over https, to its CDN hosts ([hf] cdn_hosts, default hf.co and huggingface.co); a redirect elsewhere is a 502. Files are cached per repository by the digest the Hub names them by (the sha256 of an LFS file, the git blob sha1 of a regular one): every file is checked against that digest on the way in, with its last chunk held back until it matches, and a weight file that does not change across commits is downloaded once. The Hub's answer that names the commit is asked on every download, so main never goes stale. Xet is never proxied or advertised: xetHash is removed from metadata and Xet headers and links never reach the client, so downloads stay on plain HTTP. Curation names a download by its repository id and its commit, and an alias (gpt2) by its canonical id too (openai-community/gpt2); curation.min_release_age with hf enabled is refused at start. Cached files count toward [gc] proxy_cache_max_bytes, and retention rules that name hf prune them (a * rule does not). Measured with huggingface_hub 0.36.0, 1.0.0, 1.33.0, 2.0.0, 2.1.1 and 2.2.0. Not in this release: datasets (501), private and gated repositories (a client's token is never sent upstream), uploads, and serving while the Hub is unreachable.

Changed

  • Reproducible builds: --locked everywhere, Swagger UI vendored (#1104) — the Dockerfile dropped the fuzz workspace member with sed, which rewrote Cargo.lock and meant no build could pass --locked, so a Cargo.toml change merged without its lock update was re-resolved silently at image build time; the fuzz manifest is now copied (corpus stays out) and every cargo build, clippy, test and tarpaulin run in the Dockerfile and CI passes --locked. utoipa-swagger-ui now uses the vendored feature: the Swagger UI bundle served at /api-docs comes from a crates.io crate checksummed in Cargo.lock instead of a build-time GitHub download with no digest check. Contributed by @clain23.
  • Swagger UI served from files in the repository (#1125) — /api-docs served Swagger UI through utoipa-swagger-ui, whose build unpacks the release zip with the zip crate; on 2026-10-16 every zip release but 9.0.3 was yanked, so anything that resolves dependencies afresh failed, cargo install nora-registry without --locked among them. NORA now serves the same Swagger UI 5.17.14 files itself, byte for byte as the swagger-ui-dist package ships them, under the same paths and content types; utoipa-swagger-ui, zip and eight more packages leave the build. The source maps and unused bundles embedded before are gone, and the release binary is about 9.5 MB smaller.

Deprecated

  • curation.mode left at its default with proxy registries enabled is deprecated (#1135) — such a configuration serves every upstream package unchecked. NORA still starts, but now logs a warning on every start naming the proxy registries, and nora_config_deprecated{reason="curation_mode_implicit"} is 1 so an alert can see it. From 1.5.0 it stops at startup: set curation.mode (NORA_CURATION_MODE or [curation] mode) to off to keep serving upstream packages unchecked, or to audit or enforce. An explicit off logs that upstream packages are served unchecked.

Fixed

  • Dashboard uptime shows days (#1028) — a registry up for a week read 171h 12m because the uptime formatter only ever printed hours and minutes; it now adds a day component when there is one (7d 3h 12m) and leaves it off below a day (0h 12m), with the day/hour/minute units localized alongside the rest of the dashboard. The 5-second live-refresh script recomputes the same value with the same localized units and the same arithmetic, so the server-rendered and polled displays no longer disagree. Fixed by @0x62ash.
  • Keep reachable OCI referrers during Docker GC (#1079) — follow repository-scoped index and subject graphs, retain Docker schema 1 and OCI artifact blobs, and skip the Docker sweep when a present manifest cannot be read or parsed (the GC CLI then exits nonzero). A child an index lists but storage does not hold — a platform a pull-through cache never fetched — does not stop collection, and a blob stays protected within the grace window from the moment it is mounted into a repository until its referencing manifest lands. Fixed by @mludolph.
  • A per-registry min_release_age applies without a global one (#1098) — the minimum-release-age filter was built only when curation.min_release_age was set, so [curation.npm] min_release_age = "3d" (or NORA_CURATION_<REGISTRY>_MIN_RELEASE_AGE for npm) on its own was dropped without a word: enforce mode refused to start with "no active control is configured", and with any other control NORA started and served packages younger than the configured age. The filter is now built when the global value or any per-registry value is set; the global value is the default for registries without their own, and a registry's own value wins in its registry. At startup a per-registry value is an active control in enforce mode when it applies; on an enabled proxy registry it needs a quarantine of that registry — curation.<registry>.quarantine or the global curation.quarantine — since a quarantine on another registry holds none of its downloads. A value that could never apply is refused naming its key: rpm and deb downloads are not checked by name or release date, and cpan downloads carry no release date. A value on a disabled registry is reported as unused and is not counted as a control. Every startup rule on min_release_age — this one, the global one (#741) and the Hugging Face one — now judges the registries the server mounts, however they are enabled (NORA_REGISTRIES_ENABLE, [registries].enable or a registry's own flag): before, they read the per-registry flags before the enable-set was applied, so a registry left out of the set was judged as enabled and one added by it as disabled, and NORA_REGISTRIES_ENABLE=all,hf with a global min_release_age started. NORA_HF_ENABLED, NORA_RPM_ENABLED, NORA_DEB_ENABLED and NORA_CPAN_ENABLED beside an enable-set are now warned about like the other per-registry flags. Configurations that start on 1.3.6 and stop on this release, each naming the key: a min_release_age for rpm, deb or cpan; a per-registry value on an enabled proxy registry without a quarantine of that registry (1.3.6 ignored such a value without a global one, and beside a global one accepted a quarantine on any registry); a global min_release_age without a quarantine while an enable-set mounts proxy registries whose own flags are off (NORA_REGISTRIES_ENABLE=all with [npm] enabled = false and the like); a global one with Hugging Face mounted by all,hf; and curation.require_integrity without curation.allowlist_path — the allowlist is its only reader, so it held nothing, and in enforce mode it counted as the one active control. One starts that 1.3.6 refused: a global value with only hosted registries mounted, raw among them, whose uploads it holds by their own date. With only a global value, curation explain and the startup log read as before. Hugging Face has no curation section of its own, so a setting for it — [curation.hf] in the config file, or NORA_CURATION_HF_MIN_RELEASE_AGE, NORA_CURATION_HF_QUARANTINE or NORA_CURATION_HF_QUARANTINE_TTL — stops startup with the reason instead of being ignored; Hugging Face models are held by repository id and commit in the allowlist and blocklist.
  • A scheduled GC or retention pass that takes files out of the store (orphans, metadata phantoms, files evicted by [gc] proxy_cache_max_bytes, retention keys) marks the repository indexes stale, so the dashboard and /api/ui/stats count what is on disk right after the pass. They used to count the deleted files until a restart or the next write to that registry. A dry run changes nothing. nora_artifacts_total and the per-registry nora_storage_bytes were set every 60 seconds from the repository index without rebuilding it, so after a restart they read 0, and after a write or a GC/retention pass they kept the old numbers until someone opened the dashboard or /api/ui/stats. The 60-second update now first rebuilds the index of each enabled registry that a restart, a write or a cleanup pass marked stale — one listing of that registry — and then sets the gauges; a registry nothing touched is read from memory with no storage request. The GC scheduler: done line counted only orphans in its deleted and bytes freed; it now also names the files and bytes evicted by [gc] proxy_cache_max_bytes, separately. A scheduled retention pass with dry_run = true wrote a retention-apply entry to audit.jsonl although it deleted nothing; it now writes retention-dry-run with the bytes an applied pass would free, and a pass that deletes still writes retention-apply. A dry GC pass logs what it would free and evict instead of "bytes freed".
  • Docker tag list no longer shows pull-statistics files as tags (#1132) — once a pulled image's download count had been saved, GET /v2/<name>/tags/list returned a phantom tag next to the real one ({"tags":["1.0.0","1.0.0.stats"]}), because the tag listing skipped .meta.json files but not the .stats.json file that holds the pull count. Every place that lists Docker tags (the tags API, delete by digest, retention, the web UI and the repository index) now asks one check: a file under manifests/ is a tag only if its name is a valid tag and it is not a metadata or statistics file. Pull counts are still read from the statistics file as before; tags such as a.meta.b, which were hidden by the old filter, are now listed, and a sha512: digest copy is no longer counted as a version in the UI.
  • A Hugging Face download no longer decides curation for the same commit twice (#1133) — when a download found the file in the cache just as another download was committing it (the file was already in place but its integrity pin was not yet recorded), NORA correctly fetched the file from the upstream again, but it ran the curation check for that file's commit a second time on the second fetch, so the curation allowed counter and the audit log recorded one extra decision for that one download. A commit that curation has already let through for a request is now not decided again during that request; the repository and the commit are each decided once, whether the file is served from the cache, fetched from the upstream, or fetched again after an unreadable cached copy.
  • npm retention keeps the surviving versions of a proxied package installable (#1139) — a keep_last or older_than_days rule that removed a cached tarball of a proxied package rebuilt its packument from the per-version documents that only published packages have, so the packument came back with versions: {}; with the upstream unreachable, npm install of the version retention kept failed with ENOVERSIONS. Retention now edits the cached upstream packument instead: the versions it removed are dropped, every other version keeps its upstream document (dist.tarball, shasum, integrity), a dist-tag pointing at a removed version is dropped and latest is re-pointed at the highest version left, and the stored upstream validators are cleared so the next revalidation fetches the full document. A package with published versions is regenerated from them as before, including when it also has tarballs cached from the upstream.
  • npm release age no longer restarts at every packument refetch (#1140) — with min_release_age set and upstream dates not trusted (NORA_TRUST_UPSTREAM_DATES=false, the default), an npm version's age was the modification time of the cached packument, and every refetch after NORA_NPM_METADATA_TTL (300 s by default) rewrote that file — on a 304 too. A package that clients asked for more often than the minimum age never matured: npm install was refused with "package is 0h old" indefinitely. NORA now records, per package, when it first saw each version listed in an upstream packument, and the age of a version counts from that moment: a refetch, 200 or 304, keeps it, and a version the upstream adds later starts at zero. The record sits in storage next to the cache (local and S3 alike), survives restarts, and is carried by nora backup and nora migrate; a packument cached before the upgrade starts the versions it lists at its last write time. A version no fetched packument has listed is judged as before, and the quarantine first-seen clock and trusted upstream dates are unchanged.
  • A delete on local storage no longer strips the pin of a copy written at the same moment (#1144) — a delete removed the file and then, after an await, its integrity pin, so a write of the same key that landed between the two steps lost the pin it had just recorded and the new copy was stored unpinned: the Hugging Face cache fetched it again, and other readers served it without checking it. The delete now holds the key's commit lock across both steps, as a write does. Object storage keeps the pin with the object and was not affected.
  • A Docker client prints the reason of a curation refusal (#1151) — on /v2/ a curation refusal answered 403 with NORA's refusal body only, which Docker clients cannot read: docker pull printed error parsing HTTP 403 response body: no error details found and the reason was visible only inside a raw dump. The refusal now also carries the OCI distribution error the protocol requires ({"errors":[{"code":"DENIED","message":"blocked by policy: <reason>","detail":…}]}), so docker pull and skopeo print denied: blocked by policy: …. Every field of the refusal body and the X-Nora-* headers stay where scripts read them; other registries' bodies do not change.

Security

  • A published crate's sparse index is never replaced by an upstream's (GHSA-5pr2-xx8w-79jc) — with cargo.proxy set (the default, crates.io), a crate published to NORA had its index fetched from the upstream again once it was older than cargo.metadata_ttl (300 s by default), and the answer cached over it: a crate of the same name on the upstream replaced the published one for every client. A crate with versions published here is now served only from its own entries, and no upstream is asked for it; a download of a version it does not have is 404, and nothing is fetched or stored (an upstream's bytes stored beside the published ones were listed as a version, and a keep_last retention rule could delete the published version in their favour). Copies an older release already stored beside a published crate are left for the operator to delete. NORA keeps its copy of an upstream's index at a key of its own, so publishing a name the upstream also has goes through and never imports the upstream's versions. Action may be needed: a first cargo publish over a stored index that an older release left without per-version entries beside it — an upstream crate it cached, or a crate published to NORA before per-version entries — is refused with 409 naming the key. If it is a cache, delete the key and publish again; if it is your own crate, set NORA_CARGO_IMPORT_EMBEDDED=<name> (comma-separated): NORA then serves that stored index as the crate's own and imports it at the next publish. Rollback to 1.3.x: published crates keep working; an upstream crate first fetched by 1.4.0 is fetched again by 1.3.x (its copy lives at a key 1.3.x does not read); do not run 1.3.x retention with a Cargo rule on the store, because it deletes the per-version entries (the defect fixed above).
  • - and _ name one Cargo crate, as on crates.io (GHSA-5pr2-xx8w-79jc) — with my-crate published to NORA, a dependency written my_crate was looked up under that exact spelling, found nothing local and was fetched from the upstream, where anyone could have registered it. Another spelling of a published crate is now refused (404 on the index and on downloads, naming the published spelling) and never asked of an upstream, and publishing it is a 409. NORA records each crate's spelling under cargo/_names/ once its first publish succeeds; for crates published before 1.4.0 the records are written at startup, before the server accepts requests (one listing of the published entries), and GC removes a record with its crate. Where both spellings of a name were already published, each keeps serving and accepting its own versions; a record naming a spelling that is no longer published decides nothing, and a published spelling of the name is looked up instead. NORA no longer asks an upstream for the release dates of a name it holds a record for. Multiple replicas on one store: the check that two spellings published at the same moment cannot both pass is per process. Rollback to 1.3.x: the records are ignored; nothing else changes.
  • Retention and GC no longer delete Cargo data they misread (GHSA-5pr2-xx8w-79jc) — with a keep_last rule for Cargo (or for *), retention read the per-version index entries under cargo/index-entries/ as versions of a crate named index-entries and deleted all but the newest: published versions dropped out of the sparse index at its next rebuild. Retention now counts only .crate files as versions. GC took the .crate files of one- and two-letter crate names for orphans (their index sits one directory higher) and deleted them; it now reads every index key of a crate, whatever the name's length.
  • A pushed Docker tag answers its tag, also after a pull through the proxy (GHSA-5pr2-xx8w-79jc) — a tag that had been pulled through an upstream before a local push kept answering with the upstream's manifest: the proxied copy was read first and the push did not remove it. A push now records itself in the tag's metadata and removes the proxied copy of that tag; a tag with a push record is answered by the pushed manifest only — also while a re-push of it is being written — and curation dates it by the push; nora migrate-docker-keys leaves a repository with a recorded push where it is. Action may be needed: a tag pushed before 1.4.0 over a tag that was already proxied stays shadowed by the proxied copy until it is pushed again.
  • A Docker manifest requested by digest has that digest (GHSA-5pr2-xx8w-79jc) — a manifest fetched from an upstream for a digest reference was served and cached under the requested digest without comparing it with the bytes received, and a push by digest stored whatever bytes it carried. An upstream answer with another digest now counts as a failed upstream (the next upstream is tried, nothing is cached), a push by digest with other bytes is refused with 400 DIGEST_INVALID, and a stored copy that does not hash to its digest is neither served nor counted as cached.
  • Deleting a Docker manifest that cannot be read removes nothing (GHSA-5pr2-xx8w-79jc) — a delete of a pushed tag whose metadata could not be read (a storage error) answered 202, removed the tag and kept the pushed manifest under its digest, still pullable by digest; a delete by digest skipped tags it could not read, which kept serving the deleted manifest. Both now answer 500 and remove nothing, as a pull does.
  • A package published to NORA is never replaced by an upstream package of the same name (GHSA-pg2g-wvgf-cxpp) — once npm.metadata_ttl expired, the packument of a published package was revalidated from the upstream and replaced by the upstream's document, so a client resolving the name installed whatever the public registry had under it. A published package and a proxied one no longer share a key: a published package's packument, version manifests and dist-tags (/<name>, /<name>/<version>, /<name>/<dist-tag>, -/package/<name>/dist-tags) are answered from its own keys and never ask an upstream; what an upstream serves for a package NORA has not published is cached under npm/<name>/upstream/. A published packument an older release replaced with an upstream copy is rebuilt from the published versions; a published version whose document cannot be read makes the packument answer 503 instead of silently dropping it. If NORA cannot list a package's published versions, it serves the packument it assembled, or, without one, the upstream's answer for that request without caching it. Other metadata paths, such as npm search, are answered by the upstream and not cached (search results were cached regardless of the query). A packument an older release cached for an unpublished package is read until it is first revalidated and is never written, so a rollback to 1.3.6 still finds it. Action may be needed: a publish over a packument of unknown origin on a package with no published versions — an upstream package NORA 1.3.3 or older cached, or a package published to NORA 0.9.3 or older — is refused with 409 naming the key; delete the key if it is a cache, or set NORA_NPM_IMPORT_EMBEDDED=<name> ([npm] import_embedded) if it is your own package.
  • An allowlist's pinned integrity holds on the first download and on every way a stored file is served (GHSA-5pr2-xx8w-79jc) — an allowlist entry's integrity (the SHA-256 pinned for a version) was checked only when the artifact came from NORA's cache; the first download from the upstream was served and cached without the check, and a Range request for a stored file (gems, Maven, npm, PyPI) was served before it. It is now checked before the first download is served or cached, in every format that checked it on a cache hit (ansible, cargo, conan, cpan, docker manifests, gems, go, maven, npm, nuget, pub, pypi); a range of a pinned file is served only after the whole file is checked, and the stale-copy fallbacks of CPAN and Maven (a SNAPSHOT served when every upstream fails) check it too.
  • nora curation explain answers what the server would decide (GHSA-5pr2-xx8w-79jc) — explain built its own copy of the curation engine and, in enforce mode, still gave a decision for a configuration the server refuses to start with (a list that does not parse). It now uses the engine the server builds; when the server would refuse the configuration, explain says so and exits 78.
  • An empty upstream setting switches the upstream off (GHSA-5pr2-xx8w-79jc) — NORA_GEMS_PROXY, NORA_NUGET_PROXY, NORA_CONAN_PROXY, NORA_TF_PROXY (Terraform), NORA_ANSIBLE_PROXY and NORA_CPAN_PROXY set to an empty string were read as "no upstream" by the configuration and the startup checks, but the handlers still fell back to the public default upstream (and Terraform provider files to releases.hashicorp.com). These formats now answer from storage alone when their upstream is off — search, metadata, files, and Terraform module sources stored while it was on included: a stored copy of any age is served, anything else is 404. proxy = "" in the config file now means the same as the empty environment variable.
  • A PyPI upstream's credential goes only to that upstream (GHSA-5pr2-xx8w-79jc) — the Basic credential of a PyPI upstream was sent with every file download, to whatever host the upstream's index named for the file. It now goes only to the upstream's own origin (scheme, host and port), as pip scopes a credential; a file on another host is fetched without it.
  • A pub search for an internal name stays local (GHSA-5pr2-xx8w-79jc) — a search whose query string names a package matching curation.internal_namespaces, in any parameter, case or spelling, now gets an empty result and is not sent to the upstream, as Conan's search already behaves.
  • PyPI release age reaches clients that read the HTML index (GHSA-5pr2-xx8w-79jc) — without trust_upstream_dates, a file's date was the time NORA cached pypi/<name>/dates.json, and only a request for the PEP 691 JSON index cached it, so a client reading the PEP 503 HTML index (pip before 22.2) downloaded with no date at all and min_release_age let every such download through. A download now caches the listing itself on a cold miss, so both kinds of client get the same date; a cache hit still never contacts the upstream. The cached listing also dates only the files it names: a release published after NORA cached it used to inherit the listing's age, which made it look old to min_release_age and seeded the quarantine with a first-seen time a month back; it now gets no date, and the quarantine records it as first seen now. Files an upstream lists without an upload-time, or from an upstream without PEP 691, are recorded too, so they keep their date. A listing an earlier version cached from such an upstream names no files, and those files get no date until it is removed.
  • The client address behind a reverse proxy is read from the right of X-Forwarded-For (GHSA-5pr2-xx8w-79jc) — from a trusted proxy (loopback by default) NORA took the first address in X-Forwarded-For as the client. A reverse proxy that appends to the header, as nginx does with $proxy_add_x_forwarded_for, keeps whatever the client sent at the left, so a client could choose the address the rate limiters, the failed-login lockout and the token routes keyed it by. NORA now takes the rightmost address that is not a trusted proxy, reading every X-Forwarded-For line in order. A proxy that replaces the header (Caddy header_up X-Forwarded-For {remote}) sees no change. With more than one proxy in front of NORA, list every hop in NORA_AUTH_TRUSTED_PROXIES, or the nearest unlisted hop becomes the client address.
  • Deleting a Docker manifest on S3 removes it (GHSA-5pr2-xx8w-79jc) — on S3 storage, DELETE /v2/<name>/manifests/<digest or tag> answered 202 but left the manifest served by digest until GC removed it as an orphan. The manifest of a pushed image is stored under its registry namespace, the delete fell back to the second key only when the first answered "not found", and S3 answers a delete of an absent object with success. A delete now removes every stored copy of the manifest, and a delete of an absent object answers "not found" on S3 as it does on local storage.
  • A Docker tag may not end in .meta or .stats (GHSA-5pr2-xx8w-79jc) — a tag's manifest is stored beside its metadata files under those names, so a push, read or delete of tag v1.meta reached tag v1's metadata. Such references are refused with 400 on every manifest route.

Install

# x86_64
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.0/nora-linux-amd64
chmod +x nora-linux-amd64
sudo mv nora-linux-amd64 /usr/local/bin/nora

# ARM64 (Apple Silicon, Graviton, Ampere)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.0/nora-linux-arm64
chmod +x nora-linux-arm64
sudo mv nora-linux-arm64 /usr/local/bin/nora

Docker

docker pull getnora/nora:1.4.0
Variant Image Platforms
Alpine (default) getnora/nora:1.4.0 amd64, arm64
RED OS getnora/nora:1.4.0-redos amd64
Astra Linux SE getnora/nora:1.4.0-astra amd64
GHCR ghcr.io/getnora-io/nora:1.4.0 amd64, arm64

DEB / RPM

# Debian / Ubuntu / Astra Linux (amd64)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.0/nora-amd64.deb
sudo dpkg -i nora-amd64.deb

# RHEL / Fedora / RED OS (amd64)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.4.0/nora-amd64.rpm
sudo rpm -i nora-amd64.rpm

Changelog

See CHANGELOG.md

Don't miss a new nora release

NewReleases is sending notifications on new releases.