Route container proxies through the leg that is actually reachable

The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.

Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.

The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.

Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.

Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.

Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.

Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
This commit is contained in:
retoor 2026-08-11 20:03:15 +02:00
parent ecb22f2b2d
commit 6514261730
29 changed files with 566 additions and 93 deletions

View File

@ -366,6 +366,53 @@ Failures at any implementation step block the workflow - never skip a failed ste
Gitea Actions workflow at `.gitea/workflows/test.yaml` runs on every push/PR to `master`: installs dependencies + Playwright Chromium, runs the full suite serially under coverage, publishes coverage HTML as an artifact, uploads failure screenshots. CI must be green before merging. Changes move through DTAP: Development (`make dev`) -> Test (CI suite + coverage on `master`) -> Acceptance (`master` to `production` promotion via `make deploy`) -> Production (Docker Compose stack). Only CI-green `master` commits are promoted to `production`.
## Diagnosing a production failure (the order that finds it fastest)
This procedure exists because a single "the editor is down" report turned out to be **three unrelated faults stacked on each other** (a stale URL, a firewalled network leg, and a corrupt database), and the investigation wasted hours by guessing before measuring. Work the layers outward from the browser; each step is cheap and each one eliminates a whole class of cause. **Never skip to a hypothesis, and never repair anything before the layer above it is proven healthy.**
**Layer 0 - is the request even arriving here?** Fetch the hostname over the public internet exactly as it resolves (`curl -sS -o /dev/null -w '%{http_code} %{remote_ip}' https://host/`). Compare the answering IP against this machine's own addresses (`ip -6 addr`, `curl https://api.ipify.org`). Two hostnames serve this platform by different routes - see the topology section above. **Never use `curl --resolve` to force a hostname onto an IP it does not resolve to**; that fabricates a path that no real traffic takes and produces confident, wrong conclusions.
**Layer 1 - which edge answered?** The error body identifies it. `application/problem+json` with `No site configured for host` is molohttp. A DevPlace HTML error page is the application. An nginx error page is the nginx container. A browser `ERR_*` with no body means nothing well-formed was returned at all.
**Layer 2 - same failure on both hostnames?** Run the identical authenticated request against `pravda.education` and `devplace.net`. Failing on **both** means the application or the database; failing on **one** means that host's edge. This single comparison is the highest-value measurement available and costs one command.
**Layer 3 - the application log, before any theory.** `docker logs --since 5m devplace-app-1`. Count error classes rather than reading prose (`grep -c malformed`). A recurring service-loop error is a systemic fault even when it looks unrelated to the symptom.
**Layer 4 - reproduce the failing hop in isolation.** Point the real code at the real upstream from a scratch harness rather than reasoning about it. Running `forward.proxy_http` against a live code-server is what exposed the duplicate `Date` header; reading the function had not. Use a scratch database (`DEVPLACE_DATABASE_URL`) so the harness never reaches production.
**Layer 5 - test from where the code actually runs.** The app runs **inside a container**; `127.0.0.1` there is not the host. `docker exec devplace-app-1 curl ...` is the only honest reachability test for a container-to-container hop. A hang with zero bytes means a packet was **DROPped** (firewall), a refusal means nothing is listening, and a slow error means the upstream answered badly - three different causes with three different fixes.
**Layer 6 - confirm the object exists before blaming the plumbing.** A 404 from a guard is not a proxy failure. Resolve the identifier through the application's own read surface (the workspace page, an admin JSON endpoint) with the affected account's session. A stale instance uid in a bookmarked URL looks exactly like an outage.
### Rules learned the hard way
- **State what a command will read or write before running it against production, and keep production access read-only until the diagnosis is complete.** The one write in a repair is the final swap, and it comes after verification, not before.
- **Copy before repairing, and copy the whole set.** A WAL-mode SQLite database is `.db` **plus** `-wal` **plus** `-shm`; a `.db`-only copy silently discards every transaction still in the WAL. Stop writes first, or the snapshot is inconsistent. Never leave a stale `-wal` beside a recovered file - SQLite will replay it and re-corrupt the result.
- **Repair on a copy, verify on the copy, and prove what was preserved.** `PRAGMA integrity_check` names the damaged objects; index damage is derived data and costs nothing (`REINDEX`, or `.recover`), while a table b-tree fault is the only kind that can lose rows. Diff row counts table by table between the original and the recovered file and report the delta - "it says ok" is not evidence that data survived.
- **Verify the fix through the user's own path, with their account, in a real browser.** A green unit test and a 200 from `curl` did not prove the editor worked; driving Playwright through login, the code-server password prompt and a `.monaco-workbench` selector did.
- **A measurement recorded in these files can go stale.** `services/containers/CLAUDE.md` recorded that `container_ip:port` times out from the app container while `gateway:published_host_port` connects. A later change (`make docker-attach`) inverted it, and a host firewall closed the documented leg entirely. Re-measure before trusting a recorded measurement, and update the record when it turns out to be false.
- **Report each fault separately and correct yourself explicitly.** Three stacked faults produce a symptom that no single explanation covers, and an early wrong theory is worse than no theory once it is repeated as fact.
## Production hostnames and the devplace.net SSH tunnel (verified topology, do not re-derive)
**The platform answers on two public hostnames, and they reach the same application by two completely different paths.** This has already cost one debugging session; the failure mode is that a `curl --resolve devplace.net:443:<production ip>` "test" reports `No site configured for host: devplace.net` and looks like a total outage, when in fact devplace.net never touches the production edge at all.
| | `pravda.education` | `devplace.net` |
|---|---|---|
| DNS | `95.216.15.238`, `2a01:4f9:2a:100e::2` | `88.198.21.243`, `2a01:4f8:222:2c45::2` |
| Machine | the production host itself | a separate front host (Hetzner, PTR `static.88-198-21-243.clients.your-server.de`) |
| Path in | molohttp on `:443` -> `127.0.0.1:10500` | its own proxy -> **SSH tunnel** -> `127.0.0.1:10500` on production |
| Reaches molohttp | yes | **no, never** |
**`devplace.net` is a front host that forwards over SSH.** It holds a persistent SSH session into the production host (visible there as an established inbound connection from `88.198.21.243` to port 22) and forwards through it to `127.0.0.1:10500`, which is the `docker-proxy` for the `devplace-nginx` container. The listening socket lives on the **front** host (an `ssh -L` style local forward), so the production host shows **no** sshd-owned listener - that absence is expected and is not evidence against the tunnel.
Two consequences that must not be forgotten:
- **molohttp has no `devplace.net` site, and that is correct.** Its site list is `mail`/`smtp`/`imap.molodetz.nl`, `pravda.education` and `*.tunnel.pravda.education`. devplace.net traffic enters below molohttp, straight into `127.0.0.1:10500`, so it needs no site. **Never "fix" this by adding a devplace.net site to molohttp** - devplace.net does not resolve to the production host, so such a site could never match, and its absence is not a bug.
- **Both hostnames land on the same nginx and the same app**, so a request that fails on both is failing in the application, not in either edge. That comparison is the fastest triage available here: run the same authenticated request against both hostnames. Same failure on both means look at the app or the database; a failure only on devplace.net means look at the front host's proxy (WebSocket `Upgrade` headers are the usual culprit, exactly as for the production nginx locations below).
**Testing rule.** Never point a hostname at an IP it does not resolve to in order to "test" it. Fetch each hostname over the public internet as it really resolves (`curl https://devplace.net/...` and `curl https://pravda.education/...`), because forcing devplace.net onto the production IP tests molohttp with a `Host` it deliberately does not serve and proves nothing about the real path.
## Production deployment
Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn, 2 workers) behind an **nginx** container. Full operator reference is the admin-only `Production` docs section (`templates/docs/production*.html`); conventions that must not regress:

View File

@ -135,10 +135,11 @@ test-cache-clean:
COMPOSE := docker compose -f docker-compose.yml -f docker-compose.containers.yml
DEVPLACE_DATA_DIR ?= $(CURDIR)/data
DOCKER_GID ?= $(shell stat -c '%g' /var/run/docker.sock 2>/dev/null)
DEVPLACE_CONTAINER_NETWORK ?= bridge
export DEVPLACE_DATA_DIR
export DOCKER_GID
.PHONY: docker-build docker-up docker-reload docker-down docker-logs docker-clean docker-prep ppy
.PHONY: docker-build docker-up docker-attach docker-reload docker-down docker-logs docker-clean docker-prep ppy
# Build the single shared container image every instance runs. Build once;
# rebuild only when ppy.Dockerfile, the sudo shim, or pagent change.
@ -153,10 +154,23 @@ docker-build: docker-prep
docker-up: docker-prep
$(COMPOSE) up -d
$(MAKE) docker-attach
# Workspace tunnels reach a container port that was never published on the host,
# so the app must sit on the same docker network as the instances it runs. The
# default bridge rejects the network-scoped aliases compose always sends, so
# this cannot live in docker-compose.containers.yml and is wired here instead.
docker-attach:
@app=$$($(COMPOSE) ps -q app); \
test -n "$$app" || { echo "app container is not running"; exit 1; }; \
docker network connect $(DEVPLACE_CONTAINER_NETWORK) $$app 2>/dev/null \
&& echo "attached app to the $(DEVPLACE_CONTAINER_NETWORK) network" \
|| echo "app is already on the $(DEVPLACE_CONTAINER_NETWORK) network"
docker-reload:
$(COMPOSE) restart app
$(COMPOSE) up -d --wait
$(MAKE) docker-attach
docker-down:
$(COMPOSE) down

View File

@ -1123,6 +1123,10 @@ What the overlay (`docker-compose.containers.yml`) changes:
- **Data dir at a consistent path (critical).** When the app (in its container) runs `docker run -v <path>:/app`, the daemon resolves `<path>` against the **host**, not the app container. So the workspace/data dir must be mounted at the **same absolute path** on host and in the container - the make targets set `DEVPLACE_DATA_DIR` to the project's `./data` (an absolute host path) and mount it at that identical path on both sides. (Build contexts go through the docker API as a tarball, so they can stay in the container's temp dir - only the `/app` bind mount needs path consistency.)
- **Ingress reach:** published container ports live on the **host**, so the overlay sets `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (with `extra_hosts: host-gateway`) so the `/p/<slug>` proxy can reach them. On a bare-metal `make prod` deploy the app is already on the host, so the default `127.0.0.1` works and no overlay is needed (just install the docker CLI and run the services).
One piece of wiring cannot live in the overlay:
- **Workspace tunnel reach.** A workspace tunnel serves a port the member chose, which is almost never published on the host, so the app has to dial the container directly - and it can only do that from the container's own docker network. Compose cannot attach a service to the default `bridge` network (it always sends network-scoped aliases, which that network rejects), so `make docker-up` and `make docker-reload` run `make docker-attach`, an idempotent `docker network connect` of the app container to `DEVPLACE_CONTAINER_NETWORK` (default `bridge`). A bare `docker compose up -d` skips it and every tunnel to an unpublished port answers `502`. On a bare-metal `make prod` deploy the app is already on the host and reaches container IPs with no wiring at all.
Then build the shared `ppy` image once with `make ppy` and enable **Containers** on `/admin/services`. There is no in-app image building; every instance runs that one prebuilt image.
### nginx specifics

View File

@ -22,7 +22,8 @@ workspace start.
A **tunnel** publishes one port from inside your container on a public HTTPS hostname of the form
`<port>-<name>.tunnel.pravda.education`. **Tunnel URLs are public and unauthenticated** - anyone with
the link can reach whatever you are serving.
the link can reach whatever you are serving. Forwarding a port in the editor's **Ports** view creates
the tunnel for you through the same endpoint; un-forwarding it does not remove the tunnel.
Workspaces are bounded: a count limit per user, a disk quota, an egress quota, and a tunnel limit.
An idle workspace is warned about, then stopped, then warned again, then removed. Every warning
@ -222,7 +223,9 @@ arrives as a `workspace` notification and states exactly what happens next and w
title="Create tunnel",
summary=(
"Publish a container port on a public HTTPS hostname. The URL is public "
"and unauthenticated. Refused past the tunnel limit."
"and unauthenticated. Refused past the tunnel limit. The certificate is "
"ordered right away, so the hostname answers plain HTTP for a few seconds "
"before it serves HTTPS. Forwarding a port in the editor calls this for you."
),
auth="user",
params=[

View File

@ -523,6 +523,15 @@ async def await_pending_corrections(request: Request, call_next):
return response
def _frame_ancestors() -> str:
from devplacepy.services.containers.workspace import naming
tunnel_domain = naming.domain()
if not tunnel_domain:
return "'self'"
return f"'self' https://*.{tunnel_domain}"
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
@ -532,10 +541,9 @@ async def add_security_headers(request: Request, call_next):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
if not request.url.path.startswith("/p/"):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = (
"object-src 'none'; base-uri 'self'; "
"frame-ancestors 'none'; form-action 'self'"
f"frame-ancestors {_frame_ancestors()}; form-action 'self'"
)
if request.url.path.startswith("/admin"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"

View File

@ -243,16 +243,12 @@ async def tunnel_create(
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
if data.container_port <= 0:
return json_error(400, "container_port must be between 1 and 65535")
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
if limits.max_tunnels and tunnels.count_for_instance(
instance["uid"]
) >= limits.max_tunnels:
return json_error(400, f"tunnel limit reached ({limits.max_tunnels})")
row = tunnels.create(instance, data.label, data.container_port, user["uid"])
if not row:
return json_error(400, "could not create tunnel")
try:
row = provision.publish_tunnel(
instance, data.label, data.container_port, user["uid"]
)
except provision.WorkspaceError as error:
return json_error(400, str(error))
audit_instance(
request,
user,
@ -261,7 +257,6 @@ async def tunnel_create(
project,
metadata={"hostname": row["hostname"], "port": data.container_port},
)
provision.write_manifest(instance)
return action_result(request, f"/projects/{slug}/workspace", data=row)

View File

@ -27,18 +27,8 @@ def resolve(host: str):
return row, instance, None, None
if instance.get("status") != store.ST_RUNNING:
return row, instance, None, None
gateway, _ = api.proxy_target(instance)
host_port = _published_host_port(instance, int(row.get("container_port") or 0))
return row, instance, gateway, host_port
def _published_host_port(instance: dict, container_port: int) -> int:
import json
for mapping in json.loads(instance.get("ports_json") or "[]"):
if int(mapping.get("container") or 0) == container_port:
return int(mapping.get("host") or 0)
return 0
host, port = api.tunnel_target(instance, int(row.get("container_port") or 0))
return row, instance, host, port
async def handle_http(request: Request, path: str) -> Response:

View File

@ -158,10 +158,10 @@ class WorkspaceViewOut(_Out):
status: str = ""
desired_state: str = ""
suspended: bool = False
flag_reason: str = ""
tunnel_name: str = ""
primary_url: str = ""
last_active_at: str = ""
flag_reason: Optional[str] = ""
tunnel_name: Optional[str] = ""
primary_url: Optional[str] = ""
last_active_at: Optional[str] = ""
disk_bytes: int = 0
disk_quota_mb: int = 0
disk_percent: int = 0

View File

@ -263,7 +263,7 @@ The security hotpatch that used to run per build is now baked into `ppy.Dockerfi
**Trade-off (intentional).** The only genuinely-root operation that still does NOT escalate is binding a port < 1024 - use a high port + `/p/<slug>` ingress instead. Enforcement lives entirely in the Dockerfile (no `--user` on `docker run`). The `export_to_dir` unlink-before-write fix remains as belt-and-suspenders (the app owns the workspace dir, so it may delete any stale file in it regardless of owner before rewriting it).
## `PRAVDA_*` runtime env injection
## `DEVPLACE_*` runtime env injection (function name `pravda_env`)
`api.run_spec_for` merges `api.pravda_env(instance)` over the instance's own `env_json` (PRAVDA keys win), so every running container gets these platform vars:
- `DEVPLACE_BASE_URL` - the `site_url` setting via `seo.public_base_url()`.
@ -326,9 +326,9 @@ Use `FakeBackend` (its `image_exists` returns `True`) + `runtime.set_backend`, a
## Vibe coding on-ramp (user-facing doc)
The container runtime is also the basis of "vibe coding": the public prose page `templates/docs/getting-started-vibing.html` (slug `getting-started-vibing`, `SECTION_GENERAL`, not admin-gated so the docs stay public while the feature is admin-only/Alpha) is the canonical user-facing guide. It teaches the Devii-driven flow project -> container -> start -> terminal (`create_project`, `container_create_instance`, `container_instance_action`, the `open_terminal` client action), documents the three baked-in agents (`dpc` = DevPlace Code at `/usr/bin/dpc`, the Claude-Code-class coding agent; `botje.py` = the copy of `services/containers/files/bot.py` at `/usr/bin/botje.py`; `pagent`), all metered through the container's own `DEVPLACE_API_KEY`, the full `PRAVDA_*` env table (see `api.pravda_env`), and ingress at `/p/<slug>` via `ingress_slug`/`ingress_port`.
The container runtime is also the basis of "vibe coding": the public prose page `templates/docs/getting-started-vibing.html` (slug `getting-started-vibing`, `SECTION_GENERAL`, not admin-gated so the docs stay public while the feature is admin-only/Alpha) is the canonical user-facing guide. It teaches the Devii-driven flow project -> container -> start -> terminal (`create_project`, `container_create_instance`, `container_instance_action`, the `open_terminal` client action), documents the three baked-in agents (`dpc` = DevPlace Code at `/usr/bin/dpc`, the Claude-Code-class coding agent; `botje.py` = the copy of `services/containers/files/bot.py` at `/usr/bin/botje.py`; `pagent`), all metered through the container's own `DEVPLACE_API_KEY`, the full `DEVPLACE_*` env table (see `api.pravda_env`), and ingress at `/p/<slug>` via `ingress_slug`/`ingress_port`.
When the runtime, the agent binaries, or the `PRAVDA_*`/ingress contract change, update this page alongside the source.
When the runtime, the agent binaries, or the `DEVPLACE_*`/ingress contract change, update this page alongside the source.
**The agents are gateway-only:** `dpc`/`d.py` and `botje.py`/`bot.py` use a single `molodetz` backend pointed at `DEVPLACE_OPENAI_URL` (the gateway); the former direct `api.deepseek.com` fallback backend was removed so every in-container AI call is ledgered under the run-as user and nothing bypasses `gateway_usage_ledger`. `pagent`/`.vimrc` already posted to the gateway URL (using `DEEPSEEK_API_KEY` only as a key fallback, never the DeepSeek endpoint). Rebuild the image (`make ppy`) for the change to reach running containers.
@ -478,6 +478,69 @@ schedules or tracks renewals. This whole phase did not exist - `tunnels` had no
the six `workspace_molohttp_*`/`workspace_cert_mode`/`workspace_acme_email` settings were admin
fields wired to nothing, which is why every tunnel sat at `pending` with no certificate.
**`provision.publish_tunnel` is the ONE way a user-created tunnel comes into existence** - the HTTP
route, the Devii tool and the editor all funnel through it. It owns the port check, the `max_tunnels`
quota, `tunnels.create`, `schedule_certificate` and `write_manifest`, and raises `WorkspaceError` for
every refusal. Before it existed, the route and the Devii controller each carried their own copy of
the quota check and **neither ordered a certificate**, so a user-created tunnel sat `pending` until
the (default-disabled) `WorkspaceService` happened to tick - which on an instance where that service
was never enabled is forever. `schedule_certificate` now also writes `provision.CERT_UNCONFIGURED`
into the row's `last_error` when molohttp is not configured, because a tunnel that can never be
certified must say so on the workspace page rather than sit at `pending` with a blank error.
**A tunnel reaches its port through `api.tunnel_target(instance, container_port)`, never through
`proxy_target`.** `proxy_target` answers for `/p/{slug}`, whose port is published by construction;
a tunnel's port is whatever the member decided to serve on and is almost never published, because a
workspace publishes only `editor_port`. `tunnel_target` therefore prefers the published host port
when the port happens to have one (`CONTAINER_PROXY_HOST` or the recorded gateway, exactly like
`proxy_target`) and otherwise dials `container_ip:container_port` directly. The direct leg is what
makes an arbitrary port tunnellable at all: docker cannot add a published port to a running
container, so publishing on demand would mean recreating the container and killing the very dev
server the member just asked to share.
**The direct leg needs the app on the same docker network as the instances, and that wiring cannot
live in compose.** Measured on this host: from the app container, `container_ip:port` times out
(docker's inter-network isolation) while `gateway:published_host_port` connects; from the host, and
from any container sharing the instances' network, `container_ip:port` connects. So `make dev` works
untouched and the containerized production app does not - it must be attached to the network the
instances run on. Compose cannot express that: it always sends network-scoped aliases, which the
default `bridge` rejects (`invalid endpoint settings: network-scoped aliases are only supported for
user-defined networks`). The attachment is therefore a `make docker-attach` step, run by
`docker-up` and `docker-reload` and idempotent, deriving its input like `DOCKER_GID` does
(`DEVPLACE_CONTAINER_NETWORK`, default `bridge`). A bare `docker compose up -d` skips it and
silently re-breaks every unpublished-port tunnel - one more reason the make targets are the only
supported path.
**Forwarding a port in the editor publishes it, and that is the whole point of `VSCODE_PROXY_URI`.**
`api.workspace_env` advertises `https://{{port}}-{name}.{domain}` to VS Code, so the Ports view shows
a DevPlace address for every forwarded port - but VS Code never tells DevPlace, so that address had
no `tunnels` row, was 404ed by `routers/tunnel.py` and never got a certificate. The editor promised a
URL the platform could not serve. The `Tunnels` stage in the workspace extension closes it: it
subscribes to `vscode.workspace.onDidChangeTunnels`, reads `vscode.workspace.tunnels`, and POSTs each
new `remoteAddress.port` (skipping `DEVPLACE_EDITOR_PORT`, which is already published) to
`{DEVPLACE_BASE_URL}/projects/{DEVPLACE_PROJECT_SLUG}/workspace/tunnels` with the container's own
`DEVPLACE_API_KEY` and `Accept: application/json`. Four things about it:
- **`tunnels` is a proposed API** (`checkProposedApiEnabled(extension, 'tunnels')`), so the extension
declares `enabledApiProposals: ["tunnels"]` and `product.patch.json` names it under
`extensionEnabledApiProposals`. code-server patches the check to always pass, so it works today
either way; the declarations are what keep it working if that patch goes away. Because the patch
adds a nested object, the Dockerfile's `product.json` merge now merges one level deep - a plain
`dict.update` would wipe an upstream map of the same name on a version bump.
- **It only ever creates.** Un-forwarding a port leaves the tunnel standing, because deleting it
would revoke the certificate and a re-forward would re-issue, churning against Let's Encrypt's
duplicate-certificate limit. Removal stays the explicit act it already was.
- **A port is added to the in-memory `published` set before the POST and removed again on failure**,
so a burst of change events cannot double-post and a refusal (quota, 403) can still retry on the
next change. Refusals surface both in the `DevPlace` output channel and as a warning message.
- **It uses `http`/`https` from Node, not `fetch`**, and is wrapped in the same `stage()` try/catch as
every other activation step - a workspace whose network is down must still open its editor.
Verified against the real image by driving code-server with Playwright and forwarding port 3000: the
Ports view lists the port, the extension POSTs `label=Port+3000&container_port=3000` with the API key,
and the output channel reports the public URL. Reproduce it that way, not with a mock - the Ports
view is the only trigger, there is no `Forward a Port` command in the palette in code-server.
**Two contracts that bite:**
- A `ConfigField` with `type="select"` needs `options=[{"value": ..., "label": ...}]`. Plain strings
crash `docs_api.build_services_group`, which `docs_search` indexes, so the whole docs search page

View File

@ -589,7 +589,7 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
language = (instance.get("boot_language") or "none").strip().lower()
boot = (instance.get("boot_command") or "").strip()
if profile and int(instance.get("editor_port") or 0):
command = editor.argv(instance, profile)
command = editor.wrap_with_env_export(editor.argv(instance, profile))
elif language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
@ -732,18 +732,30 @@ def _host_port_for(port_maps: list, container_port: int) -> int:
return 0
def reachable_target(instance: dict, container_port: int, port_maps: list) -> tuple:
container_ip = (instance.get("container_ip") or "").strip()
if container_ip and container_port > 0:
return container_ip, container_port
host_port = _host_port_for(port_maps, container_port)
if not host_port:
return None, None
gateway = (instance.get("container_gateway") or "").strip()
return config.CONTAINER_PROXY_HOST or gateway or "127.0.0.1", host_port
def proxy_target(instance: dict) -> tuple:
port_maps = json.loads(instance.get("ports_json") or "[]")
container_port = _ingress_container_port(instance, port_maps)
if not container_port:
return None, None
host_port = _host_port_for(port_maps, container_port)
if config.CONTAINER_PROXY_HOST:
return (config.CONTAINER_PROXY_HOST, host_port) if host_port else (None, None)
if not host_port:
return reachable_target(instance, container_port, port_maps)
def tunnel_target(instance: dict, container_port: int) -> tuple:
if container_port <= 0:
return None, None
gateway = (instance.get("container_gateway") or "").strip()
return (gateway or "127.0.0.1", host_port)
port_maps = json.loads(instance.get("ports_json") or "[]")
return reachable_target(instance, container_port, port_maps)
def instance_runtime(instance: dict) -> dict:

View File

@ -52,7 +52,7 @@ def _resolve_devplace_url() -> str:
base = os.environ.get("DEVPLACE_BASE_URL", "").strip().rstrip("/")
if base:
return base
return os.environ.get("DEVPLACE_URL", "https://devplace.net").strip().rstrip("/")
return os.environ.get("DEVPLACE_URL", "").strip().rstrip("/")
def _resolve_llm_endpoint() -> str:
@ -63,11 +63,7 @@ def _resolve_llm_endpoint() -> str:
DEVPLACE_URL = _resolve_devplace_url()
DEVPLACE_API_KEY = (
os.environ.get("DEVPLACE_API_KEY")
or os.environ.get("DEVPLACE_API_KEY")
or "019ea58c-fae0-7112-8025-e629a54104a4"
)
DEVPLACE_API_KEY = os.environ.get("DEVPLACE_API_KEY", "").strip()
MENTION_POLL_SECONDS = int(os.environ.get("MENTION_POLL_SECONDS", "30"))
DM_POLL_SECONDS = int(os.environ.get("DM_POLL_SECONDS", "10"))
BOT_USERNAME = os.environ.get("BOT_USERNAME", "")
@ -2812,8 +2808,17 @@ async def _agent_answer_for_devplace(
async def devplace_bot_loop() -> None:
"""Run the DevPlace bot: poll mentions and DMs forever."""
logger.info("Botje starting — DevPlace bot with full X-agent capabilities")
if not DEVPLACE_URL or not DEVPLACE_API_KEY:
logger.error(
"DEVPLACE_BASE_URL and DEVPLACE_API_KEY are not set. Both are injected "
"automatically inside a DevPlace-managed container; set them manually "
"only when running botje.py outside one.",
)
return
logger.info("DevPlace URL: %s", DEVPLACE_URL)
logger.info("API key: %s...", DEVPLACE_API_KEY[:12] if DEVPLACE_API_KEY else "(none)")
logger.info("API key: %s...", DEVPLACE_API_KEY[:12])
dp = DevPlace(DEVPLACE_URL, DEVPLACE_API_KEY)

View File

@ -1,6 +1,8 @@
// retoor <retoor@molodetz.nl>
const fs = require("fs");
const http = require("http");
const https = require("https");
const vscode = require("vscode");
const AGENT_PATH = "/usr/bin/dpc";
@ -8,6 +10,7 @@ const AGENT_TERMINAL = "DevPlace Code";
const SHELL_TERMINAL = "pravda@workspace";
const BOOT_KEY = "devplace.bootMarker";
const PANEL_STEPS = { short: 0, normal: 2, tall: 5, maximized: 0 };
const PUBLISH_TIMEOUT_MS = 20000;
class Profile {
constructor() {
@ -90,7 +93,8 @@ class BootTerminals {
createAgent() {
return vscode.window.createTerminal({
name: AGENT_TERMINAL,
shellPath: AGENT_PATH,
shellPath: "/bin/bash",
shellArgs: ["-l", "-c", `exec ${AGENT_PATH}`],
iconPath: new vscode.ThemeIcon("rocket"),
isTransient: false,
});
@ -226,6 +230,136 @@ class Presence {
}
}
class Tunnels {
constructor(output) {
this.output = output;
this.published = new Set();
this.base = (process.env.DEVPLACE_BASE_URL || "").replace(/\/+$/, "");
this.apiKey = process.env.DEVPLACE_API_KEY || "";
this.slug = process.env.DEVPLACE_PROJECT_SLUG || "";
this.editorPort = Number(process.env.DEVPLACE_EDITOR_PORT || 0);
}
get configured() {
return Boolean(this.base && this.apiKey && this.slug);
}
async watch(context) {
if (!this.configured) {
this.output.appendLine(
"tunnels: this workspace has no DevPlace credentials, so forwarded ports stay private",
);
return;
}
context.subscriptions.push(
vscode.workspace.onDidChangeTunnels(() =>
this.sync().catch((error) =>
this.output.appendLine(`tunnels: sync failed: ${error}`),
),
),
);
await this.sync();
}
async sync() {
const rows = (await vscode.workspace.tunnels) || [];
for (const row of rows) {
const port = Number((row.remoteAddress || {}).port || 0);
if (!port || port === this.editorPort) continue;
if (this.published.has(port)) continue;
await this.publish(port);
}
}
async publish(port) {
this.published.add(port);
let answer;
try {
answer = await this.post(port);
} catch (error) {
this.published.delete(port);
this.output.appendLine(`tunnels: port ${port} could not be published: ${error}`);
return;
}
if (answer.status >= 400) {
this.published.delete(port);
this.output.appendLine(
`tunnels: DevPlace refused port ${port} (${answer.status}): ${answer.body.slice(0, 300)}`,
);
vscode.window.showWarningMessage(
`DevPlace could not publish port ${port}: ${this.refusal(answer.body)}`,
);
return;
}
const url = this.publishedUrl(answer.body, port);
this.output.appendLine(`tunnels: port ${port} is published at ${url}`);
vscode.window.showInformationMessage(
`Port ${port} is published at ${url}. It serves HTTPS once its certificate is issued.`,
);
}
post(port) {
const url = new URL(
`${this.base}/projects/${encodeURIComponent(this.slug)}/workspace/tunnels`,
);
const body = new URLSearchParams({
label: `Port ${port}`,
container_port: String(port),
}).toString();
const client = url.protocol === "https:" ? https : http;
const options = {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
Accept: "application/json",
"X-API-KEY": this.apiKey,
},
timeout: PUBLISH_TIMEOUT_MS,
};
return new Promise((resolve, reject) => {
const call = client.request(url, options, (response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () =>
resolve({
status: response.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
}),
);
});
call.on("timeout", () => call.destroy(new Error("request timed out")));
call.on("error", reject);
call.end(body);
});
}
refusal(body) {
try {
const parsed = JSON.parse(body);
return (parsed.error && parsed.error.message) || "the request was refused";
} catch (error) {
return "the request was refused";
}
}
publishedUrl(body, port) {
try {
const parsed = JSON.parse(body);
const hostname = parsed.data && parsed.data.hostname;
if (hostname) return `https://${hostname}`;
} catch (error) {
/* fall through to the pattern below */
}
const pattern =
process.env.DEVPLACE_TUNNEL_PORT_PATTERN || "{port}-{name}.{domain}";
return `https://${pattern
.replace("{port}", String(port))
.replace("{name}", process.env.DEVPLACE_TUNNEL_NAME || "")
.replace("{domain}", process.env.DEVPLACE_TUNNEL_DOMAIN || "")}`;
}
}
async function stage(output, name, run) {
try {
return await run();
@ -245,6 +379,7 @@ async function activate(context) {
new BootTerminals(profile, context.workspaceState).open(),
);
await stage(output, "layout", () => new Layout(profile).apply(Boolean(opened)));
await stage(output, "tunnels", () => new Tunnels(output).watch(context));
}
function deactivate() {}

View File

@ -18,6 +18,9 @@
"activationEvents": [
"onStartupFinished"
],
"enabledApiProposals": [
"tunnels"
],
"capabilities": {
"untrustedWorkspaces": {
"supported": true

View File

@ -9,5 +9,10 @@
"privacyStatementUrl": "https://pravda.education/docs/privacy.html",
"twitterUrl": "",
"requestFeatureUrl": "https://pravda.education/issues",
"licenseName": "DevPlace Terms of Service"
"licenseName": "DevPlace Terms of Service",
"extensionEnabledApiProposals": {
"devplace.devplace-workspace": [
"tunnels"
]
}
}

View File

@ -37,6 +37,8 @@ RESPONSE_HOP_HEADERS = {
"trailers",
"transfer-encoding",
"upgrade",
"date",
"server",
}
WS_HANDSHAKE_HEADERS = {

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import json
import logging
import shlex
from dataclasses import asdict, dataclass
from pathlib import Path
@ -383,6 +384,22 @@ def argv(instance: dict, profile: EditorProfile) -> list[str]:
return command
ENV_EXPORT_FILE = "/etc/profile.d/devplace-env.sh"
_ENV_EXPORT_SCRIPT = (
"import os, pathlib, shlex\n"
f"path = pathlib.Path({ENV_EXPORT_FILE!r})\n"
"lines = ['export ' + k + '=' + shlex.quote(v) for k, v in sorted(os.environ.items()) if k.startswith('DEVPLACE_')]\n"
"path.write_text('\\n'.join(lines) + '\\n' if lines else '')\n"
)
def wrap_with_env_export(command: list[str]) -> list[str]:
export_step = f"umask 022; python3 -c {shlex.quote(_ENV_EXPORT_SCRIPT)} 2>/dev/null || true"
script = f"{export_step}; exec {shlex.join(command)}"
return ["/bin/sh", "-c", script]
def env_for(profile: EditorProfile) -> dict:
return {
"DEVPLACE_EDITOR_APP_NAME": APP_NAME,

View File

@ -13,6 +13,10 @@ from . import editor, flags, naming, quota, tunnels
MANIFEST_DIRECTORY = ".devplace"
MANIFEST_NAME = "tunnels.json"
CERT_UNCONFIGURED = (
"certificate issuance is not configured; an administrator must set the "
"molohttp base URL and credentials before this address serves HTTPS"
)
_pending_certificates: set[asyncio.Task] = set()
@ -69,10 +73,31 @@ async def ensure(project: dict, user: dict) -> dict:
return store.get_instance(instance["uid"])
def publish_tunnel(
instance: dict, label: str, container_port: int, owner_uid: str
) -> dict:
if container_port <= 0 or container_port > 65535:
raise WorkspaceError("container_port must be between 1 and 65535")
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
if limits.max_tunnels and tunnels.count_for_instance(
instance["uid"]
) >= limits.max_tunnels:
raise WorkspaceError(f"tunnel limit reached ({limits.max_tunnels})")
row = tunnels.create(instance, label, container_port, owner_uid)
if not row:
raise WorkspaceError("could not create tunnel")
schedule_certificate(row)
write_manifest(instance)
return tunnels.get(row["uid"]) or row
def schedule_certificate(tunnel: dict | None) -> bool:
from . import certs
if not tunnel or not certs.configured():
if not tunnel or tunnels.keeps_certificate(tunnel):
return False
if not certs.configured():
tunnels.update(tunnel["uid"], {"last_error": CERT_UNCONFIGURED})
return False
try:
loop = asyncio.get_running_loop()
@ -150,8 +175,8 @@ def unsuspend(instance: dict) -> dict:
def editor_target(instance: dict) -> tuple[str, int]:
host, port = api.proxy_target(instance)
return host, port
port = int(instance.get("editor_port") or api.EDITOR_DEFAULT_PORT)
return api.tunnel_target(instance, port)
def manifest_payload(instance: dict) -> dict:

View File

@ -57,6 +57,10 @@ def count_for_instance(instance_uid: str) -> int:
return _table().count(instance_uid=instance_uid, deleted_at=None)
def keeps_certificate(row: dict) -> bool:
return row.get("deleted_at") is None and row.get("status") == STATUS_ACTIVE
def create(
instance: dict, label: str, container_port: int, user_uid: str
) -> dict | None:
@ -68,23 +72,22 @@ def create(
revived = table.find_one(hostname=hostname)
stamp = _now()
if revived:
table.update(
{
"uid": revived["uid"],
"instance_uid": instance["uid"],
"project_uid": instance.get("project_uid", ""),
"user_uid": user_uid,
"label": label or f"port {container_port}",
"container_port": container_port,
"desired_state": "present",
"status": STATUS_PENDING,
"last_error": "",
"updated_at": stamp,
"deleted_at": None,
"deleted_by": None,
},
["uid"],
)
changes = {
"uid": revived["uid"],
"instance_uid": instance["uid"],
"project_uid": instance.get("project_uid", ""),
"user_uid": user_uid,
"label": label or f"port {container_port}",
"container_port": container_port,
"desired_state": "present",
"updated_at": stamp,
"deleted_at": None,
"deleted_by": None,
}
if not keeps_certificate(revived):
changes["status"] = STATUS_PENDING
changes["last_error"] = ""
table.update(changes, ["uid"])
return table.find_one(uid=revived["uid"])
uid = generate_uid()
table.insert(

View File

@ -110,19 +110,9 @@ class WorkspaceController:
def _tunnel_create(self, args: dict) -> Any:
instance = self._resolve(args.get("project_slug", ""))
port = int(args.get("container_port") or 0)
if port <= 0:
raise WorkspaceError("container_port is required")
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
if limits.max_tunnels and tunnels.count_for_instance(
instance["uid"]
) >= limits.max_tunnels:
raise WorkspaceError(f"tunnel limit reached ({limits.max_tunnels})")
row = tunnels.create(
row = provision.publish_tunnel(
instance, args.get("label", ""), port, self.owner_id
)
if not row:
raise WorkspaceError("could not create tunnel")
provision.write_manifest(instance)
return {
"ok": True,
"tunnel": row,

View File

@ -19,7 +19,7 @@ Seven HTTP middlewares run as a stack around every request, listed outermost fir
| `track_presence` | For the resolved current user on every non-asset request, calls `presence.touch(uid)` (a throttled `last_seen` write). |
| `maintenance_middleware` | When `maintenance_mode="1"`, returns a 503 `error.html` for non-admins, but always allows `/static`, `/avatar`, `/auth`, `/admin`, `/openai`, and admin users, so an operator can never lock themselves out. |
| `rate_limit_middleware` | Per-IP limit for mutating methods (POST/PUT/DELETE/PATCH), held in an in-process `defaultdict`. Reads `rate_limit_per_minute` / `rate_limit_window_seconds` from `site_settings`, floored to `max(1, ...)`, and is worker-count-aware. `/openai` and `/xmlrpc` are excluded. |
| `add_security_headers` | Sets `X-Content-Type-Options: nosniff` always, `X-Robots-Tag: index, follow` unless the handler already set it (so pages can opt out of indexing), plus HSTS, `Referrer-Policy`, a CSP and `X-Frame-Options: DENY` (except the `/p/` ingress), and no-store cache headers on `/admin`. |
| `add_security_headers` | Sets `X-Content-Type-Options: nosniff` always, `X-Robots-Tag: index, follow` unless the handler already set it (so pages can opt out of indexing), plus HSTS, `Referrer-Policy`, and a CSP whose `frame-ancestors` allows `'self'` and the workspace tunnel domain (except the `/p/` ingress, which sets no CSP at all), and no-store cache headers on `/admin`. Framing is controlled by `frame-ancestors` alone - no `X-Frame-Options` is sent, because it cannot express an allow-list and browsers honour the stricter of the two. |
| `await_pending_corrections` | After the handler runs, awaits any pending AI correction/modifier futures parked on `request.scope` (sync apply mode). |
| `refresh_db_snapshot` | Calls `refresh_snapshot()` so each request sees committed data. |

View File

@ -3,6 +3,29 @@
The nginx front door, how it serves each route, and the production-specific rules it enforces. See also [Production overview](/docs/production.html), [Deploy and update](/docs/production-deploy.html), and [Static asset caching and versioning](/docs/static-caching.html).
## Public hostnames: two front doors, one application
The platform answers on **two** public hostnames that reach the same application by completely different routes. Knowing which is which is the difference between a five minute diagnosis and an hour of chasing the wrong edge.
| | `pravda.education` | `devplace.net` |
|---|---|---|
| DNS | `95.216.15.238`, `2a01:4f9:2a:100e::2` | `88.198.21.243`, `2a01:4f8:222:2c45::2` |
| Machine | the production host | a separate front host |
| Path in | molohttp on port 443, proxying to `127.0.0.1:10500` | its own proxy, then an **SSH tunnel** to `127.0.0.1:10500` on production |
| Passes through molohttp | yes | **no** |
`devplace.net` runs on its own machine and holds a persistent SSH session into the production host, forwarding through it to `127.0.0.1:10500` - the `docker-proxy` socket for the nginx container. The forwarded listener lives on the **front** host, so the production host shows no sshd listening socket for it. That absence is expected.
**molohttp deliberately has no `devplace.net` site.** Its sites are `mail`, `smtp` and `imap.molodetz.nl`, `pravda.education`, and the workspace tunnel wildcard `*.tunnel.pravda.education`. Traffic for `devplace.net` enters underneath molohttp, so it needs no site there and adding one would achieve nothing - the hostname does not resolve to the production host, so such a site could never match.
**Triage rule.** Run the same authenticated request against both hostnames and compare:
- Fails on **both** - the fault is in the application or the database. Neither edge is involved.
- Fails on **devplace.net only** - the fault is in the front host's proxy. WebSocket `Upgrade` and `Connection` headers are the usual cause, exactly as documented for the nginx locations below.
- Fails on **pravda.education only** - the fault is in molohttp or its site configuration.
**Never test a hostname by forcing it onto an IP it does not resolve to.** Using `curl --resolve devplace.net:443:<production ip>` sends `Host: devplace.net` to molohttp, which correctly answers `404 No site configured for host: devplace.net`. That result says nothing about the real path and reads convincingly like a total outage. Always fetch each hostname over the public internet as it genuinely resolves.
## Build and configuration
The nginx image (`nginx/Dockerfile`) renders `nginx/nginx.conf.template` at start through `nginx/start.sh`, which substitutes a small allow-list of variables (`NGINX_CACHE_CONFIG`, `NGINX_CACHE_MAX_SIZE`, `NGINX_MAX_BODY_SIZE`) and leaves nginx runtime variables such as `$http_upgrade` untouched. The host's `devplacepy/static` directory is bind-mounted read-only at `/app/static` for package assets, and the consolidated `<DEVPLACE_DATA_DIR>/uploads` directory is bind-mounted read-only at `/data/uploads` (the `/static/uploads/` location aliases it), so both served assets and uploads always match the running code and data without an image rebuild.
@ -62,7 +85,7 @@ Verify from an IPv6-only vantage point (or force the family): `curl -6 -I https:
## Security headers and caching
The server block sets `X-Content-Type-Options`, `X-Frame-Options: DENY`, `X-XSS-Protection`, and `Referrer-Policy`, inherited only by locations that declare no `add_header` of their own. gzip is enabled for text, JSON, JS, CSS, and SVG. The micro-cache is off by default; set `NGINX_CACHE_ENABLED=true` to cache proxied 200s for one minute with `X-Cache-Status` reporting.
The server block sets `X-Content-Type-Options`, `X-XSS-Protection`, and `Referrer-Policy`, inherited only by locations that declare no `add_header` of their own. It deliberately sets no `X-Frame-Options`: every location without its own `add_header` is proxied to the app, which owns framing policy via the CSP `frame-ancestors` directive, and an nginx-level header would be re-added on top of the app's response - that is what previously defeated the `/p/` ingress exemption, since `location /p/` declares no `add_header` and the app intentionally sends no framing headers there. gzip is enabled for text, JSON, JS, CSS, and SVG. The micro-cache is off by default; set `NGINX_CACHE_ENABLED=true` to cache proxied 200s for one minute with `X-Cache-Status` reporting.
## Troubleshooting

View File

@ -109,6 +109,23 @@ Press `F1` and type `DevPlace` for the full list:
| **DevPlace: Show public tunnels** | Pick one of your live public addresses |
| **DevPlace: Open the DevPlace editor guide** | This page |
## Publishing a port from the editor
Forward a port in the editor's **Ports** view and DevPlace publishes it for you.
The moment you forward it, the editor registers the port with DevPlace, which
creates the tunnel, orders its HTTPS certificate and answers with the public
address - the same address the Ports view shows you. Publishing counts against
your tunnel quota, so a port DevPlace refuses is reported back in the editor with
the reason.
Two things to know:
- The address serves HTTPS as soon as the certificate is issued, which takes a
few seconds. Until then your browser warns about the certificate name.
- Un-forwarding the port in the editor does **not** remove the tunnel. Public
addresses are removed deliberately, on your workspace page or by asking Devii,
so a restarted dev server never silently loses its link.
## Related
- [Get started with vibing](/docs/getting-started-vibing.html) - the container

View File

@ -247,6 +247,7 @@
<li><code>sudo</code> and <code>apt install</code> work with no extra setup.</li>
<li>Python, Rust, Nim and Swift toolchains are preinstalled.</li>
<li>Ports below 1024 cannot bind. Use a high port and a tunnel.</li>
<li>Forwarding a port in the editor's <strong>Ports</strong> view publishes it here automatically.</li>
<li>Your public URLs are also in <code>/app/.devplace/tunnels.json</code>.</li>
</ul>
</div>

View File

@ -24,7 +24,6 @@ server {
client_max_body_size ${NGINX_MAX_BODY_SIZE};
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy strict-origin-when-cross-origin;

View File

@ -12,9 +12,9 @@ ENV PYTHONUNBUFFERED=1 \
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl wget vim ack ca-certificates build-essential libpq-dev \
git openssh-client curl wget vim ack ca-certificates build-essential libpq-dev \
tmux apache2-utils procps htop iftop iotop netcat-openbsd zip unzip \
fakeroot xz-utils pkg-config \
rsync jq fakeroot xz-utils pkg-config \
binutils gnupg2 libc6-dev libcurl4-openssl-dev libedit2 libedit-dev \
libncurses-dev libpython3-dev libsqlite3-0 libsqlite3-dev uuid-dev \
libxml2-dev libz3-dev tzdata zlib1g-dev \
@ -99,7 +99,7 @@ COPY vscode/branding/devplace-login.css /tmp/devplace-login.css
COPY vscode/product.patch.json /tmp/product.patch.json
RUN set -eu; \
cat /tmp/devplace-login.css >> /usr/local/lib/code-server/src/browser/pages/login.css; \
python3 -c "import json,pathlib; p=pathlib.Path('/usr/local/lib/code-server/lib/vscode/product.json'); d=json.loads(p.read_text()); d.update(json.loads(pathlib.Path('/tmp/product.patch.json').read_text())); p.write_text(json.dumps(d, indent=2))"; \
python3 -c "import json,pathlib; p=pathlib.Path('/usr/local/lib/code-server/lib/vscode/product.json'); d=json.loads(p.read_text()); patch=json.loads(pathlib.Path('/tmp/product.patch.json').read_text()); d.update({k: ({**d[k], **v} if isinstance(v, dict) and isinstance(d.get(k), dict) else v) for k, v in patch.items()}); p.write_text(json.dumps(d, indent=2))"; \
rm -f /tmp/devplace-login.css /tmp/product.patch.json
COPY sudo /usr/local/bin/sudo
COPY aptroot /usr/local/bin/aptroot

View File

@ -444,7 +444,7 @@ def test_referrer_policy_header(app_server):
def test_x_frame_options_header(app_server):
r = requests.get(f"{BASE_URL}/feed", allow_redirects=True)
assert r.headers.get("X-Frame-Options") == "DENY"
assert "X-Frame-Options" not in r.headers
def test_x_frame_options_excluded_for_ingress_proxy(app_server):
@ -457,7 +457,7 @@ def test_content_security_policy_header(app_server):
csp = r.headers.get("Content-Security-Policy", "")
assert "object-src 'none'" in csp
assert "base-uri 'self'" in csp
assert "frame-ancestors 'none'" in csp
assert "frame-ancestors 'self'" in csp
assert "form-action 'self'" in csp

View File

@ -1,6 +1,7 @@
# retoor <retoor@molodetz.nl>
import json
import shlex
import pytest
@ -949,9 +950,14 @@ def test_the_run_spec_applies_the_quota_cpu_and_memory_to_a_workspace():
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
assert spec.cpu_limit == limits.cpu_limit()
assert spec.mem_limit == limits.mem_limit()
assert spec.command[0] == "code-server"
assert "--app-name" in spec.command
assert "--disable-workspace-trust" in spec.command
assert spec.command[0] == "/bin/sh"
assert spec.command[1] == "-c"
script = spec.command[2]
assert "code-server" in script
assert "--app-name" in script
assert "--disable-workspace-trust" in script
assert editor.ENV_EXPORT_FILE in script
assert shlex.split(script)[-1] == "/app"
def test_the_run_spec_seeds_the_editor_state_and_stamps_a_boot_marker(monkeypatch, tmp_path):
@ -988,3 +994,49 @@ def test_a_workspace_without_an_editor_port_still_gets_its_size():
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
assert spec.cpu_limit == limits.cpu_limit()
assert spec.command == ["sleep", "infinity"]
def test_publish_tunnel_records_that_certificates_are_not_configured():
instance = _instance()
row = provision.publish_tunnel(instance, "web", 3000, OWNER)
assert row["status"] == tunnels.STATUS_PENDING
assert row["last_error"] == provision.CERT_UNCONFIGURED
def test_publish_tunnel_enforces_the_tunnel_quota():
set_setting("workspace_max_tunnels", "1")
try:
instance = _instance()
provision.publish_tunnel(instance, "web", 3000, OWNER)
with pytest.raises(WorkspaceError):
provision.publish_tunnel(instance, "api", 3001, OWNER)
finally:
set_setting("workspace_max_tunnels", "5")
def test_publish_tunnel_refuses_a_port_outside_the_valid_range():
instance = _instance()
with pytest.raises(WorkspaceError):
provision.publish_tunnel(instance, "web", 0, OWNER)
def test_tunnel_route_resolves_an_unpublished_port_through_the_container():
from devplacepy.routers import tunnel as tunnel_router
instance = _instance(container_ip="172.17.0.9", container_gateway="172.17.0.1")
row = provision.publish_tunnel(instance, "web", 3000, OWNER)
tunnels.update(row["uid"], {"status": tunnels.STATUS_ACTIVE})
resolved, matched, host, port = tunnel_router.resolve(row["hostname"])
assert resolved["uid"] == row["uid"]
assert matched["uid"] == instance["uid"]
assert (host, port) == ("172.17.0.9", 3000)
def test_republishing_a_live_tunnel_keeps_its_certificate():
instance = _instance()
row = provision.publish_tunnel(instance, "web", 3000, OWNER)
tunnels.update(row["uid"], {"status": tunnels.STATUS_ACTIVE, "last_error": ""})
again = provision.publish_tunnel(instance, "web", 3000, OWNER)
assert again["uid"] == row["uid"]
assert again["status"] == tunnels.STATUS_ACTIVE
assert again["last_error"] == ""

View File

@ -507,3 +507,45 @@ def test_raw_query_survives_a_hash_in_the_path():
request.scope["path"] = "/weird/a b#c"
assert forward.raw_query(request) == "keep=1"
assert request.url.query == ""
def test_tunnel_target_prefers_the_direct_container_leg():
instance = {
"ports_json": '[{"host": 20500, "container": 8443, "proto": "tcp"}]',
"container_gateway": "172.17.0.1",
"container_ip": "172.17.0.9",
}
assert api.tunnel_target(instance, 8443) == ("172.17.0.9", 8443)
def test_tunnel_target_falls_back_to_the_published_port_without_a_container_ip():
instance = {
"ports_json": '[{"host": 20500, "container": 8443, "proto": "tcp"}]',
"container_gateway": "172.17.0.1",
}
assert api.tunnel_target(instance, 8443) == ("172.17.0.1", 20500)
def test_proxy_target_and_tunnel_target_agree_on_the_same_port():
instance = {
"ports_json": '[{"host": 20500, "container": 8443, "proto": "tcp"}]',
"container_gateway": "172.17.0.1",
"container_ip": "172.17.0.9",
"ingress_port": 8443,
}
assert api.proxy_target(instance) == api.tunnel_target(instance, 8443)
def test_tunnel_target_dials_the_container_for_an_unpublished_port():
instance = {
"ports_json": '[{"host": 20500, "container": 8443, "proto": "tcp"}]',
"container_gateway": "172.17.0.1",
"container_ip": "172.17.0.9",
}
assert api.tunnel_target(instance, 3000) == ("172.17.0.9", 3000)
def test_tunnel_target_is_empty_without_a_route_to_the_port():
instance = {"ports_json": "[]", "container_gateway": "172.17.0.1"}
assert api.tunnel_target(instance, 3000) == (None, None)
assert api.tunnel_target({"container_ip": "172.17.0.9"}, 0) == (None, None)

View File

@ -1,6 +1,7 @@
# retoor <retoor@molodetz.nl>
import json
import shlex
import pytest
@ -308,6 +309,23 @@ def test_every_optional_flag_is_declared():
assert flag in command
def test_wrap_with_env_export_runs_the_original_command_through_a_shell():
command = editor.argv(_instance(), editor.resolve(OWNER))
wrapped = editor.wrap_with_env_export(command)
assert wrapped[0] == "/bin/sh"
assert wrapped[1] == "-c"
script = wrapped[2]
assert script.rstrip().endswith(shlex.join(command))
def test_wrap_with_env_export_writes_only_devplace_prefixed_vars():
script = editor.wrap_with_env_export(["true"])[2]
assert "python3 -c" in script
assert editor.ENV_EXPORT_FILE in script
assert "DEVPLACE_" in script
assert "startswith" in script
def test_env_for_exports_the_profile_to_the_container():
env = editor.env_for(editor.resolve(OWNER))
assert env["DEVPLACE_EDITOR_APP_NAME"] == editor.APP_NAME