An operating position, not a demo
Every claim on this page is either a config value you can read
in this repository or a decision documented, with its reasoning,
in CLAUDE.md. Nothing here is aspirational.
The system, top to bottom
One image, dpe-box:latest, plays four roles by
environment variable alone: DPE_ROLE=web runs the
browser-facing orchestrator, DPE_ROLE=ssh runs a real SSH
server (dpe-sshd), DPE_ROLE=agent-gateway runs
dpe-gatewayd, the control plane every workspace's own
dpe-agentd connects to, and anything else runs a member's
own workspace. None of the three infrastructure roles touch the Docker
socket directly; all three reach it through the same locked-down proxy
that can list, create, and exec into containers and nothing else. Every
workspace container sits on a second, NAT-only network with no
published ports at all, ever, on any workspace container, at the
Docker level: nothing on the internet reaches one directly. A member
can still put one specific port of their own on the public internet,
deliberately, through the agent gateway described below; that path
never publishes a Docker port either, see that section for exactly
how it works instead.
graph TD
Browser["Browser
xterm.js, installable PWA"]
SSHClient["Any real SSH client
ssh / sftp / -L forwarding"]
Molohttp["molohttp
TLS termination, 127.0.0.1 only"]
Web["dpe-web
FastAPI, DPE_ROLE=web, active slot"]
WebB["dpe-web-b
idle blue-green slot"]
Ssh["dpe-ssh
asyncssh, DPE_ROLE=ssh, port 222"]
Gateway["dpe-gateway
DPE_ROLE=agent-gateway"]
Proxy["podman-socket-proxy
7 scoped API groups only"]
Sock[("/var/run/docker.sock")]
DevPlace["devplace.net
auth-token + profile scrape"]
WSA["workspace: member A
dpe-ws-<hash>"]
WSB["workspace: member B
dpe-ws-<hash>"]
WSn["... one container per member
never deleted, only started/stopped"]
Browser -->|"wss:// https://"| Molohttp
Molohttp -->|"active slot, cutover on deploy"| Web
Molohttp -.->|"idle until the next deploy"| WebB
SSHClient -->|"port 222, direct"| Ssh
Web -->|"tcp, dpe_control network
internal, no internet egress"| Proxy
Ssh -->|"tcp, dpe_control network
internal, no internet egress"| Proxy
Gateway -->|"tcp, dpe_control network
internal, no internet egress"| Proxy
Proxy -->|"read-only bind mount"| Sock
Sock -.manages via podman API.-> WSA
Sock -.manages via podman API.-> WSB
Sock -.manages via podman API.-> WSn
Web -->|"own DevPlace identity only"| DevPlace
Ssh -->|"own DevPlace identity only"| DevPlace
Ssh -.->|"direct tcp, dpe_workspaces network, local port forwarding only"| WSA
Gateway -.->|"one outbound WS per workspace
see the agent-gateway diagram below"| WSA
subgraph net_workspaces["dpe_workspaces network: NAT egress out, nothing published in"]
WSA
WSB
WSn
end
The agent-gateway's own routing (domain pool, reverse proxy, admin surface) is drawn in full detail in its own diagram further down this page; this one only places it among the other three infrastructure roles.
The terminal wire protocol: no envelope, because the transport already has one
Most home-grown terminal-over-websocket designs wrap every byte in
a JSON envelope or base64 it "to be safe." That is unnecessary work
that also risks corrupting binary output, and this system does not do
it: a WebSocket frame is already typed, binary or text, at the
protocol level, so /ws/terminal uses that native
distinction as the entire protocol. Binary frames are raw PTY bytes,
both directions, no encoding step to get wrong. Text frames are a two
message JSON control channel (init, resize).
That is the whole protocol. One socket, two channels, multiplexed by
the transport's own frame type instead of a hand-rolled header byte.
sequenceDiagram
participant B as Browser (xterm.js)
participant W as FastAPI (TerminalBridge)
participant C as tmux attach (docker exec, user dev)
B->>W: wss:// connect /ws/terminal
B->>W: text frame {"type":"init","cols":120,"rows":32}
W->>C: docker exec -u dev tmux new-session -A -s main
loop live session, unbuffered
C-->>W: raw PTY bytes
W-->>B: binary frame (PTY bytes, zero envelope)
B->>W: binary frame (keystrokes / paste)
W->>C: raw bytes written straight to the PTY
end
B->>W: text frame {"type":"resize","cols":.., "rows":..} (80ms debounced)
W->>C: exec_resize (executor thread, never blocks the event loop)
Every blocking Docker SDK call reachable from this path runs in an
executor thread; the byte pump itself uses raw
asyncio.loop.sock_recv/sock_sendall on the
hijacked exec socket, native non-blocking I/O with no thread pool in
the hot path. That split, real async where it matters, threaded where
a library forces it, is what keeps one member's slow Docker call from
ever stalling another member's keystrokes.
Embedding the terminal elsewhere: one script tag
Section 14's own custom-element rewrite exists specifically so the terminal is a genuine, self-contained component, not a page. Dropping it into another page, on this same origin or a different one, is one script tag:
<script src="https://dpe.cloud.pravda.education/static/js/embed.js"
data-skin="molodetz"
data-target="#dpe-mount"></script>
The loader injects the terminal's own stylesheet, xterm and its
addons, and the same <dpe-terminal-app> element and
app.js module this product's own terminal.html
renders, no separate build step and no bundler on the host page's
side. Every asset it requests is resolved against its own
<script> tag's origin, never the host document's, so
it works correctly regardless of which site loads it.
| Attribute | On | What it does |
|---|---|---|
data-skin | the loader's own <script> tag | Forwarded straight onto the created <dpe-terminal-app data-skin="...">. Any registered skin id works (Section 65): default, molodetz, devplace. Omit it for default. |
data-target | the loader's own <script> tag | A CSS selector for an existing element to mount the widget into. Omitted: the widget is inserted immediately after the loader's own script tag, the ordinary widget-loader convention. |
data-skin | <dpe-terminal-app> directly, if you build the markup by hand instead of using the loader | The same skin override, read straight off the component itself: CSS custom properties set on any element cascade to that element's own descendants regardless of what the surrounding page declares, so this works identically whether or not the host document has a data-skin of its own on <html>. |
What this does not do: authenticate a visitor. The terminal still
reaches /ws/terminal with this product's own
SameSite=Lax session cookie (Section 6); that cookie is
only ever sent on requests to this product's own origin, so an
embedded widget on a genuinely different site renders correctly
(the shell, the xterm view, the connection indicator) and then shows
a closed connection, honestly and immediately, exactly the same
handshake rejection a signed-out visitor's browser already gets
attempting this same endpoint on this product's own pages, never a
silent hang or a broken-looking crash. Embedding on this same
origin, where the cookie already applies, works exactly as this
product's own terminal page does today, with nothing further
needed. A real cross-origin authenticated embed is a
separate, security-relevant decision (Section 14 already names
exactly what it would take) and is not something this loader
attempts on its own.
The clipboard bridge (OSC 52 from vim/tmux, and any text
selected in the terminal, landing in the real system clipboard) rides
on navigator.clipboard, which only exists in a secure
context. Embedded directly into the host page's own DOM (this loader
never uses an iframe, so no separate frame-permissions policy is
involved), the widget runs under the host page's own origin
and security state: on an HTTPS (or localhost) host page
the clipboard bridge works exactly as it does on this product's own
pages; on a plain-HTTP host page navigator.clipboard is
unavailable and the bridge degrades to a no-op, the terminal itself
still works normally, copy and paste just fall back to whatever the
browser's own default terminal text-selection/paste handling offers.
Isolation: what "sandboxed" means here, specifically
| Layer | What actually enforces it |
|---|---|
| Filesystem | No volume, no bind mount, for any workspace user data, ever. A container's $HOME lives only in its own writable layer, bounded by a kernel-enforced storage_opt quota. |
| Capabilities | cap_drop: ALL, then an explicit allow-list matching Docker's own unprivileged default, minus NET_RAW/NET_ADMIN: packet spoofing and sniffing between co-tenant containers on the same bridge network is closed off entirely, on purpose. |
sudo | Real root inside the container, capped by the same bounding set either way: no SYS_ADMIN, no SYS_MODULE, no SYS_PTRACE, no Docker socket. Root in here still cannot leave here. |
| Network | Two networks: an internal, no-egress one carrying only Docker-API traffic between the orchestrator and the socket proxy, and a NAT-egress one for every workspace container, with zero published ports on any of them. |
| Docker API surface | Neither the web, ssh, nor agent-gateway service mounts /var/run/docker.sock. All three talk to one proxy locked to seven scoped API groups; volumes, swarm, system and secrets are hard-disabled at the proxy, not just unused. |
| Credentials | Every workspace container gets exactly one member's own DevPlace API key, read from that member's own account, injected only into that member's own container. No operator key, no shared budget, ever crosses a container boundary. |
| Identity | A member's container name and storage path are both derived with SHA-256 from their DevPlace username; there is exactly one identity scheme, never a second one layered on top, regardless of whether they type that username or their resolved handle at login. |
| SSH forwarding | Local port forwarding (ssh -L) is validated against the caller's own container before it is ever allowed; remote forwarding (ssh -R) is refused unconditionally by default. |
The agent gateway: a public URL for one port, with zero published Docker ports
dpe-agentd runs inside every workspace container, one
outbound WebSocket to dpe-gateway's control port, nothing
ever listens for it to be reached. That single, member-initiated
outbound connection is the entire mechanism; no Docker port is ever
published on a workspace container to make this work, the same
invariant the isolation table above already holds for everything else.
graph TD
Svc["a member's own process
listening on one port, inside their container"]
Agentd["dpe-agentd
inside that same workspace container"]
GwControl["dpe-gateway
control port 9000, internal only"]
Pool[("domain pool
pre-issued hostname + certificate")]
Molohttp["molohttp
dynamic site API"]
Internet["public internet"]
GwProxy["dpe-gateway
proxy port 21214"]
Admin["operator
current DPE admin (oldest active member)"]
GwAdmin["dpe-gateway
admin port 9001"]
Agentd -->|"outbound only
HMAC handshake + real container-IP check"| GwControl
Agentd -->|"port opened"| GwControl
GwControl -->|"assign()"| Pool
Pool -->|"issue_certificate / list_sites"| Molohttp
Internet -->|"https://<hash>.cloud.pravda.education"| Molohttp
Molohttp -->|"reverse proxy"| GwProxy
GwProxy -->|"direct HTTP/WS, dpe_workspaces network
never through the control WS"| Svc
Admin -->|"authenticated session"| GwAdmin
GwAdmin -->|"cmd_request / file_get / file_put
same control WS, fully audited"| GwControl
GwControl -.->|pushed to| Agentd
subgraph one_container["one member's own dpe-ws-<hash>, zero published Docker ports throughout"]
Svc
Agentd
end
| Parameter | Value |
|---|---|
| Handshake | server nonce + HMAC-SHA256 proof over a per-member agent_token (32 random bytes, generated once, stored on that member's own record, never another member's); the claimed container name's real Docker-network IP is independently cross-checked against the actual peer address before authentication succeeds |
| Token delivery | base64-framed export lines in .dpe-env (dpe.dpeenv.DpeEnvFile), the same live-pushable, quote-safe mechanism already used for DEVPLACE_API_KEY/DEVPLACE_HANDLE |
| Exposure scope | one identity_hash can only ever have its own ports assigned a pool hostname; the proxy resolves the target container fresh, by identity_hash, on every single request, never a cached address |
| Reachability | an auto-claimed port is public (no auth) immediately by default (Settings.auto_confirm_exposed_ports); dpe-expose still exists on top of that to add HTTP Basic Auth (PBKDF2-hashed, member-set), and a deployment can turn the default off to require an explicit dpe-expose call before anything is reachable at all, in which case an unconfirmed claim stays indistinguishable from an unclaimed one |
| Hostname release | freed within about one poll interval (~2 seconds) once the member's own agent reports the port genuinely closed, never the old day-long catch-all grace window, which still exists but only ever fires when nothing is left running to report a graceful close; if the pool has nothing else ready to hand out, assignment also reclaims the oldest inactive hostname, the calling member's own first, a stranger's only if they have none, and never one still genuinely open |
| Re-notification | a toast fires again whenever a port is still, or becomes again, reachable, not only the first time; bounded by a cooldown so a routine ~10-second keep-alive re-announcement never re-toasts a port that was never actually closed |
| Admin control surface | ping / exec / file-get / file-put across any connected agent, gated on dpe.adminrule.is_admin() (the oldest still-active registered member, computed live from real data, never a stored flag), every call fully audited (caller, target, command or path, outcome) |
| Ports published on the gateway itself | 21214 (proxy, molohttp-facing) and 9001 (admin, loopback) only; 9000 (control) is not published at all, reachable only from containers already on dpe_workspaces |
Decisions with numbers behind them, not guesses
| Parameter | Value | Why this number |
|---|---|---|
| Memory / swap | 2 GiB / 2.5 GiB | Doubled from 1 GiB on an explicit request, still with 512 MiB of swap headroom so a brief spike degrades instead of an instant kill. Originally 256 MiB, raised after a real OOM kill on a live harness. |
| CPU | 2 vCPU | Raised from 0.5 to 2 on the same request. Still the anti-abuse control for CPU-bound misuse, just a looser one; memory is not that control. |
| Disk | 2 GB | Kernel-enforced storage_opt quota where the host storage driver supports it; the code detects and reports when it silently cannot. |
| Process slots | 4096 | Raised from 1024 (itself raised from 256 after a real fork-exhaustion) to 4096 on the same request. Enough for real parallel compiles; not enough for a fork bomb to matter. |
| Resize debounce | 80 ms | Coalesces a resize storm (window drag, mobile rotation) into one exec_resize call instead of dozens. |
escape-time | 0 ms | Removes the default Escape-key lag every unconfigured tmux has. |
| Scrollback | 50,000 lines | Long enough for a real working session's history to still be there when you need it. |
| Upload progress refresh | forced, not polled | tmux refresh-client -S is issued on every reported step, instead of waiting on the default 5s status-interval, so the status bar reflects reality immediately. |
| Credential cache TTL | 24 hours | Long enough that a repeat login skips devplace.net entirely; short enough that a rotated password is never trusted stale for more than a day. |
| Credential hash | PBKDF2-HMAC-SHA256, 200,000 rounds | The one value cached from a login is never the password itself, only a deliberately slow, per-member-salted derivation of it. |
| Generic cache defaults | 5,000 entries, 24h TTL | Every cache in this codebase (user records, the handle index) is the same dpe.cache.Cache class, sized the same way, so behavior stays predictable everywhere it is used. |
| Port re-notify cooldown | 300 seconds | Long enough that a port's own routine ~10-second keep-alive re-announcement never re-toasts a member for a port that was never actually closed; short enough that nobody has to remember an hours-old toast to know a port is still live. |
Live cache stats, from this process, right now
Every cache in this codebase is one dpe.cache.Cache
instance (in-process, LRU-bounded, optionally TTL'd); this table is
not a mockup, it is Cache.registry() read at the moment
this page was rendered.
| Cache | Entries | Max size | TTL | Hits | Misses | Hit rate |
|---|---|---|---|---|---|---|
user-records |
1 | 5000 | no expiry | 84 | 1 | 99% |
current-admin |
0 | 1 | 30s | 0 | 0 | n/a |
handle-index |
0 | 1 | no expiry | 0 | 0 | n/a |
internaltrust.container_peers |
0 | 32 | 15.0s | 0 | 0 | n/a |
internaltrust.network_gateways |
0 | 32 | 15.0s | 0 | 0 | n/a |
exposed-ports |
0 | 5000 | no expiry | 0 | 0 | n/a |
domain-pool |
0 | 1 | no expiry | 0 | 0 | n/a |
ratelimit.login |
1 | 20000 | 120s | 1 | 1 | 50% |
ratelimit.upload |
0 | 20000 | 120s | 0 | 0 | n/a |
Rate limiting: security and shared-host courtesy in one mechanism
Both the login form and the file-upload endpoint sit behind a
small in-process sliding-window limiter
(dpe.ratelimit.RateLimiter), keyed on the real client
address as resolved by uvicorn's own trusted-proxy handling
(request.client.host, only ever rewritten from
X-Forwarded-For when the immediate peer is molohttp
itself, per Settings.trusted_proxy_ips; a caller cannot
spoof its way past this by supplying its own forwarded-for header).
Sign-in attempts are capped at 10 per minute per address: enough for
a real person who mistyped a password, nowhere near enough for
credential stuffing against DevPlace's own auth endpoint on this
box's behalf. Uploads are capped at 30 per minute per address: this
exists for resource courtesy as much as security, since every
reported upload step means a real docker exec call into
a container, and a shared host has no interest in one member's
script turning that into a flood. A limit hit returns a plain
429 with a Retry-After header, not a silent
drop. The SSH gateway runs the same idea twice over, independently:
50 authentication attempts
per 60 minutes
per source address, and separately per attempted username, so
neither "many usernames from one address" nor "one username from
many addresses" can turn this box into a hammer against DevPlace's
own login endpoint; a short, explicit allowlist of this host's own
test/benchmark addresses (Settings.ssh_rate_limit_trusted_nets)
bypasses this specific limiter, never a real member's traffic. A
fourth, independent instance guards the agent-gateway's own
handshake: 60
attempts per 30
seconds per source, sized specifically so an entire fleet of
dpe-agentd processes reconnecting at once after a
dpe-gateway restart self-heals in well under a minute
rather than locking itself out for the better part of an hour; each
reconnect also waits a small random jitter
(Settings.agent_reconnect_jitter_seconds) so a
fleet-wide restart does not retry in perfect lockstep.
Deploys: blue-green for web, live-provisioning for everything else
Two separate deploy paths, for two structurally different targets.
A workspace container is never recreated for an ordinary code change:
scripts/live-provision.sh docker cp's an
updated binary or config file straight into every already-running
container's filesystem, no restart. web is different, a
single shared process every member's browser tab routes through, so
a naive recreate drops every open /ws/terminal connection
at once. Fixed with a real blue-green swap, not a warning label.
| Parameter | Value |
|---|---|
| Slots | 2, dpe-web (port 21212) and dpe-web-b (port 21213), one docker-compose.yml YAML anchor, one difference: container name and published port |
| Cutover mechanism | edit /etc/molohttp/config.json's one upstream URL for site_dpe_cloud_pravda, SIGHUP reload, never restart |
| Upstream resolution point | once, at each WebSocket's own upgrade handshake (molohttp's own WSTunnel.swift); never re-consulted for that connection's remaining lifetime |
| Reload side effect on an open connection | none; ConfigManager.swift's reload() only replaces molohttp's in-memory config, touches no listener, no socket |
| Drain signal | GET /internal/drain-status, trusted only from real loopback plus Settings.drain_status_trusted_ips (a host-to-published-port call hairpins through the dpe_workspaces bridge gateway address, never literal 127.0.0.1), backed by dpe.connectiontracker, a lock-protected in-process counter |
| Idle-slot retirement | none, on a timer; the previous slot is recreated on the next deploy, after its own drain check |
| First-ever deploy through this path | drains against a pre-drain-status container; falls back to one fixed grace wait instead of polling a route that does not exist yet |
| SSH equivalent | not built; molohttp has no TCP-passthrough handler type at all, only HTTP/WS. Design: a 5th DPE_ROLE, a small dependency-free asyncio TCP splice, same two-slot shape. Deferred because a dropped SSH connection costs one ssh re-run, not a dead browser tab. |
Config-push path, for shared, non-secret environment values: every
workspace container's /root/.bashrc and
/home/dev/.bashrc source /etc/dpe-env-loader.sh,
which sources, with auto-export (set -a), any of
/dpe/.env, /dpe/.env.*, /dpe/*.env.
/dpe is already a live, read-only bind mount (see the
isolation table above), so a file dropped there on the host reaches
every already-running container's next shell with zero rebuild, zero
recreate, zero deploy step of any kind. Never a channel for a
per-member secret, which stays on the separate, already-existing
.dpe-env/DEVPLACE_API_KEY path.
Two real paths exist for shipping a code change, and the
Makefile itself is explicit about which one a live
deployment needs. make provision (image build, a plain
docker compose up -d web, then live-provision.sh's
filesystem-only patches to every already-running workspace container)
is for first-time bring-up and local development; its own naive
web recreate drops every open terminal connection with
no drain, no health check, and no rollback, and the target prints
that warning every time it runs. make deploy-web
(scripts/deploy-web.sh) is the blue-green path above,
the one an already-live deployment with real member sessions actually
needs. It is also a real sudo edit to molohttp's
own shared config, the same reverse proxy fronting every other site
on this host, not just this one; that step is run by a human, at a
real terminal, every time, including by an AI session working on
this codebase, which builds, tests, and verifies the deploy script
but never executes it unattended. The two commands below are held to
the identical standard, for a sharper reason: they destroy data, not
just risk a dropped connection or a shared config edit.
Destructive commands require a human who typed the words, every time
Two commands in this codebase can destroy real data, and both are built the same way: print a full, real report of exactly what is about to go, then refuse to proceed without a human typing an exact phrase at a real, connected terminal.
| Command | Destroys | Confirmation phrase | Fails closed when |
|---|---|---|---|
make prune-workspaces | Every workspace container (never data/users/*.json, member records survive it) | delete all workspace data | No real, connected terminal (/dev/tty); a piped or pre-written answer hangs, it does not proceed |
make purge | Every workspace container and every stored member record | purge everything permanently | Same /dev/tty gate; the operator sees every affected username first, never an API key |
An AI session working on this codebase may build these commands, verify their gate correctly blocks a wrong or missing answer, and show the report they print. It never supplies the actual confirmation phrase itself, under any framing, including one that seems, in the moment, like an obviously wanted outcome. If a human wants either command to actually run, they type the phrase themselves.
How a member's data survives us
A workspace container is created exactly once per member and
afterward only ever started or stopped, never deleted, which is what
makes persistence work with no volume at all (see the isolation table
above): the container's own writable layer, bounded by its
storage_opt quota, is the persistence layer.
Nothing routine in this system, an image rebuild, a web
deploy, a live-provisioning pass, ever touches that container's
filesystem in a way that could lose what is on it.
| What could lose data | What actually stops it |
|---|---|
| A maintenance pass judging a container "probably empty" from a quick look | Not a judgment call: scripts/workspace-data-report.sh runs docker diff against every container in scope and prints a real verdict; a human sees that report, container by container, before anything destructive proceeds |
| A general "yes, go ahead" from earlier in a conversation being read as blanket permission | Does not count; the confirmation above has to be given after the report is already in front of the human, for that specific, named action |
A member record's stored secrets (devplace_api_key, agent_token, cached password hash) leaking through a log line or a response body | None of them are ever logged or returned in any response; the cached password value is never the password itself, only a per-member-salted PBKDF2 derivation of it (200,000 rounds), and only ever compared, never decrypted back out |
| A JSON record file left half-written by a crash mid-save | Every write goes to a temp file first, fsync'd, then atomically renamed into place (os.replace); a reader never observes a partial file |
| A member's admin/API-provider spend being drained by someone else's traffic | Every credential injected into a workspace container is that one member's own, sourced from their own authenticated login; no operator key and no other member's key is ever forwarded into any container, under any circumstance |
The one honest limit, stated plainly rather than glossed over: this is quota and process-lifetime protection, not off-host backup. A host failure below the container layer (disk hardware, the host itself) is not covered by anything on this page, and nothing here claims otherwise.
Security rules, in one place
Every rule below is enforced by real, running code, most of it detailed elsewhere on this page; this table exists so the whole security posture can be read in one pass instead of assembled from the rest of the page by hand.
| Rule | Enforced by |
|---|---|
| A workspace never gets more Linux capabilities than it needs, and never the ones that would let it touch another tenant's traffic | cap_drop: ALL plus an explicit allow-list, NET_RAW/NET_ADMIN withheld even though sudo itself is granted; see the isolation table above |
| No process outside a container ever gets the real Docker socket | A single socket-proxy, scoped to seven read/attach API groups; volumes, swarm, system, and secrets are hard-disabled at the proxy itself |
| A member's traffic never reaches another member's container, by network position alone | Zero published ports on any workspace container, ever; the agent-gateway proxy resolves a target by identity_hash fresh on every request, never a cached address |
| An auto-detected open port's reachability is a deliberate, explicit setting, never an accident | The confirmed gate: by default (Settings.auto_confirm_exposed_ports) a claim is confirmed, and therefore reachable, the instant a hostname is assigned; a deployment can flip that setting off to require the member to run dpe-expose themselves first, in which case an unconfirmed claim proxies identically to an unclaimed one |
| Login and upload endpoints cannot be hammered through this box against DevPlace's own servers | Sliding-window rate limits, keyed on the real client address, never a spoofable header; see the rate-limiting section above |
| A forwarded client address or protocol is only ever trusted from this host's own known proxy hop | uvicorn's own forwarded_allow_ips, scoped to Settings.trusted_proxy_ips; nothing reads X-Forwarded-For by hand |
| A session cookie cannot be replayed across sites | SameSite=Lax on every session cookie this product issues, Secure whenever the request is genuinely HTTPS |
| Publicly cacheable assets can be loaded cross-origin; anything that touches a session cookie cannot | Access-Control-Allow-Origin: * is set on exactly one mount, /static/* (needed for the embed loader above); no authenticated route sets any CORS header at all |
| Admin authority on the agent-gateway is never a flag someone could leave mis-set | Computed from real data (the oldest still-registered member whose workspace container still exists), never a stored boolean; a 30-second cache only bounds how often a burst of calls re-scans, and is invalidated immediately on the same SIGUSR1 every other in-process cache already listens for |
| A shell command built from untrusted text (a filename, a clipboard paste) cannot become a shell injection | Base64 the value, shlex.quote the base64, decode it back inside the container; the one sanctioned pattern everywhere a value crosses that boundary |
| Destroying data always needs a human who has already seen what it costs | The confirmation-phrase gate above, plus the per-container report-and-ask procedure this product's own house rules hold every maintenance action to |
Verified against the real, running stack, not against a mental model of it
Every one of the following was a genuine, shipped defect, each
caught by actually connecting to the real service and only fixed
once reproduced, never guessed at from reading the code. tests/
exists so the next one is caught the same way, automatically, instead of
by a member reporting it: tests/integration/ drives the
real dpe-web/dpe-ssh containers and a real,
disposable DevPlace.NET account over genuine SSH/HTTP, never a mock of
Docker or of DevPlace; tests/e2e/ drives a real Chromium
browser against the real login and terminal pages. A separate,
scoped review-agent system under .claude/agents/
(security, SSH-gateway correctness, container isolation, caching,
performance, code quality, agent-gateway/event-protocol correctness,
data-safety/never-lose-data, and public-docs accuracy) checks future
changes against exactly these bug classes before they ship again;
none of it is allowed to run a destructive command against a
member's data on its own.
| Found | What broke, and how it was caught |
|---|---|
| Startup race | A brand-new container's own tmux bootstrap could lose a race against the very first incoming exec, throw duplicate session: main under set -eu, and kill the container's PID 1 mid-login. Fixed with an idempotent has-session || new-session in entrypoint.sh and tmux's own atomic new-session -A on every attach path. |
| Non-interactive exec hangs forever | eof_received returning False made asyncssh close the reply channel's own send side the instant a non-interactive client sent stdin EOF, before any output had been written. Fixed by returning True and shutting the Docker exec socket's write half explicitly once the pump loop ends. |
| IPv6 clients silently blackholed | Port 222 was published as 0.0.0.0:222:222, IPv4 only. An IPv6-preferring client's SYN hit this host's default-deny firewall with no RST at all, indistinguishable from a hang until a long OS timeout gave up. Fixed by dropping the explicit host IP so Docker's own docker-proxy binds both families, exactly like every other port-published service on this host already does. |
| Piped stdin never terminated the remote command | A non-tty exec's own input pump stopped forwarding bytes on the SSH channel's EOF but never closed the write half of the underlying Docker socket, so any remote process reading its own stdin to completion (wc -c, cat) blocked forever waiting for more. Fixed with an explicit socket.SHUT_WR once the input pump loop ends, non-tty sessions only. |
| SSH exit codes were always 0 | Every non-interactive ssh host cmd reported success regardless of the real outcome. Fixed by querying the real exec result via Docker's own exec_inspect and passing that exit code back to the channel. |
| One host key type only | A real, narrowly-built client (ChromeOS Secure Shell) could not complete key exchange against an Ed25519-only server and failed with an opaque, non-protocol error. Fixed by generating and offering an RSA host key alongside Ed25519, never instead of it. |
| SFTP binary missing on old containers | An already-running container created before openssh-sftp-server was added to the image never picked it up, because the live-provisioning retrofit script hadn't been updated to match. Fixed by adding the package to both the image and the retrofit list, and confirmed the fix live, without recreating the container. |
| Concurrent first-ever logins could reject each other | Two connections racing get_or_create_container for the same brand-new identity meant the loser of Docker's own container-name uniqueness check got an unhandled 409 Conflict, rejecting that login outright. Fixed by catching exactly that status and retrying a fetch of the container the winner just created. |
| A hidden overlay that was never actually hidden | The upload dropzone's own class declared display: flex, which beats the browser's default [hidden] rule regardless of the hidden attribute's state, so it sat visible over the terminal permanently. Fixed with an explicit, higher-specificity [hidden] companion rule. |
| Deleting a user record on disk did nothing | web and ssh each cache user records in-process; deleting the JSON file underneath a warm cache brought the "deleted" record right back on the next login. Fixed with a SIGUSR1 handler in both running processes that clears the real, live cache, not a throwaway interpreter's copy of it. |
That same SIGUSR1 fix could never actually arrive | entrypoint.sh execed the app under su dev -c '...', which manages its own session and does not forward signals to its child; a real docker kill -s SIGUSR1 reached su and stopped there. Fixed by replacing su with setpriv (a direct, no-fork privilege drop), collapsing the process tree to exactly tini → dpe / tini → dpe-sshd, plus the one added KILL capability web/ssh needed for a cross-uid signal to be forwarded at all. |
| An event's own fields could silently overwrite its envelope type | The agent-gateway wire protocol builds each frame as {"type": ..., **fields}; a field literally named type (which every entry in the real event catalog carries) clobbered the envelope's real frame type on the wire, corrupting anything that shape reached. Fixed by nesting fields under its own key instead of flattening it into the envelope dict, with a unit test reproducing the exact collision; a second, independent instance of the same landmine was later found and fixed in the browser-notification path that flattened the same way. |
| A successful browser notification looked exactly like a hang | deliver_to_browser's failure branch correctly told the caller "no browser tab was open," but its success branch simply returned, telling the caller nothing; every fire-and-forget notify/render-HTML call waited its full timeout even when delivery genuinely succeeded. Widening the timeout looked like partial progress and was not the fix. Fixed by sending an explicit, immediate result frame on the success path too. |
| An auto-detected open port was reachable from the public internet before anyone asked for that | The port-exposure feature claimed a real, certified hostname and proxied traffic to it the instant a wildcard-bound port was detected, with no member confirmation and no visible signal, in effect contradicting this page's own no-network-access-without-asking invariant. Fixed by adding an explicit confirmed gate and a proxy-level check that treats an unconfirmed claim exactly like an unclaimed one, plus a toast the instant a port opens. That gate's own default was later reversed, deliberately, back to reachable-immediately (see the Reachability row above); the gate itself is unchanged and is exactly what makes that default safe rather than a repeat of the same defect. |
| The domain pool grew without bound | Topping the pool up minted a brand-new candidate hostname on every check that found nothing immediately retryable, without telling apart "nothing in flight" from "something in flight but still inside its own backoff window"; left running for a couple of days this produced thousands of abandoned, permanently-failed records instead of one hostname retried patiently. Fixed by waiting on any pending issuance before minting a second one, plus a bounded retry cap so a candidate that is genuinely, permanently bad is eventually abandoned rather than retried forever. |
| A maintenance write could leave the service unable to read its own data back | An ad hoc, no-flag docker exec against a live container wrote a data file as root instead of the real, uid-1000 service user; the file's contents were correct, but the running process that owned it could no longer even open it. Fixed generically, not just for that one incident: the one shared JSON write path now chowns every file it writes back to the real service user immediately before the atomic rename, best-effort, so a mistake like this self-heals on the very next write instead of causing an outage. |
| A closed port never actually freed its hostname in less than a day | The workspace agent already sent a real, correctly-shaped "this port just closed" frame within about two seconds of it happening; the gateway's own control-frame dispatcher simply had no branch for it and silently dropped it, every time, since the feature first shipped, leaving only the much slower catch-all grace window to ever reclaim it. Fixed by wiring the existing frame up to release the hostname immediately; the slow window stays as the correct fallback for a container that disappears with nothing left running to report the close gracefully. |
A hostname changed outside dpe-expose never reached the member | An operator-side data fix that rotated which hostname a port mapped to updated the gateway's own records correctly but told nobody; re-running dpe-expose looked like the obvious repair and printed the stale hostname right back, since neither the member's own status file nor the config watcher had any reason to believe anything had changed. Fixed with a real, admin-gated resync path that reads the gateway's own current record and pushes it to the member's agent and browser directly, independent of dpe-expose ever being invoked. |
| A live link's own trailing punctuation ended up inside the link | The toast's auto-linkify pattern excluded only whitespace and a literal ) from a URL match, correctly handling one existing, parenthesized message but not a plain sentence's own trailing full stop; the period was swallowed straight into the href, producing a hostname molohttp had never issued a certificate for. Fixed by stripping the same small set of trailing punctuation every mainstream auto-linkifier already strips, then re-inserting it as ordinary text right after the link. |
Real numbers, from a real run, not a live gauge
The figures below are a point-in-time snapshot, not a
continuously-updated measurement: they came from actually running
tests/integration/ssh/test_ssh_benchmark.py against this
host's own dpe-ssh container over loopback, once, at the
time this section was last edited. They will drift from the real,
current numbers as the deployment changes; run
make test-integration yourself for what this host does
right now, rather than trusting a static table to stay current on its
own.
| Path | Measured | What it means |
|---|---|---|
Bare exec round trip (true, 20 calls) | ~48 ms average | The floor cost of one docker exec hop for a non-interactive SSH command; this is a local, same-host figure, not a wide-area one. |
| SFTP upload, 32 MB | ~168 MB/s | Client to container, through the real sftp-server binary, not a virtual filesystem. |
| SFTP download, 32 MB | ~170 MB/s | Same path, reverse direction. |
Exec-channel upload, 32 MB piped to wc -c | ~154 MB/s | Raw stdin over a non-tty exec channel, demuxed correctly on the way in. |
Exec-channel download, 32 MB from /dev/zero | ~132 MB/s | Raw stdout over a non-tty exec channel, demuxed correctly on the way out. |
These are same-host, loopback measurements, the honest number for what this deployment's own Docker-exec plumbing costs; they are not a substitute for a real wide-area figure between two separate hosts, which is a different, larger number dominated by network RTT rather than this plumbing. Where a bottleneck has not actually been measured, this page says so rather than inventing a plausible one.
Larger transfers and concurrency, a separate one-time run
A second, independent benchmark, scripts/sshtest.py,
against real 1 GB-plus payloads and multiple concurrent streams,
over this same host's real network path (molodetz.nl →
pravda.education), not loopback. A point-in-time snapshot, like the
table above, not a live gauge; re-run the script for this host's
current numbers.
| Download (server → client) | Speed |
|---|---|
| 512 MB | ~170 MB/s |
| 1 GB | ~160 MB/s |
| 2 GB | ~145 MB/s |
Upload (client → server, rsync) | Speed |
|---|---|
| 64 MB | ~120 MB/s |
| 128 MB | ~188 MB/s |
| 256 MB | ~275 MB/s |
| Concurrent streams (256 MB total) | Aggregate speed |
|---|---|
| 2× | ~127 MB/s |
| 4× | ~115 MB/s |
| 8× | ~85 MB/s |
| 16× | ~56 MB/s |
Concurrency degrades close to linearly past 2 streams: this path
is one TCP connection per SSH session, so parallel transfers compete
for the same connection's own throughput rather than adding new
capacity. Latency over this real network path: ~100–300 ms
average and P50, ~130 ms P95. Negotiated cipher:
chacha20-poly1305, AsyncSSH 2.24.0.
This page is generated from the same source this whole product is built from; if something here stops matching reality, that is a defect, not a rounding error. See what's actually installed →