Skip to content

pavelrevak/devpi-admin

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

37 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.

About

Alternative devpi admin web interface

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors