Skip to content

External browser preview via mxcli tunnel-hub (single-app → multi-tenant)#11

Merged
ako merged 12 commits into
mainfrom
claude/mxbuild-diagnostics-spike-emta6h
Jul 23, 2026
Merged

External browser preview via mxcli tunnel-hub (single-app → multi-tenant)#11
ako merged 12 commits into
mainfrom
claude/mxbuild-diagnostics-spike-emta6h

Conversation

@ako

@ako ako commented Jul 23, 2026

Copy link
Copy Markdown
Owner

What this adds

A locally-running Mendix app (mxcli run --local) can now be made reachable in a browser at a public URL — without the app leaving its machine and without committing WIP. The app stays put and reverse-tunnels out over a single 443 connection to a static hub; only live HTTP flows through the tunnel. This works even from an egress-only environment (Claude Code on the web), which was the driving use case.

Two commands:

  • mxcli run --hub <url> [--hub-secret user:pass] — a cross-cutting ingress flag (implies --local). It registers with the hub, boots the runtime with ApplicationRootUrl set to the assigned URL (so the SPA/originURI work under the public origin), opens an embedded chisel reverse tunnel, and heartbeats. The control connection's proxy is resolved from the environment honouring NO_PROXY, so an external hub goes through the egress proxy while a loopback hub connects directly.
  • mxcli tunnel-hub --domain <d> [--secret ...] — the static relay, run once on a small VPS.

Single-app (slices 1–3)

tunnel-hub fronts one app on the hub domain via chisel's --backend; run --hub sets ApplicationRootUrl to the hub URL. Validated live from a Claude Code web container against a Scaleway relay.

Multi-tenant (slice 4)

tunnel-hub --domain mxcli.org fronts many apps at per-preview subdomains over one 443:

  • New cmd/mxcli/tunnelhub package: a Registry of previews keyed by prefix/project/solution/branch/worktree (re-registration keeps a stable URL), subdomain-slug allocation ([prefix-]project[-branch]; main/master collapses to the project; collisions disambiguated by worktree then a numeric suffix), reverse-port allocation, heartbeat-based availability (available/stale) with TTL reaping, and deterministic sorting.
  • Registration API: POST /api/register, /api/status (heartbeat), /api/deregister, GET /api/backends, with an optional shared-secret gate.
  • Single-443 front: routes by Host — the hub host serves the chisel control (WebSocket upgrade), the API, and the admin page; each <subdomain>.<domain> reverse-proxies to that preview's tunnel with the public Host preserved. Certificates are issued per subdomain via autocert, gated to the hub host or a currently-registered subdomain. A :80 listener handles the ACME challenge + https redirect.
  • Admin overview (https://hub.<domain>/): a self-contained (no external assets) sortable table — status dot, solution, project, branch, clickable URL, registered / last-seen / last-used, uptime — sortable by any column, auto-refreshing.
  • Client: run --hub auto-detects project (.mpr name) and branch (git), with --hub-prefix / --hub-project / --hub-solution / --hub-branch / --hub-worktree overrides; falls back to the single-app behaviour when the hub has no registration API.

Also fixes the tunnel to retry forever (chisel's MaxRetryCount defaulted to 0, so it gave up on the first drop) so a preview survives a hub restart or a network blip.

Testing

  • go build ./..., go vet, gofmt clean.
  • New unit tests in cmd/mxcli/tunnelhub (registry allocation/identity/availability/sorting, the registration API, front host-routing) and an in-process end-to-end integration test: register a preview, tunnel a backend to its reverse port with a real embedded chisel client, and assert a request to <sub>.<domain> routes through the tunnel with the public Host preserved.
  • Client tests in cmd/mxcli/docker: NO_PROXY-aware proxy resolution, an in-process tunnel round-trip, and ApplicationRootUrl boot-config injection.
  • Live E2E on real infrastructure (Scaleway VPS + mxcli.org wildcard DNS): both the single-app and multi-tenant paths verified from a Claude Code web container — chisel's WebSocket survives the session's MITM egress proxy, the Mendix app renders over HTTPS on its assigned subdomain, and it appears available in the overview. Also verified from a second, independent Claude Code web session running a from-source build.

Follow-ups (out of scope)

  • Deeper multi-tenant auth (per-container tokens instead of one shared secret, admin-page auth).
  • Optional persistence of the registry across hub restarts (clients re-register on reconnect today).
  • Per-container change summaries in the overview.

Design record and the verification write-up are in docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4


Generated by Claude Code

claude added 12 commits July 23, 2026 05:55
Verified end-to-end from a Claude Code web container: chisel's WebSocket
reverse tunnel connects OUT through the session's mandatory MITM egress proxy
to a Scaleway VPS relay on 443, and a real Mendix app running in the container
renders in a browser via that relay. Closes the proposal's load-bearing open
question (WebSocket-through-proxy) positive, and confirms the live-tunnel model
(Option D) as preferred for Claude Code web — the app stays in the DEV
container, the relay is static, nothing is pushed.

Captures the implementation gotchas for the future tunnel-hub / dev --hub
build: chisel default port is 8080 not 443 (-p 443 for --tls-domain); the
client must be handed --proxy $HTTPS_PROXY explicitly; DNS + inbound 80 (ACME)
are the only setup blockers; and ApplicationRootUrl must be set to the public
subdomain (served over 443, not a raw port) for a correct SPA preview.

Updates Open Q9 (resolved), the Option D coupling row, and the slice-3 risk
note accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
… / mxcli tunnel-hub)

Slice 3 of the warm-loop proposal: a locally-running Mendix app is made
reachable in a browser at a public URL, without leaving the machine and without
committing. The app stays in its (possibly egress-only) environment and reverse-
tunnels out over 443 to a static hub; only live HTTP flows through the tunnel.

- mxcli run --hub <url> [--hub-secret user:pass]: a cross-cutting ingress flag
  that implies --local. Boots the runtime with ApplicationRootUrl set to the hub
  URL (so the SPA/originURI work under that origin) and opens an embedded chisel
  reverse tunnel from the app port out to the hub. The proxy for the control
  connection is resolved from the environment honouring NO_PROXY, so an external
  hub goes through the egress proxy while a loopback hub connects directly.

- mxcli tunnel-hub --domain <d> [--secret ...]: the static relay. Embeds a chisel
  reverse server with automatic Let's Encrypt (or --tls-cert/--tls-key) and
  proxies non-tunnel HTTP down the tunnel to the app, so everything rides a single
  443 connection (works from egress-only environments like Claude Code on the web).
  Single-app for this slice; multi-tenant registration + subdomains are a follow-on.

The chisel WebSocket transport was verified live out through the Claude Code web
egress proxy to a real VPS relay; here the embedded client/server, the
ApplicationRootUrl boot wiring, and a full run --hub -> hub -> app round trip are
covered by an in-process tunnel test plus a local end-to-end boot. Mendix renders
byte-identically through the hub's Host-rewriting backend, so no Host-preserving
shim is needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Add the --hub/--hub-secret flags and an "External browser preview" section to the
run-local skill and the docs-site page, and mark slice 3 shipped in the warm-loop
proposal (code + in-process tunnel test + local end-to-end; external E2E against
the Scaleway hub pending).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…oundation)

The data plane for the multi-tenant hub: a Registry of registered previews keyed
by identity (solution/project/branch/worktree) so reconnects keep a stable URL,
with subdomain-slug allocation (project-branch; main collapses to the project;
collisions disambiguated by worktree then a numeric suffix), reverse-port
allocation from a range, heartbeat-based availability (available/stale) with TTL
reaping, and deterministic sorting by last-used / registered / project+solution.

Plus the registration API over it: POST /api/register (returns subdomain, url,
reverse port, control URL, token, heartbeat interval), POST /api/status (heartbeat
by bearer token), POST /api/deregister, GET /api/backends (for the admin page),
with an optional shared-secret gate on registration.

Foundation only — the 443 front (host routing + dynamic TLS + chisel control),
the admin overview page, and the run --hub registration client follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
… + admin page

The serving plane for the multi-tenant hub, plus an optional hostname prefix.

- server.go: one TLS front on 443 that routes by Host — the hub host serves the
  chisel control connection (WebSocket upgrade), the registration API, and the
  admin page; each <subdomain>.<domain> reverse-proxies to that preview's chisel
  reverse port with the public Host preserved. Certificates are issued per host
  via autocert, gated by a HostPolicy that only allows the hub host or a currently
  registered subdomain (so random subdomains can't drive issuance). A :80 listener
  serves ACME HTTP-01 + redirects to https. Friendly pages for unknown / offline
  previews. One embedded chisel reverse server fans in all client tunnels.

- admin.go: a self-contained (no external assets) overview page that polls
  /api/backends and renders a sortable table — status (available/stale dot),
  solution, project, branch, clickable URL, registered / last-seen / last-used,
  uptime — sortable by any column, auto-refreshing.

- Optional prefix: [prefix-]project[-branch] namespaces the hostname
  (organization / solution / team / env), passed through the registry + API and
  part of a backend's stable identity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
… command

Completes slice 4 end to end. `mxcli run --hub` now registers with the hub before
booting: it auto-detects project (.mpr name) and branch (git), takes
--hub-prefix/--hub-project/--hub-solution/--hub-branch/--hub-worktree overrides,
POSTs /api/register, and uses the assigned subdomain URL as ApplicationRootUrl,
tunnels to the assigned reverse port, and heartbeats (deregistering on exit). It
falls back to the slice-1 single-app behaviour when the hub has no registration
API, so both hub types keep working.

`mxcli tunnel-hub` now runs the multi-tenant server (registry + registration API +
admin overview + single-443 host-routing front with per-subdomain autocert), keyed
off --domain (the wildcard base) with the control/admin at hub.<domain>. A
background reaper frees ports for expired backends.

An in-process integration test drives the whole path — register a preview, tunnel
a backend to its reverse port with a real embedded chisel client, and assert a
request to <sub>.<domain> routes through the tunnel with the public Host preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…solution, overview)

Update the run-local skill + docs-site with the multi-tenant flags
(--hub-prefix/-project/-solution/-branch/-worktree), per-preview subdomains, the
sortable availability overview, and stable URLs on re-register. Mark slice 4 built
in the warm-loop proposal (external Scaleway E2E pending).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…t drop

chisel's client MaxRetryCount defaulted to 0, so the reverse tunnel quit on the
first disconnect it couldn't instantly recover (observed as "client: Give up"
after a hub restart). Set MaxRetryCount = -1 so the preview reconnects
indefinitely and survives a hub restart or a transient network blip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The tunnel-hub work added a call path (docker.proxyForURL ->
httpproxy.Config.ProxyFunc -> idna.Profile.ToASCII) into golang.org/x/net/idna,
which govulncheck flags as GO-2026-5026 (failure to reject ASCII-only
Punycode-encoded labels) in x/net@v0.54.0. Fixed in v0.55.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The load-bearing project doc (CLAUDE.md Implemented list) and the README
described only run --local; add the external browser preview (run --hub +
multi-tenant tunnel-hub: subdomains, prefix/solution/branch/worktree, autocert,
registration API, availability overview).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…rvice)

Replace the mxcli.org examples in CLI help, the run-local skill, and the docs-site
with example.com placeholders, and state plainly that there is no hosted hub —
you run your own mxcli tunnel-hub on a host you control. Add a security note (this
version uses a single shared secret with open registration, so keep the hub to
people you trust; per-tenant auth is a follow-up).

Also reconcile the preview docs with the shipped multi-tenant command: the hub is
started with --domain <base> (not the hub host) and previews are served at
<project>-<branch>.<base>, with the admin overview at hub.<base> — the old
single-app framing ("--domain hub...", "Preview available at hub...") was stale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…osure)

Replace the remaining mxcli.org references (the proposal's verification record,
code comment examples, and test fixtures) with example.com, so a scanner can't
find and target the real hub host from the repo. Only hub-domain usage was
changed; there was no www.mxcli.org (docs) or repo-link usage to preserve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
@ako
ako merged commit 8b1faff into main Jul 23, 2026
5 checks passed
ako pushed a commit that referenced this pull request Jul 23, 2026
Record the multi-project-solution orchestration as slice 5: several apps of one
solution running side by side in a single container, each its own runtime +
database with auto-allocated ports, all registered under one --hub-solution and
grouped in the hub overview, with sibling URLs wired from the hub-assigned
subdomains via a mxcli.solution.yaml manifest driven by `mxcli run --solution`.

It's an orchestration layer over shipped primitives (per-project DB/deploy
isolation, port-triple flags, the slice-4 hub's solution grouping) — no new
runtime or hub mechanics. Also marks slices 3 & 4 verified-live and merged (PR #11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants