Skip to main content

Modern web UI plugin for devpi-server — drop-in replacement for devpi-web

Project description

devpi-admin

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.

Features

Dashboard

  • 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_serial vs. 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 admin logo 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

Indexes

  • Visual cards color-coded by type: green (stage), amber (volatile stage), blue (mirror)
  • Warning tags for ACL edge cases:
    • world-writable - acl_upload contains :ANONYMOUS:; supply-chain risk
    • no upload - acl_upload is 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 needed
    • Tokens (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 locked
    • Refresh 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, or devpi-client) goes back to upstream (etag-conditional, cheap)
    • Edit / Delete (owner / root)
  • Create / edit / delete indexes via modal dialogs
  • bases editor with drag & drop priority ordering and transitive inheritance display
  • acl_upload and acl_read tag pickers with user selection dropdown
  • volatile, mirror_url, title configuration
  • Mirror package allow/deny lists (package_allowlist, package_denylist) — see Mirror access control below

Read access control (acl_read)

  • 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_read permission 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

Mirror access control (allow/deny lists)

  • Per-mirror package_allowlist and package_denylist filter the projects, versions and simple-index links served from upstream. Only type=mirror indexes 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
  • 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 blocklisturllib3<1.26.5, cryptography<41.0.0
    • Internal namespace banmycompany-* keeps PyPI typosquats from shadowing private packages on a public mirror
    • Whitelist-only mirrors — paste curated requirements.txt style entries into package_allowlist; everything else is blocked

Status endpoint & monitoring (/+status)

  • devpi-server's /+status is 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 …/+status work as before.

  • Monitoring without credentials: point Zabbix / Nagios / an uptime check at GET /+status and alert on HTTP != 200 or result.status != "ok". status is "fatal" when a replica has active replication errors (stuck replica / plugin mismatch); otherwise "ok". Fine-grained lag thresholds require authentication.

Admin tokens (scoped, revocable)

  • Opaque adm_<id>.<secret> tokens bound to a (user, index, scope) triple. Scope is read (pip install) or upload (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
    read GET, HEAD /+api, /<token.user>/<token.index>/...
    upload GET, HEAD, POST, PUT /+api, /<token.user>/<token.index>/...

    DELETE is never granted, even with upload scope - 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 on villapro/staging link to /root/pypi/+f/...); without this exception pip would follow the link with the bound token and get 403. Limited to GET on +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_upload of 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 indexes dict)
    • 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=3600 returns a ready-to-use pip.conf (text/plain) in one HTTP call. For upload, use POST /+admin-api/token with {"scope": "upload"}.

Users

  • 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-tokens plugin 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 new button) — 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 with read as 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 a user:token pair — but only the configs that actually match the issued token's intent (no pip.conf for upload-only or public-index tokens; no .pypirc for read-only tokens).

Packages

  • Client-side search with PEP 503 name normalization and relevance ranking (exact match > prefix match > substring match, then shortest name first) so searching requests in a 780k-project upstream surfaces requests itself, not django-requests-cache first
  • 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 install command

Package detail (PyPI-like layout)

  • Sidebar: metadata (author, license, Python version, keywords, platform, maintainer, extras, project URLs, dependencies), pip install command, 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

General

  • Anonymous browsing - visitors can explore public indexes without logging in; admin actions (create/edit/delete) appear only after authentication. Private indexes (acl_read without :ANONYMOUS:) are hidden from anonymous root listing.
  • Hardened SPA delivery - strict Content-Security-Policy (no inline scripts, connect-src limited 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

Installation

pip install devpi-admin

This 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-server

You should uninstall devpi-web - devpi-admin replaces it entirely:

pip uninstall devpi-web

Both 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.

Replicas: install on every node

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 devpi

Replication 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.

Recommended for production: --restrict-modify root

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.

Optional plugins

devpi-tokens coexistence

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 devpi

The 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.

Usage

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.

CI/Ansible: short-lived pip.conf via the API

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.txt

Replication race: wait_replicas

When 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.conf

For 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.

Running behind a reverse proxy

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; }
    }
}

Real client IP

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.

Hide version banners

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 - the devpi client checks it for compatibility
  • X-Devpi-Serial - drives replica sync and the client's wait_replicas logic
  • X-Devpi-Uuid / X-Devpi-Master-Uuid / X-Devpi-Primary-Uuid - replicas use these to confirm they are talking to the expected primary

Login rate-limiting

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 nodelay lets a short legitimate spike (e.g. a CI fan-out) through immediately; only requests beyond the burst get 429.
  • Tune rate/burst to 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.

How it works

devpi-admin registers a devpi_server entry point with several @hookimpls:

  • devpiserver_get_features - advertises the plugin in /+api.
  • devpiserver_indexconfig_defaults - registers acl_read as an indexconfig field with an ACLList marker so devpi normalizes its values on every PUT/PATCH.
  • devpiserver_stage_get_principals_for_pkg_read - feeds acl_read into devpi's pyramid ACL, which applies the pkg_read permission natively on every download path (+f/, +e/, simple page).
  • devpiserver_get_identity - recognizes adm_<id>.<secret> admin tokens, validates them against keyfs (constant-time hash compare), sets adm.is_admin_token in 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. new indexes dict via tx.get_value_at). Primary only - replicas are read-only.

The tween does several things on every request:

  1. Captures replica poll info. Matches GET /+changelog/{N}-? and records start_serial + last_seen keyed by the X-DEVPI-REPLICA-UUID header. This is the data source for /+admin-api/replicas and the dashboard's stuck-replica detection.
  2. 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, sets adm.token_meta in the request environ for the identity hook to reuse.
  3. Enforces token scope and index binding:
    • read scope -> only GET/HEAD allowed
    • upload scope -> adds POST/PUT (DELETE is never granted)
    • URL must be /+api or under /<token.user>/<token.index>/.... Anything else (other indexes, SPA, /+admin-api/*, /+login, root listing, /<user>) returns 403.
  4. Redirects HTML browser requests on / to /+admin/ while leaving JSON requests intact.
  5. Returns 404 for GET /<user>/<index>/... (index, simple, project, version, file) when the requestor lacks pkg_read - devpi's own listing endpoints have no permission check, so we add one.
  6. Returns 403/404 for GET /<user> when the requestor is neither the user themselves nor root - devpi otherwise leaks the full list of that user's private indexes.
  7. Filters the GET / JSON response to remove indexes the requestor can't read, and adds Cache-Control: private, no-store so 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.

Requirements

  • Python 3.9+
  • devpi-server 6.19 <= version < 7.0 - we rely on tx.get_value_at, the X-DEVPI-REPLICA-UUID header and the polling_replicas dict shape introduced in 6.19; the upper bound is held until 7.x compatibility is verified.
  • A browser with ES6 support (Promise, fetch, sessionStorage)

Routes (UI)

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)

API

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).

Session and discovery

GET /+admin-api/session

Cheap auth check; the frontend pings this on tab focus to detect expired sessions.

  • Auth: required
  • 200: {"valid": true, "user": "alice"}
  • 403: not authenticated

GET /+admin-api/public-url

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"}

Project metadata

GET /+admin-api/versions/{user}/{index}/{project}

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_read on the index
  • 200: {"versions": ["1.0", "0.9", "0.8"]}

GET /+admin-api/versiondata/{user}/{index}/{project}/{version}

Metadata + file links for a single version (PEP 426 / PEP 621 fields plus +links with href, basename, hash_spec, upload log).

  • Auth: pkg_read on the index
  • 200: {"result": {...}}
  • 404: version doesn't exist

Tokens

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.

POST /+admin-api/token

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?} - token is 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

GET /+admin-api/pip-conf?index=u/i&user=&ttl=&label=&wait_replicas=

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
    

GET /+admin-api/users/{user}/tokens

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}

DELETE /+admin-api/users/{user}/tokens

Revoke ALL tokens for a user.

  • Auth: the user themselves, or root
  • 200: {"revoked": N, "user": "alice"}

GET /+admin-api/indexes/{user}/{index}/tokens

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_read on the index (404 otherwise)
  • 200: {"result": [...], "count": N} - same record shape as /users/{user}/tokens

DELETE /+admin-api/tokens/{token_id}

Revoke a single token.

  • Auth: owner of the token, or root
  • 200: {"revoked": true, "id": "abc..."}
  • 404: token id not found

Mirror cache

POST /+admin-api/mirror/{user}/{index}/refresh-cache

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

Replication observability (primary only)

GET /+admin-api/replicas

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.

Project layout

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

Development

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.)

Releasing

Version is derived from the git tag via setuptools-scm. To release:

  1. git tag v0.1.0 && git push --tags
  2. On GitHub: Releases -> Draft new release -> select tag -> Publish
  3. The publish.yml workflow runs tests, builds wheel+sdist, and uploads to PyPI via trusted publishing (no API tokens needed - configure the GitHub environment pypi in PyPI settings).

Author

Pavel Revak pavelrevak@gmail.com

License

MIT - see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

devpi_admin-1.5.0.tar.gz (168.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

devpi_admin-1.5.0-py3-none-any.whl (112.5 kB view details)

Uploaded Python 3

File details

Details for the file devpi_admin-1.5.0.tar.gz.

File metadata

  • Download URL: devpi_admin-1.5.0.tar.gz
  • Upload date:
  • Size: 168.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for devpi_admin-1.5.0.tar.gz
Algorithm Hash digest
SHA256 eedb9a5d02ef2c085bf35ecfb56b3eed652f0bb4400727f85c05d39f4e3beac2
MD5 61a1f9feb9720ddc3f4ac3133aa4a27a
BLAKE2b-256 0337cf25efdd9682424d74897192c1961599a899f89161509df865e0fe822fce

See more details on using hashes here.

Provenance

The following attestation bundles were made for devpi_admin-1.5.0.tar.gz:

Publisher: publish.yml on pavelrevak/devpi-admin

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file devpi_admin-1.5.0-py3-none-any.whl.

File metadata

  • Download URL: devpi_admin-1.5.0-py3-none-any.whl
  • Upload date:
  • Size: 112.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for devpi_admin-1.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f1da1bfd8ddead0b3739438b52587c164076108008da8893daf0016277002cc0
MD5 f58bf59fb4be82517162d117e9887269
BLAKE2b-256 5830fa28d0619447e1d48d2227b59723451fb0a6c6a8b01803b86cf576ca4919

See more details on using hashes here.

Provenance

The following attestation bundles were made for devpi_admin-1.5.0-py3-none-any.whl:

Publisher: publish.yml on pavelrevak/devpi-admin

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page