Run-it-yourself CloakCode hub — serves the phone app + multiplexes phone and VS Code providers
372
# extensions connect to the provider listener (wss) on 3544; the phone reaches the
# PWA via the gateway's own private tunnel (enable it below)
docker run --rm -p 3544:3544 ghcr.io/lsiddiquee/cloakcode-gateway:latest
# pin a version: ...cloakcode-gateway:v0.1.2
In the image the operator listener (PWA + phone) binds loopback 127.0.0.1:3543 — reach it via
the gateway's own private Dev Tunnel (below), or front it with your own ingress by setting
-e CLOAKCODE_GATEWAY_HOST=0.0.0.0 and publishing -p 3543:3543. The provider listener binds
0.0.0.0:3544 (wss); publish it with -p 3544:3544 so extensions can connect. Configure with the
same environment variables via -e:
docker run --rm -p 3544:3544 \
-e CLOAKCODE_GATEWAY_TOKEN=<shared-secret> \
-e CLOAKCODE_TUNNEL=devtunnel \
-v cloakcode-devtunnel:/home/app/.local/share/DevTunnels \
ghcr.io/lsiddiquee/cloakcode-gateway:latest
The image bundles the devtunnel CLI (inert unless you enable it). To host a private Dev Tunnel
straight from the container, enable it and mount a volume for the token so you only sign in once:
docker run -p 3544:3544 \
-e CLOAKCODE_TUNNEL=devtunnel \
-v cloakcode-devtunnel:/home/app/.local/share/DevTunnels \
ghcr.io/lsiddiquee/cloakcode-gateway:latest
On first run it prints a device code + URL to the console (docker logs) — open the URL in any
browser and enter the code. The sign-in is device-code, so -it is not needed (it runs fully
detached); it blocks until you finish, and if the code expires the container exits — just restart. The
token lives in the mounted volume, so later runs sign in silently. Sign-in defaults to GitHub; set
-e CLOAKCODE_TUNNEL_PROVIDER=microsoft for a Microsoft account. The container runs as a non-root user
(app). Prefer your own ingress instead? Leave the tunnel off and front the published port with
Cloudflare Tunnel / Tailscale / a reverse proxy.
A container is ephemeral — without volumes, replacing it (an image upgrade, docker rm, a
recreate) regenerates the operator TOTP secret (so every paired phone must re-enrol), drops the
Dev Tunnel sign-in, and discards the action log. Mount a volume for each piece of state you want
to keep — and you can relocate the files with env vars if you'd rather point them at one shared
volume:
| State | Default path in the container | Relocate with | Keep it with |
|---|---|---|---|
| Operator TOTP secret (+ confirmed) | /home/app/.cloakcode/operator-totp.secret | CLOAKCODE_MFA_SECRET_FILE | -v cloakcode-mfa:/home/app/.cloakcode |
| Dev Tunnel sign-in token | /home/app/.local/share/DevTunnels | (fixed — mount the path) | -v cloakcode-devtunnel:/home/app/.local/share/DevTunnels |
| Action log (JSONL) | /app/cloakcode-gateway.jsonl | CLOAKCODE_GATEWAY_LOG_FILE ("" = off) | point it into a mounted dir (see below) |
All three at once — TOTP secret survives upgrades, tunnel signs in once, and the action log lands on a named volume:
docker run -p 3544:3544 \
-v cloakcode-mfa:/home/app/.cloakcode \
-v cloakcode-devtunnel:/home/app/.local/share/DevTunnels \
-v cloakcode-logs:/data \
-e CLOAKCODE_GATEWAY_LOG_FILE=/data/gateway.jsonl \
-e CLOAKCODE_TUNNEL=devtunnel \
ghcr.io/lsiddiquee/cloakcode-gateway:latest
Prefer one volume for everything? Relocate the secret + log into it and mount it once:
-e CLOAKCODE_MFA_SECRET_FILE=/data/totp.secret -e CLOAKCODE_GATEWAY_LOG_FILE=/data/gateway.jsonl -v cloakcode-data:/data
(the Dev Tunnel token dir is fixed, so mount it separately if you use the built-in tunnel).
When the gateway is exposed — a live tunnel (the phone path) or a wide 0.0.0.0 operator bind —
operator TOTP is on automatically (secure-by-exposure). Here's the whole flow: gateway → phone →
extension. Force it on/off with CLOAKCODE_MFA=required / CLOAKCODE_MFA=off.
# The phone reaches the operator via a private tunnel, which EXPOSES it → MFA turns
# on. Mount a volume so the TOTP secret (and the tunnel sign-in) survive container
# replacement. Extensions connect to the provider listener on 3544.
docker run -p 3544:3544 \
-e CLOAKCODE_TUNNEL=devtunnel \
-v cloakcode-mfa:/home/app/.cloakcode \
-v cloakcode-devtunnel:/home/app/.local/share/DevTunnels \
ghcr.io/lsiddiquee/cloakcode-gateway:latest
On first run the console prints the instance name + the connect URLs and reports that enrolment is
required — until you pair an authenticator the hub serves only the pairing screen, no session
data. (No tunnel? The operator stays on loopback with MFA off; front it with your own private tunnel /
ingress, or set -e CLOAKCODE_GATEWAY_HOST=0.0.0.0 -p 3543:3543 to expose the PWA directly.)
Open the gateway app — easiest on a desktop browser so you can scan the on-screen QR with your phone's authenticator app:
http://<gateway-host>:<gateway-port>docker logs).The app shows a QR code. Scan it into an authenticator app (Google Authenticator, 1Password, …),
then enter the current 6-digit code to confirm. The secret is generated once and stored 0600
(in the mounted volume). Once confirmed, the gateway is active and serves normally.
Desktop vs phone. Opening the page on a desktop lets you scan the QR with your phone's authenticator. If you open it on the phone itself, you can't scan a QR on the same screen — tap/copy the shown secret into your authenticator instead.
Lock it down with strict enrolment. By default (
CLOAKCODE_MFA_ENROL=browser) the QR + secret are served to whoever opens the gateway during enrolment — convenient, but it means anyone who reaches the gateway before you've paired could enrol their own authenticator and take over. SetCLOAKCODE_MFA_ENROL=strictso the secret is never sent over the wire — the QR is shown only on an interactive TTY (never the persistentdocker logsstream, drift audit S7). Scan it from an attached terminal, then enter a code in the app; a headless run instead points at the0600secret file (retrieve it once out-of-band, e.g.docker exec … cat) or use browser enrolment.
Every later phone/browser logs in with the current 6-digit code and gets a session token (12 h, or 30 days with "remember this device"), so reconnects don't re-prompt. Replayed and repeatedly wrong codes are rejected.
In each VS Code window point the extension at the gateway:
"cloakcode.gatewayUrl": "ws://<gateway-host>:<gateway-port>"
The extension connects, and because the gateway requires auth it asks the extension to sign in (it does not fall back to an embedded bridge). Click the Sign In prompt, or run CloakCode: Sign in to Gateway from the Command Palette, and enter a current 6-digit code from the same authenticator you enrolled in step 2. The extension stores the issued provider token (per gateway URL) and reconnects. (Set the URL after activation? Run CloakCode: Reconnect or reload the window.)
Headless/automation instead of interactive sign-in? Present a static machine-to-machine secret:
CLOAKCODE_GATEWAY_TOKENon the gateway +cloakcode.gatewayTokenon the extension (Provider token).
Refresh the gateway in your browser/phone — the Copilot sessions from that VS Code window now appear. Open one to see the live transcript; a blocked session shows a "Needs your input" card you can answer remotely.
In VS Code settings, point the extension at the gateway's provider listener (several windows can share one):
"cloakcode.gatewayUrl": "wss://<gateway-host>:3544"
Also set cloakcode.gatewayCertFingerprint to the pin from the gateway's Connect an extension view
(all a self-signed gateway needs). An insecure gateway (CLOAKCODE_PROVIDER_INSECURE=1) uses a plain
ws:// URL instead.
If you started the gateway with a token, set the same value on the extension so it can register as a provider — see Provider token below.
If the gateway requires operator TOTP (the default when exposed), the extension connects but the gateway asks it to sign in — it does not fall back to an embedded bridge. Click the Sign In prompt (or run CloakCode: Sign in to Gateway) and enter a current 6-digit code from the authenticator you enrolled on the gateway; the extension stores the issued provider token per URL and reconnects. Full walkthrough: Full setup.
For a gateway on another machine or container, bind the provider listener wide
(CLOAKCODE_TLS_HOST=0.0.0.0 — the Docker image already does), then use that host's IP with the
provider port in gatewayUrl (e.g. wss://192.168.1.10:3544) plus the
cloakcode.gatewayCertFingerprint pin from its Connect an extension view. (The operator/PWA
listener stays on loopback — reach the phone via the gateway's tunnel.)
The gateway and every extension that connects to it authenticate the provider↔gateway link with one shared secret. When you run the gateway separately, the token must be identical on both sides and configured in both places — otherwise the gateway rejects the extension and its sessions never reach your phone.
Set the same value on the gateway and on every VS Code window that connects:
# gateway (env) — npx
CLOAKCODE_GATEWAY_TOKEN=<shared-secret> npx @cloakcode/gateway
# gateway (env) — Docker
# gateway (env) — Docker (publish the provider listener so extensions can reach it)
docker run --rm -p 3544:3544 -e CLOAKCODE_GATEWAY_TOKEN=<shared-secret> ghcr.io/lsiddiquee/cloakcode-gateway:latest
// VS Code settings — must match the gateway's token exactly
"cloakcode.gatewayToken": "<shared-secret>"
provider.auth_reject and closes the connection.openssl rand -hex 32. The CLOAKCODE_GATEWAY_TOKEN env var
overrides the cloakcode.gatewayToken setting on the extension side.The gateway binds two role-scoped listeners:
CLOAKCODE_GATEWAY_HOST (default 127.0.0.1),
fronted by your private Dev Tunnel (which supplies TLS). Operators only.CLOAKCODE_TLS_HOST (default 127.0.0.1; the Docker image sets 0.0.0.0) :CLOAKCODE_TLS_PORT
(default 3544). Providers only; a
provider is never served on the operator bind.The provider listener is wss:// by default — with no BYO cert the gateway generates and persists
a self-signed pair under ~/.cloakcode (key 0600, never logged) and prints its SHA-256
fingerprint — the pin. BYO a real CA / mkcert / corporate cert with CLOAKCODE_TLS_CERT_FILE +
CLOAKCODE_TLS_KEY_FILE:
# wss on the default provider port (3544), auto self-signed cert:
npx @cloakcode/gateway
# a fixed provider port + BYO cert/key:
CLOAKCODE_TLS_PORT=7443 CLOAKCODE_TLS_CERT_FILE=./gw.crt CLOAKCODE_TLS_KEY_FILE=./gw.key npx @cloakcode/gateway
# INSECURE plain ws (trusted network only — warned in console + UI):
CLOAKCODE_PROVIDER_INSECURE=1 npx @cloakcode/gateway
An encrypted overlay / reverse proxy (Tailscale, WireGuard, ssh -L, Caddy/nginx) with both
listeners on loopback is still the lowest-friction path when you already have one.
Pair an extension from the app: open the PWA (behind your tunnel + TOTP) → Settings → Connect an
extension. It shows one pairing URL to paste into the extension's cloakcode.gatewayUrl — the
reachable wss:// address with this gateway's fingerprint attached as a #fp=… fragment, so the
address and its pin can never drift apart (the fragment is never transmitted; the extension splits it
off locally). The extension then fetches the certificate, accepts it only if it matches that pin,
and uses it as the trust anchor — failing closed on a mismatch, never downgrading to
trust-on-first-use. The bare pin is shown too for anyone who prefers cloakcode.gatewayCertFingerprint
as a separate setting. A gateway whose certificate a real authority already vouches for (a public
CA, or your org's root deployed to the device) needs no setting beyond the URL. The console printout
is the fallback when no tunnel is up. The fingerprint is public (an integrity pin, not a secret);
the private key never leaves the gateway.
The phone → gateway boundary is gated by a time-based one-time code (RFC 6238 TOTP) whenever the
hub is exposed — a wide bind or a live tunnel. Force it with CLOAKCODE_MFA=required, turn it
off with CLOAKCODE_MFA=off; unset means secure-by-exposure (off for pure loopback dev). For the
end-to-end walkthrough see Full setup.
Pair once (enrolment). On first run the gateway generates a secret and persists it 0600 to
CLOAKCODE_MFA_SECRET_FILE (default ~/.cloakcode/operator-totp.secret). A fresh secret is
unconfirmed — the hub runs in enrolment mode, serving only the pairing screen until you verify
a code. Default (browser): open the gateway URL and the app shows the QR — scan it into an
authenticator app, then enter a code to confirm. Strict (CLOAKCODE_MFA_ENROL=strict): the
secret is never sent over the wire — the QR + otpauth URI are printed to the console instead; scan
there and verify in the app. Either way the secret is shown once; later runs reuse the file.
Each phone logs in with the current 6-digit code; the gateway returns a signed session token (12h, or 30d with “remember this device”) so reconnects don't re-prompt until it expires. A reused code (replay) and repeated bad codes (lockout) are rejected. The secret is never sent to the phone or written to the action log.
Identifying a gateway (CLOAKCODE_INSTANCE_ID). Each gateway has an instance id used as
its authenticator label (the otpauth account — so the app shows CloakCode: <id>), its Dev-Tunnel
name seed, and the name shown to the phone (in the app header). It defaults to the machine
hostname (the Windows computer/NetBIOS name, or the Unix hostname) — printed at startup as
[cloakcode-gateway] instance: <id> — so gateways on different machines are already distinguishable
with no configuration.
Running more than one gateway on one machine (e.g. office + home)? Set a distinct
CLOAKCODE_INSTANCE_ID on each (office, home, …) so the authenticator entries read
CloakCode: office / CloakCode: home and the phone shows which one you're connected to, instead of
two identical hostnames. The VS Code extension stores each gateway's issued token separately (per
URL), so switching cloakcode.gatewayUrl between them never re-pairs.
In Docker, mount -v cloakcode-mfa:/home/app/.cloakcode so the TOTP secret survives container
replacement (the image runs as app, so its home is /home/app), or relocate it with
CLOAKCODE_MFA_SECRET_FILE — see
Persisting state across container upgrades for
all the volumes (secret, tunnel token, action log).
| var | default | meaning |
|---|---|---|
CLOAKCODE_GATEWAY_HOST | 127.0.0.1 | operator listener bind (PWA + phone); keep loopback and front it with a private tunnel |
CLOAKCODE_GATEWAY_PORT | 3543, else a free port | operator port — also the port segment of the Dev Tunnel URL. Unset ⇒ try 3543 and fall back to a free port; 0 ⇒ always ephemeral; a value ⇒ lock it |
CLOAKCODE_TUNNEL | (off) | devtunnel → auto-host a private tunnel and print the phone URL |
CLOAKCODE_TUNNEL_PROVIDER | github | Docker only: github or microsoft for the container's device-code sign-in; defaults to GitHub |
CLOAKCODE_INSTANCE_ID | (machine hostname) | tunnel-name seed and authenticator label (e.g. office/home, so multiple gateways are distinguishable in your app) |
CLOAKCODE_GATEWAY_TOKEN | (off) | provider↔gateway shared secret; extensions must present the same value |
CLOAKCODE_MFA | (secure by exposure) | operator TOTP: required to force it, off to disable; unset ⇒ on when the hub is exposed (wide bind / live tunnel), off for pure loopback |
CLOAKCODE_MFA_SECRET_FILE | ~/.cloakcode/operator-totp.secret | where the base32 TOTP secret persists (0600); mount it as a volume in Docker |
CLOAKCODE_MFA_ENROL | browser | strict never sends the pairing secret over the wire (console QR only) |
CLOAKCODE_MFA_RESET | (off) | 1 regenerates the secret (lockout recovery) and re-enters enrolment |
CLOAKCODE_TLS_HOST | 127.0.0.1 (0.0.0.0 in Docker) | provider listener bind — the dedicated endpoint extensions connect to (separate from the operator listener) |
CLOAKCODE_TLS_PORT | 3544, else a free port | provider listener port (always on); same rule as the operator port (0 = ephemeral, a value locks it). Pair extensions via Connect an extension in the app |
CLOAKCODE_TLS_CERT_FILE | (auto self-signed) | BYO PEM cert for the wss provider listener (with _KEY_FILE); unset ⇒ an auto self-signed pair persisted under ~/.cloakcode |
CLOAKCODE_TLS_KEY_FILE | (auto self-signed) | BYO PEM private key for wss (with _CERT_FILE); a 0600 secret, never logged |
CLOAKCODE_PROVIDER_INSECURE | (off) | 1 ⇒ serve the provider listener as insecure plain ws:// (no cert) — trusted-network only; warned in console + UI |
CLOAKCODE_GATEWAY_LOG_FILE | ./cloakcode-gateway.jsonl | on-disk action log (JSONL); set empty to disable |
CLOAKCODE_WEB_DIR | bundled web/ | PWA directory to serve (defaults to the bundled app) |
CLOAKCODE_LOG_LEVEL | info | trace/debug/info/warn/error (CLOAKCODE_VERBOSE=1 ⇒ debug) |
CLOAKCODE_VERBOSE | (off) | 1 ⇒ shorthand for CLOAKCODE_LOG_LEVEL=debug (per-RPC detail: relay routing, sessions.list) |
The gateway logs provider / operator connect + disconnect by default; raise the level (or
CLOAKCODE_VERBOSE=1) for per-RPC detail.
The two trust boundaries are authenticated separately:
CLOAKCODE_MFA=required, disable with CLOAKCODE_MFA=off.
See Operator auth (TOTP) — pair once, then each phone logs in with a
6-digit code and resumes with a 12h/30d session token.CLOAKCODE_GATEWAY_TOKEN so only
extensions holding the secret can register (machine-to-machine; never shown to the phone).Both set together? The two hops authenticate independently — enabling one never disables the other:
verifyProviderCredential accepts either a TOTP-issued
token or the static token — an OR, not a priority or override. With MFA only, the
extension must sign in with a code to obtain a token, so TOTP gates this hop too; adding a
static token just supplies a second accepted credential (the headless escape hatch). On its
side, the extension prefers its stored sign-in token and falls back to the static one.Still prefer loopback + a private tunnel (default host 127.0.0.1; the Dev Tunnel is private,
sign-in required) over a wide 0.0.0.0 bind on an untrusted network — TOTP gates control, but a
private tunnel keeps the surface off the open internet.
Content type
Image
Digest
sha256:1e69261cd…
Size
117.1 MB
Last updated
2 months ago
docker pull likhan/cloakcode-gateway