A modern web UI plugin for devpi-server - a drop-in replacement for
devpi-web. Ships as a Python package that registers itself as a devpi-server plugin via the
standard entry point mechanism, so a single pip install devpi-admin is enough.
The UI itself is a bundled single-page application (pure HTML + CSS + vanilla JavaScript, no
build step) served under /+admin/. All devpi REST API endpoints remain untouched - the SPA
talks to the standard devpi JSON API directly.
- Server info with version of devpi-server and all installed plugins (auto-detected)
- Cache metrics with hit-rate bars (storage, changelog, relpath caches)
- Whoosh search index queue status
- Replica status (primary only, authenticated users only) - per-replica cards with
authoritative
applied_serialvs. primary serial. Three states:- in sync - replica matches primary serial
- lagging - replica is behind but advancing
- stuck - replica has been polling the same serial for >=30 s; usually means a
server-side plugin (
devpi-admin,devpi-web, ...) is missing or out of date on the replica
- Topbar health indicator - the
devpi adminlogo is coloured green / orange / red on every page, refreshed every 30 s in the background:- server reachable, all replicas in sync
- at least one replica lagging (visible to authenticated primary operators)
- server not responding
- Visual cards color-coded by type: green (stage), amber (volatile stage), blue (mirror)
- Warning tags for ACL edge cases:
world-writable-acl_uploadcontains:ANONYMOUS:; supply-chain riskno upload-acl_uploadis empty; nobody (not even owner / root) can publish
- Index card kebab:
pip.conf(public indexes only) — static one-click pip.conf with the index URL, no token issuance neededTokens(owner / root only) — opens the per-index unified Tokens modal with two sections (Admin + Devpi), shows existing tokens for this index, lets you issue new ones with the index pre-filled and lockedRefresh cache(mirror indexes only, any authenticated user) — invalidates the in-memory per-project and project-names caches; the next+simple/<project>/query (from pip, the UI, ordevpi-client) goes back to upstream (etag-conditional, cheap)Edit/Delete(owner / root)
- Create / edit / delete indexes via modal dialogs
baseseditor with drag & drop priority ordering and transitive inheritance displayacl_uploadandacl_readtag pickers with user selection dropdownvolatile,mirror_url,titleconfiguration- Mirror package allow/deny lists (
package_allowlist,package_denylist) — see Mirror access control below
- Per-index list of principals allowed to read the index (download packages, browse simple)
- Default
[:ANONYMOUS:]- public, behaves like devpi-web - Set to specific users (
alice,bob) to make the index private - Special principals:
:ANONYMOUS:(everyone, including unauthenticated) and:AUTHENTICATED:(any logged-in user) - Enforced natively by devpi via the
pkg_readpermission on every download path, plus a tween that filters the root listing (GET /) — invisible indexes are removed, and any user left with no readable index is dropped entirely so the listing can't be used to enumerate accounts (root sees everyone; you always see yourself) — and rejects direct access to private indexes with 404
- Per-mirror
package_allowlistandpackage_denylistfilter the projects, versions and simple-index links served from upstream. Onlytype=mirrorindexes carry these fields; stage indexes are unaffected - Empty allowlist = pass-through (everything allowed except denylist). Non-empty allowlist = whitelist mode (only listed entries reach pip)
- Denylist always wins — overrides any allowlist match
- Entry formats (one per line in the modal):
- PEP 508 requirement —
numpy,numpy>=2.0,urllib3<1.26.5 - Glob in name part —
mycompany-*,*-internal,mycompany-*<2.0
- PEP 508 requirement —
- Multi-layer enforcement so a denylist hit cannot be bypassed:
+simple/<project>/— denied versions never appear in pip's discovery (devpi-server's customizer hooks:get_projects_filter_iter,get_versions_filter_iter,get_simple_links_filter_iter)/<user>/<index>listing — denied projects vanish from the project list+f/<hash>/<filename>direct download — tween returns 404 even for previously cached files (defense in depth against shared/bookmarked URLs). The cached file stays on disk; removing the deny rule restores access without re-fetching upstream
- Use cases:
- CVE blocklist —
urllib3<1.26.5,cryptography<41.0.0 - Internal namespace ban —
mycompany-*keeps PyPI typosquats from shadowing private packages on a public mirror - Whitelist-only mirrors — paste curated
requirements.txtstyle entries intopackage_allowlist; everything else is blocked
- CVE blocklist —
-
devpi-server's
/+statusis public by design and exposes operational detail (server data directory path, node UUIDs, component versions, replica outside-URLs and serials). This plugin reduces it for anonymous callers to a coarse health verdict only:{"type": "status", "result": {"status": "ok"}} -
Authenticated callers (password or token auth) still get the full, unchanged detail — so the SPA dashboard and ad-hoc
curl -u user:pass …/+statuswork as before. -
Monitoring without credentials: point Zabbix / Nagios / an uptime check at
GET /+statusand alert onHTTP != 200orresult.status != "ok".statusis"fatal"when a replica has active replication errors (stuck replica / plugin mismatch); otherwise"ok". Fine-grained lag thresholds require authentication.
-
Opaque
adm_<id>.<secret>tokens bound to a(user, index, scope)triple. Scope isread(pip install) orupload(twine /devpi upload). A leaked token is contained to one index and one operation class - no cross-index or upgrade path. -
Tokens are persisted in keyfs as SHA-256 hashes only - the plaintext secret is shown exactly once at issuance. A keyfs dump (replica disk, backup) does not yield usable tokens. Lookup compares hashes via
hmac.compare_digest(constant-time). -
TTL configurable per-token (60 s up to 1 year), uniquely revocable
-
Tween enforcement matrix:
scope allowed methods allowed paths readGET, HEAD /+api,/<token.user>/<token.index>/...uploadGET, HEAD, POST, PUT /+api,/<token.user>/<token.index>/...DELETEis never granted, even withuploadscope - package removal must use password auth. Anything outside the bound index path returns 403, including the SPA,/+admin-api/*(so a token cannot mint further tokens),/+login,/, and/<user>. Bases exception:GET /<base>/<idx>/+f/<...>is also allowed when<base>/<idx>is in the bound stage's SRO (bases inheritance). devpi's+simple/view on a stage emits file links pointing directly at the base index (e.g. mirror-fed packages onvillapro/staginglink to/root/pypi/+f/...); without this exception pip would follow the link with the bound token and get 403. Limited toGETon+f/— cross-index+simple/and writes remain blocked. -
Issuance rules: regular users may issue for themselves; root may issue for other users (admin delegation) but not for itself. Admin-token-authenticated requests cannot issue further tokens. Issuance verifies the target user is in
acl_read/acl_uploadof the target index. -
Management rules: list / revoke is allowed for the token owner or root. The per-index token list shows all tokens for index owner / root; other callers see only tokens bound to themselves.
-
Auto-cleanup:
- User delete -> all tokens for that user removed from keyfs
- Index delete -> all tokens bound to that index removed (USER subscriber diffs the
indexesdict) - Legacy tokens (pre-hash storage, or pre-
index/scope) wiped at startup
-
Audit log: failed lookups (unknown id, secret mismatch, expired, deleted user, legacy token) are logged at WARNING/INFO so an operator can spot bruteforce attempts.
-
CI/Ansible-friendly:
GET /+admin-api/pip-conf?index=user/index&ttl=3600returns a ready-to-usepip.conf(text/plain) in one HTTP call. For upload, usePOST /+admin-api/tokenwith{"scope": "upload"}.
- Create, edit (email, password), delete users (admin only)
- Tokens manager (kebab -> Tokens) - unified modal with one or two sections:
- Admin tokens (built-in) — per-user list with label, index, scope, expiry, issuer, IP; individual revoke or "Reset all"
- Devpi tokens (only when the
devpi-tokensplugin is installed) — list of macaroon tokens with parsed restrictions (indexes, allowed permissions, projects, expires, not-before); individual revoke - Empty sections hide automatically (no clutter). Banner above Devpi section (dismissible per user) explains the different threat model — raw secret in keyfs vs. hash-only Admin storage.
- Issue token (
+ Issue newbutton) — single unified modal for both backends:- Token type selector at the top picks Admin vs. Devpi (Devpi default when the plugin is installed). Hidden when only Admin is available.
- Index picker shows everything the bound user can access (owns, or appears in
acl_read/acl_upload). Devpi uses a multi tag picker; Admin uses a single select. - Admin scope dropdown adapts to the picked index: public indexes get only
upload(read tokens are useless when anyone can read), private indexes get both withreadas default. - Devpi permissions are checkboxes; destructive operations (
del_*,index_modify,index_delete) are tucked behind an "Advanced" toggle with a visual warning. - Expiry: presets (1 hour to 1 year) plus a
Custom…datetime option. Optional Not-before for delayed activation (Devpi only). - On success the modal swaps to a read-once view with the raw token, pip.conf,
.pypirc,TWINE_*env, and auser:tokenpair — but only the configs that actually match the issued token's intent (no pip.conf for upload-only or public-index tokens; no.pypircfor read-only tokens).
- Client-side search with PEP 503 name normalization and relevance ranking
(exact match > prefix match > substring match, then shortest name first) so
searching
requestsin a 780k-project upstream surfacesrequestsitself, notdjango-requests-cachefirst - Stage indexes load packages automatically. Mirror indexes (e.g.
root/pypi≈ 780k upstream projects, ~17 MB) require an explicit "Browse full index" click — no auto-fetch - Package cards with latest version and
pip installcommand
- Sidebar: metadata (author, license, Python version, keywords, platform, maintainer,
extras, project URLs, dependencies),
pip installcommand, file downloads with upload dates - Version list: every known version of the package, newest first, each linking to its own detail view
- README: rendered markdown (via
marked.js); fetched from PyPI.org for mirror packages where devpi doesn't cache the description
- Anonymous browsing - visitors can explore public indexes without logging in; admin
actions (create/edit/delete) appear only after authentication. Private indexes
(
acl_readwithout:ANONYMOUS:) are hidden from anonymous root listing. - Hardened SPA delivery - strict
Content-Security-Policy(no inline scripts,connect-srclimited to same-origin +pypi.org,frame-ancestors 'none'),X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer. Markdown READMEs are sanitised before rendering (script/iframe/event handlers stripped, dangerous URL schemes blocked). - Dark / light / auto theme with half-circle icon for auto mode
- Responsive mobile menu with hamburger toggle
- ESC + outside-click dismissal for modals, dropdown menus, mobile menu
- Login via modal - no separate login page
pip install devpi-adminThis pulls in devpi-server as a dependency. If you are using devpi in a dedicated venv
(recommended), install the plugin into the same venv:
/var/lib/pypi/venv/bin/pip install devpi-admin
systemctl --user restart devpi # or however you run devpi-serverYou should uninstall devpi-web - devpi-admin replaces it entirely:
pip uninstall devpi-webBoth plugins can technically coexist but it is not recommended. devpi-admin intercepts /
for HTML requests while devpi-web would still serve its own HTML on other routes like
/<user>/<index>/<package>, leading to a confusing mixed experience.
devpi-admin registers custom keyfs keys (+admin/tokens/...,
+admin/user-tokens/..., +admin/index-tokens/...). The primary writes to these on
every token issue / revoke. Replicas without devpi-admin installed cannot apply
those changelog entries - import_changes fails with AssertionError on the
missing keyfs key, the replica rolls back to the prior serial, and replication stalls.
The dashboard's stuck-replica detection is designed exactly for this: a stuck
state on a replica card almost always means a plugin (typically devpi-admin itself,
also devpi-web, devpi-postgresql) is missing or out of date on the replica. Recovery
is straightforward:
# on the replica
~/.venv/bin/pip install --upgrade devpi-admin # match primary version
systemctl restart devpiReplication resumes from the failed serial automatically - no manual keyfs surgery.
Upgrade order: replicas first, then primary. If you upgrade the primary first and that release introduces a new keyfs key, replicas would crash on the very next poll.
See INSTALL.md section 11 for full step-by-step replica setup and dashboard interpretation.
devpi-server starts in an open mode by default - anyone (including unauthenticated
clients) can PUT /<newuser> to create an account, and any logged-in user can
PUT /<user>/<index> to spin up indexes under their own account. The devpi-admin UI
hides those buttons from non-root users, but a direct API call (curl, devpi user -c)
will still succeed.
Pass --restrict-modify root to devpi-server to lock structural operations
(create/modify/delete of users and indexes) down to root only. Per-index
acl_upload/acl_read are unaffected, so day-to-day uploads and downloads keep working
under the existing per-index permissions.
ExecStart=/opt/pypi/venv/bin/devpi-server \
--serverdir /var/lib/pypi/data \
--restrict-modify root \
...See INSTALL.md for a full systemd unit example.
devpi-admin plays nicely with the optional devpi-tokens plugin. When
installed, the SPA detects it (via /+api features list and /+status
versioninfo) and automatically merges Devpi tokens into the same Tokens
modal that lists Admin tokens — same kebab item ("Tokens"), same
"+ Issue new" flow, same per-index Tokens modal. The user picks the
backend in the Issue form via a token-type selector.
/var/lib/pypi/venv/bin/pip install devpi-tokens
systemctl --user restart devpiThe two token systems run side by side without conflict:
| Admin tokens | Devpi tokens | |
|---|---|---|
| Plugin | devpi-admin (built-in) |
devpi-tokens (optional) |
| Storage | SHA-256 hash in keyfs | Raw HMAC key in keyfs |
| Listable (incl. derived) | yes, all | initial only — derived macaroons are stateless |
| Audit log on lookup | yes | no |
| HTTP method whitelist | read blocks DELETE; upload blocks DELETE |
relies on --allowed permission filter |
| Multi-index per token | no (1:1) | yes |
| Per-project filter | no | yes (--projects) |
| Cross-user index | no (must be in ACL) | yes (any user/index pair) |
| CLI compatibility | UI / API only | works with devpi token-login |
Threat model note. Macaroon HMAC verification requires the secret in
plaintext on the server, so devpi-tokens cannot hash-store; a leaked
backup or replica disk dump exposes working credentials. Prefer Admin
tokens for privileged workflows. The UI surfaces this via a (dismissible
per-user) security banner above the Devpi section of every Tokens modal.
acl_read (provided by devpi-admin) applies to both token systems
identically — devpi evaluates pkg_read ACL against whichever identity
the auth chain produced, regardless of token source.
Testing without the plugin installed. Append ?no-devpi-tokens to any
SPA URL to make the UI behave as if the plugin weren't there (kebab item
disappears, type selector hides, etc.). Saves you a pip uninstall + restart
round-trip when verifying graceful degradation.
After restart, open:
http://<your-devpi-host>:3141/
Browser visits to / are redirected to /+admin/, which serves the SPA. Direct links like
http://<host>:3141/+admin/#packages/ci/testing work and can be bookmarked.
devpi CLI tools and other JSON clients are unaffected - they send Accept: application/json
and bypass the redirect.
For automation that needs to install from a private index, store the service user's password
as a secret and let the pipeline mint a fresh short-lived pip.conf per run:
# Gitea Actions example
- name: Install dependencies
env:
DEVPI_USER: ${{ secrets.DEVPI_USER }} # e.g. "gitea-ci"
DEVPI_PASSWORD: ${{ secrets.DEVPI_PASSWORD }}
run: |
mkdir -p ~/.pip
AUTH=$(printf '%s:%s' "$DEVPI_USER" "$DEVPI_PASSWORD" | base64)
curl -sf -H "X-Devpi-Auth: $AUTH" \
"https://devpi.example.com/+admin-api/pip-conf?index=company/private&ttl=3600&wait_replicas=10" \
> ~/.pip/pip.conf
pip install -r requirements.txtWhen devpi runs as primary + replicas behind a load balancer, a freshly issued token
exists on the primary instantly but takes one polling cycle (~37 s by default) to reach
replicas. An Ansible-style playbook that issues a token and immediately uses it through
the LB may hit a replica that doesn't know the token yet - and get 401.
Both POST /+admin-api/token and GET /+admin-api/pip-conf accept a wait_replicas
parameter. The primary blocks until every currently-polling replica has caught up to
the commit serial, bounded by 30 s. Stale replicas (silent for >2 min) are skipped so an
offline replica never blocks the caller.
# Wait up to 10 s for replicas; default cap is 30 s if you pass `true`/`1`.
curl -sf -H "X-Devpi-Auth: $AUTH" \
"https://devpi.example.com/+admin-api/pip-conf?index=company/private&ttl=3600&wait_replicas=10" \
> ~/.pip/pip.confFor POST /+admin-api/token, send {"wait_replicas": 10} in the JSON body. The response
includes a replication block (synced, waited, timed_out, replicas, ...) so the
client can decide whether to retry.
The token issued is read-scoped - usable only for GET/HEAD on /+api and the
bound /<user>/<index>/.... It cannot upload, modify indexes, change passwords,
exchange itself for a session token, or issue another token. It expires after ttl
seconds. The service user must have pkg_read on the target index. Root may issue
for other users (admin delegation) but never for itself.
For uploads, POST /+admin-api/token with {"scope": "upload"} returns a token that
adds POST/PUT to the bound index - usable from twine or devpi upload:
# CI publish step
- name: Publish wheel
run: |
AUTH=$(printf '%s:%s' "$DEVPI_USER" "$DEVPI_PASSWORD" | base64)
TOKEN=$(curl -sf -H "X-Devpi-Auth: $AUTH" -H 'Content-Type: application/json' \
-d '{"index":"company/release","scope":"upload","ttl_seconds":900}' \
"https://devpi.example.com/+admin-api/token" | jq -r .token)
twine upload --repository-url https://devpi.example.com/company/release/ \
-u "$DEVPI_USER" -p "$TOKEN" dist/*Upload tokens still cannot DELETE - package removal must use password auth.
For an internet-facing or shared deployment, terminate TLS at a reverse proxy and apply a few hardening rules there. devpi-server is open by design - read privacy, rate-limiting and banner hiding are added by this plugin and the proxy, not by core. The plugin must be installed on every node (primary and every replica) - a node without it serves everything unfiltered; see Replicas: install on every node.
Annotated nginx example (adapt CIDRs, cert paths and the backend address):
http {
upstream devpi_backend { server 127.0.0.1:3141; }
# Per-client-IP counter for login throttling (see "Login rate-limiting").
# $binary_remote_addr - key: client IP (needs the real IP, see below)
# zone=login:10m - name + ~160k-IP table
# rate=20r/m - 20 logins/min/IP; generous on purpose, a whole
# office often shares one NAT IP
limit_req_zone $binary_remote_addr zone=login:10m rate=20r/m;
limit_req_status 429; # return 429, not nginx's default 503
server {
listen 443 ssl;
# ssl_certificate ...; ssl_certificate_key ...;
server_tokens off; # hide nginx's own version banner
# Trust X-Forwarded-For only from your own proxy/LB so the *real*
# client IP drives rate-limiting and token client_ip logging.
# Mirror this in DEVPI_ADMIN_TRUSTED_PROXIES on devpi-server.
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_hide_header X-Devpi-Server-Version; # hide devpi version banner
# more_clear_headers Server; # 'Server: waitress' - needs
# headers-more-nginx-module
location / {
proxy_pass http://devpi_backend;
}
# Throttle the login endpoint (password auth runs an expensive hash).
location = /+login {
limit_req zone=login burst=10 nodelay;
proxy_pass http://devpi_backend;
}
# /+authcheck only exists for devpi-lockdown's auth_request subrequest.
# If you don't run devpi-lockdown it's an unused ACL-probe endpoint - deny it.
location = /+authcheck { return 404; }
}
}DEVPI_ADMIN_TRUSTED_PROXIES (a comma-separated CIDR list) tells the plugin whose
X-Forwarded-For to honour for the client_ip shown on issued tokens; without it the
header is ignored so clients can't forge their logged IP:
DEVPI_ADMIN_TRUSTED_PROXIES=10.0.0.0/8,127.0.0.1
The nginx set_real_ip_from / real_ip_header pair is the proxy-side equivalent: without
it, $binary_remote_addr is the proxy's own IP and the login limit collapses to a single
bucket for everyone. Set both, consistently.
X-Devpi-Server-Version, nginx's Server token and Server: waitress only help an
attacker match known CVEs. Strip them as shown above. Keep the other X-Devpi-*
headers - they are protocol, not banners, and stripping them breaks things:
X-Devpi-Api-Version- thedevpiclient checks it for compatibilityX-Devpi-Serial- drives replica sync and the client'swait_replicaslogicX-Devpi-Uuid/X-Devpi-Master-Uuid/X-Devpi-Primary-Uuid- replicas use these to confirm they are talking to the expected primary
devpi hashes passwords with Argon2 (m=65536,t=3,p=4 → ~64 MiB RAM + CPU per attempt).
That cost slows password guessing but is also a DoS lever: every POST /+login - and every
request carrying a raw password in Authorization: Basic / X-Devpi-Auth - runs one
hash, so a few hundred concurrent attempts can exhaust RAM/CPU. (Clients that log in once
and reuse the returned signed token don't pay the hash again - only raw-password attempts
do.) devpi-server has no built-in throttling.
burst=10 nodelaylets a short legitimate spike (e.g. a CI fan-out) through immediately; only requests beyond the burst get 429.- Tune
rate/burstto your login volume - or better, have CI exchange the password for a token once (see the token API above) and reuse it so it never re-hits/+login. - This stops single-source login floods and guessing - the realistic threat for an internal index. nginx can't tell a raw password from a signed token in the auth header, so it can't single out the other hash-triggering requests; a distributed (many-IP) flood is an infrastructure-layer concern (upstream DDoS protection), not this rule's job.
devpi-admin registers a devpi_server entry point with several @hookimpls:
devpiserver_get_features- advertises the plugin in/+api.devpiserver_indexconfig_defaults- registersacl_readas an indexconfig field with anACLListmarker so devpi normalizes its values on everyPUT/PATCH.devpiserver_stage_get_principals_for_pkg_read- feedsacl_readinto devpi's pyramid ACL, which applies thepkg_readpermission natively on every download path (+f/,+e/, simple page).devpiserver_get_identity- recognizesadm_<id>.<secret>admin tokens, validates them against keyfs (constant-time hash compare), setsadm.is_admin_tokenin the request environ for downstream tween checks.devpiserver_pyramid_configure- registers the SPA, custom API views, the tween, the token keyfs keys, and a USER-key subscriber that cleans up tokens on user delete AND on per-user-index removal (diffs old vs. newindexesdict viatx.get_value_at). Primary only - replicas are read-only.
The tween does several things on every request:
- Captures replica poll info. Matches
GET /+changelog/{N}-?and recordsstart_serial+last_seenkeyed by theX-DEVPI-REPLICA-UUIDheader. This is the data source for/+admin-api/replicasand the dashboard's stuck-replica detection. - Validates admin tokens by direct
tokens.lookup()(not via pyramid identity, which would pin a stale identity through/+login's mid-request header swap). On valid token, setsadm.token_metain the request environ for the identity hook to reuse. - Enforces token scope and index binding:
readscope -> only GET/HEAD alloweduploadscope -> adds POST/PUT (DELETE is never granted)- URL must be
/+apior under/<token.user>/<token.index>/.... Anything else (other indexes, SPA,/+admin-api/*,/+login, root listing,/<user>) returns 403.
- Redirects HTML browser requests on
/to/+admin/while leaving JSON requests intact. - Returns 404 for
GET /<user>/<index>/...(index, simple, project, version, file) when the requestor lackspkg_read- devpi's own listing endpoints have no permission check, so we add one. - Returns 403/404 for
GET /<user>when the requestor is neither the user themselves norroot- devpi otherwise leaks the full list of that user's private indexes. - Filters the
GET /JSON response to remove indexes the requestor can't read, and addsCache-Control: private, no-storeso a shared cache cannot serve one user's filtered view to another.
The SPA HTML (/+admin/) is served with security headers - strict
Content-Security-Policy (no inline scripts, restricted connect-src to
'self' + https://pypi.org for the README fallback, frame-ancestors 'none'),
plus X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer.
The plugin uses devpi-server internals: xom.model.getstage, stage.list_versions,
stage.get_versiondata, stage.get_releaselinks, xom.keyfs.
The mirror access control (package_allowlist / package_denylist) is implemented
on top of devpi-server's stage customizer hooks (get_projects_filter_iter,
get_versions_filter_iter, get_simple_links_filter_iter). devpi-server rejects
duplicate customizer registrations for a given index_type, so instead of providing
our own class we monkey-patch our methods onto the upstream MirrorCustomizer
(an empty pass-through class designed exactly for this kind of extension). The
patch runs once at module import. The tween additionally enforces denylist on
direct +f/ downloads to neutralise previously-cached or shared file URLs.
- Python 3.9+
- devpi-server 6.19 <= version < 7.0 - we rely on
tx.get_value_at, theX-DEVPI-REPLICA-UUIDheader and thepolling_replicasdict shape introduced in 6.19; the upper bound is held until 7.x compatibility is verified. - A browser with ES6 support (
Promise,fetch,sessionStorage)
Routing is hash-based, so any of these URLs can be bookmarked or shared:
| Hash | View |
|---|---|
# |
Status dashboard (default) |
#indexes |
All indexes |
#indexes/<user> |
Indexes filtered by user |
#packages/<user>/<index> |
Packages in an index |
#package/<user>/<index>/<name> |
Package detail (latest version) |
#package/<user>/<index>/<name>?version=<ver> |
Specific version |
#users |
User management (requires login) |
In addition to serving the SPA, devpi-admin exposes its own JSON API under
/+admin-api/. Authentication uses the standard devpi-server header
X-Devpi-Auth: base64(user:token). Responses are application/json unless noted
(/+admin-api/pip-conf returns text/plain).
Cheap auth check; the frontend pings this on tab focus to detect expired sessions.
- Auth: required
- 200:
{"valid": true, "user": "alice"} - 403: not authenticated
Canonical "outside" URL of this deployment, derived from
request.application_url (respects --outside-url and X-Forwarded-* headers).
The SPA uses this for static pip.conf / .pypirc previews so they match what the
backend would emit when behind a reverse proxy.
- Auth: none (URL is not a secret; even anonymous viewers of public indexes need it)
- 200:
{"url": "https://devpi.example.com"}
All known versions of a project, newest first. Backed by stage.list_versions() so
the result is consistent across primary and replicas (PROJSIMPLELINKS in keyfs is
replicated via the changelog).
- Auth:
pkg_readon the index - 200:
{"versions": ["1.0", "0.9", "0.8"]}
Metadata + file links for a single version (PEP 426 / PEP 621 fields plus +links
with href, basename, hash_spec, upload log).
- Auth:
pkg_readon the index - 200:
{"result": {...}} - 404: version doesn't exist
Tokens are opaque adm_<id>.<secret> strings bound to a (user, index, scope) triple.
Only the SHA-256 of the secret is persisted in keyfs.
Issue a new token.
- Auth: required (regular user for self; root may issue for other users; admin-token requests cannot issue further tokens)
- Body (JSON):
{ "user": "alice", // optional, default = authenticated; root may set freely (not "root") "index": "alice/dev", // required "scope": "read" | "upload", // required "ttl_seconds": 3600, // optional; 60 <= ttl <= 1 year, default 1h "label": "ci-build", // optional, <= 200 chars "wait_replicas": 10 // optional; block up to N seconds for replicas to catch up } - 200:
{token, user, index, scope, issued_at, expires_at, label, replication?}-tokenis the plaintext, returned once. - 403: target user lacks scope perm on index, root issuing for itself, admin-token call, etc.
- 404: index doesn't exist
Issue a read token + return a ready-to-use pip.conf in one call (CI/Ansible-friendly).
- Auth: required (same rules as
POST /token) - 200:
text/plain[global] index-url = https://alice:adm_xxx.yyy@devpi.example.com/alice/dev/+simple/ trusted-host = devpi.example.com
List active tokens for a user.
- Auth: the user themselves, or root
- 200:
{"result": [{id, id_short, user, index, scope, issuer, issued_at, expires_at, expires_in, label, client_ip}, ...], "count": N}
Revoke ALL tokens for a user.
- Auth: the user themselves, or root
- 200:
{"revoked": N, "user": "alice"}
List tokens bound to an index. Non-root callers see only tokens they own; root sees
every token for the index. Returns 404 (not 403) when the caller has no pkg_read so
private index existence is not leaked.
- Auth:
pkg_readon the index (404 otherwise) - 200:
{"result": [...], "count": N}- same record shape as/users/{user}/tokens
Revoke a single token.
- Auth: owner of the token, or root
- 200:
{"revoked": true, "id": "abc..."} - 404: token id not found
Invalidate the in-memory mirror caches so the next pip / UI / devpi-client request
re-checks upstream. Lazy — no upstream fetch happens at the moment of the call; the
re-fetch is triggered by the next +simple/<project>/ lookup that traverses this
mirror (etag-conditional, typically one cheap HTTP round-trip per project actually
queried). Two caches are expired:
cache_retrieve_times— per-project last-fetch timestamp + etag (every tracked project, in one pass)cache_projectnames— full PyPI project-name list (refetched on the next "list all projects" call)
Useful when waiting for a freshly-published upstream release that's still hidden
behind the mirror_cache_expiry TTL (default 30 min).
- Auth: required (any authenticated user)
- Primary only: replicas return 400 (caches are process-local; replicas sync the persisted state via the changelog stream once the primary refetches)
- 200:
{"result": {"projects_invalidated": N, "projectnames_invalidated": true}} - 400: index is not a mirror, or the call hit a replica
- 404: index doesn't exist
Last-known poll info per replica, captured from each GET /+changelog/{N}- request via
a tween. The applied_serial field is the highest serial the replica has actually
applied (start_serial - 1 from its most recent poll). Compare against /+status
serial for true lag.
Why this isn't polling_replicas from /+status: devpi-server overwrites
xom.polling_replicas[uuid].serial during streaming and gives a misleading "caught up"
reading once the response generator drains. Capturing start_serial at the request
boundary is the only stable signal the primary alone can produce.
- Auth: required
- 200:
{ "result": { "<replica-uuid>": { "start_serial": 103, "applied_serial": 102, "last_seen": 1712345678.9, "age_seconds": 3, "stuck_seconds": 47, "remote_ip": "10.0.0.5", "outside_url": "https://replica.example.com" } } } - Entries auto-expire after 10 min of silence. Dict size capped at 256 entries (least-recently-seen evicted first) so an attacker spamming UUIDs cannot exhaust primary memory.
devpi-admin/
├── pyproject.toml
├── README.md
├── LICENSE
├── .github/workflows/
│ ├── tests.yml - CI on push/PR (Python 3.10 - 3.14)
│ └── publish.yml - publish to PyPI on release
├── dev/ - untracked dev-only prototypes (e.g. demo-graph.html)
├── devpi_admin/
│ ├── __init__.py - version (from git tag via setuptools-scm)
│ ├── main.py - Pyramid hooks, tween, API views
│ ├── tokens.py - admin token gen / lookup / revoke / list (keyfs storage)
│ ├── customizer.py - mirror package allow/deny filter (patches MirrorCustomizer)
│ └── static/
│ ├── index.html - SPA entry point
│ ├── css/style.css
│ └── js/
│ ├── api.js - devpi REST wrapper + auth
│ ├── theme.js - theme toggle (light/dark/auto)
│ ├── marked.min.js - vendored markdown renderer
│ └── app.js - routing, views, rendering
└── tests/
├── test_acl_read.py - acl_read hooks, tween guards (scope/index), token issuance
│ rules, _check_index_perm, USER-changed handler, replica poll
│ tween + endpoint, public-url
├── test_filter.py - package allow/deny customizer + tween +f/ block
├── test_hooks.py - pluggy hook registration
├── test_json_safe.py - readonly view conversion
├── test_package.py - entry point, static files
├── test_pipconf.py - pip.conf credential helpers
├── test_tokens.py - token format, issue/lookup/revoke, reset_for_index,
│ list_for_index, end-to-end cleanup chain
├── test_tween.py - redirect behavior
├── test_view_helpers.py - _get_stage_or_404, _check_read_access, CSP headers
└── test_wants_html.py - Accept header heuristic
git clone <repo>
cd devpi-admin
python -m venv .venv
.venv/bin/pip install -e ".[dev]"The dev extra pulls in pytest. A bare pip install -e . works too - the test suite
is also runnable with the stdlib unittest runner.
The static files live at devpi_admin/static/ and can be edited in place - changes show
up on the next browser reload, no restart of devpi-server required (static views read
from disk on each request). Python changes (main.py, tokens.py) require a
devpi-server restart.
Run the unit tests:
# pytest (recommended for local development)
pytest tests/ -q
# unittest (matches the CI invocation)
PYTHONWARNINGS="ignore::UserWarning" python -m unittest discover -v tests/(The PYTHONWARNINGS shim hides an unrelated deprecation warning emitted by Pyramid 2.1
when it imports pkg_resources.)
Version is derived from the git tag via setuptools-scm. To release:
git tag v0.1.0 && git push --tags- On GitHub: Releases -> Draft new release -> select tag -> Publish
- The
publish.ymlworkflow runs tests, builds wheel+sdist, and uploads to PyPI via trusted publishing (no API tokens needed - configure the GitHub environmentpypiin PyPI settings).
Pavel Revak pavelrevak@gmail.com
MIT - see LICENSE.