Skip to content

GitHub Pages Studio and isolated local compute

The public Studio is a static build at https://opendpd.com/studio/. It calls https://api.opendpd.com through a named Cloudflare Tunnel. Dataset analysis, preprocessing and CPU execution run inside the dedicated local QEMU VM. CUDA training and inference run in a restricted host container pulled by a private agent; the VM remains the only public API and owns every session. GitHub Pages serves JavaScript, styles and images only. This deployment replaces the earlier cloud pull proposal.

Browser ──HTTPS──> GitHub Pages: opendpd.com/studio/
   └──────HTTPS──> Cloudflare: api.opendpd.com/api/v1/…
                         │ named tunnel, outbound connection from host
                  cloudflared (opendpd-tunnel user)
                         │ 127.0.0.1:18765
                  QEMU (opendpd-sandbox user, restrict=on)
                         │ guest :8765
                  Public API boundary (worker user)
                         ├── random session A / private database / supervisor
                         └── random session B / private database / supervisor
                                   └── global FIFO compute slot
                  Private /_gpu API (not in Tunnel ingress)
                         ▲ authenticated pull via 127.0.0.1:18765
                  Host GPU agent (no listening socket)
                         └── one restricted CUDA container / one temporary job

Sessions and deletion

Use random session directories, never an IP address as a workspace identity. People behind a university, company, mobile network or VPN can share an IP. An IP also changes and is not an authentication credential. Two visitors using the same IP receive independent workspaces and cannot list or download each other's files. A browser tab stores its random bearer token in sessionStorage; tokens are sent only in the Authorization header, never in links or query strings. The API normally retains only token hashes in memory. During admission it temporarily retains the new session capability for at most three minutes so that a lost response can be retried without creating another workspace; the first authenticated request immediately discards that plaintext copy. A token is not bound to an IP.

/run/opendpd-web/                    # private 2 GiB tmpfs, no swap
  sessions/<random 48-hex id>/       # permissions 0700
    lease.json                      # fixed expiry, no raw IP or token
    workspace/                      # inputs, preprocessing, runs, exports, cache
  service-tmp/                      # shared library temporary files

During the trial, two hours without foreground user activity expires every temporary workspace in the creation-IP group. Groups use the existing ephemeral IP HMAC (IPv6 /48); they never grant access to another workspace. A valid session remains in its original group when its client changes network. Session creation and authenticated POST /api/v1/web/activity initialize or renew the group. Reads, waiting-room polling and running jobs do not renew it. An expired group is revoked before a new admission can initialize another lease, so later activity cannot revive old credentials. The browser coalesces foreground events to one report per minute and sends no periodic keepalive for untouched tabs.

All sessions also expire at the next 11:55 or 23:55 UTC hard cutoff. New sessions pause until 12:00 or 00:00 UTC, respectively. Every five seconds the API stops expired workspaces and deletes their files. Independently, a systemd timer at 11:59 and 23:59 UTC restarts the entire API cgroup: it terminates remaining requests and workers, then discards its private tmpfs. The stop timeout is 20 seconds with SIGKILL as the final fallback. This leaves margin before any session data reaches 12 hours. Sessions do not get a fresh 12 hours when they are accessed or when new files are generated. Late visitors therefore have a shorter session; the persistent top bar displays the earlier inactivity or scheduled cleanup start in UTC, with local time in its tooltip. Authenticated session responses include server_time, idle_expires_at and inactivity_seconds, while expires_at remains the hard deadline. At the client deadline, a session read checks whether another workspace renewed the shared group before discarding credentials. Active requests can delay individual deletions until the next sweep; the independent reset bounds that delay. Worker shutdown, database close and directory deletion run off the API event loop.

Refreshing a browser or reconnecting within the same tab resumes the existing session, events and saved plots. Restarting the API or VM discards every session; there is no recovery across a service restart, backup or persistent user database.

New workspaces start in English; an explicit saved language still wins. New experiments prefer detected CUDA, falling back to CPU when it is unavailable. An explicit device choice or imported experiment configuration is preserved.

On touchscreens, one finger scrolls the page and two fingers zoom/pan the plot. The shared controller uses the fingers' distance and moving midpoint, suppresses duplicate Safari/Plotly handling, and defers live-data redraws until touch ends. Phone controls have 44-pixel targets and enlarged plots fill the viewport. Native page zoom remains available outside plot axes. Touch handling follows the Touch Events guidance and touch-action behavior.

Swap and core dumps are disabled. Do not introduce VM memory snapshots, hibernation, workspace backups or request-body logging: those would invalidate the retention design. If the host is suspended across the deadline, the persistent timer runs on resume; deletion cannot execute while the host is asleep. Operate this host continuously and keep its clock synchronized. User-downloaded files and Cloudflare/GitHub platform logs are outside this server-side file deletion policy. Raw client IPs are not stored by the app; Cloudflare necessarily processes them at the edge.

CSV dataset uploads use a dedicated authenticated, rate-limited endpoint. Files must have a .csv suffix and contain UTF-8 comma-separated paired numeric samples: two complex columns or four real I/Q columns, with an optional header. Limits are 25 MiB, 1,000,000 sample rows and four accepted uploads per session. The server scans every row in a private quarantine folder before exposing any preview or import reference. Binary/control bytes, invalid encoding, oversized lines, broken CSV, non-finite samples and formulas/code in data cells are rejected; failed and interrupted partial uploads are deleted immediately. Successful uploads receive a random identifier and SHA-256 proof bound to their session. Import and preview routes only accept those validated sources, and imports reserve workspace capacity before materialisation. Users may correct their own dataset metadata; FFT size (65,536), subchannel count (32), rates and text lengths are bounded, and reference-waveform bindings are forbidden. This validates a restricted data format; it does not execute files or claim antivirus certification.

Dataset/code/package/checkpoint path imports, shell execution, RF control and executable deployment exports remain unavailable publicly. Downloading a generated checkpoint is allowed: the GPU agent sends a size-bounded, checksum-verified snapshot through the authenticated private bridge; browser downloads require the owning session. Downloads read a stable snapshot while newer best weights are saved atomically. After success the same control downloads the final selected model (best validation score). All generated and uploaded copies remain under the existing expiry policy.

Security and capacity

The public entrypoint is python -m opendpd.web, not opendpd gui. An explicit route allowlist sits ahead of separate local-app instances; new desktop routes are not automatically public. Because an id pattern such as /datasets/<id> also matches a static sibling route, routes that must never be published are listed by name in policy.NEVER_PUBLIC, and tests/unit/test_public_policy_surface.py fails if a static route becomes reachable without being listed. Each app has its own workspace, SQLite database and supervisor. Read, download, event polling, cancel and retry requests all resolve within the authenticated workspace.

The host listens for the API and administrative SSH only on loopback, at ports 18765 and 22224. QEMU uses restrict=on, no bridge and no guest-initiated Internet or LAN connections. The guest service and its workers cannot modify source or the runtime installation. Resource controls apply to the entire service cgroup; workers inherit a small environment without tunnel credentials.

Cloudflared rewrites the HTTP Host to a private, random origin credential. The API requires that Host, a trusted direct peer, HTTPS forwarding metadata, one valid CF-Connecting-IP, and exactly the configured browser Origin. Uvicorn proxy-header handling is disabled, and X-Forwarded-For is ignored. The public hostname alone cannot reach the origin through a forged local request. Keep the private Host and tunnel credential out of Git, browser builds and logs. Trusted host administrators remain inside the trust boundary.

CORS uses one explicit origin and no cookies. API responses are no-store with security headers. The frontend has a restrictive CSP; authenticated downloads reject redirects and foreign origins. Add frame-ancestors 'none' as a Cloudflare response header for /studio/ because a meta CSP cannot enforce it.

Limit Default
Live sessions 256 globally; 64 created per network budget interval
Trial inactivity 2 hours per creation-IP group; foreground activity only; hard scheduled expiry still applies
Waiting room 1,024 pending tickets; 16/network; three-minute heartbeat lease; five-second browser polling
HTTP requests 120/minute per session, 3,600/minute per IP; preflight has a separate 3,600/minute bucket; 32 concurrent globally, 3/session; Uvicorn connection ceiling 128
JSON request body 64 KiB, 10-second receive deadline; no multipart
Jobs 8/session, 96/network and 512 globally per budget interval; 2 pending/session, 128 pending globally
Heavy API work 2 concurrent analyses/generations/exports globally; one workspace mutation at a time; cancellation is exempt
Concurrent training/inference 1 globally across every session, oldest queued experiment first
Job runtime 30 minutes, followed by cancellation and forced termination
Public model/training parameters bounded layers, widths, batches, frames, epochs and threads
Storage 256 MiB/session with bounded route checks and rotating background scans (16 workspaces every five seconds, up to 80 seconds per full pass); 2 GiB hard limit globally; heavy writers reserve capacity before receiving data, retaining 64 MiB free headroom
Files/processes 64 MiB/file, 256 tasks, 4 CPU equivalents, 6 GiB API cgroup RAM

IPv6 addresses share a /48 rate-limit bucket. IP quotas use an ephemeral HMAC key and reset at the UTC day boundary or a service restart, including the twice-daily reset. Thus the production budget interval is at most 12 hours. They discourage abuse but do not identify people or stop distributed clients. Global concurrency, memory, disk and job budgets bound the effect of IP rotation. There is no Turnstile implementation; do not describe this demo as bot-proof. Cloudflare edge controls and monitoring remain useful for floods that exceed the local admission layer.

The VM has 4 vCPUs and 8 GiB RAM. The host's RTX 4090 Laptop GPU also drives the desktop, so it is not passed through to QEMU. CUDA is advertised only while the private agent is healthy and after its exact container image has executed a real CUDA operation. CPU work continues inside the VM.

Shared CUDA execution

opendpd-gpu.service pulls reviewed jobs from /_gpu/ using a separate random secret. That path is outside the Tunnel's ^/api/v1/.*$ ingress. The VM also requires the host-local Host header and refuses Origin/Cloudflare headers on private operations. It never initiates a network connection back to the host. Neither a browser session token nor the tunnel Host grants GPU broker access.

Every job receives a fresh 0700 directory on the host agent's 2 GiB tmpfs, containing only that tenant's data and experiment dependencies. No credentials, SQLite database, host home or container-engine socket enter the container. Transfers enforce regular files, canonical relative paths, member counts and size limits. Live-output reads use openat with O_NOFOLLOW for every path component, including against a running container changing a parent directory.

Podman runs a pinned image as UID 65532 with a read-only root filesystem, all capabilities dropped, no-new-privileges, no network, 6 GiB memory with no swap, 4 CPU equivalents, 128 processes, 64 MiB files and a maximum 30-minute lifetime. OCI logging is disabled: stdout goes only to the temporary workspace. The agent streams logs, events and snapshots into the VM; final artifacts are copied back before the supervisor records completion. Each completed, failed or cancelled job is removed from the host immediately. Stop/restart removes labelled containers and discards the service's tmpfs. Deadline checks and 30-second leases stop work if the VM is unavailable or a session expires.

The global FIFO admits one experiment at a time, across browser sessions and IPs. Opening another tab cannot acquire another GPU slot. Users see queued status and can cancel their own queued or running job. The agent also refuses a second GPU lease until the previous one finishes. This intentionally favors predictable memory use over simultaneous kernels from different users.

The 50% PyTorch allocator ceiling and a 4 GiB free-memory admission check leave headroom for the desktop; they are not hardware memory partitions. This GPU has no MIG isolation in this deployment. Containers share the host NVIDIA kernel driver, a weaker boundary than the CPU VM. Only reviewed application code, built-in datasets and validated numeric CSV data are accepted; enabling arbitrary uploaded code would require a new isolation design.

Deployment

Use the versioned units and scripts in deployment/web.

  1. Preserve the existing DNS records before changing nameservers. For this domain the initial scan found four GitHub Pages A records, four AAAA records and www → lab-emi.github.io. Squarespace additionally contains three GitHub verification TXT records at _github-pages-challenge-gaochangw, _github-pages-challenge-lab-emi and _github-pages-challenge-gaochangw.opendpd.com (the last name is relative to the zone, so its full name ends in .opendpd.com.opendpd.com). Preserve these records too; an automatic apex DNS scan misses them. Keep registration at Squarespace; change DNS hosting only. Cloudflare assigned ali.ns.cloudflare.com and scott.ns.cloudflare.com. Check authoritative NS and DS records before and after switching. Never remove an active DNSSEC chain without coordinating the registrar change.
  2. The existing custom domain belongs to lab-emi/opendpd-site. Its Pages workflow builds the landing page, checks out a reviewed commit of this repository, builds frontend/ with VITE_STUDIO_MODE=web, VITE_API_ORIGIN=https://api.opendpd.com and relative base URLs, then adds that build at /studio/ in one Pages artifact. This preserves the existing homepage and makes the frontend run at the exact allowed Origin. Set that workflow's STUDIO_REF variable or its studio_ref dispatch input to deploy a later reviewed OpenDPD revision. Updating this repository alone does not update the production frontend. This repository's Docs workflow validates the web build and publishes documentation with a redirect from its /studio/ to the canonical app. No API secrets belong in either repository's Actions variables or frontend build variables.
  3. On the existing sandbox host, stop the CPU validation VM before running sudo bash deployment/web/provision-vm.sh. It creates a separate frozen base, writable web overlay and SSH public-key seed. It refuses to overwrite an existing web disk and does not switch GPUs.
  4. Prepare the Python runtime offline for the guest's OS, architecture and Python version, with the project's core and gui dependencies plus uv, setuptools, setuptools_scm and wheel. Copy source to /opt/opendpd and the runtime to /opt/opendpd-venv. Existing validation-container runtimes can be reused, but install this source revision into the venv: a different working directory alone will not update training subprocess imports.
  5. Create root-only /etc/opendpd-web.env inside the guest:
OPENDPD_WEB_ORIGIN=https://opendpd.com
OPENDPD_WEB_API_HOST=api.opendpd.com
OPENDPD_WEB_TUNNEL_HOST=REPLACE_WITH_RANDOM_48_HEX.internal

Generate the private value with secrets.token_hex(24) + '.internal' and put the identical value in cloudflared's httpHostHeader. Do not use the placeholder. Run sudo bash /opt/opendpd/deployment/web/install-guest.sh. The provided units require worker UID/GID 1001 and systemd with tmpfs and cgroup resource controls. 6. Create a named, dashboard-managed Cloudflare Tunnel for api.opendpd.com. The installed production tunnel is opendpd-studio. Configure one published application with hostname api.opendpd.com, path ^/api/v1/.*$, service http://127.0.0.1:18765, and the private HTTP Host from step 5. Retain the default 404 for unmatched requests. Save its token at host /etc/opendpd-web/tunnel.token (0600), owned by the dedicated opendpd-tunnel account. Install cloudflared-opendpd-remote.service; the token is read from that file and is absent from command-line arguments. An account-wide management certificate is not needed by the service. Publish no SSH, TCP, private-network or catch-all service routes. Never use a Quick Tunnel for this service. 7. Enable the connector and opendpd-web-vm.service. Verify that Cloudflare reports it healthy and that local 127.0.0.1:20242/ready has active connections. The alternative cloudflared.yml.example and cloudflared-opendpd.service are for a locally managed tunnel with a JSON credential; do not run both connector variants or expect local ingress YAML to replace dashboard-managed configuration. Route only the API hostname to the tunnel. Keep the API cache bypassed, use HTTPS, and avoid browser challenges on API OPTIONS requests. If the frontend is proxied through Cloudflare, use Full (strict) TLS to GitHub Pages. Do not use Flexible TLS. 8. From the public browser, test session creation, built-in dataset analysis, PA → DPD → inference, authenticated downloads, cross-session denial, expiry and reset. Keep public routing disabled until these controls and the guest reset timer are ready. The local health check is:

curl -f -H 'Host: 127.0.0.1' http://127.0.0.1:18765/healthz

The service has no third-party telemetry or mail notifications. Inspect systemctl status and the journal for the VM, connector, guest API and reset timer. A failed cleanup disables session admission and makes health return 503. Stop the connector to take the public API offline; leave the guest reset timer running so data is still purged. Source updates require restarting the guest API, which intentionally invalidates all existing sessions. A release that adds public routes (2.4.1 added the two NR test model routes) must reach the API before the site: redeploy the API first, then set STUDIO_REF.

Host reboot recovery

From 2.4.2, the VM unit enables virtio-balloon-pci,free-page-reporting=on and uses cache=none for its qcow2 disk. Linux reports unused guest pages for host reclamation while retaining all 8 GiB of guest capacity; direct I/O avoids duplicating the guest's disk cache on the host. There is no balloon size controller and no forced cache flush. A guest kernel with virtio balloon and free-page reporting support is required (the production Ubuntu 6.8 kernel supports both). Restart the VM to apply the new unit after draining jobs; this expires temporary sessions. Measure RSS/PSS once per QEMU process, not once per thread, and check cgroup file cache separately: sudo python3 deployment/web/measure-memory.py --output /tmp/vm-memory.json. The optional --exercise-mib 1024 verifies allocation and release during a maintenance window. See the 2.4.2 release notes.

Everything restarts unattended after a host reboot; no login is needed. opendpd-web-vm.service, opendpd-tunnel-guard.service, the connector and opendpd-gpu.service are enabled system units, and the guest enables opendpd-web.service and its reset timer. Temporary sessions do not survive, by design. Three details keep that path reliable:

  • /run is empty after a reboot. opendpd-gpu.service lists Podman and runc runtime directories in ReadWritePaths=, and systemd refuses to start a unit when one is missing (status=226/NAMESPACE), so /etc/tmpfiles.d/opendpd-gpu.conf creates them during early boot.
  • The agent only Wants= the CDI refresh. A Requires= dependency on a one-shot fails the agent's start job permanently after a single failure. The refresh is re-run before every agent restart, and the agent's CUDA probe exits non-zero until the GPU is usable, so the pair retries every five seconds.
  • poweroff.py waits for QEMU to exit. systemd terminates remaining processes as soon as ExecStop= returns, which would otherwise cut the guest's power during its shutdown on every host reboot.

After a reboot, check systemctl is-active opendpd-web-vm opendpd-gpu opendpd-tunnel-guard cloudflared-opendpd-remote and the local health command above. The guest API needs roughly a minute after the VM unit reports active.

DNS migration and connection failures

A healthy Tunnel does not prove that visitors can resolve the API hostname. After a nameserver change, some recursive resolvers can still query the old provider and cache NXDOMAIN for an API record created only at Cloudflare. Compare the client's configured resolver, public resolvers and both providers:

dig api.opendpd.com A
dig @1.1.1.1 api.opendpd.com A
dig @8.8.8.8 api.opendpd.com A
dig @ali.ns.cloudflare.com api.opendpd.com A
dig @ns01.squarespacedns.com api.opendpd.com A

Prepare the required records at both providers before changing nameservers where possible. If migration has already started, retain the old zone during the transition and update it where the old provider permits this. Do not switch nameservers back just to enable editing. Negative answers already cached by routers or recursive resolvers remain until their TTL expires or that resolver's administrator clears them; flushing only the browser or OS cache does not clear an upstream cache.

An /etc/hosts override or curl --resolve can separate DNS failures from TLS/API failures, but does not validate public DNS. Repeat the browser session test with the normal resolver after removing any diagnostic override. A successful lookup immediately after removal can still be a cached positive answer; also check the configured upstream resolver directly.

The web client bounds concurrent reads to two, leaving an API slot for Stop. Reads time out after 30 seconds. Event polls resume from their last sequence, replay backlog, honor rate-limit backoff and resume when the tab comes online. A background request failure preserves the existing experiment view. Resync invalidates the run's snapshots, history and artifacts without submitting a new experiment; the terminal remains read-only and has a Stop experiment button. The frontend gives a localized connection error and an explicit Retry action. Waiting-room admission is the sole automatically retried POST: its capability makes lost-response retries idempotent during the admission lease. Other mutation requests are not automatically retried, and bearer tokens are never sent to an alternate origin. Browser fetch errors cannot by themselves distinguish DNS, offline, TLS and CORS failures.

Since 2.2.8, routes load on demand and abandoned query reads are cancelled to release the two-read budget. Hosted idle run lists/counts poll every 30 seconds; active runs retain ten-second list updates and their existing event stream. The activity POST shares the read budget so it cannot consume Stop's reserved slot. It requires the same bearer, origin, trusted proxy and bounded empty JSON body as other authenticated routes, but does not take the dataset-mutation mutex. Activity is client-reported and is a resource-retention policy, not a bot detector.

Validation evidence

Local validation on 2026-09-12 passed 279 Python unit tests (1 skipped), 32 public/local Studio integration tests, 178 existing frontend tests and 4 web-client tests. Frontend type checking, lint, production build and npm audit also passed (zero reported dependency vulnerabilities at that check).

The dedicated offline production VM completed real built-in analysis, PA training, DPD training and inference. A second session could not read the first session's dataset, run or events; upload requests were denied in that earlier rollout. CSV uploads were subsequently enabled with the 2.2.0 quarantine controls above. The daily-reset unit was invoked during an active training run to verify worker termination, token invalidation and replacement of the tmpfs with an empty session directory. Source is read-only to the worker, guest Internet egress is blocked and swap is disabled. Public DNS/Tunnel activation and a browser test through the real HTTPS hostname are separate release checks.

GPU/reconnection validation on 2026-09-13 passed 281 Python unit tests, 191 frontend tests and the web-mode browser recovery journey. The candidate systemd GPU service completed PA training, DPD training and inference on CUDA. Two sessions exercised queueing and cross-session cancellation denial; both cancellation and loss of the VM transport removed the container and left no host job files. Private bridge tests also cover lease expiry and unsafe archive or live-output paths.

References: GitHub Pages custom workflows, Cloudflare domain onboarding, Tunnel routing, Tunnel origin parameters.

Installing the GPU agent

Install Podman and NVIDIA Container Toolkit with CDI on the host. Build the reviewed source with podman build -f deployment/web/GpuContainerfile -t localhost/opendpd-gpu:studio .. Network access here is for dependency installation during the build; experiment containers always use --network=none. Record the resulting local sha256: image ID in /etc/opendpd-web/gpu-agent.env as OPENDPD_GPU_IMAGE.

Install the reviewed Python source at /opt/opendpd-gpu, owned by root and not writable by the worker. Create a random 32-byte hex secret in a root-only file and point OPENDPD_GPU_TOKEN_FILE at it in the host environment file. Set the same value as OPENDPD_GPU_TOKEN in the guest's root-only API environment file. Never place this value in Git, frontend variables or deployment output.

Install opendpd-gpu-cdi.service and opendpd-gpu.service, and copy opendpd-gpu.tmpfiles.conf to /etc/tmpfiles.d/opendpd-gpu.conf (then run systemd-tmpfiles --create /etc/tmpfiles.d/opendpd-gpu.conf). Refresh systemd and enable the GPU agent. Restart the guest API only after draining existing runs, since that operation expires all sessions. The CDI service regenerates the device specification on startup; rebuild or reprobe after driver/image changes. Validate real CUDA training, queueing, cancellation, restored plots, lost-lease termination and empty host temporary storage before publishing the frontend.

Studio 2.2.4 rollout

Deploy the reviewed 2.2.4 source consistently to the isolated API, the private GPU agent and its pinned container image, and the opendpd-site Pages build. Drain queued/running experiments before replacing the API; retain prior source and image pins for rollback. Restarting the API expires existing temporary sessions. Verify reported versions before the public generation → Virtual PA → paired dataset → CUDA PA/DPD training/testing journey.

Generator records contain input only. The /pa-library/ routes perform bounded mathematical simulation inside the owning temporary workspace, preserve synthetic provenance and require explicit paired-dataset creation. New PSD signal_node fields are display metadata; tenant boundaries, quotas, expiry and numerical metric protocols remain unchanged. Publication-figure and optional GitHub dataset-contribution capabilities keep their existing operator policy.

2.2.6 resource status and security update

Authenticated GET /api/v1/system/status exposes aggregate session/job counts and cached resource samples. A single background sampler per API process reads CPU/RAM every five seconds; the private GPU agent samples host CPU/RAM and GPU 0 and sends validated telemetry to POST /_gpu/resources. That endpoint requires the existing private bridge credential and refuses browser origins. Status reads neither run GPU commands nor disclose process names, IPs, workspace identifiers or dataset details. Job counts are cached across viewers; collection errors produce unknown counts instead of zero. Samples older than 20 seconds are stale.

Workspace POST/PUT operations serialize within a tenant; cancellation remains available. Heavy API work has a separate global admission limit, and temporary-space reservations prevent concurrent writers from spending the same free capacity. Cleanup scans run off the event loop and tolerate files atomically replaced by legitimate writers. Failed cleanup still disables new sessions.

The runtime image pins PyTorch 2.14/CUDA 13.2 by digest, applies Ubuntu updates and removes unused build headers and package installers. Keep the existing network-free, non-root, read-only worker limits. Deploy reviewed source to the API, agent, image and Pages together, with no queued/running jobs at the swap and rollback copies retained. See load semantics and the 2.2.6 security review.

2.2.7 admission and bounded scheduling

POST /api/v1/web/sessions accepts an empty JSON object. The web client generates a 256-bit ticket and stores it in tab-scoped sessionStorage before the first request, sending it as Authorization: Queue <ticket>. The server hashes it and bounds waiting tickets before creating any workspace. Legacy requests without a ticket still work and receive a ticket if queued. Responses use 201 for admission (session bearer and fixed expiry) or 202 for waiting (position, capacity/storage/cleanup reason, five-second retry delay). Credentials are excluded from React Query data, URLs, telemetry and logs.

Each live ticket preserves FIFO position, refreshes its three-minute lease and reserves no worker, dataset directory or CPU task. A dropped admission response can be replayed with the same ticket; after the first authenticated request, replay returns queue_expired rather than exposing an acknowledged session token. Expired tickets rejoin at the back; shutdown discards the in-memory queue. POST /web/queue/cancel requires the same queue credential and removes only that ticket or its still-unclaimed admission. A queue credential cannot access workspace routes or the private GPU bridge.

POST /web/sessions/end requires the normal session bearer. It marks that workspace closed immediately; expiry maintenance waits for in-flight operations before deleting it. The UI requires explicit confirmation before this destructive action. A later queue poll can reclaim the closed slot immediately after requests finish.

One shared supervisor thread polls only workspaces with submitted jobs. The dispatcher reads pending runs once per iteration and chooses the oldest globally; empty workspaces run neither a supervisor loop nor the unavailable local sweep controller. A separate shared resource sampler remains. Admission builds routing tables off the event loop, reserves storage for workspace metadata and skips full quota scans. Maintenance checks expiry on every pass but spreads storage scans across 16 workspaces per pass. Existing heavy-operation limits and the one-job compute semaphore remain in force. See the 2.2.7 validation for measured empty-workspace capacity and its limits.

In 2.2.8, job-count snapshots query only the registered supervisors with submitted work, under the same lock as dispatch and retirement. Empty workspace counts are known to be zero; a SQLite failure for actual submitted work remains unknown. Admission acknowledgment uses a direct ticket lookup rather than scanning all waiting tickets for each authenticated request. See the 2.2.8 measurements.

Signal inspection (2.2.9)

The explicit public allowlist includes signal-source listing, analysis and numeric CSV upload. Signal uploads use the same trusted-origin, bearer, body streaming, timeout and quarantine boundary as paired CSV uploads, with a separate validated array store. Every field is checked before publication; limit 25 MiB, eight columns, two million numeric fields and one million rows. Storage is reserved before upload, and each workspace retains at most 16 such signals.

Analysis uses at most one million selected samples, a 16,384-point PSD and a bounded 256 × 128 spectrogram display. Requests occupy the shared heavy-computation limit and workspace mutation mutex, with a per-network rate limit. They do not reserve disk because analysis itself writes no files. Source identifiers resolve only within the authenticated workspace; unavailable-source errors disclose no host paths. Status and cancellation remain usable while numeric capacity is full.

Maintenance hardening in 2.2.10

Install opendpd-tunnel-guard.service and /etc/opendpd-web/tunnel-guard.sh before starting the dashboard-managed connector. The script needs nftables and the dedicated opendpd-tunnel identity. It atomically replaces only the inet opendpd_tunnel table. New loopback connections from that identity may reach TCP 127.0.0.1:18765; SSH, metrics and other loopback services are refused. Internet tunnel connections and other host identities retain their existing firewall policy. Test the allowed API connection and refused SSH/metrics connections after installation.

The GPU service retains rootful Podman on this installation, with an explicit capability set for OCI mounts, namespace setup and ownership changes. NoNewPrivileges, loopback-only IP access, restricted address families, protected kernel settings and inaccessible VM/tunnel secrets also apply to the agent. Containers retain their separate unprivileged UID, empty capability set, read-only root and disabled network. A startup check rejects writable source trees, symlinks and exposed GPU credentials. This reduces the rootful agent's reach; it does not equate to a rootless deployment.

requirements-gpu.lock fixes the Python additions to the digest-pinned CUDA image; installation requires hashes. requirements-vm.lock records the Python 3.14 Linux CPU environment, including its CPU tensor wheel. Build a wheelhouse on a connected staging host with those exact hashes, then install offline with uv pip sync --python /opt/opendpd-venv/bin/python --no-index --find-links /opt/wheelhouse --require-hashes requirements-vm.lock. The source package is installed separately with --no-deps --no-build-isolation. Record the source commit and final OCI image digest for each deployment. OS security updates must be rebuilt and tested separately.

The guest installer moves an existing OPENDPD_GPU_TOKEN value into /etc/opendpd-web-gpu.token, readable only by root and the worker group, and stores only OPENDPD_GPU_TOKEN_FILE in the environment file. New installations should use the file setting directly. Keep public dataset publication disabled unless a separately reviewed submission identity and approval process are configured.

The VM administrator has passwordless sudo solely inside the isolated disposable VM; the host administrator's SSH key is root-readable only. Provision the baseline from a reviewed clean image with no user captures, credentials or histories. The production installer refuses to replace an existing VM disk.

Multi-preset generation in 2.2.11

POST /signal-generator/batches accepts up to 16 distinct presets and four million total samples. POST /pa-library/datasets validates all sources before registering any datasets, simulates each at its own sample rate, and saves a collection. Captures use ordinary versioned dataset storage so existing training/evaluation operates on one selected capture with truthful frequency metadata. The first capture is the parent; other captures refer to its ID. The parent manifest records membership, sample counts and independent sample rates. Failed registration removes only datasets created by that operation.

The two new mutations have explicit public allowlist entries, schema validation, per-IP rates, shared numeric admission and aggregate disk estimates. Custom-dataset capability gating also covers the one-step endpoint. Authenticated GET /datasets/{id}/download returns a CSV or collection ZIP and shares heavy-read admission. collection=false selects a single capture/version. Export reads verify file membership and hashes; generated replay source is frozen and hash checked, never evaluated by the server. Response completion removes temporary export files. No user-supplied executable code is accepted.

Deploy the same reviewed 2.2.11 commit to the API, GPU agent/container and static site after draining pending jobs. Retain source/image rollback copies; a server restart expires temporary sessions. Validate multi-preset generation, dataset navigation, authenticated ZIP download and a CUDA training/testing job after deployment.

Triton runtime in 2.2.12

The GPU image retains GCC, libc development headers and matching Python development headers because Triton compiles its driver and kernel launchers on first use. A per-container 256 MiB tmpfs at /run/opendpd-triton, writable by the unprivileged worker with sticky mode 1777, is executable and supplies TRITON_CACHE_DIR. It contains generated libraries and disappears with the container. /tmp and /workspace remain noexec; containers retain disabled networking, a read-only root, no capabilities and the existing resource limits.

Before polling for jobs, the agent runs python -m opendpd.web.gpu_probe in the same sandbox with an empty cache. The probe requires Triton acceleration and finite DeltaGRU outputs, input gradients and weight gradients. A missing toolchain or unusable executable cache fails startup instead of advertising unusable GPU capacity. Allow up to 60 seconds for this cold probe. Deploy the updated agent together with the matching 2.2.12 image.

2.2.18 resource and transfer limits

Export GET routes share the heavy-work budget. JSON bodies are bounded and read before reserving compute/storage; CSV uploads retain a timeout and one heavy request per network. Each network has at most six in-flight requests and one heavy request. Export files are cached and workspace locks do not serialize different tenants. The 65,536-entry session-budget table refuses only new networks when full; IPv6 addresses share a /48 budget. A distributed attacker can still consume finite service capacity; edge rate limiting remains necessary.

GPU inputs accept only data/artifact layouts. Container entry points use Python safe-path mode from /; live reads and final output walks anchor every untrusted path component to directory descriptors. The container is removed before output collection. The host agent remains rootful, so this boundary still requires operational defense in depth.

NR test models in 2.4.1

The 2.4.1 Signal Generator opens on the NR FR1 standard test models of 3GPP TS 38.141-1 clause 4.9.2. Two literal allowlist entries make them reachable in the public Studio:

Route Purpose Cost
GET /signal-generator/nr-test-models The catalogue: models, parameter ranges, resource-block tables, fixed parameters, defaults Static for a server version, 43,581 bytes (about 43 KB)
POST /signal-generator/nr-test-models/resolve A recipe in, the complete generator configuration and its summary out A pure function of the request: 2.9 KB of JSON for one carrier, 14.7 KB for sixteen

resolve reads and writes no workspace data, so policy.STATELESS_POSTS exempts it from what other generator POSTs need: it sets no mutation flag, so it also runs while a workspace change is in progress, and it reserves no storage and takes no shared expensive-request slot. The web interface calls it 0.4 seconds after the last edit, so it stays inside every other boundary: the allowlist, the exact Origin, a valid session, the 120 requests per minute per session, 3 concurrent requests per session and 6 per network, a JSON-only body of at most 64 KiB with the ten-second receive deadline, and a mutation rule (nr-test-model) that allows 120 requests per minute per network. The catalogue GET is not a heavy request.

Three routes accept test-model recipes: resolve, POST /signal-generator/batches and POST /signal-generator/signals. Their mutation rules are marked recipe=True and work in this order. First the request is charged against the per-network rate (120 per minute for resolve; the signal-generator budget of 12 batch and 24 single-signal requests per minute, counted together, for the other two), so a body that turns out to be invalid counts as well and repeated invalid bodies end in 429 rate_limited. Then the body is validated in a worker thread (asyncio.to_thread), so that the check never occupies the event loop that every tenant and the health probe share. Both steps apply to every body these routes receive, ordinary generator configurations included, not only to recipes: 2.4.0 validated first and charged only what passed, so an invalid body to /signal-generator/signals or /signal-generator/batches cost nothing, and from 2.4.1 it counts. Measured with the public test client, 30 invalid posts of {"preset_id": "wifi7-20", "n_samples": 5} to /signal-generator/signals gave 24 x 422 and 6 x 429 on 2.4.1 and 30 x 422 on 2.4.0, so a client that keeps sending invalid requests receives 429 sooner. Validation is closed-form arithmetic, not synthesis: no waveform and no NB-IoT frame is built, and the plan of a recipe is derived once and kept in a bounded memo (256 recipes per process, refusals included with their message), so the recipe validator, the generator configuration and the batch share one derivation. The most expensive body the 64 KiB limit lets through, 16 configurations of 16-carrier NR-N-TM (about 48 KB), costs about 10 ms of CPU cold and 3 ms warm, measured as thread CPU time on the development machine. An invalid body is answered with 422 invalid_request: for resolve with the field and message of every violation (for example that 5 MHz is not defined for 60 kHz), for the other two with the generic hint. Other mutation rules keep their order (validate, then charge).

Generating a test-model signal uses the ordinary POST /signal-generator/batches and /signals routes with their existing limits: 16 configurations and 4,000,000 samples per batch, 1,000,000 per signal, the per-network signal-generator budget, one shared expensive-request slot and the workspace mutation flag. The server re-derives the structural fields from the recipe, so a stale or hand-edited configuration is a 422 that asks for the recipe to be edited.

A test-model capture can be large. Measured with the public test client on 2.4.1 (one 100 MHz carrier at 30 kHz: 122.88 MS/s and 983,040 samples, which is the 8 ms that the 1,000,000-sample limit leaves of the standard's 10 ms):

What is stored Size
After generation (7.9 MB of I/Q samples, 1.2 MB manifest with the analysis curves, provenance) 9.0 MB
CSV export (input.csv, created on first request and kept) 25.5 MB
ZIP archive (download, created on first request and kept; it contains the CSV again) 18.5 MB
Generation, CSV and archive together 53 MB of the 256 MiB (268 MB) temporary workspace

The admission estimate is 100 B x samples + 2 MB per configuration, 100.3 MB for this capture, because it also covers the exports, and it is checked together with the bytes already stored. Two such captures in one request pass while the workspace holds less than 67.8 MB; three (300.9 MB) are refused with 413 workspace_limit before anything is generated. Requested one at a time and never exported, 19 captures of this size (172.0 MB) fit before the 20th is refused. Shorten the capture (the recipe's subframes) or lower the oversampling to store more. Resolving takes a few milliseconds (about 3 ms for one 100 MHz carrier, 5 ms for sixteen 10 MHz carriers); generating a capture of this size took 0.16 s for one 100 MHz carrier, 0.33 s for four (983,040 samples at 491.52 MS/s, 2 ms) and 0.94 s for sixteen carriers of 10 MHz at 15 kHz (245.76 MS/s, 4 ms of the standard's 10 ms), each storing 9.1 to 9.3 MB, with a peak process size of 0.4 GB for the whole measurement.

Deploy the API before the site. An API of 2.4.0 answers both routes with 403 feature_unavailable, even before a session exists. The 2.4.1 web build treats a 403 or 404 for the catalogue as "no test models available" and opens on the generic preset matrix with nr-20 selected, as 2.4.0 did, without a notice; any other failure of that request (a server error, no connection, a reply that is not a catalogue; it is not retried automatically) also leaves the matrix in place, with a one-line notice and a Retry button. The Signal Generator page waits for that request before it shows any family. Redeploy the guest API as described under Deployment, then set STUDIO_REF of lab-emi/opendpd-site; the site built first only shows the matrix until the API serves the new routes. An older site on a newer API needs nothing: configurations without a recipe keep their identifiers, manifests and dataset provenance byte for byte.

Rollback. A workspace that holds 2.4.1 test-model signals cannot be read by 2.4.0: the strict 2.4.0 models reject the new nr_test_model key and the new standard_test_model coverage value, so one such signal makes the signal list, the Signal Analyzer sources and datasets, the signal's own GET and download and a PA simulation on it answer 500 (ordinary signals can still be opened one by one). On the public Studio the tenants are cleared by the rollback itself: starting the public service (2.4.0 as well as 2.4.1) removes every session directory, so no tenant workspace survives into the old API; python -m opendpd.web --cleanup does the same after the service has stopped. Do not start 2.4.0 on a copy of a session directory that holds test-model signals. For a persistent local workspace, remove or archive the signals/sg-* folders whose manifest.json has "coverage": "standard_test_model", or reset the workspace, before running 2.4.0.