A sovereign, HuggingFace-compatible model registry.
Single Rust binary. One config file. S3-compatible storage. Anything a client fetches through it lives in your bucket until you explicitly remove it.
Set HF_ENDPOINT and nothing else changes:
export HF_ENDPOINT=https://rf.internal
python -c "from transformers import AutoModel; AutoModel.from_pretrained('Qwen/Qwen3-0.6B')"
The design is in doc/spec.md. Architectural conventions this
project inherits are in ~/git/architecture
— workspace layout, deployment, systemd hardening, firewalld, SELinux, port
allocation.
(repo, revision, file) triple has been
served, it is servable forever with no network path to any upstream.main resolves to the same commit on every later
request until an operator repins it.It does not reimplement the Hub's semantics. It records the exact HTTP metadata
upstream returned at first fetch — ETag, X-Repo-Commit, X-Linked-Etag,
X-Linked-Size — and replays it byte-identically, so a ~/.cache/huggingface
populated through rustingface is interchangeable with one populated directly
from the Hub.
crates/
rustingface-entities/ manifest, ref and repo schemas; bucket key layout;
configuration; the X-Error-Code taxonomy. No I/O.
rustingface-core/ resolver, freeze pinning, single-flight, the
streaming tee, policy, gc/verify/refresh. Defines the
Store and Upstream ports.
rustingface-data/ adapters: object_store (S3 and local) and reqwest.
rustingface-api/ the axum surface and the header contract.
rustingface/ the binary: serve, plus the admin subcommands.
web/ Vite + React + SWC + TS; the operator-facing UI
asset/ systemd, firewalld, nginx, config template
script/infra-setup.sh one-time host provisioning
test/conformance/ the suite that drives a real huggingface_hub
Two departures from architecture/generic.md, both deliberate:
-api plus -cli. doc/spec.md §9 specifies a single
binary with subcommands so the CLI and the service cannot drift. The library
crate split is unchanged.rustingface-api is a library, consumed by the binary, rather than being
the binary itself. Same reason.web/ is a static single-page app served from the nginx host. Three routes:
/ renders this readme, /models is a paged, filterable listing of what the
bucket holds, and /{namespace}/{name} shows one repository — its pinned refs,
the files actually stored, and its model card.
It reads a /v1/ API that is deliberately separate from /api/. That
surface belongs to the Hub: it is recorded and replayed verbatim, and adding
routes of our own to it risks a client mistaking one for the real thing.
/v1/ answers a question the Hub has no equivalent for — what is in this
bucket — which is inventory, not the model search the spec rules out.
GET /v1/status what this instance is; whether it is sealed
GET /v1/repos?page=&per_page=&type=&q= paged inventory
GET /v1/repos/{type}/{repo_id} one repository, with its stored files
Two things the UI is careful about, because model cards are third-party content:
<script>, onerror, a javascript:
href, an <iframe>, an SVG-embedded script and an inline handler: none
execute, and ordinary markdown still renders.cd web && pnpm install
pnpm dev # proxies /v1, /api and the resolve paths to a local service
VITE_DEV_API=http://bob.hanzalova.internal:20482 pnpm dev
rustingface's URL space is the Hub's, so /Qwen/Qwen3-0.6B is both a page a
person reads and the prefix of a file a client downloads. The vhost splits them
the same way the Hub does — anything containing /resolve/, plus /api/ and
/v1/, goes to the service; everything else is the app, with client-side
routes falling back to index.html.
The one consequence worth knowing: a repository named v1/repos or api/…
collides with those prefixes. /v1/repos/… is disambiguated in the router (its
first segment is always a repo type, so anything else is handed back to the
resolve path), and the Hub itself cannot host a repo at api/models either.
cargo build --release
cargo test --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
Locally, against a directory instead of a bucket:
cargo run -- --config asset/config/config.toml.example serve
HF_ENDPOINT=http://127.0.0.1:20482 hf download Qwen/Qwen3-0.6B config.json
storage.local_path forfeits guarantee 5 — a directory is not a bucket you can
re-point a new host at — and the service says so at startup. It exists for
tests and trials.
One TOML file, /etc/rustingface/config.toml by default. Every key is
overridable by environment (RUSTINGFACE__SERVER__LISTEN). Secrets are named
by path, never inline, and a path that does not exist is retried under
$CREDENTIALS_DIRECTORY so one file works both under systemd's
LoadCredential= and when run by hand. See
asset/config/config.toml.tmpl.
The two settings worth understanding:
policy.ref_resolution — freeze (default) records the first resolution
of a mutable ref and reuses it indefinitely. follow re-resolves upstream,
which breaks guarantee 4 and warns at startup.
upstream.enabled — false seals the registry. No upstream client is
constructed at all, so there is no code path that could reach the network.
auth.mode / auth.anonymous — bearer requires a token from
auth.token_file; anonymous = "catalog" then carves out the /v1/
surface so an unauthenticated visitor can browse the inventory and read
model cards, but cannot download a weight. That carve-out is safe because
every handler under /v1/ reads the bucket and never consults upstream, so
an anonymous visitor cannot cause anything to be stored.
policy.allow_new_repos — false restricts serving to repositories
already in the bucket. This, not auth, is what bounds how large the
bucket gets: a token says who, not what, and any token holder can
otherwise cause an arbitrary model to be fetched. Growing the registry
becomes an operator action (rustingface fetch).
The reference deployment sets it true, because the point of that
deployment is that a client pointed here by HF_ENDPOINT mirrors what it
uses on first request. There the bucket is bounded by who holds a token, and
policy.allowlist is available to confine on-demand mirroring to expected
namespaces without switching it off.
Same binary, subcommands.
rustingface serve # default
rustingface fetch <repo>[@rev] [--files …] # warm the bucket ahead of need
rustingface pin <repo> <ref> <commit> # set a ref explicitly
rustingface refresh <repo> [<ref>] # re-resolve upstream and repin
rustingface list [--repo-type models] # stored repos, revisions, sizes
rustingface show <repo>[@rev] # manifest contents
rustingface rm <repo>[@rev] --yes # remove refs/manifests (NOT blobs)
rustingface gc [--dry-run] # delete blobs no manifest references
rustingface verify [<repo>] # re-read blobs, check digests
rustingface doctor # config, S3 reachability, permissions
rm and gc are separate on purpose. Removal detaches metadata; reclamation
is a second, explicit, dry-runnable step. Nothing deletes bytes without an
operator typing gc.
| Concern | Where |
|---|---|
| Service | bob.hanzalova.internal:20482 |
| Object storage | MinIO on caveman.kosherinata.internal:9000, bucket rustingface |
| Ingress (mesh) | rf.internal, internal CA, via the hanzalova edge proxy |
| Ingress (public) | rustingface.com, Let's Encrypt, same proxy and webroot |
| Frontend | /var/www/rustingface on hanzalova, served by the same vhost |
| Port | 20482, derived from the service name per architecture/port-allocations.md §3 |
Deployment is CI-driven (.gitea/workflows/deploy.yml): push to main, the
workflow runs the lint/test gate, builds a static musl binary and the frontend,
ships both as gitea_ci over scoped sudo, and health-checks. The service is
probed with /healthz and then rustingface doctor, which proves the bucket is
actually writable rather than merely that a socket opened; the frontend is
probed through rf.internal from the proxy, since a CI runner is a plain
container that does not carry the internal root CA.
The frontend deploys to hanzalova (/var/www/rustingface) and the service to
bob, so both hosts carry a scoped gitea_ci sudoers drop-in. The proxy's is
the narrower of the two: a webroot rsync, a relabel, nginx -t and a reload.
One-time host provisioning is script/infra-setup.sh, run by an operator from
a workstation with full sudo. It is idempotent and skips unreachable hosts.
Required Gitea repo secrets: RSYNC_SSH_KEY, S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY, CLIENT_TOKENS (one accepted client token per line),
and HF_TOKEN (optional; only for gated repositories).
rustingface.com is a shop window: an operator evaluating whether to run their
own instance can browse the catalogue and read model cards without a
credential. Downloads need a token, on both names — auth is enforced by the
service, not per-vhost, so a token is required on rf.internal too.
The public vhost additionally closes /metrics, which the service leaves
auth-exempt so a mesh Prometheus can scrape it without a credential. That
exemption is fine on the mesh and unacceptable publicly, so nginx closes it
rather than the service relaxing it.
The service holds no durable state, which is what makes the systemd hardening
easy: ProtectSystem=strict with no ReadWritePaths and no StateDirectory
at all.
cargo test --workspace # unit + the sovereignty suite
python3 -m venv .venv && .venv/bin/pip install -r test/conformance/requirements.txt
.venv/bin/python test/conformance/conformance.py --binary target/release/rustingface
crates/rustingface-api/tests/sovereignty.rs runs the whole service against a
fake Hub that speaks the real header protocol and counts what it was asked for:
cold fetch, sealed replay, single-flight (eight clients, one upstream fetch),
client disconnect mid-transfer, range resume, freeze stability across an
upstream commit move, gc after rm, digest mismatch, deduplication, policy,
bearer auth, and state portability.
test/conformance/conformance.py drives a pinned, unmodified
huggingface_hub through rustingface against the real Hub, then seals it and
re-runs. It also re-checks the X-Error-Code strings against the installed
client, because those are client-internal constants rather than a stable API —
a client upgrade is a spec-review trigger.
Run the real offline test by hand before trusting a deployment: null-route
huggingface.co at the site router and verify there, not by trusting the
service.
The Hub is migrating to Xet-backed storage. rustingface implements none of it:
it never advertises Xet capability downstream and never negotiates it upstream,
so files arrive whole through the Git LFS bridge. The cost is fetch-time
bandwidth on first pull. The benefit is that the on-disk format stays "files
with names" and no future maintainer can strand your data behind a
chunk-reconstruction format. upstream.disable_xet = false is refused at
startup.
GPL-3.0-or-later.
26 activities
64c1e0e Merge branch 'feat/allow-on-demand-mirroring'4b35913 feat(deploy): mirror on demand for authenticated clients059c781 fix(conformance): the hf CLI check was silently skipping in CIa91aaf3 feat(infra-setup): create the public DNS records, idempotently5244585 ci(deploy): read the nginx logs without sudo29e4b08 fix(web): pnpm 11 renamed the build allowlist to allowBuilds78d35cf ci(web): declare the build allowlist in both places, and report pnpm's view05c3e56 fix(web): put the build-script allowlist where pnpm 10 actually reads it32621f9 fix(infra-setup): the DNS snippet it printed was not valid shell8595529 fix(web): approve the two dependencies that need install scripts3790f25 fix(core): the per-key manifest lock did not actually exclude5a94d90 fix(ci): restore the format gate and match the runner's package policy99e0999 chore(web): stop tracking tsc incremental build stateacd47a3 feat(web): add the operator UI and the /v1 inventory API it reads010a8bb fix(bin): make observability.log_format actually take effect4f72e4b feat(bin): flag serving gated weights without client auth2d2dd71 fix(bin): don't refuse to start over a credential the config never readsd3be793 fix(deploy): stop the placeholder guard tripping over its own commenta27da00 ci(deploy): don't let the journal step bury the real failureae3a463 ci(deploy): name the missing secret instead of failing inside ssh0aa27df fix(infra-setup): don't report a cert mismatch mid-reload8578ca6 docs: readme and agent notesaafe132 fix(core): hold the last chunk until the blob and manifest are durabled461d46 fix(core): stop concurrent manifest writes from losing a served file1fe721e fix(data): record the Hub's headers, not the CDN's, across a redirectcb1ca8b feat(rustingface): implement the registry, admin CLI and deployment