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:
@@ -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=[
|
||||
|
||||
+10
-2
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"activationEvents": [
|
||||
"onStartupFinished"
|
||||
],
|
||||
"enabledApiProposals": [
|
||||
"tunnels"
|
||||
],
|
||||
"capabilities": {
|
||||
"untrustedWorkspaces": {
|
||||
"supported": true
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ RESPONSE_HOP_HEADERS = {
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"date",
|
||||
"server",
|
||||
}
|
||||
|
||||
WS_HANDSHAKE_HEADERS = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user