diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 7bbcb07fb..f6a5a6818 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -515,3 +515,4 @@ extracting `OffsetExpression`/`LimitExpression`. | `DESCRIBE PAGE` output will not re-parse for any page carrying a **pluggable widget**: `mxcli check` on it fails with `extraneous input ':'` / `extraneous input '('` from the first widget onward. Separately, a boolean property the author set is missing from the description entirely | Two independent defects in the same path. **Emit**: explicit properties were written with a raw `%s`, so every string lost its quotes — a JSON `spec: {"a": 1}` then broke the parse at its first brace. **Read**: `extractExplicitProperties` skipped any value of `"true"`/`"false"` as a "common default", so booleans never reached the output | `mdl/executor/cmd_pages_describe_output.go` (`explicitPropValue`, `isBareLiteral`), `mdl/executor/cmd_pages_describe_pluggable.go` (`buildPropertyValueTypeMap`, `extractExplicitProperties`), `mdl/executor/cmd_pages_describe.go` (`rawExplicitProp.ValueType`) | **Fixing one half alone is worse than the bug.** Quote without emitting booleans and the description re-parses cleanly while silently dropping a property — a wrong page that validates. Both halves ship together or neither. **Quote by the DECLARED type, never the value's shape**: `ValueType.Type` sits in the widget's `Type.ObjectType.PropertyTypes`, the same array `buildPropertyTypeKeyMap` already walks for `PropertyKey` and throws away; a String property holding `"30"` or `"true"` is indistinguishable from a number once it is a string in BSON, and must still come back quoted. Where no type is declared, fall back to the value's shape and quote anything not plainly numeric or boolean — quoting is the safe direction, since an unquoted arbitrary string may not parse at all. **The round trip is the test, not the output**: describe → `check` → `exec` → describe must be byte-identical and leave `mx check` at 0 errors. Tests `mdl/executor/cmd_pages_describe_pluggable_roundtrip_test.go`; example `mdl-examples/bug-tests/pluggable-describe-roundtrip.mdl`, verified end to end on 11.12.1. **Uncovered while verifying, NOT fixed**: giving a property in a conditionally shown group a non-default value writes a widget Mendix rejects with CE0463 — ProgressCircle's `showLabel: true` and `labelType: 'percentage'` both do it with no DESCRIBE involved, while the same widget's General-group properties take non-default values happily. Reported in mxcli-ledger FINDINGS #104 | | `DESCRIBE PAGE` output will not re-parse for any page carrying a **pluggable widget**: `mxcli check` on it fails with `extraneous input ':'` / `extraneous input '('` from the first widget onward. Separately, a boolean property the author set is missing from the description entirely | Two independent defects in the same path. **Emit**: explicit properties were written with a raw `%s`, so every string lost its quotes — a JSON `spec: {"a": 1}` then broke the parse at its first brace. **Read**: `extractExplicitProperties` skipped any value of `"true"`/`"false"` as a "common default", so booleans never reached the output | `mdl/executor/cmd_pages_describe_output.go` (`explicitPropValue`, `isBareLiteral`), `mdl/executor/cmd_pages_describe_pluggable.go` (`buildPropertyValueTypeMap`, `extractExplicitProperties`), `mdl/executor/cmd_pages_describe.go` (`rawExplicitProp.ValueType`) | **Fixing one half alone is worse than the bug.** Quote without emitting booleans and the description re-parses cleanly while silently dropping a property — a wrong page that validates. Both halves ship together or neither. **Quote by the DECLARED type, never the value's shape**: `ValueType.Type` sits in the widget's `Type.ObjectType.PropertyTypes`, the same array `buildPropertyTypeKeyMap` already walks for `PropertyKey` and throws away; a String property holding `"30"` or `"true"` is indistinguishable from a number once it is a string in BSON, and must still come back quoted. Where no type is declared, fall back to the value's shape and quote anything not plainly numeric or boolean — quoting is the safe direction, since an unquoted arbitrary string may not parse at all. **The round trip is the test, not the output**: describe → `check` → `exec` → describe must be byte-identical and leave `mx check` at 0 errors. Tests `mdl/executor/cmd_pages_describe_pluggable_roundtrip_test.go`; example `mdl-examples/bug-tests/pluggable-describe-roundtrip.mdl`, verified end to end on 11.12.1. Reported in mxcli-ledger FINDINGS #104 | | Authoring a pluggable widget property that lives in a **conditionally shown group** writes a widget Mendix rejects with **CE0463**, while the same widget's other properties take non-default values happily. On ProgressCircle both `showLabel: true` and `labelType: 'percentage'` do it; `showLabel: false` and General-group properties are clean. No DESCRIBE involved | Two gaps on the same axis. **Serialization**: #574 nulls the TextTemplate of a HIDDEN conditional property, but left a VISIBLE one null — and Mendix stores an empty `Forms$ClientTemplate` there. Null and empty are each invalid in the other's state, so nulling hidden ones was only half the rule. **Extraction**: the editorConfig reader did not understand a ternary's ELSE branch (`cond ? (…) : hidePropertiesIn([…])`), so the `showLabel` gate was never seen and `labelText` read as visible whenever `labelType` was `"text"` — its default | `mdl/backend/widgetobj/builder.go` (`ApplyVisibilityRules`, `bsonFieldIsNil`), `mdl/executor/editorconfig_extract.go` (`parseGuard` `:` case, `ternaryCondition`, `trailingExpr`) | **Fixing one gap alone inverts the bug rather than closing it** — filling visible templates without the missing gate made `showLabel: false` fail where `true` had, because mxcli still thought labelText was visible. Measured both ways round before and after; a fix that moves which case fails is not a fix. **Let Mendix say what the shape should be**: `mx update-widgets` on a COPY of the failing project reconciles the widget, and diffing that against mxcli's output named the single meaningful path (`Object/Properties[N]/Value/TextTemplate` null vs `Forms$ClientTemplate`) out of 969. **The good/bad control does the isolation for free**: authoring the same widget with the boolean both ways gave two documents differing in exactly ONE path, so no other candidate needed testing. **Only CONDITIONAL properties are filled** — Studio Pro's convention for an unset TextTemplate is not uniform (a DataGrid custom-content column stores null for `tooltip` and an empty template for `exportValue`, per `emptyClientTemplateRules`), so filling every unset one would trade this bug for its mirror image. **The extractor's preamble matters**: the ternary is preceded by a whole `switch`, and walking back past the `?` to the function start yields a fragment with an unbalanced `}` that parses to nothing and looks like "unsupported shape" — hence `trailingExpr`. Regression signal: the widgetdemo showcase applies with **0 CE0463** (its 4 CE1613 are a pre-existing attribute reference). Tests `mdl/backend/widgetobj/widget_visibility_test.go`, `mdl/executor/editorconfig_extract_test.go`; example `mdl-examples/bug-tests/pluggable-describe-roundtrip.mdl` now exercises the group. Reported in mxcli-ledger FINDINGS #104 follow-on | +| Windows Defender flags the mxcli **Windows** release binary as `Trojan:Script/Sabsik.EN.A!ml`; enterprise EDR (Defender for Endpoint, CrowdStrike, SentinelOne) blocks it harder. Not the generic unsigned-Go-binary false positive of #185 | The binary genuinely embedded **chisel**, a dual-use tunnelling/pivoting tool (SSH over WebSocket), on every platform — although the tunnel only ever runs inside a Linux container. `run --hub` linked `chisel/client`, `tunnel-hub` linked `chisel/server`, so windows/darwin carried 32 packages incl. the whole `x/crypto/ssh` stack for a feature they cannot use | `cmd/mxcli/docker/tunnel_linux.go` + `tunnel_other.go` (client seam), `cmd/mxcli/tunnelhub/control_linux.go` + `control_other.go` (server seam), `scripts/check-tunnel-deps.sh` (guard) | **Never obfuscate, pack or rename to dodge the scanner** — attacker tradecraft, and it makes the binary less trustworthy, not more. **Code signing does not fix this class**: a signed binary containing chisel is still flagged behaviourally; signing only addresses #185's generic false positive. The fix is to stop shipping the capability where it is unused: one interface per seam, `_linux.go` impl + `!linux` stub, commands still registered everywhere but failing with an actionable message. **Prove absence three ways, and know that `go tool nm` is not one of them** — release ldflags `-s -w` strip the symbol table, so nm reports "no symbols" whether or not the code is linked and would give a false pass; use `go list -deps`, `go version -m`, and `strings` (nm only on a deliberately unstripped build). **Guard against the transitive path, not the name**: match the module list (`x/crypto/ssh`, `gorilla/websocket`, `armon/go-socks5`, `jpillora/*`) so re-entry without the word "chisel" still trips it, and assert a **positive control** (chisel IS in the linux graph) so the check cannot pass vacuously. Verified by re-adding the import and watching the guard fail on all four windows/darwin targets. Result: -13.5 MB (-14.7%) on windows+darwin, linux unchanged. See ADR-0009 | diff --git a/.claude/skills/mendix/bootstrap-app.md b/.claude/skills/mendix/bootstrap-app.md index 3f8a5326c..e31b2f7c1 100644 --- a/.claude/skills/mendix/bootstrap-app.md +++ b/.claude/skills/mendix/bootstrap-app.md @@ -121,7 +121,9 @@ drop the `./` if it came pre-installed on `PATH`. 8. **(Optional) browser preview from a cloud session:** `./mxcli run --hub https://hub.mxcli.org -p .mpr`, and report the preview URL it prints. Needs `MXCLI_HUB_KEY` on the environment; without it, continue as a - normal local run. + normal local run. `--hub` ships in the **Linux** build only (a cloud session is a + Linux container, so it works there); on a native Windows/macOS mxcli it fails with + an explanatory message — continue as a normal local run. --- diff --git a/.claude/skills/mendix/run-local.md b/.claude/skills/mendix/run-local.md index 0ba6dbdcb..536e856d6 100644 --- a/.claude/skills/mendix/run-local.md +++ b/.claude/skills/mendix/run-local.md @@ -322,10 +322,18 @@ which makes it look like a change you just made broke authentication. ## External browser preview (`--hub`) +> **Linux builds only.** `--hub` and `mxcli tunnel-hub` ship in the **Linux** build +> only. The tunnel embeds a general-purpose tunnelling tool that gets the Windows +> and macOS binaries flagged by Defender and enterprise EDR for a capability they +> can never use, so it is left out of them. On Windows/macOS the commands exist and +> show help, but fail with an explanatory message — run mxcli inside the project's +> devcontainer (where the warm loop already runs) to use `--hub`. See +> [ADR-0009](https://github.com/mendixlabs/mxcli/blob/main/docs/13-decisions/0009-tunnel-is-linux-only.md). + `--hub ` exposes the running app in a **browser at a public URL** without the app leaving this machine and without committing — for reviewing work-in-progress from a phone/tablet, or from an egress-only environment like Claude Code on the web. The app -stays here; a **chisel reverse tunnel** dials *out* to a hub over 443 and the hub proxies +stays here; a **reverse tunnel** dials *out* to a hub over 443 and the hub proxies browser requests back down it. Nothing is pushed — only live HTTP — and everything rides one 443 connection, so it works through an egress-only proxy. diff --git a/.github/workflows/push-test.yml b/.github/workflows/push-test.yml index e8b377a50..3dd87bbb6 100644 --- a/.github/workflows/push-test.yml +++ b/.github/workflows/push-test.yml @@ -6,6 +6,51 @@ permissions: contents: read jobs: + # The tunnel seam has a !linux half (stub + its tests) that the ubuntu job can + # only compile, never run. This job actually executes it on real Windows and + # macOS runners, so "--hub fails with an actionable message" is a tested claim + # rather than a cross-compile that type-checked. See ADR-0009. + # + # Neither package depends on the generated ANTLR parser, so this needs no + # grammar step and stays fast. + tunnel-seam-cross-platform: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: '1.26.6' + - name: Test the tunnel seam + shell: bash + # Scoped with -run to the seam's own tests. The full test binaries are still + # COMPILED for this platform, so a Windows/macOS build break is still caught; + # only the !linux stub behaviour is executed. + # + # Running the whole packages here fails on Windows for reasons that predate + # this change and are unrelated to the tunnel: several tests assert POSIX file + # modes (0600) that Windows does not implement — os.Chmod only toggles the + # read-only bit, so Stat reports 666 — plus one path-separator assumption. + # Tracked separately in #897; widening this job is that issue's job, not this + # one's. + # + # -run can pass vacuously if the tests are renamed or deleted, so assert that + # the expected number actually ran. + run: | + out=$(go test -v -count=1 -run 'Unsupported' ./cmd/mxcli/docker/... ./cmd/mxcli/tunnelhub/...) + echo "$out" + n=$(printf '%s\n' "$out" | grep -c '^--- PASS: Test.*Unsupported' || true) + echo "seam tests executed: $n" + if [ "$n" -lt 4 ]; then + echo "FAIL: expected at least 4 tunnel-seam tests to run, -run matched $n." + echo " The !linux stubs in cmd/mxcli/docker and cmd/mxcli/tunnelhub" + echo " must each keep a test whose name contains 'Unsupported'." + exit 1 + fi + build-and-test: runs-on: ubuntu-latest steps: @@ -28,6 +73,13 @@ jobs: run: make build - name: Test run: make test + - name: Check tunnel stays Linux-only + # The embedded tunnel (chisel) must never reach the Windows/macOS builds — + # it gets mxcli flagged by Defender and enterprise EDR on managed corporate + # endpoints, which is most of our audience. See ADR-0009. The script also + # asserts a positive control (chisel IS in the linux graph) so it cannot + # pass vacuously. + run: ./scripts/check-tunnel-deps.sh - name: Check MDL example scripts # Single source of truth: `make check-mdl` covers BOTH doctype-tests/ and # bug-tests/ (skipping *.test.mdl, inverting *.fail.mdl negative tests, and diff --git a/CHANGELOG.md b/CHANGELOG.md index dde975e12..4591cb121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **The embedded tunnel now ships in the Linux build only** (`mxcli run --hub`, `mxcli tunnel-hub`). The tunnel embeds [chisel](https://github.com/jpillora/chisel), a dual-use tunnelling tool that appears in threat intelligence as a pivoting component. It only ever runs inside a Linux container, but every platform linked it — so Microsoft Defender flagged the Windows binary (`Trojan:Script/Sabsik.EN.A!ml`) and enterprise EDR flags this class of payload harder still, blocking mxcli on the managed corporate endpoints most Mendix developers use. Both chisel imports now sit behind a one-interface, Linux-only seam; a CI guard (`scripts/check-tunnel-deps.sh`, `make check-tunnel-deps`) fails the build if chisel or its SSH/websocket/socks dependencies reappear in a windows/darwin dependency graph. **Windows and macOS release binaries are 13.5 MB smaller (-14.7%)** and contain no tunnelling code. The Linux build is unchanged. On other platforms the two commands remain registered and documented but fail with an actionable message. Note that this is a *different* problem from the `Wacatac.C!ml` report in [#185](https://github.com/mendixlabs/mxcli/issues/185), which was a genuine generic Go-binary false positive: here the capability really was in the binary, and code signing would not have addressed it. We did not obfuscate or repack anything — the fix is not shipping the capability where it is unused. ([#890](https://github.com/mendixlabs/mxcli/issues/890), [ADR-0009](docs/13-decisions/0009-tunnel-is-linux-only.md)) + - **Breaking, narrow:** `mxcli tunnel-hub` can no longer be hosted on Windows or macOS — move the hub to a Linux host. Developers on native Windows/macOS installs must run mxcli inside the project's devcontainer to use `--hub`. + - `tunnelhub.ServerOptions.ChiselAddr` is renamed to `ControlAddr` (it addresses the platform-agnostic control server). + - **Go toolchain 1.26.5 → 1.26.6** for GO-2026-6218 (`net/url`), GO-2026-6090 (`crypto/tls`), GO-2026-6089 (`net/http`), GO-2026-6088 (`encoding/xml`), GO-2026-5972 (`encoding/asn1`) and GO-2026-5026 (`net/http`, via `golang.org/x/net/idna`). All six are standard-library advisories fixed in go1.26.6; no mxcli code changed. Bumped in `go.mod` and in all three workflows (`push-test`, `release`, `nightly`) together, so released binaries are not still linked against the vulnerable standard library. ## [0.17.0] - 2026-08-10 diff --git a/CLAUDE.md b/CLAUDE.md index 8e516425f..970992194 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -363,6 +363,38 @@ disable identity preservation. **Any test asserting "nothing changed" must inclu the control run with it set** — otherwise the test passes against a build that never had the fix, which is exactly how PR #125 shipped green. +### The Tunnel Is Linux-Only, On Purpose — Do Not "Restore" It + +`mxcli run --hub` and `mxcli tunnel-hub` embed [chisel](https://github.com/jpillora/chisel), +a dual-use tunnelling tool that appears in threat intelligence as a pivoting +component. Shipping it in the Windows and macOS binaries — where the tunnel can +never run — got them flagged by Defender (`Trojan:Script/Sabsik.EN.A!ml`) and +denied by enterprise EDR, which blocks mxcli for corporate Mendix developers on +managed endpoints. It is now built **for Linux only**. See +[ADR-0009](docs/13-decisions/0009-tunnel-is-linux-only.md). + +This looks like a portability gap and is not one. Making the tunnel cross-platform +again re-introduces the detection for the large majority of downloads. + +- **All chisel imports live behind two seams**, one interface each: + `tunnelConn` / `startTunnel` (`cmd/mxcli/docker/tunnel_linux.go` + `tunnel_other.go`) + and `controlServer` / `newControlServer` (`cmd/mxcli/tunnelhub/control_linux.go` + + `control_other.go`). Adding a chisel import anywhere else is the mistake the + guard exists to catch. +- **`scripts/check-tunnel-deps.sh` (CI, and `make check-tunnel-deps`) fails the + build** if chisel or its tunnelling-specific dependencies — the SSH/websocket/ + socks stack included, which is how it would come back without the word "chisel" + appearing — reach a windows/darwin dependency graph. It asserts a positive + control first (chisel *is* in the linux graph), so it cannot pass vacuously. +- **The hub seam is at `Start`, not construction**, so the portable front + (registry, API, auth, routing) stays testable on every platform. +- **Never obfuscate, pack, or rename to evade detection.** That is attacker + tradecraft and makes things strictly worse. The only legitimate fix is not + shipping the capability where it is unused. Code signing does **not** substitute: + a signed binary containing chisel is still flagged behaviourally. +- Do not conflate this with #185 (`Wacatac.C!ml`), which was a genuine generic + Go-binary false positive with a different remedy. + ### Theme Files: Where SCSS Actually Compiles Styling written to the wrong place fails **silently** — the build succeeds and the diff --git a/Makefile b/Makefile index 97c55fce3..76f556554 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ GO_BUILD_FLAGS = -trimpath # Clean version for VS Code extension (must be valid semver: major.minor.patch) VSCE_VERSION = $(shell echo "$(VERSION)" | sed 's/^v//; s/-.*//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$$' || echo "0.0.0") -.PHONY: build build-debug size release clean test engine-diff test-mdl check-mdl check-skill-mdl check-widget-versions grammar completions sync-skills sync-commands sync-lint-rules sync-changelog sync-all docs documentation docs-site docs-serve vscode-ext vscode-install source-tree sbom sbom-report lint lint-go lint-ts fmt vet +.PHONY: build build-debug size release clean test engine-diff test-mdl check-mdl check-skill-mdl check-tunnel-deps check-widget-versions grammar completions sync-skills sync-commands sync-lint-rules sync-changelog sync-all docs documentation docs-site docs-serve vscode-ext vscode-install source-tree sbom sbom-report lint lint-go lint-ts fmt vet # Helper: copy file only if content differs (avoids mtime updates that invalidate go build cache) # Usage: $(call copy-if-changed,src,dst) @@ -220,6 +220,12 @@ check-skill-mdl: build @./scripts/check-skill-mdl.sh ./$(BUILD_DIR)/$(BINARY_NAME) .claude/skills/mendix @./scripts/check-skill-mdl.sh ./$(BUILD_DIR)/$(BINARY_NAME) docs-site/src +# Guard: the embedded tunnel (chisel) must stay out of the Windows/macOS builds. +# See docs/13-decisions/0009-tunnel-is-linux-only.md. Needs no build — it reads +# the dependency graph — so it is cheap to run before pushing. +check-tunnel-deps: + @./scripts/check-tunnel-deps.sh + # Run integration tests (requires mx binary / mxbuild) test-integration: CGO_ENABLED=0 go test -tags integration -count=1 -timeout 30m ./... diff --git a/README.md b/README.md index 5a96ca3d3..c3943d2f6 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,8 @@ mxcli run --hub https://hub.example.com -p app.mpr # -> a shareable previ `mxcli tunnel-hub --domain example.com` is the static relay you run once on a small VPS; it can front many previews at per-subdomain hosts across projects, solutions, branches, and worktrees, with a sortable availability overview at `hub.example.com/`. See **[Local Dev Loop → External browser preview](https://mendixlabs.github.io/mxcli/tools/run-local.html)**. +> **`--hub` and `tunnel-hub` are Linux-only.** The tunnel exists to get a preview out of a Linux container — the only place it ever ran — so it is built for Linux alone. Embedding a general-purpose tunnelling tool in the Windows and macOS binaries, which can never use it, got them flagged by Microsoft Defender and enterprise EDR, blocking mxcli on the managed corporate laptops most Mendix developers work on. On those platforms the commands still exist and show help, but fail with an explanatory message; run mxcli inside the project's devcontainer to use `--hub`. Everything else in `mxcli run --local` is unaffected. We did **not** obfuscate the dependency to dodge the scanners — the fix is not shipping the capability where it is unused. Rationale: [ADR-0009](docs/13-decisions/0009-tunnel-is-linux-only.md). + ### Existing project For an existing Mendix project, use `mxcli init` to add AI tooling and a Dev Container: diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index b3c3a96d4..7c4ee80aa 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -37,10 +37,16 @@ Requirements: name. Override with --db-host/--db-name/--db-user/--db-password. With --hub, the running app is exposed in a browser at a public URL through an -mxcli tunnel-hub, without leaving this machine: a chisel client reverse-tunnels +mxcli tunnel-hub, without leaving this machine: a tunnel client reverse-tunnels the local app out over 443, and the runtime boots with ApplicationRootUrl set to the hub URL so the app works under that origin. --hub implies --local. +--hub is available in the Linux build only. The tunnel exists to get a preview +out of a Linux container, so shipping it in the Windows and macOS binaries would +only get them flagged by endpoint security for a capability they never use. On +those platforms --hub fails with an explanatory message; run mxcli inside the +project's devcontainer to use it. + The Mendix runtime log — server-side stack traces and your microflow LOG output — is written to /.mxcli/runtime.log so a server-side error is debuggable (the browser only shows a generic dialog). mxcli both tees the @@ -103,6 +109,13 @@ Examples: // only serving mode wired today; a future PAD path will accept --hub too). hubKey := "" if hub != "" { + // Fail here rather than after booting the app: on a build without the + // tunnel (everything but Linux — ADR-0009) --hub can never succeed, and + // the user should learn that before waiting out a runtime start. + if !docker.TunnelSupported() { + fmt.Fprintf(os.Stderr, "Error: %v\n", docker.ErrTunnelUnsupported) + os.Exit(1) + } local = true // Present a per-user hub API key to an authenticated hub (MXCLI_HUB_KEY // env → ~/.mxcli/auth.json). Empty for open hubs; the shared --hub-secret diff --git a/cmd/mxcli/cmd_tunnelhub.go b/cmd/mxcli/cmd_tunnelhub.go index 6f2ca9f9c..30ab53093 100644 --- a/cmd/mxcli/cmd_tunnelhub.go +++ b/cmd/mxcli/cmd_tunnelhub.go @@ -30,9 +30,13 @@ var tunnelHubCmd = &cobra.Command{ Each app self-registers and is served at its own subdomain (-., or -- with --hub-prefix); the hub host (hub.) serves the registration API, the admin overview, and -the chisel control connection. Everything rides one 443 connection, so apps in +the tunnel control connection. Everything rides one 443 connection, so apps in egress-only environments (e.g. Claude Code on the web) can reverse-tunnel out. +The hub is available in the Linux build only — it is a daemon you deploy on a +host, and the tunnel it embeds is left out of the Windows and macOS binaries so +they are not flagged by endpoint security for a capability they never use. + You run your own hub — there is no hosted service. Stand it up on a host you control (a small VPS with a domain). @@ -59,6 +63,13 @@ Then, in each app's environment: --hub-solution CustomerPortal -p app.mpr `, Run: func(cmd *cobra.Command, args []string) { + // Fail before touching cert caches, key stores or session files: on a build + // without the tunnel (everything but Linux — ADR-0009) the hub can never + // serve, so it should not create state on the way to finding that out. + if !tunnelhub.HubSupported() { + fmt.Fprintf(os.Stderr, "Error: %v\n", tunnelhub.ErrHubUnsupported) + os.Exit(1) + } domain, _ := cmd.Flags().GetString("domain") hubHost, _ := cmd.Flags().GetString("hub-host") secret, _ := cmd.Flags().GetString("secret") diff --git a/cmd/mxcli/docker/tunnel.go b/cmd/mxcli/docker/tunnel.go index 0e7034b4d..91a3b0ea4 100644 --- a/cmd/mxcli/docker/tunnel.go +++ b/cmd/mxcli/docker/tunnel.go @@ -3,43 +3,54 @@ package docker import ( - "context" + "errors" "fmt" "io" "net/url" "os" "strings" - "time" - chclient "github.com/jpillora/chisel/client" "golang.org/x/net/http/httpproxy" ) // DefaultHubBackendPort is the port an mxcli tunnel-hub proxies public requests -// to (its chisel server's --backend), and the reverse port the client tunnels +// to (its tunnel server's --backend), and the reverse port the client tunnels // into. The two must agree; both default to 9000. const DefaultHubBackendPort = 9000 -// TunnelOptions configures an outbound chisel reverse tunnel from a locally -// running app to an mxcli tunnel-hub, so the app is reachable in a browser at -// the hub's public URL. The app never leaves this machine — only live HTTP flows -// through the tunnel. +// ErrTunnelUnsupported is what every tunnel entry point returns on a build that +// ships without the tunnel (everything but Linux — see tunnel_stub.go and +// ADR-0009). It is a single value so the early flag check in `mxcli run` and the +// run-time failure say exactly the same thing. +var ErrTunnelUnsupported = errors.New( + "the browser preview tunnel (--hub) is only available in Linux builds of mxcli\n\n" + + "--hub reverse-tunnels the running app out to a tunnel-hub, and that tunnel ships\n" + + "only in the Linux build, because that is the only place it runs: inside the\n" + + "container. Windows and macOS builds leave it out deliberately.\n\n" + + "Run mxcli inside the project's devcontainer (or any Linux container) to use --hub.\n" + + "Everything else about `mxcli run --local` works here unchanged.\n\n" + + "See https://mendixlabs.github.io/mxcli/tools/run-local.html") + +// TunnelOptions configures an outbound reverse tunnel from a locally running app +// to an mxcli tunnel-hub, so the app is reachable in a browser at the hub's +// public URL. The app never leaves this machine — only live HTTP flows through +// the tunnel. type TunnelOptions struct { - // HubURL is the tunnel-hub base URL, e.g. https://hub.example.com. The chisel + // HubURL is the tunnel-hub base URL, e.g. https://hub.example.com. The // control connection dials it over 443. HubURL string // LocalPort is the local app port to expose (e.g. 8080). LocalPort int - // RemotePort is the hub's chisel-server reverse port, which the hub proxies - // public traffic to (must match the hub's --backend port). Default 9000. + // RemotePort is the hub's reverse port, which the hub proxies public traffic + // to (must match the hub's --backend port). Default 9000. RemotePort int - // Secret is the shared chisel auth ("user:pass"), matching the hub's --secret. + // Secret is the shared tunnel auth ("user:pass"), matching the hub's --secret. // Optional but recommended. Secret string // Proxy is the outbound HTTP CONNECT proxy the control connection dials // through. In a Claude Code web session egress is proxy-only, so this must be - // set; it defaults from HTTPS_PROXY/https_proxy in the environment. chisel does - // not read the proxy env itself, so we pass it explicitly. + // set; it defaults from HTTPS_PROXY/https_proxy in the environment. The tunnel + // client does not read the proxy env itself, so we pass it explicitly. Proxy string // PublicURL is the browser-facing URL the app is served at (an assigned // subdomain on a multi-tenant hub). Defaults to HubURL when empty. @@ -48,13 +59,25 @@ type TunnelOptions struct { Stdout io.Writer } +// tunnelConn is the platform half of the tunnel seam. Only the Linux build has +// an implementation; keeping it to one interface with one method is what stops +// build tags spreading past these three files. +type tunnelConn interface { + // Close tears the tunnel down. + Close() +} + // Tunnel is a running reverse tunnel to a hub. type Tunnel struct { - client *chclient.Client - cancel context.CancelFunc + conn tunnelConn publicURL string } +// TunnelSupported reports whether this build can open a hub tunnel. Callers use +// it to reject --hub during flag validation, rather than booting a whole app and +// failing at the last step. +func TunnelSupported() bool { return tunnelSupported } + func (o *TunnelOptions) applyDefaults() { if o.RemotePort == 0 { o.RemotePort = DefaultHubBackendPort @@ -70,7 +93,8 @@ func (o *TunnelOptions) applyDefaults() { // proxyForURL resolves the outbound HTTP proxy for hubURL from the standard // proxy environment (HTTPS_PROXY etc.), honouring NO_PROXY — so an external hub // goes through the egress proxy while a loopback or allow-listed hub connects -// directly. chisel does not consult the proxy env itself, so we do it here. +// directly. The tunnel client does not consult the proxy env itself, so we do it +// here. func proxyForURL(hubURL string) string { u, err := url.Parse(hubURL) if err != nil || u.Host == "" { @@ -83,9 +107,9 @@ func proxyForURL(hubURL string) string { return p.String() } -// StartTunnel opens the reverse tunnel and returns once the chisel client has -// started connecting (it retries in the background until the process exits). -// Call Stop to tear it down. +// StartTunnel opens the reverse tunnel and returns once the client has started +// connecting (it retries in the background until the process exits). Call Stop to +// tear it down. On non-Linux builds it returns ErrTunnelUnsupported. func StartTunnel(o TunnelOptions) (*Tunnel, error) { o.applyDefaults() if o.HubURL == "" { @@ -95,34 +119,16 @@ func StartTunnel(o TunnelOptions) (*Tunnel, error) { return nil, fmt.Errorf("local app port is required") } - // R::127.0.0.1: — the hub's chisel server listens on - // and forwards to this app's port (the app binds 127.0.0.1). - remote := fmt.Sprintf("R:%d:127.0.0.1:%d", o.RemotePort, o.LocalPort) - cfg := &chclient.Config{ - Server: o.HubURL, - Proxy: o.Proxy, - Auth: o.Secret, - Remotes: []string{remote}, - KeepAlive: 25 * time.Second, - MaxRetryCount: -1, // retry forever: survive a hub restart or network blip - MaxRetryInterval: 30 * time.Second, - } - c, err := chclient.NewClient(cfg) + conn, err := startTunnel(o) if err != nil { - return nil, fmt.Errorf("configuring tunnel client: %w", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - if err := c.Start(ctx); err != nil { - cancel() - return nil, fmt.Errorf("starting tunnel: %w", err) + return nil, err } public := o.PublicURL if public == "" { public = o.HubURL } - t := &Tunnel{client: c, cancel: cancel, publicURL: strings.TrimRight(public, "/")} + t := &Tunnel{conn: conn, publicURL: strings.TrimRight(public, "/")} via := "" if o.Proxy != "" { via = " (via proxy)" @@ -139,10 +145,7 @@ func (t *Tunnel) Stop() { if t == nil { return } - if t.cancel != nil { - t.cancel() - } - if t.client != nil { - _ = t.client.Close() + if t.conn != nil { + t.conn.Close() } } diff --git a/cmd/mxcli/docker/tunnel_linux.go b/cmd/mxcli/docker/tunnel_linux.go new file mode 100644 index 000000000..5782c0f26 --- /dev/null +++ b/cmd/mxcli/docker/tunnel_linux.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +// This is the single place in the package that imports the chisel tunnel client, +// so the Windows and macOS binaries never link it. See ADR-0009 for why that +// matters; the CI guard in .github/workflows/push-test.yml enforces it. + +package docker + +import ( + "context" + "fmt" + "time" + + chclient "github.com/jpillora/chisel/client" +) + +const tunnelSupported = true + +// chiselTunnel is the Linux implementation of tunnelConn. +type chiselTunnel struct { + client *chclient.Client + cancel context.CancelFunc +} + +func (c *chiselTunnel) Close() { + if c == nil { + return + } + if c.cancel != nil { + c.cancel() + } + if c.client != nil { + _ = c.client.Close() + } +} + +// startTunnel opens the reverse tunnel. Options are already validated and +// defaulted by StartTunnel. +func startTunnel(o TunnelOptions) (tunnelConn, error) { + // R::127.0.0.1: — the hub's chisel server listens on + // and forwards to this app's port (the app binds 127.0.0.1). + remote := fmt.Sprintf("R:%d:127.0.0.1:%d", o.RemotePort, o.LocalPort) + cfg := &chclient.Config{ + Server: o.HubURL, + Proxy: o.Proxy, + Auth: o.Secret, + Remotes: []string{remote}, + KeepAlive: 25 * time.Second, + MaxRetryCount: -1, // retry forever: survive a hub restart or network blip + MaxRetryInterval: 30 * time.Second, + } + c, err := chclient.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("configuring tunnel client: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + if err := c.Start(ctx); err != nil { + cancel() + return nil, fmt.Errorf("starting tunnel: %w", err) + } + return &chiselTunnel{client: c, cancel: cancel}, nil +} diff --git a/cmd/mxcli/docker/tunnel_linux_test.go b/cmd/mxcli/docker/tunnel_linux_test.go new file mode 100644 index 000000000..349bf0130 --- /dev/null +++ b/cmd/mxcli/docker/tunnel_linux_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +// The end-to-end tunnel test drives a real chisel server, so it lives with the +// Linux-only implementation. See ADR-0009. + +package docker + +import ( + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + chserver "github.com/jpillora/chisel/server" +) + +// StartTunnel must reverse-tunnel a local port out to a hub so requests to the +// hub's reverse port reach the local app. This exercises the real embedded +// chisel client + server end to end, in-process (no external binary). +func TestTunnelRoundTrip(t *testing.T) { + // The "local app" the tunnel exposes. + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "hello from %s", r.Host) + })) + defer backend.Close() + backendPort := mustPort(t, backend.URL) + + // The hub: a chisel reverse server on a free port. + hubPort := freePort(t) + srv, err := chserver.NewServer(&chserver.Config{Reverse: true}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + if err := srv.Start("127.0.0.1", strconv.Itoa(hubPort)); err != nil { + t.Fatalf("hub Start: %v", err) + } + defer srv.Close() + + // The reverse port the hub will open and forward to the backend. + remotePort := freePort(t) + + tun, err := StartTunnel(TunnelOptions{ + HubURL: "http://127.0.0.1:" + strconv.Itoa(hubPort), + LocalPort: backendPort, + RemotePort: remotePort, + Proxy: "", // loopback: no proxy + Stdout: io.Discard, + }) + if err != nil { + t.Fatalf("StartTunnel: %v", err) + } + defer tun.Stop() + + if tun.PublicURL() != "http://127.0.0.1:"+strconv.Itoa(hubPort) { + t.Errorf("PublicURL = %q", tun.PublicURL()) + } + + // The client connects + opens the reverse listener asynchronously; poll it. + reverseURL := fmt.Sprintf("http://127.0.0.1:%d/", remotePort) + body := pollGet(t, reverseURL, 10*time.Second) + if body == "" { + t.Fatalf("no response through tunnel at %s", reverseURL) + } + // The request reached the backend through the tunnel. + if want := "hello from "; len(body) < len(want) || body[:len(want)] != want { + t.Errorf("through-tunnel body = %q, want prefix %q", body, want) + } +} + +func mustPort(t *testing.T, rawURL string) int { + t.Helper() + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("parse %q: %v", rawURL, err) + } + p, err := strconv.Atoi(u.Port()) + if err != nil { + t.Fatalf("port of %q: %v", rawURL, err) + } + return p +} + +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("free port: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +func pollGet(t *testing.T, url string, timeout time.Duration) string { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + resp, err := http.Get(url) + if err == nil { + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return string(b) + } + } + time.Sleep(150 * time.Millisecond) + } + return "" +} diff --git a/cmd/mxcli/docker/tunnel_other.go b/cmd/mxcli/docker/tunnel_other.go new file mode 100644 index 000000000..ae2063b25 --- /dev/null +++ b/cmd/mxcli/docker/tunnel_other.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux + +// The tunnel ships only in the Linux build. This stub keeps the rest of the +// package compiling unchanged on Windows and macOS, and — because it is the only +// other half of the seam — guarantees those binaries link no tunnel code at all. +// See ADR-0009 and .github/workflows/push-test.yml (the dependency guard). + +package docker + +const tunnelSupported = false + +func startTunnel(TunnelOptions) (tunnelConn, error) { return nil, ErrTunnelUnsupported } diff --git a/cmd/mxcli/docker/tunnel_other_test.go b/cmd/mxcli/docker/tunnel_other_test.go new file mode 100644 index 000000000..aa5ca45b4 --- /dev/null +++ b/cmd/mxcli/docker/tunnel_other_test.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux + +package docker + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +// On a build that ships without the tunnel, --hub must fail with the actionable +// message rather than panicking, hanging, or pretending it worked. See ADR-0009. +func TestTunnelUnsupportedOffLinux(t *testing.T) { + if TunnelSupported() { + t.Fatal("TunnelSupported() = true on a non-Linux build") + } + + var out bytes.Buffer + tun, err := StartTunnel(TunnelOptions{ + HubURL: "https://hub.example.com", + LocalPort: 8080, + Stdout: &out, + }) + if !errors.Is(err, ErrTunnelUnsupported) { + t.Fatalf("StartTunnel error = %v, want ErrTunnelUnsupported", err) + } + if tun != nil { + t.Errorf("StartTunnel returned a tunnel (%v) alongside the error", tun) + } + // It must not claim to have exposed anything. + if out.Len() != 0 { + t.Errorf("StartTunnel wrote progress output on an unsupported build: %q", out.String()) + } +} + +// The message is the whole point of not hiding the command: it has to say where +// the tunnel does work and where to read more. +func TestUnsupportedMessageIsActionable(t *testing.T) { + msg := ErrTunnelUnsupported.Error() + for _, want := range []string{"Linux", "--hub", "devcontainer", "https://mendixlabs.github.io/mxcli/"} { + if !strings.Contains(msg, want) { + t.Errorf("ErrTunnelUnsupported message is missing %q:\n%s", want, msg) + } + } +} diff --git a/cmd/mxcli/docker/tunnel_test.go b/cmd/mxcli/docker/tunnel_test.go index 09cd49582..93d71a3e8 100644 --- a/cmd/mxcli/docker/tunnel_test.go +++ b/cmd/mxcli/docker/tunnel_test.go @@ -3,17 +3,7 @@ package docker import ( - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "net/url" - "strconv" "testing" - "time" - - chserver "github.com/jpillora/chisel/server" ) // proxyForURL must route an external hub through the egress proxy but connect to @@ -40,96 +30,3 @@ func TestProxyForURL(t *testing.T) { } } } - -// StartTunnel must reverse-tunnel a local port out to a hub so requests to the -// hub's reverse port reach the local app. This exercises the real embedded -// chisel client + server end to end, in-process (no external binary). -func TestTunnelRoundTrip(t *testing.T) { - // The "local app" the tunnel exposes. - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "hello from %s", r.Host) - })) - defer backend.Close() - backendPort := mustPort(t, backend.URL) - - // The hub: a chisel reverse server on a free port. - hubPort := freePort(t) - srv, err := chserver.NewServer(&chserver.Config{Reverse: true}) - if err != nil { - t.Fatalf("NewServer: %v", err) - } - if err := srv.Start("127.0.0.1", strconv.Itoa(hubPort)); err != nil { - t.Fatalf("hub Start: %v", err) - } - defer srv.Close() - - // The reverse port the hub will open and forward to the backend. - remotePort := freePort(t) - - tun, err := StartTunnel(TunnelOptions{ - HubURL: "http://127.0.0.1:" + strconv.Itoa(hubPort), - LocalPort: backendPort, - RemotePort: remotePort, - Proxy: "", // loopback: no proxy - Stdout: io.Discard, - }) - if err != nil { - t.Fatalf("StartTunnel: %v", err) - } - defer tun.Stop() - - if tun.PublicURL() != "http://127.0.0.1:"+strconv.Itoa(hubPort) { - t.Errorf("PublicURL = %q", tun.PublicURL()) - } - - // The client connects + opens the reverse listener asynchronously; poll it. - reverseURL := fmt.Sprintf("http://127.0.0.1:%d/", remotePort) - body := pollGet(t, reverseURL, 10*time.Second) - if body == "" { - t.Fatalf("no response through tunnel at %s", reverseURL) - } - // The request reached the backend through the tunnel. - if want := "hello from "; len(body) < len(want) || body[:len(want)] != want { - t.Errorf("through-tunnel body = %q, want prefix %q", body, want) - } -} - -func mustPort(t *testing.T, rawURL string) int { - t.Helper() - u, err := url.Parse(rawURL) - if err != nil { - t.Fatalf("parse %q: %v", rawURL, err) - } - p, err := strconv.Atoi(u.Port()) - if err != nil { - t.Fatalf("port of %q: %v", rawURL, err) - } - return p -} - -func freePort(t *testing.T) int { - t.Helper() - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("free port: %v", err) - } - defer l.Close() - return l.Addr().(*net.TCPAddr).Port -} - -func pollGet(t *testing.T, url string, timeout time.Duration) string { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - resp, err := http.Get(url) - if err == nil { - b, _ := io.ReadAll(resp.Body) - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - return string(b) - } - } - time.Sleep(150 * time.Millisecond) - } - return "" -} diff --git a/cmd/mxcli/tunnelhub/control_linux.go b/cmd/mxcli/tunnelhub/control_linux.go new file mode 100644 index 000000000..5e969e764 --- /dev/null +++ b/cmd/mxcli/tunnelhub/control_linux.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +// This is the single place in the package that imports the chisel tunnel server, +// so the Windows and macOS binaries never link it. See ADR-0009 for why that +// matters; the CI guard in .github/workflows/push-test.yml enforces it. + +package tunnelhub + +import ( + "fmt" + + chserver "github.com/jpillora/chisel/server" +) + +const hubSupported = true + +// chiselControl is the Linux implementation of controlServer. +type chiselControl struct{ srv *chserver.Server } + +func (c *chiselControl) Start(host, port string) error { return c.srv.Start(host, port) } +func (c *chiselControl) Close() error { return c.srv.Close() } + +// newControlServer builds the embedded reverse-tunnel control server. auth is the +// shared "user:pass" every client presents; empty leaves the hub open. +func newControlServer(auth string) (controlServer, error) { + srv, err := chserver.NewServer(&chserver.Config{Reverse: true, Auth: auth}) + if err != nil { + return nil, fmt.Errorf("tunnel control server: %w", err) + } + return &chiselControl{srv: srv}, nil +} diff --git a/cmd/mxcli/tunnelhub/control_other.go b/cmd/mxcli/tunnelhub/control_other.go new file mode 100644 index 000000000..4a37267c9 --- /dev/null +++ b/cmd/mxcli/tunnelhub/control_other.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux + +// The tunnel control server ships only in the Linux build. This stub keeps the +// rest of the package — registry, API, auth, keys, sessions, admin, and the whole +// Host-routing front — compiling *and testable* unchanged on Windows and macOS, +// while guaranteeing those binaries link no tunnel code at all. See ADR-0009 and +// the dependency guard in .github/workflows/push-test.yml. +// +// The seam is at Start, not at construction, on purpose: a Server that cannot +// bind a control server is still a Server whose routing can be exercised, so the +// portable tests run on every platform rather than only where the tunnel ships. + +package tunnelhub + +const hubSupported = false + +// unsupportedControl stands in for the control server on builds without it. +type unsupportedControl struct{} + +func (unsupportedControl) Start(string, string) error { return ErrHubUnsupported } +func (unsupportedControl) Close() error { return nil } + +func newControlServer(string) (controlServer, error) { return unsupportedControl{}, nil } diff --git a/cmd/mxcli/tunnelhub/control_other_test.go b/cmd/mxcli/tunnelhub/control_other_test.go new file mode 100644 index 000000000..1eb752b75 --- /dev/null +++ b/cmd/mxcli/tunnelhub/control_other_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux + +package tunnelhub + +import ( + "context" + "errors" + "strings" + "testing" +) + +// On a build that ships without the tunnel, the hub must fail with the actionable +// message rather than panicking or binding a half-working front. See ADR-0009. +func TestHubUnsupportedOffLinux(t *testing.T) { + if HubSupported() { + t.Fatal("HubSupported() = true on a non-Linux build") + } + + // Construction still succeeds — only Start refuses — so the portable routing + // tests in this package keep running on every platform. + reg := NewRegistry(RegistryOptions{Domain: "example.com"}) + srv, err := NewServer(ServerOptions{Domain: "example.com", Registry: reg}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + if err := srv.Start(context.Background(), "127.0.0.1:0", "127.0.0.1:0"); !errors.Is(err, ErrHubUnsupported) { + t.Fatalf("Start error = %v, want ErrHubUnsupported", err) + } +} + +// The message is the whole point of not hiding the command: it has to say where +// the hub does run and where to read more. +func TestHubUnsupportedMessageIsActionable(t *testing.T) { + msg := ErrHubUnsupported.Error() + for _, want := range []string{"Linux", "tunnel-hub", "linux-amd64", "https://mendixlabs.github.io/mxcli/"} { + if !strings.Contains(msg, want) { + t.Errorf("ErrHubUnsupported message is missing %q:\n%s", want, msg) + } + } +} diff --git a/cmd/mxcli/tunnelhub/integration_test.go b/cmd/mxcli/tunnelhub/integration_linux_test.go similarity index 90% rename from cmd/mxcli/tunnelhub/integration_test.go rename to cmd/mxcli/tunnelhub/integration_linux_test.go index 3e6653a77..ad4916cd5 100644 --- a/cmd/mxcli/tunnelhub/integration_test.go +++ b/cmd/mxcli/tunnelhub/integration_linux_test.go @@ -1,5 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 +//go:build linux + +// This end-to-end test drives a real chisel client against the embedded control +// server, so it lives with the Linux-only implementation. See ADR-0009. + package tunnelhub import ( @@ -57,17 +62,17 @@ func TestFront_ProxiesThroughTunnel(t *testing.T) { srv, err := NewServer(ServerOptions{ Domain: "example.com", Registry: reg, - ChiselAddr: "127.0.0.1:" + strconv.Itoa(chiselPort), + ControlAddr: "127.0.0.1:" + strconv.Itoa(chiselPort), CertCacheDir: t.TempDir(), }) if err != nil { t.Fatalf("NewServer: %v", err) } // Start just the embedded chisel server (Start() would also bind TLS). - if err := srv.chisel.Start("127.0.0.1", strconv.Itoa(chiselPort)); err != nil { + if err := srv.control.Start("127.0.0.1", strconv.Itoa(chiselPort)); err != nil { t.Fatalf("chisel start: %v", err) } - defer srv.chisel.Close() + defer srv.control.Close() // Register the preview -> assigned reverse port. b, err := reg.Register(RegisterRequest{Project: "App", Branch: "main", AppPort: backendPort}) diff --git a/cmd/mxcli/tunnelhub/server.go b/cmd/mxcli/tunnelhub/server.go index cb696b33b..2db674658 100644 --- a/cmd/mxcli/tunnelhub/server.go +++ b/cmd/mxcli/tunnelhub/server.go @@ -4,6 +4,7 @@ package tunnelhub import ( "context" + "errors" "fmt" "html" "net/http" @@ -12,7 +13,6 @@ import ( "strings" "time" - chserver "github.com/jpillora/chisel/server" "golang.org/x/crypto/acme/autocert" "github.com/mendixlabs/mxcli/cmd/mxcli/tunnelhub/audit" @@ -33,19 +33,19 @@ type ServerOptions struct { // .. Domain string // HubHost is the control/admin/API host (default "hub."+Domain). Clients dial - // their chisel control connection here and the admin page lives here. + // their tunnel control connection here and the admin page lives here. HubHost string // Registry is the shared backend store. Registry *Registry - // TunnelAuth is the shared chisel auth ("user:pass"); empty disables auth. + // TunnelAuth is the shared tunnel auth ("user:pass"); empty disables auth. TunnelAuth string // RegisterSecret optionally gates /api/register (matched against X-Hub-Secret). RegisterSecret string // CertCacheDir is the autocert certificate cache directory. CertCacheDir string - // chiselAddr is the internal address the embedded chisel control server binds + // ControlAddr is the internal address the embedded tunnel control server binds // (default 127.0.0.1:8100). Not public — the front proxies the WS here. - ChiselAddr string + ControlAddr string // Auth, when enabled, adds the GitHub OAuth viewer plane: /auth/* on the hub // host, a session cookie, backend-list filtering, and (when RequireAuth) an // owner check on preview + admin access. Nil / open mode preserves today's @@ -59,25 +59,58 @@ type ServerOptions struct { KeysFile string } -// Server is the running multi-tenant hub: one embedded chisel reverse server -// (fanning in all client tunnels) behind a single-443 TLS front that routes by -// Host — the hub host to the admin/API/chisel-control, each preview subdomain to -// its tunnel. +// ErrHubUnsupported is what Server.Start returns on a build that ships without +// the tunnel (everything but Linux — see control_other.go and ADR-0009). It is a +// single value so the early flag check in `mxcli tunnel-hub` and the run-time +// failure say exactly the same thing. +var ErrHubUnsupported = errors.New( + "mxcli tunnel-hub is only available in Linux builds of mxcli\n\n" + + "The hub embeds a reverse-tunnel server, which ships only in the Linux build\n" + + "because that is the only place it runs: the hub is a daemon you deploy on a\n" + + "host, and the previews it fronts are tunnelled out of Linux containers.\n" + + "Windows and macOS builds leave it out deliberately.\n\n" + + "Run the hub on a Linux host (or in any Linux container) with the linux-amd64\n" + + "or linux-arm64 binary from the releases page.\n\n" + + "See https://mendixlabs.github.io/mxcli/tools/run-local.html") + +// HubSupported reports whether this build can run a tunnel hub. Callers use it to +// reject `tunnel-hub` during flag validation, before touching cert caches or key +// stores on disk. +func HubSupported() bool { return hubSupported } + +// controlServer is the platform half of the hub: the embedded reverse-tunnel +// control server that every client tunnel fans in to. It ships only in the Linux +// build (control_linux.go); control_other.go refuses to construct one. Keeping it +// to this two-method interface is what stops build tags spreading through the +// package — the registry, API, auth, admin and TLS front are all portable. +type controlServer interface { + // Start binds the control server on host:port. It is loopback-only; the TLS + // front proxies the control WebSocket to it. + Start(host, port string) error + // Close stops the control server. + Close() error +} + +// Server is the running multi-tenant hub: one embedded reverse-tunnel control +// server (fanning in all client tunnels) behind a single-443 TLS front that +// routes by Host — the hub host to the admin/API/tunnel-control, each preview +// subdomain to its tunnel. type Server struct { opts ServerOptions reg *Registry - chisel *chserver.Server + control controlServer manager *autocert.Manager http *http.Server apiMux *http.ServeMux admin http.Handler - chiselProxy *httputil.ReverseProxy // -> internal chisel control (WS) - appProxy *httputil.ReverseProxy // -> 127.0.0.1: (per request) + controlProxy *httputil.ReverseProxy // -> internal tunnel control (WS) + appProxy *httputil.ReverseProxy // -> 127.0.0.1: (per request) } -// NewServer wires the registry, API, admin page, embedded chisel server, and the -// TLS front. Call Start to listen. +// NewServer wires the registry, API, admin page, embedded control server, and +// the TLS front. Call Start to listen; on a build without the tunnel it is Start +// that returns ErrHubUnsupported. func NewServer(o ServerOptions) (*Server, error) { if o.Domain == "" { return nil, fmt.Errorf("Domain is required") @@ -85,16 +118,16 @@ func NewServer(o ServerOptions) (*Server, error) { if o.HubHost == "" { o.HubHost = "hub." + o.Domain } - if o.ChiselAddr == "" { - o.ChiselAddr = "127.0.0.1:8100" + if o.ControlAddr == "" { + o.ControlAddr = "127.0.0.1:8100" } if o.Registry == nil { return nil, fmt.Errorf("Registry is required") } - chisel, err := chserver.NewServer(&chserver.Config{Reverse: true, Auth: o.TunnelAuth}) + control, err := newControlServer(o.TunnelAuth) if err != nil { - return nil, fmt.Errorf("chisel server: %w", err) + return nil, err } // A shared key store backs /api/keys + X-Hub-Key registration (only reachable @@ -120,17 +153,18 @@ func NewServer(o ServerOptions) (*Server, error) { api.Mount(apiMux) s := &Server{ - opts: o, - reg: o.Registry, - chisel: chisel, - apiMux: apiMux, - admin: NewAdmin(o.Registry), + opts: o, + reg: o.Registry, + control: control, + apiMux: apiMux, + admin: NewAdmin(o.Registry), } - // Front proxies: chisel control (WS) to the internal chisel server, and app - // traffic to the per-request reverse port (Host preserved as the public host). - chiselURL := &url.URL{Scheme: "http", Host: o.ChiselAddr} - s.chiselProxy = httputil.NewSingleHostReverseProxy(chiselURL) + // Front proxies: the tunnel control connection (WS) to the internal control + // server, and app traffic to the per-request reverse port (Host preserved as + // the public host). + controlProxyURL := &url.URL{Scheme: "http", Host: o.ControlAddr} + s.controlProxy = httputil.NewSingleHostReverseProxy(controlProxyURL) s.appProxy = &httputil.ReverseProxy{ Director: func(req *http.Request) { @@ -187,14 +221,14 @@ func (s *Server) subOf(host string) (string, bool) { return sub, true } -// ServeHTTP routes by Host: the hub host serves chisel control (WS upgrade), +// ServeHTTP routes by Host: the hub host serves tunnel control (WS upgrade), // the API, and the admin page; a preview subdomain proxies to its tunnel. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { host := stripPort(r.Host) switch { case host == s.opts.HubHost: if isWebSocketUpgrade(r) { - s.chiselProxy.ServeHTTP(w, r) // chisel client control connection + s.controlProxy.ServeHTTP(w, r) // tunnel client control connection return } if strings.HasPrefix(r.URL.Path, "/api/") { @@ -239,12 +273,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -// Start binds the internal chisel server and the public TLS front (443), plus an +// Start binds the internal control server and the public TLS front (443), plus an // HTTP :80 listener for ACME challenges and http->https redirects. It blocks // until ctx is cancelled. func (s *Server) Start(ctx context.Context, httpsAddr, httpAddr string) error { - if err := s.chisel.Start("127.0.0.1", portOf(s.opts.ChiselAddr)); err != nil { - return fmt.Errorf("starting chisel: %w", err) + if err := s.control.Start("127.0.0.1", portOf(s.opts.ControlAddr)); err != nil { + return fmt.Errorf("starting tunnel control server: %w", err) } s.http = &http.Server{ @@ -282,11 +316,11 @@ func (s *Server) Start(ctx context.Context, httpsAddr, httpAddr string) error { defer cancel() _ = s.http.Shutdown(shutCtx) _ = httpSrv.Shutdown(shutCtx) - _ = s.chisel.Close() + _ = s.control.Close() return nil case err := <-errc: _ = httpSrv.Close() - _ = s.chisel.Close() + _ = s.control.Close() return err } } diff --git a/docs-site/src/tools/run-local.md b/docs-site/src/tools/run-local.md index f08e2153c..524eca890 100644 --- a/docs-site/src/tools/run-local.md +++ b/docs-site/src/tools/run-local.md @@ -151,10 +151,33 @@ the file. Use `--runtime-log ` to relocate it or `--runtime-log -` to turn ## External browser preview (`--hub`) +> **Linux builds only.** `--hub` and `mxcli tunnel-hub` are available in the +> **Linux** build of mxcli only. +> +> The tunnel exists to get a preview *out of a Linux container*, which is the only +> place it ever ran. Shipping it in the Windows and macOS binaries meant those +> binaries embedded a general-purpose tunnelling tool they could never use — and +> endpoint security noticed: Microsoft Defender flagged the Windows binary, and +> enterprise EDR (Defender for Endpoint, CrowdStrike, SentinelOne) flags this class +> of payload harder still. That blocked mxcli on exactly the managed corporate +> laptops most Mendix developers work on. +> +> So it is built for Linux only. On Windows and macOS the commands still exist and +> still show help, but fail with a message pointing you here. To use `--hub`, run +> mxcli **inside the project's devcontainer** (or any Linux container) — which is +> where the warm loop already runs. Everything else in `mxcli run --local` is +> unaffected. +> +> We deliberately did **not** hide the dependency to dodge the scanners; that would +> be dishonest and would make the binary less trustworthy, not more. The fix is not +> shipping the capability where it is not used. See +> [ADR-0009](https://github.com/mendixlabs/mxcli/blob/main/docs/13-decisions/0009-tunnel-is-linux-only.md). + + `--hub ` makes the running app reachable **in a browser at a public URL** — without the app leaving this machine and without committing. It's for reviewing work-in-progress from a phone or tablet, or from an egress-only environment such as Claude Code on the web. -The app stays local and a **chisel reverse tunnel** dials *out* to a hub over 443; the hub +The app stays local and a **reverse tunnel** dials *out* to a hub over 443; the hub proxies browser requests back down the tunnel. Nothing is pushed — only live HTTP — and because everything rides a single 443 connection, it works even from an egress-only proxy. diff --git a/docs-site/src/tutorial/claude-code-web.md b/docs-site/src/tutorial/claude-code-web.md index 73261c2cb..4bc01f014 100644 --- a/docs-site/src/tutorial/claude-code-web.md +++ b/docs-site/src/tutorial/claude-code-web.md @@ -158,6 +158,10 @@ mxcli run --hub https://hub.mxcli.org -p App.mpr # prints a shareable previe `--hub` implies `--local`, so you get the warm loop *and* a public preview URL in one command — edit here, hot-apply, refresh the tab. +> `--hub` ships in the **Linux** build only, and a Claude Code web session is a +> Linux container, so it works here. It is the native Windows/macOS installs that +> leave the tunnel out — see [Linux builds only](../tools/run-local.md#external-browser-preview---hub). + See [mxcli run --local](../tools/run-local.md) for `--watch`, `--ensure-db`, `--setup`, the screenshot flags, and the full `--hub` reference. diff --git a/docs/13-decisions/0009-tunnel-is-linux-only.md b/docs/13-decisions/0009-tunnel-is-linux-only.md new file mode 100644 index 000000000..b38c1f5a9 --- /dev/null +++ b/docs/13-decisions/0009-tunnel-is-linux-only.md @@ -0,0 +1,150 @@ +# ADR-0009: The embedded tunnel ships in Linux builds only + +- **Status**: Accepted +- **Date**: 2026-08-14 +- **Related**: [#890](https://github.com/mendixlabs/mxcli/issues/890); [#185](https://github.com/mendixlabs/mxcli/issues/185) (a *different*, genuine false positive); `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` (slices 3–4, the hub); `docs/11-proposals/PROPOSAL_hub_authentication.md` + +## Context + +`mxcli run --hub` reverse-tunnels a locally running app out to an `mxcli +tunnel-hub` so it is reachable in a browser — the feature that makes previews work +from an egress-only environment like Claude Code on the web. Both ends embed +[chisel](https://github.com/jpillora/chisel) as a library: the client in +`cmd/mxcli/docker`, the server in `cmd/mxcli/tunnelhub`. + +Chisel is a well-known dual-use tool. It tunnels SSH over WebSocket, and it +appears in threat intelligence as a post-exploitation pivoting component. Because +mxcli built one binary per platform from one dependency graph, **every** platform +linked it — including the Windows and macOS builds, where the tunnel can never +run. + +The consequences are not hypothetical: + +- Windows Defender flags the Windows release binary as + `Trojan:Script/Sabsik.EN.A!ml`. +- Enterprise EDR (Defender for Endpoint, CrowdStrike, SentinelOne) detects this + class of payload far more aggressively than consumer Defender, and enterprise + application-control policies deny it outright. + +That lands squarely on mxcli's core audience: corporate Mendix developers on +managed endpoints, for whom a detection is not a dismissible warning but a blocked +download and a helpdesk ticket. + +**This is a different problem from [#185](https://github.com/mendixlabs/mxcli/issues/185), +and must not be conflated with it.** #185 was `Trojan:Win32/Wacatac.C!ml` on v0.6: +a generic Go-binary machine-learning false positive of the kind that hits cosign, +syft, gitleaks and most other unsigned static Go binaries, correctly answered as +such. This one is different in kind — the binary genuinely *did* contain a +tunnelling and pivoting tool. Two things follow. First, the honest description is +not "false positive": the capability was really there. Second, **code signing does +not fix it.** A signed binary containing chisel is still a signed binary +containing chisel; behavioural EDR still flags it and application-control still +denies it. Signing remains worth doing for #185's class of problem, and does +nothing for this one. + +The measured footprint on a Windows build, before this change: 9 chisel packages +plus the whole `golang.org/x/crypto/ssh` stack, `gorilla/websocket`, +`armon/go-socks5`, `golang.org/x/net/proxy`, and the `jpillora/*` support +libraries — 32 packages that exist solely to serve a feature those binaries cannot +use. + +## Decision + +The tunnel is built for Linux only. Each chisel import sits behind a one-interface +seam with a `_linux.go` implementation and a `!linux` stub — `tunnelConn` / +`startTunnel` in `cmd/mxcli/docker`, `controlServer` / `newControlServer` in +`cmd/mxcli/tunnelhub` — so Windows and macOS binaries link no tunnel code at all, +while `run --hub` and `tunnel-hub` stay registered everywhere and fail with an +actionable message naming the Linux-container constraint. A CI guard +(`scripts/check-tunnel-deps.sh`) fails the build if chisel or any of its +tunnelling-specific dependencies reappear in a non-Linux dependency graph. + +## Consequences + +**Positive.** + +- The Windows and macOS binaries no longer contain a tunnelling/pivoting tool. + This is a real reduction in shipped capability, not a re-labelling: the entire + SSH-over-WebSocket stack is gone. +- Release binaries shrank by **13.5 MB (-14.67%)**: windows/amd64 91,808,768 → + 78,336,000 bytes; darwin/amd64 91,878,912 → 78,401,200. Linux grew 8,192 bytes + (+0.01%) for the interface indirection. Measured with release flags + (`CGO_ENABLED=0 -trimpath -ldflags="-s -w"`) and with the embedded-skills + directory held identical on both sides — `cmd/mxcli/skills/` is gitignored and + regenerated by `make sync-skills`, so a naive before/after straddling a sync + attributes ~240 KB of doc churn to the code change. +- Attack surface is aligned with actual use — the capability exists only where the + feature runs. + +**Negative — be honest about these.** + +- **`mxcli tunnel-hub` can no longer be run on Windows or macOS.** Anyone hosting a + hub on a macOS box must move it to Linux. We judged this near-zero impact (the + hub is a public-facing daemon; you deploy it on a host) but it is a genuine + capability removal, not just a repackaging. +- **A developer on Windows or macOS cannot use `--hub` natively.** They must run + mxcli inside the devcontainer. This is where it already ran for the audience the + feature was built for, but it is one more reason a native install is not + equivalent to the container. +- Two seams now carry build tags. They are narrow (one interface each, three files + each) but they are a maintenance obligation: a new tunnel feature must be added + on both sides of each seam. +- The guard's forbidden list is manually maintained. If a *legitimate, + non-tunnelling* use for `gorilla/websocket` ever arrives, the guard will block it + and someone must consciously edit the list — deliberately a speed bump, but a + speed bump. + +**Neutral.** + +- `go.mod` is unchanged. This is link-time removal, not a module drop; chisel is + still a dependency of the Linux build. +- The Linux build is unchanged in behaviour. The tunnel works exactly as before. + +## What this decision explicitly rules out + +Do not attempt to defeat the detection while keeping the capability. Obfuscating, +packing, renaming to hide the dependency, or otherwise evading endpoint security is +attacker tradecraft. It would be worse than the problem: it converts a truthful +detection into a deceptive binary, destroys the ability of a security team to +reason about what mxcli does, and would rightly get the project treated as hostile. +The only legitimate fix is the one taken here — **stop shipping the capability +where it is not used.** + +One clarification, since it borders on that line: this change renamed +`ServerOptions.ChiselAddr` to `ControlAddr` and the `chiselProxy` field to +`controlProxy`, which removed the last four chisel-derived string literals from +the non-Linux binaries. That is naming accuracy, not concealment — on those builds +the field genuinely addresses a generic `controlServer` that is not chisel. Nothing +is hidden: `control_linux.go` and `tunnel_linux.go` name chisel plainly, as does +`go.mod`, and the Linux binary still carries 450 chisel string literals. Had the +choice been between an accurate name and a detectable one, the accurate name is +only acceptable *because the code is genuinely gone*. + +## Alternatives considered + +**Code signing the Windows binary.** Worth doing on its own merits (it addresses +#185's class of false positive) but does not solve this: EDR flags the behaviour +and the embedded tool, not the absence of a signature. + +**Submitting the binary to Microsoft as a false positive.** It is not one. The +tool really was in there. Submitting it would be both dishonest and futile — and +even a successful Defender allowlist would not move Defender for Endpoint, +CrowdStrike or SentinelOne. + +**Shelling out to an external chisel binary instead of embedding it.** Moves the +detection rather than removing it, and makes it worse: mxcli would then be +*downloading and executing* a flagged tunnelling binary at run time, which is a +stronger EDR signal than linking it, and a supply-chain question besides. + +**Dropping the tunnel entirely, all platforms.** Rejected: the browser preview from +an egress-only container is a real and load-bearing feature for the Claude Code web +workflow, and it runs on Linux where the detection problem does not bite the same +audience. + +**Making it a build-tag opt-in (`-tags tunnel`) rather than GOOS-gated.** Rejected +as fragile in the direction that matters. The Linux release must always have it and +the Windows release must never have it; deriving that from GOOS means the guarantee +cannot be lost by forgetting a flag in a release script. + +**A runtime feature flag.** Does not help at all — the code would still be linked, +which is the entire problem. diff --git a/docs/13-decisions/README.md b/docs/13-decisions/README.md index e6d3edeb1..7cf6b2c0b 100644 --- a/docs/13-decisions/README.md +++ b/docs/13-decisions/README.md @@ -102,6 +102,7 @@ This preserves the audit trail. | [0006](0006-mcp-capability-model.md) | Version-aware MCP capability model | Proposed | | [0007](0007-mcp-read-model-session-overlay.md) | MCP backend read model — disk base with session overlay | Proposed | | [0008](0008-identity-and-idempotence.md) | Skip unchanged writes; never renumber element IDs in place | Accepted | +| [0009](0009-tunnel-is-linux-only.md) | The embedded tunnel ships in Linux builds only | Accepted | ## Candidates to back-fill diff --git a/scripts/check-tunnel-deps.sh b/scripts/check-tunnel-deps.sh new file mode 100755 index 000000000..ec9884708 --- /dev/null +++ b/scripts/check-tunnel-deps.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# Guard: the embedded tunnel (chisel and its tunnelling-specific dependencies) +# must not reach the Windows or macOS builds. +# +# Why this exists — see docs/13-decisions/0009-tunnel-is-linux-only.md. Chisel is +# a dual-use pivoting tool that appears in threat intelligence; shipping it in +# binaries that never use it gets mxcli flagged by Defender and enterprise EDR, +# which blocks adoption on managed corporate endpoints. The tunnel only ever runs +# inside a Linux container, so it is built only for Linux. +# +# Without this guard the import comes back the next time someone edits the hub +# code, and nothing would notice until a user's EDR does. +# +# Usage: +# scripts/check-tunnel-deps.sh # dependency-graph check (default) +# scripts/check-tunnel-deps.sh --binary P # also inspect a built binary at P +set -uo pipefail + +# Modules that must never appear in a non-Linux dependency graph. This is a +# module list, not a grep for "chisel": the SSH/websocket/socks stack is most of +# what makes an EDR classifier fire, and it would come back through a transitive +# edge without the name "chisel" appearing anywhere. +FORBIDDEN=( + "github.com/jpillora/chisel" + "github.com/jpillora/ansi" + "github.com/jpillora/backoff" + "github.com/jpillora/requestlog" + "github.com/jpillora/sizestr" + "github.com/gorilla/websocket" + "github.com/armon/go-socks5" + "github.com/andrew-d/go-termutil" + "github.com/tomasen/realip" + "golang.org/x/crypto/ssh" + "golang.org/x/net/proxy" +) + +fail=0 + +# --- 1. Positive control ----------------------------------------------------- +# Prove the check can actually see chisel before trusting it to report absence. +# Without this, a typo'd package pattern or a `go list` that errors out would +# make every platform look clean and the guard would pass vacuously. +linux_hits=$(GOOS=linux GOARCH=amd64 go list -deps ./... 2>/dev/null | grep -c '^github.com/jpillora/chisel') +if [ "$linux_hits" -eq 0 ]; then + echo "FAIL: positive control — expected chisel in the linux dependency graph, found none." + echo " Either the tunnel was removed entirely (update this guard) or 'go list' is broken." + echo " Refusing to report the other platforms clean on the strength of a check that sees nothing." + exit 1 +fi +echo "ok: positive control — linux graph contains chisel ($linux_hits packages)" + +# --- 2. The guard ------------------------------------------------------------ +for goos in windows darwin; do + for goarch in amd64 arm64; do + deps=$(GOOS="$goos" GOARCH="$goarch" go list -deps ./... 2>/dev/null) + if [ -z "$deps" ]; then + echo "FAIL: could not compute the $goos/$goarch dependency graph." + fail=1 + continue + fi + hits="" + for mod in "${FORBIDDEN[@]}"; do + # Match the module path or any package under it, not a substring. + found=$(printf '%s\n' "$deps" | grep -E "^${mod}(/|$)" || true) + [ -n "$found" ] && hits+="$found"$'\n' + done + if [ -n "$hits" ]; then + echo "FAIL: $goos/$goarch links tunnel code that must be Linux-only:" + printf '%s' "$hits" | sed 's/^/ /' + fail=1 + else + echo "ok: $goos/$goarch is free of tunnel dependencies" + fi + done +done + +# --- 3. Optional binary inspection ------------------------------------------ +# The dependency graph is the authoritative check; this confirms it against a +# real artifact. Note that `go tool nm` is useless on a release binary: the +# release ldflags (-s -w) strip the symbol table, so nm reports "no symbols" +# whether or not chisel is linked. Module info and string literals survive. +if [ "${1:-}" = "--binary" ] && [ -n "${2:-}" ]; then + bin="$2" + echo "--- inspecting $bin ---" + if go version -m "$bin" 2>/dev/null | grep -Eq "$(IFS='|'; echo "${FORBIDDEN[*]}")"; then + echo "FAIL: build info in $bin still lists a forbidden module:" + go version -m "$bin" | grep -E "$(IFS='|'; echo "${FORBIDDEN[*]}")" | sed 's/^/ /' + fail=1 + else + echo "ok: no forbidden module in the binary's build info" + fi + n=$(strings -n 6 "$bin" 2>/dev/null | grep -ci chisel || true) + if [ "${n:-0}" -ne 0 ]; then + echo "FAIL: $n chisel string literals found in $bin" + fail=1 + else + echo "ok: no chisel string literals in the binary" + fi +fi + +if [ "$fail" -ne 0 ]; then + echo + echo "The tunnel must stay behind the Linux-only seam:" + echo " cmd/mxcli/docker/tunnel_linux.go (chisel client)" + echo " cmd/mxcli/tunnelhub/control_linux.go (chisel server)" + echo "with a !linux stub beside each. See docs/13-decisions/0009-tunnel-is-linux-only.md." + exit 1 +fi +echo "All platforms clean."