diff --git a/CLAUDE.md b/CLAUDE.md index f419ffdc..608f7642 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:` "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: diff --git a/Makefile b/Makefile index 7d82a10b..5838ac4a 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index df41ab83..b3191ce6 100644 --- a/README.md +++ b/README.md @@ -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 :/app`, the daemon resolves `` 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/` 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 diff --git a/devplacepy/docs_api/groups/workspaces.py b/devplacepy/docs_api/groups/workspaces.py index 0a95c8d7..b77e7933 100644 --- a/devplacepy/docs_api/groups/workspaces.py +++ b/devplacepy/docs_api/groups/workspaces.py @@ -22,7 +22,8 @@ workspace start. A **tunnel** publishes one port from inside your container on a public HTTPS hostname of the form `-.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=[ diff --git a/devplacepy/main.py b/devplacepy/main.py index 08c93403..a3a5a104 100644 --- a/devplacepy/main.py +++ b/devplacepy/main.py @@ -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" diff --git a/devplacepy/routers/projects/containers/workspace.py b/devplacepy/routers/projects/containers/workspace.py index 35b86aa1..2b9f673a 100644 --- a/devplacepy/routers/projects/containers/workspace.py +++ b/devplacepy/routers/projects/containers/workspace.py @@ -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) diff --git a/devplacepy/routers/tunnel.py b/devplacepy/routers/tunnel.py index 1fd8cfd2..49251eb2 100644 --- a/devplacepy/routers/tunnel.py +++ b/devplacepy/routers/tunnel.py @@ -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: diff --git a/devplacepy/schemas/containers.py b/devplacepy/schemas/containers.py index 8b4f4598..bd46a7b2 100644 --- a/devplacepy/schemas/containers.py +++ b/devplacepy/schemas/containers.py @@ -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 diff --git a/devplacepy/services/containers/CLAUDE.md b/devplacepy/services/containers/CLAUDE.md index 8d6fdc8e..2728e8cd 100644 --- a/devplacepy/services/containers/CLAUDE.md +++ b/devplacepy/services/containers/CLAUDE.md @@ -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/` 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/` 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/` 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 diff --git a/devplacepy/services/containers/api.py b/devplacepy/services/containers/api.py index 3c85abeb..c86c321a 100644 --- a/devplacepy/services/containers/api.py +++ b/devplacepy/services/containers/api.py @@ -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: diff --git a/devplacepy/services/containers/files/bot.py b/devplacepy/services/containers/files/bot.py index df6807c4..e17aa86e 100644 --- a/devplacepy/services/containers/files/bot.py +++ b/devplacepy/services/containers/files/bot.py @@ -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) diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/extension.js b/devplacepy/services/containers/files/vscode/devplace-workspace/extension.js index 4cef7284..51ad5873 100644 --- a/devplacepy/services/containers/files/vscode/devplace-workspace/extension.js +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/extension.js @@ -1,6 +1,8 @@ // retoor 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() {} diff --git a/devplacepy/services/containers/files/vscode/devplace-workspace/package.json b/devplacepy/services/containers/files/vscode/devplace-workspace/package.json index 65ea820b..b95ead23 100644 --- a/devplacepy/services/containers/files/vscode/devplace-workspace/package.json +++ b/devplacepy/services/containers/files/vscode/devplace-workspace/package.json @@ -18,6 +18,9 @@ "activationEvents": [ "onStartupFinished" ], + "enabledApiProposals": [ + "tunnels" + ], "capabilities": { "untrustedWorkspaces": { "supported": true diff --git a/devplacepy/services/containers/files/vscode/product.patch.json b/devplacepy/services/containers/files/vscode/product.patch.json index 0347e5d5..8f6c43bd 100644 --- a/devplacepy/services/containers/files/vscode/product.patch.json +++ b/devplacepy/services/containers/files/vscode/product.patch.json @@ -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" + ] + } } diff --git a/devplacepy/services/containers/forward.py b/devplacepy/services/containers/forward.py index 695e838e..0d17b63f 100644 --- a/devplacepy/services/containers/forward.py +++ b/devplacepy/services/containers/forward.py @@ -37,6 +37,8 @@ RESPONSE_HOP_HEADERS = { "trailers", "transfer-encoding", "upgrade", + "date", + "server", } WS_HANDSHAKE_HEADERS = { diff --git a/devplacepy/services/containers/workspace/editor.py b/devplacepy/services/containers/workspace/editor.py index 15611015..a4f1eba3 100644 --- a/devplacepy/services/containers/workspace/editor.py +++ b/devplacepy/services/containers/workspace/editor.py @@ -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, diff --git a/devplacepy/services/containers/workspace/provision.py b/devplacepy/services/containers/workspace/provision.py index 63be3be6..87b46524 100644 --- a/devplacepy/services/containers/workspace/provision.py +++ b/devplacepy/services/containers/workspace/provision.py @@ -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: diff --git a/devplacepy/services/containers/workspace/tunnels.py b/devplacepy/services/containers/workspace/tunnels.py index 9e78962f..2d97b71c 100644 --- a/devplacepy/services/containers/workspace/tunnels.py +++ b/devplacepy/services/containers/workspace/tunnels.py @@ -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( diff --git a/devplacepy/services/devii/workspace/controller.py b/devplacepy/services/devii/workspace/controller.py index 4f8b9f46..4d76e6b5 100644 --- a/devplacepy/services/devii/workspace/controller.py +++ b/devplacepy/services/devii/workspace/controller.py @@ -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, diff --git a/devplacepy/templates/docs/architecture-backend.html b/devplacepy/templates/docs/architecture-backend.html index 2fa6b10a..2078c2de 100644 --- a/devplacepy/templates/docs/architecture-backend.html +++ b/devplacepy/templates/docs/architecture-backend.html @@ -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. | diff --git a/devplacepy/templates/docs/production-nginx.html b/devplacepy/templates/docs/production-nginx.html index 56a4df23..265ea5a6 100644 --- a/devplacepy/templates/docs/production-nginx.html +++ b/devplacepy/templates/docs/production-nginx.html @@ -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:` 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 `/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 diff --git a/devplacepy/templates/docs/workspace-editor.html b/devplacepy/templates/docs/workspace-editor.html index 784b3849..98c81c9d 100644 --- a/devplacepy/templates/docs/workspace-editor.html +++ b/devplacepy/templates/docs/workspace-editor.html @@ -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 diff --git a/devplacepy/templates/workspace.html b/devplacepy/templates/workspace.html index 34292279..59658d5a 100644 --- a/devplacepy/templates/workspace.html +++ b/devplacepy/templates/workspace.html @@ -247,6 +247,7 @@
  • sudo and apt install work with no extra setup.
  • Python, Rust, Nim and Swift toolchains are preinstalled.
  • Ports below 1024 cannot bind. Use a high port and a tunnel.
  • +
  • Forwarding a port in the editor's Ports view publishes it here automatically.
  • Your public URLs are also in /app/.devplace/tunnels.json.
  • diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index 464b92bc..8a84a2cd 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -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; diff --git a/ppy.Dockerfile b/ppy.Dockerfile index 3bf86d0b..5ba3e3c2 100644 --- a/ppy.Dockerfile +++ b/ppy.Dockerfile @@ -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 diff --git a/tests/api/feed.py b/tests/api/feed.py index 4f2e1965..b026a701 100644 --- a/tests/api/feed.py +++ b/tests/api/feed.py @@ -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 diff --git a/tests/api/projects/workspace.py b/tests/api/projects/workspace.py index 18b6fba2..eec05c4a 100644 --- a/tests/api/projects/workspace.py +++ b/tests/api/projects/workspace.py @@ -1,6 +1,7 @@ # retoor 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"] == "" diff --git a/tests/unit/services/containers/api.py b/tests/unit/services/containers/api.py index 45aee097..115fdb3e 100644 --- a/tests/unit/services/containers/api.py +++ b/tests/unit/services/containers/api.py @@ -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) diff --git a/tests/unit/services/containers/workspace/editor.py b/tests/unit/services/containers/workspace/editor.py index 4d8cdb9e..323bbb60 100644 --- a/tests/unit/services/containers/workspace/editor.py +++ b/tests/unit/services/containers/workspace/editor.py @@ -1,6 +1,7 @@ # retoor 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