Skip to content

ops(runtime): make bundled MQTT and pprof exposure explicit settings (#372) - #431

Merged
dborup merged 5 commits into
masterfrom
codex/issue-372-mqtt-pprof-hardening
Oct 13, 2026
Merged

dborup merged 5 commits into
masterfrom
codex/issue-372-mqtt-pprof-hardening

Conversation

@dborup

@dborup dborup commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Relates to #372

Plan

The issue asks for three things that touch running deployments, and the hosts this
repo deploys to differ: one production runs its own Mosquitto, and another host runs
with pprof enabled and reached remotely. So the plan was to make every exposure
an explicit setting whose shipped default reproduces today's behaviour byte for
byte, add the validation and diagnostics that are inert by construction, and leave
every behaviour-changing flip to the owner.

  1. Turn each hard-coded exposure into a variable with a behaviour-preserving default
    (PROD_MQTT_BIND, PPROF_BIND, PPROF_HOST, per-role pprof ports).
  2. Move the pprof listener decision into one tested place (internal/pprofbind)
    shared by the server and the ingestor, instead of two inline copies.
  3. Validate the external-broker arrangement in the ingestor and in manage.sh,
    as diagnostics; make the refusal-to-start opt-in (MQTT_SOURCE_STRICT).
  4. Close the precedence gap that could silently select the wrong broker.
  5. Document exactly what an existing install sees after pulling, with a migration
    note, and keep every broker credential out of every diagnostic.

No default is flipped, no deployment is touched, and nothing was run against staging
or production.

What changed

Explicit bind settings (defaults reproduce today's behaviour)

Setting Default What the default does Operator can set
PROD_MQTT_BIND (empty) docker-compose.yml publishes the bundled broker's 1883 with no host interface, i.e. on both 0.0.0.0 and [::] — the rendered mapping is byte-identical to master 127.0.0.1 for host-only
PPROF_BIND (empty) staging compose files publish 6060/6061 with no host interface, i.e. IPv4 and IPv6 — as before 127.0.0.1 to require a tunnel
PPROF_HOST (+ PPROF_HOST_SERVER / _INGESTOR) (empty) in-process listener binds every interface, i.e. ":"+port — as before 127.0.0.1 for a process with no published port
PPROF_PORT_SERVER / PPROF_PORT_INGESTOR 6060 / 6061 same ports as before; the shared PPROF_PORT still applies to both any free, distinct pair
MQTT_SOURCE_STRICT false a broker error is logged and the ingestor still starts true to refuse to start

Both bind variables are interpolated conditionally —
"${PROD_MQTT_BIND:+${PROD_MQTT_BIND}:}${PROD_MQTT_PORT:-1883}:1883" — so an unset
variable renders no host interface at all. A :-0.0.0.0 default would not have been
equivalent: docker publishes an unqualified mapping on both 0.0.0.0 and [::], but
an explicit IPv4 host publishes IPv4 only, which would have dropped IPv6
reachability on the next up -d. docker compose config on all five compose files
now renders port mappings byte-identical to origin/master with nothing set, and
docker port shows 0.0.0.0:P and [::]:P as before.

PPROF_BIND and PPROF_HOST are deliberately separate and documented as such: a
loopback listener inside a container is unreachable through a docker port mapping,
because docker's proxy connects to the container's network address. Narrowing a
containerised deploy is PPROF_BIND's job; PPROF_HOST is for a process that runs
without a port mapping.

internal/pprofbind (new, shared module)

One resolver for both process roles, replacing the two inline ":"+pprofPort copies.
ENABLE_PPROF stays opt-in on the exact string true — unchanged — and a value like
1 or TRUE now says in the log that it left pprof off instead of being swallowed.
Per-role ports exist because the pre-existing shared PPROF_PORT applied to both
processes, so moving one collided the other; the shared variable still works.

In-process settings reach the container

MQTT_BROKER, MQTT_TOPIC, MQTT_SOURCE_STRICT, ENABLE_PPROF and the
PPROF_HOST* / PPROF_PORT* settings are read by the server and the ingestor, not by
docker compose, so every app compose file (docker-compose.yml, .no-mosquitto.yml,
both staging files and .example.yml) forwards them through its environment: block
with an empty default. An unset setting arrives as an empty string, which every
reader ignores — pprofbind skips a blank host or port, LoadConfig skips a blank
MQTT_BROKER, MQTT_SOURCE_STRICT is false unless truthy — so an install that never
set one is unaffected. Without this, the documented "one line in .env" reached
docker compose and stopped there.

One non-inert consequence, disclosed in the migration note. MQTT_BROKER
overrides mqttSources in config.json (LoadConfig precedence). Before the
compose files forwarded it, a value in .env was dead and config.json decided; now
it decides. A host with a stale MQTT_BROKER= line in .env and real
mqttSources would switch to that single env source on the next restart.
docs/deployment.md, DEPLOY.md and .env.example say so and point at
./manage.sh broker-check, which prints the sources in the ingestor's own
precedence. This is the only behaviour an existing install can see change in this
PR; everything else is inert when unset.

Broker validation (ingestor + manage.sh)

DISABLE_MOSQUITTO=true turns the bundled broker off but configures no replacement.
With no mqttSources, LoadConfig falls back to mqtt://localhost:1883 — the
address of the broker that was just disabled — so ingest stops while the container
still reports healthy. Reported now, before any connection attempt:

  • no configured source in external-broker mode (error)
  • every configured source on loopback in external-broker mode (error); one of several
    (warning, the rest still ingest)
  • a malformed broker URL — empty, no scheme, non-MQTT scheme, no host (error)
  • a DISABLE_MOSQUITTO value whose meaning depends on the launcher (warning).
    ./manage.sh resolves the value itself: dc_prod/dc_staging pick the compose
    file with is_true() (1/true/TRUE/yes/y/on), and the no-mosquitto
    files hard-code DISABLE_MOSQUITTO=true, so on a manage.sh-managed host every one
    of those values gives an external broker. A bare docker compose up against
    docker-compose.yml passes the value straight to the entrypoint, which disables
    the bundled broker only on the exact true, so yes there leaves the bundled
    broker running. broker-check reports the mode manage.sh will select and
    warns about the gap; the ingestor's [broker] warn: line reports the mode its own
    container
    is in. ResolveBrokerMode compares the value untrimmed, exactly as the
    entrypoint does.

manage.sh broker-check reports the same and exits 1, so a runbook can gate on it;
setup, start, update, restore and promote run it advisory-only and never
refuse a command over it (cmd_start runs it after migrate_config/ensure_config,
so it reads the config the container will use). Environment precedence matches the
ingestor's (MQTT_BROKER over config.json), and the legacy single-broker
mqtt.broker block counts as a source only when mqttSources yields none, which
is when LoadConfig promotes it.

Credentials out of diagnostics

Every broker URL in a Go diagnostic goes through the existing brokerurl.Mask, and
hosts are parsed out of the masked form, so no user-info, query or fragment can
reach a comparison or a message. manage.sh gained a mask_broker_text filter used
for the broker report and for the docker logs lines verify_health already echoed
(those can come from an ingestor of any version).

Reuse, not parallel helpers

brokerurl for masking; stripYAMLComments / extractSubBlock from
staging_compose_broker_test.go for the compose-shape assertions (its
extractStagingGoBlock was generalised to extractServiceBlock rather than copied);
the is_true() truthiness set is mirrored rather than redefined. The new internal
module is wired into both go.mod files, both Dockerfile builder sections (the
check-dockerfile-internal-pkgs.sh invariant), and the CI shared-packages step.

Left for the owner

Each of these changes behaviour on at least one existing install, so none is applied
here. All are one line in .env — and, since round 2, every one of those lines
actually reaches the process that reads it (see "In-process settings reach the
container" above).

  1. Narrow the bundled broker to loopback — PROD_MQTT_BIND=127.0.0.1.
    Recommended where no gateway connects to the bundled broker from off-host. The
    bundled broker is plaintext MQTT with no auth; publishing it on every interface is
    the exposure the issue is about. Do not set it where observers reach 1883
    directly — they would lose the broker at the next up -d.
  2. Narrow the pprof host ports to loopback — PPROF_BIND=127.0.0.1.
    Recommended, with an SSH tunnel (ssh -L 6060:127.0.0.1:6060 host) for access.
    /debug/pprof/ is unauthenticated and can be used to stall a process. This one
    needs a decision
    : at least one host runs with pprof enabled and is profiled
    remotely, and this would require a tunnel there.
  3. Make the bundled broker opt-in — flipping the DISABLE_MOSQUITTO default from
    false to true in docker-compose.yml. Recommended as the eventual shape, but
    it is the one change that can silently stop ingest on an install that relies on the
    bundled broker, so it wants a release note rather than a quiet default flip.
    Nothing here depends on it.
  4. Make broker errors fatal — MQTT_SOURCE_STRICT=true. Recommended on hosts
    with an external broker
    : it converts "healthy but ingesting nothing" into a loud
    startup failure. Leave it off where a start during broker maintenance must succeed.
  5. Normalise DISABLE_MOSQUITTO parsing. The entrypoint takes only the exact
    true; manage.sh's is_true() also takes 1/TRUE/yes/y/on. This PR
    reports the gap rather than closing it. Note the corrected reasoning: a
    manage.sh-managed host set to yes is already external, because dc_prod
    swaps in docker-compose.no-mosquitto.yml, which hard-codes
    DISABLE_MOSQUITTO=true. The host that would move is one started with a bare
    docker compose up against docker-compose.yml, where yes leaves the bundled
    broker running — teaching the entrypoint is_true() would switch that host to an
    external broker on the next restart. Recommended once no host uses such a value
    — broker-check will say.

Verification

  • cd cmd/server && go test ./... — ok (48s)
  • cd cmd/ingestor && go test ./... — ok (110s)
  • cd internal/pprofbind && go vet ./... && go test -race ./... — ok
  • sh test-all.sh — 240 passed, 0 failed
  • node test-frontend-helpers.js — 713 passed, 0 failed
  • sh scripts/check-xss-sinks.sh --diff origin/master — clean (no public/** changes)
  • Live server on a migrated fixture DB: /api/healthz, /api/health, /api/stats,
    /api/mqtt/status all 200. PPROF_HOST=127.0.0.1 → /debug/pprof/ 200 on
    loopback, connection refused via the host address. Default (no PPROF_HOST) →
    200 via the host address, confirming the default is unchanged. ENABLE_PPROF=1 →
    pprof absent, with the new reason logged.
  • Fork guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml.
  • cmd/server read-only invariant test passes; no new map[string]interface{}
    outside tests; no hardcoded colours (no frontend files touched).
  • Playwright E2Es were not run locally — no frontend file changed and the browser
    deps are not installed in this environment; CI's E2E job covers them.

The bundled mosquitto's host port and the pprof listeners were exposed
by hard-coded values: docker-compose.yml published 1883 on every
interface, the staging compose files published 6060/6061 the same way,
and both processes listened on ":"+port. There was no setting to narrow
any of them, and nothing validated the external-broker arrangement that
DISABLE_MOSQUITTO=true selects.

Each exposure is now an explicit setting whose shipped default
reproduces today's behaviour exactly, so an existing install that pulls
this changes nothing:

  PROD_MQTT_BIND=0.0.0.0        host interface for the bundled broker
  PPROF_BIND=0.0.0.0            host interface for the pprof ports
  PPROF_HOST=                   in-process pprof bind host
  PPROF_PORT_SERVER=6060        per-role pprof ports, kept distinct
  PPROF_PORT_INGESTOR=6061
  MQTT_SOURCE_STRICT=false      make a broker error stop the ingestor

internal/pprofbind resolves the listener for one process role from the
environment. It keeps ENABLE_PPROF opt-in on the exact string "true",
and now says so when a value like "1" or "TRUE" leaves pprof off. The
per-role ports exist because the shared PPROF_PORT applied to both
processes, so moving one collided the other.

The ingestor reports its broker arrangement before connecting: no
configured source in external mode (where LoadConfig's localhost
fallback points at the broker that was just disabled), loopback-only
sources, malformed broker URLs, and a DISABLE_MOSQUITTO value that the
container entrypoint and manage.sh read differently. Diagnostics only;
MQTT_SOURCE_STRICT turns errors into a refusal to start.

manage.sh gains broker-check, which reports the same thing and exits 1,
and runs it advisory-only from setup, start, update, restore and
promote. Every broker URL in a diagnostic goes through brokerurl.Mask,
and the MQTT log lines verify_health echoes are masked too.

No default is flipped and no deployment is changed; narrowing any bind
to loopback is left to the operator.

Relates to #372

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dborup

dborup commented Oct 13, 2026

Copy link
Copy Markdown
Owner Author

Rapport — CS-Minimax PR#431 #372 — head c5a1d64

Status: Implemented; every acceptance point has a test that was red before and is green after, plus at least one mutant shown red per point; CI green on all three jobs; five behaviour-changing choices deferred under "Left for the owner".

Requirements → test → mutant

# Requirement Test (red before → green after) Mutant shown red
1 Bundled MQTT host port gets an explicit, configurable bind, default preserving today's exposure cmd/server/compose_bind_372_test.go: TestProdCompose_BundledMQTTHostPortBindIsExplicit, TestProdCompose_NoUnboundMQTTMappingRemains [T] revert the publish to "${PROD_MQTT_PORT:-1883}:1883" → both red; flip the shipped default to 127.0.0.1 → red [T]
2 pprof host ports get an explicit bind and stay on distinct ports TestStagingComposes_PprofHostPortsBindExplicitlyAndStayDistinct (both staging compose files) [T] collapse 6061 onto 6060 → red [T]
3 pprof listener bind host is an explicit setting; opt-in unchanged; per-role ports kept distinct internal/pprofbind/pprofbind_test.go (12 tests: defaults, host opt-in incl. IPv6/wildcard, per-role precedence, shared PPROF_PORT compatibility, exact-true gate, unusable port/host, URL) + cmd/server/pprof_bind_wiring_test.go, cmd/ingestor/pprof_bind_wiring_test.go [T] Ingestor.DefaultPort 6061→6060 → 4 red; drop the per-role PPROF_HOST_<ROLE> override → red; enable pprof on any non-empty ENABLE_PPROF → red [T]
4 External-broker mode validated before start: missing source, loopback-only, malformed, env precedence cmd/ingestor/broker_mode_test.go: TestExternalMode_MissingSourceIsAnError, ..._LoopbackOnlySourcesAreAnError, ..._OneLoopbackAmongRealSourcesIsOnlyAWarning, TestBundledMode_LoopbackSourcesAreFine, TestMalformedBrokerIsAnErrorInEitherMode, TestWellFormedSchemesAreAccepted, TestLoadConfigMarksTheDefaultedSource, TestEnvBrokerPrecedenceCountsAsExplicit, TestIngestorMainReportsBrokerSetupBeforeConnecting [T] treat a loopback-only external setup as acceptable → red; stop recording the defaulted source in LoadConfig → red [T]
5 Environment precedence cannot silently select the wrong broker TestResolveBrokerMode_MatchesTheEntrypointSpelling, TestBrokerMode_AmbiguousValueIsReportedNotSilentlyResolved, TestBrokerMode_UnambiguousValuesAreQuiet + test-issue-372-broker-check.js ("reports the mode the container will actually be in") [T] read DISABLE_MOSQUITTO with manage.sh truthiness instead of the entrypoint's exact match → 3 Go tests red; same change in manage.sh → JS test red [T]
6 Credentials out of diagnostics TestDiagnosticsNeverCarryCredentials (5 URL shapes, user/password/query token) + test-issue-372-broker-check.js ("credentials never reach the output", "mask_broker_text strips credentials out of a log line", "verify_health masks the MQTT log lines it echoes") [T] print the raw broker URL in the loopback diagnostic → red; drop masking from the manage.sh broker report → red [T]
7 Setup / update / restore / staging / promote paths reviewed together; refusal-to-start is opt-in test-issue-372-broker-check.js ("start, update, restore, promote and setup all run the broker preflight", "the preflight never blocks a command", "broker-check is discoverable in the help text") + TestStrictModeIsOptIn, TestReportBrokerSetup_LogsEveryDiagnosticAndOnlyRefusesUnderStrict [T] make a broker error fatal regardless of MQTT_SOURCE_STRICT → red [T]
8 Documentation + migration note for an existing install TestEnvExample_DocumentsEveryNewKnob (6 knobs with their defaults), TestDeploymentDocs_CarryTheComposePullMigrationNote [T] drop PPROF_HOST from .env.example → red; remove the migration heading → red [T]

Red-before was established by restoring each touched file from origin/master and re-running: manage.sh → 11 of 15 JS cases red; both main.go files → 5 wiring tests red; internal/pprofbind absent → build failure. Guard-only (green on master by design, kept as a regression guard): TestNoComposeFileHardcodesALoopbackBind.

CI per job

Job Result
✅ Go Build & Test pass (25m 40s) [K]
🎭 Playwright E2E Tests pass (28m 30s) [K]
🧱 Validate Docker Image pass (45s) [K]
🏗️ Build & Publish Docker Image (gate) pass (12s) [K]
📦 Release Artifacts / 🚀 Deploy Staging / 📝 Publish Badges skipped (fork guards, as expected) [K]

Local, before pushing [T]:

  • cd cmd/server && go test ./... → ok (48s)
  • cd cmd/ingestor && go test ./... → ok (110s)
  • cd internal/pprofbind && go vet ./... && go test -race ./... → ok
  • sh test-all.sh → 240 passed, 0 failed
  • node test-frontend-helpers.js → 713 passed, 0 failed
  • sh scripts/check-xss-sinks.sh --diff origin/master → clean (no public/** changes)
  • fork guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml
  • cmd/server read-only invariant test passes; no new map[string]interface{} outside tests; no frontend files touched, so no colour literals

Behavioural confirmation against a locally built server on a migrated fixture DB [A]:

  • /api/healthz, /api/health, /api/stats, /api/mqtt/status → 200
  • PPROF_HOST=127.0.0.1 → /debug/pprof/ 200 on loopback, connection refused via the host address
  • no PPROF_HOST → 200 via the host address, i.e. the shipped default is unchanged
  • ENABLE_PPROF=1 → pprof absent, with the new reason logged

All jobs green on the first run — no rerun needed, and the known flaky test (#271) did not fire.

Remaining

  • Five deferred choices are in the PR description under "Left for the owner", each with a recommendation: narrow the bundled broker to loopback; narrow the pprof host ports to loopback (needs a decision — at least one host is profiled remotely); make the bundled broker opt-in by flipping the DISABLE_MOSQUITTO default; turn on MQTT_SOURCE_STRICT; normalise DISABLE_MOSQUITTO parsing. None is applied here, because none is inert for every existing install.
  • The staging-like rollout in the issue's acceptance is not part of this PR: it requires host access, which is out of scope here. ./manage.sh broker-check plus the ingestor's startup [broker] lines are what that rollout would read.
  • Playwright E2Es were not run locally — no frontend file changed and the browser dependencies are not installed in this environment; the CI E2E job covers them.
  • docs/agents/skills/qa-suite.md references scripts/pprof-snapshot.sh, which does not exist in scripts/. Pre-existing and unrelated; left alone.

@dborup-agent

Copy link
Copy Markdown
Collaborator

Review — CS-pve-agent1 PR#431 — head c5a1d64

Dom: REQUEST CHANGES

Evidence tags: [T] = I ran it and saw the result. [A] = analysis of the code or docs, not executed. [K] = known from CI or GitHub metadata.

Overall, the Go side is solid. internal/pprofbind, the ingestor broker diagnostics, masking and MQTT_SOURCE_STRICT all behave as described, live and under mutation. The problems are in the operational surface, which is exactly where #372 is sensitive:

  • The "defaults reproduce today's behaviour byte for byte" claim is false. The new compose default drops the IPv6 publish of 1883 and of 6060/6061.
  • manage.sh broker-check mis-predicts the broker mode, and so passes, in two realistic setups.
  • Several settings are documented as one-line .env changes, but they never reach the container under compose.

Note: there is no earlier review on this PR and no earlier PR for #372, so I checked every point against the issue itself.

Findings

# Sev Finding Evidence
1 Blocker "${PROD_MQTT_BIND:-0.0.0.0}:…:1883" and "${PPROF_BIND:-0.0.0.0}:6060/6061" are not equivalent to the old unqualified mappings. Docker publishes an unqualified port on both 0.0.0.0 and [::], but 0.0.0.0: publishes IPv4 only. An existing install that pulls this compose file and runs up -d loses IPv6 reachability of the bundled broker (prod) and of pprof (both staging files). The PR body, docs/deployment.md ("Nothing changes for an existing install … same interfaces") and the compose comments all claim otherwise, and this change is not listed under "Left for the owner". [T] docker compose config, master vs head: docker-compose.yml, docker-compose.staging.yml and docker-compose.staging.no-mosquitto.yml gain host_ip: 0.0.0.0. docker-compose.no-mosquitto.yml and docker-compose.example.yml are identical. [T] Same image, real Mosquitto: under the master compose, docker port shows 0.0.0.0:P and [::]:P, and an MQTT CONNECT over ::1 gets a CONNACK. Under the head compose, only 0.0.0.0:P is published and ::1 is refused. [T] The same result with a minimal busybox pair.
2 Major broker-check reports the wrong mode for an "ambiguous" DISABLE_MOSQUITTO on any manage.sh-managed host. dc_prod and dc_staging choose the compose file with is_true, so yes/1/on selects docker-compose.no-mosquitto.yml (or staging.no-mosquitto.yml). Those files hard-code DISABLE_MOSQUITTO=true, so the container runs in external mode. bundled_broker_enabled mirrors the entrypoint instead, so the check prints "the bundled broker WILL start", skips source validation entirely and exits 0. This is the "precedence silently selects the wrong broker" case the issue asks to close. The same inverted reasoning appears in DEPLOY.md ("a value in that gap leaves the bundled broker running") and in owner item 5 ("closing it would move a host set to yes from the bundled broker to an external one"): under manage.sh, that host is already external. [T] Local Docker, .env with DISABLE_MOSQUITTO=yes and config.json = {}. manage.sh start printed "Broker mode: bundled mosquitto" and exit 0. The container had DISABLE_MOSQUITTO=true and logged internal MQTT broker disabled and [broker] error: … no mqttSources are configured. [T] Mutant M6 (bundled_broker_enabled → ! is_true …) turns exactly one JS case red, "an ambiguous value reports the mode the container will actually be in". That test pins the wrong expectation.
3 Major MQTT_SOURCE_STRICT, PPROF_HOST*, PPROF_PORT_* and MQTT_BROKER placed in .env never reach the container. docker-compose.yml passes only NODE_ENV, DISABLE_MOSQUITTO and DISABLE_CADDY, and there is no env_file. This has three consequences. (a) Owner item 4, "make broker errors fatal — one line in .env", does nothing on a compose install, and .env.example labels these "Used by: the ingestor". (b) broker_sources honours MQTT_BROKER from .env and reports PASS, while the container ignores it and falls back to localhost:1883. The remediation text even recommends "set MQTT_BROKER=… in .env". (c) The JS case "MQTT_BROKER overrides a loopback-only config.json" pins (b). [T] docker compose config with those keys in .env: none appear in the prod container's environment. [T] Local Docker with DISABLE_MOSQUITTO=true, MQTT_SOURCE_STRICT=true, MQTT_BROKER=mqtt://broker.example:1883 and config.json = {}. manage.sh start printed "MQTT source mqtt://broker.example:1883 … passed". The container logged [broker] error: … no mqttSources are configured and then continuing anyway; set MQTT_SOURCE_STRICT=true ….
4 Minor Test gap. Removing the user-info strip in brokerHost (mutant M2) survives the whole suite. A credentialed loopback-only source (mqtt://u:p@localhost:1883) is then no longer flagged in external mode. TestDiagnosticsNeverCarryCredentials checks only for leaks, never that the loopback error still fires. [T] Probe: head returns 1 error; under M2 it returns 0 diagnostics; the suite stays green.
5 Nit broker_sources greps every "broker" key, so it counts the legacy mqtt.broker block even when mqttSources exists. The ingestor ignores the legacy block in that case. [T] With config.example.json and external mode, manage.sh reports "2 of 4 … cannot connect". CheckBrokerSources on the same file reports "1 of 3".
6 Nit In cmd_start, the preflight runs before migrate_config/ensure_config, so on a first start it reads the pre-migration or absent config. [A]
7 Nit ResolveBrokerMode trims whitespace but the entrypoint compares the raw value, so " true" is external to Go and bundled to the entrypoint. [A]
8 Nit The PR title "harden … defaults" says the opposite of what the PR does: no default is hardened. The commit headline ("make … explicit settings") is accurate; suggest retitling. [A]

Suggested non-behaviour-changing shape for #1: "${PROD_MQTT_BIND:+${PROD_MQTT_BIND}:}${PROD_MQTT_PORT:-1883}:1883", with the default left empty, and the same for PPROF_BIND. I verified that it renders without host_ip when unset and with host_ip: 127.0.0.1 when set [T]. The compose tests and docs would then need to say "empty = unchanged dual-stack publish". The alternative is to move "IPv4-only publish" under "Left for the owner".

For #3, passing these variables through the compose environment: block (empty defaults are inert, because LoadConfig ignores an empty MQTT_BROKER) is a deployment change, so it is the owner's call. Until then, the docs and broker_sources should not treat .env as reaching the ingestor.

Points vs. issue #372

Issue item Status in this PR
Bundled Mosquitto explicit opt-in Not done; deferred as owner item 3 with a reason. Honest. [A]
External mode validated before start Ingestor: done, with tests, red-before [T]. manage.sh: implemented, but false-passes in #2 and #3.
Credentials out of diagnostics Done. 0 leaks in the ingestor log, the broker-check output and the restore output, with a credentialed URL in a live container [T].
Bundled host port on loopback Not done (owner item 1), but the default did change, IPv6 being dropped (#1). That is not disclosed.
pprof opt-in, loopback by default, distinct ports Opt-in unchanged; loopback is opt-in via PPROF_HOST (owner item 2); distinct ports kept. The acceptance line "local-only when enabled" is therefore not met by default. This is honestly deferred, but #372 cannot be closed by this PR. The body correctly says only "Relates to".
Setup / update / restore / staging / docs reviewed for precedence Preflight is wired into all five commands and is advisory only [T for start and restore]. The precedence model is wrong for manage.sh's own compose-file selection (#2) and for .env → container (#3).
Acceptance: tests for both modes, malformed, localhost-only, missing source, env precedence Go: present, red-before [T]. Env-precedence tests in JS pin the wrong model (#2, #3).
Acceptance: all relevant Compose variants Prod plus both staging files are tested. no-mosquitto.yml and example.yml are unchanged [T]. The tests pin the 0.0.0.0 default, which causes #1.
Acceptance: staging-like rollout Not done; deferred because it needs host access. Correct to defer.

Scope: no scope creep found. No product default is flipped in the env or app sense, apart from #1, and no new config is silently active. The five owner items are well argued, but item 4's "one line in .env" and item 5's rationale need correcting (#3, #2).

Tests and mutants

On the merged tree (git merge-tree --write-tree origin/master c5a1d640…, base 8f40525f) [T]:

  • cmd/server: go vet clean; go test ./... ok.
  • cmd/ingestor: go vet clean; go test ./... ok.
  • internal/pprofbind: go vet plus go test -race, ok.
  • sh test-all.sh: 242 passed, 0 failed.
  • node test-frontend-helpers.js: 713 passed, 0 failed.
  • bash scripts/check-xss-sinks.sh --diff origin/master: clean; no public/** changes. sh cannot parse the script, which is bash by its shebang.
  • check-dockerfile-internal-pkgs.sh: ok. The Docker image builds from head [T].
  • Fork guards: 9 in deploy.yml and 1 in release-fast-path.yml, both unchanged. No closing keywords. No new map[string]interface{} outside tests. No colour literals. cmd/server changes only the pprof wiring, so it stays read-only. Commit author and committer are dborup <kontakt@meshview.dk> [T].

Red-before [T]: with the master runtime files plus the new tests, the JS suite shows 12 of 15 red, and all five compose/doc Go tests are red. The new ingestor and pprofbind tests do not compile without the new code.

E2E [T]: local Go server on e2e-fixture.db, prepared as in CI (freshen, inline seed SQL, corescope-migrate, seeds 2073, 199 and 245). test-e2e-playwright.js with fail-fast removed gives 131 passed, 1 failed and 7 skipped, identically on the merged tree and on origin/master. The single failure, "Version info lives on Perf dashboard", fails the same way on master, so it is environmental here and unrelated; it passes in CI. The PR has no E2E of its own and touches no frontend.

Live checks [T]:

Check Result
Server, default No 6060 listener
ENABLE_PPROF=true *:6060; 200 on v4, v6 and the external address. Matches master.
ENABLE_PPROF=true, PPROF_HOST=127.0.0.1 Loopback only; ::1 and the external address refused
ENABLE_PPROF=1 Off, with the reason logged
Ingestor ENABLE_PPROF=true *:6061
MQTT_SOURCE_STRICT=true (process env) Exit 1
Non-strict Logs the error and continues
Bundled mode with a loopback source Quiet
/api/healthz, /api/health, /api/stats, /api/mqtt/status All 200

CI [K]: Go Build & Test, Playwright E2E, Validate Docker Image and the Docker gate all passed on run 38391488315. Release, Deploy and Badges were skipped by the fork guards. Neither known flake (#256, #267) is involved.

Mutants (mine) [T]:

Mutant Result
M1 Ambiguous = false Killed: 3 tests red
M2 drop the user-info strip in brokerHost Survived (finding #4)
M3 shared PPROF_PORT beats per-role Killed: TestPerRolePortOverridesSharedPort
M4 StrictBrokerSources always true Killed: 2 tests red
M5 drop query stripping in mask_broker_text Killed: 2 JS cases red
M6 manage.sh mode via is_true 1 JS case red. This is the case that pins the wrong expectation (finding #2).
M7 "*" no longer means every interface Killed: TestHostOptInBindsLoopbackOnly

Docker scripts [T]:

  • manage.sh start was run in the bundled, ambiguous and external-with-.env setups.
  • manage.sh restore with a credentialed loopback-only config.json: the preflight ran after the config restore, masked the URL, and did not block. The container logged the matching [broker] error.

Not verified

  • manage.sh update (needs a git remote pull), setup (interactive) and promote (needs a staging container) were not run against Docker. They are covered only by the JS ordering tests [A].
  • The staging compose files were not brought up as real containers: they need the external meshcore-net network. The IPv6 finding there rests on the resolved compose config plus the same Docker mechanism I proved on prod [T for config, A for runtime].
  • No staging-like rollout; no host or production access, as instructed.
  • I did not check whether any live observer or remote profiler actually reaches the broker or pprof over IPv6. Whether Foreign Traffic analytics + Scopes tab redesign #1 bites in practice is for the owner to confirm.

dborup and others added 2 commits October 13, 2026 14:41
…eal broker mode

Round-2 review fixes for #431 (#372).

1. Blocker — IPv4/IPv6 equivalence. `${X:-0.0.0.0}:port:port` is not the
   old unqualified mapping: docker publishes an unqualified port on both
   0.0.0.0 and [::], while an explicit IPv4 host publishes IPv4 only, so
   pulling the new compose file dropped IPv6 reachability of the bundled
   broker and of pprof. Both binds are now conditional,
   `${X:+${X}:}port:port`, with an empty shipped default, so the rendered
   mapping is byte-identical to master when the variable is unset and
   narrows only when it is set.
2. Major — broker-check reported the entrypoint's reading of
   DISABLE_MOSQUITTO, but manage.sh resolves the value itself: dc_prod and
   dc_staging pick the compose file with is_true(), and the no-mosquitto
   files hard-code DISABLE_MOSQUITTO=true. So yes/1/on/TRUE are EXTERNAL on
   a manage.sh host, and the check used to say "bundled" and skip source
   validation. It now reports the mode manage.sh selects, and the gap
   warning names the launcher that actually reads the value differently.
3. Major — MQTT_SOURCE_STRICT, MQTT_BROKER, MQTT_TOPIC and the PPROF_HOST*
   / PPROF_PORT* settings never reached the container: no compose file
   forwarded them and none declared env_file. Every app compose file now
   forwards them through `environment:` with an EMPTY default, which every
   reader ignores, so an install that never set one is unaffected.
4. Minor — a credentialed loopback-only external source is now pinned, not
   only checked for leaks, so dropping the user-info strip in brokerHost no
   longer silences the diagnostic.
5. Nit — the legacy mqtt.broker block is counted only when mqttSources
   yields no source, matching LoadConfig.
6. Nit — cmd_start runs the preflight after migrate_config/ensure_config,
   so it reads the config.json the container will use.
7. Nit — ResolveBrokerMode no longer trims DISABLE_MOSQUITTO: the
   entrypoint compares it raw, so " true" keeps the bundled broker and is
   now reported as such.

The five "Left for the owner" choices are unchanged; only item 5's factual
rationale is corrected in the PR body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adminopenclaw8-sketch adminopenclaw8-sketch changed the title ops(runtime): harden bundled MQTT and pprof defaults (#372) ops(runtime): make bundled MQTT and pprof exposure explicit settings (#372) Oct 13, 2026
dborup and others added 2 commits October 13, 2026 15:16
…dling

Two guards for the finding-5 fix in broker_sources: an empty mqttSources
array must fall back to the legacy mqtt.broker block, as LoadConfig does,
and a "]" inside a topic pattern must not truncate the array. Each kills a
distinct mutant of mqtt_sources_region (drop the bracket depth count; drop
the string-awareness).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…BROKER

Forwarding the in-process settings into the container (round-2 finding 3) is
inert for every install that never set one — except MQTT_BROKER. Before this
release the compose files did not forward it, so a value in .env was dead and
config.json decided the sources; now it reaches the ingestor, where LoadConfig
lets it override mqttSources entirely. A host with a stale MQTT_BROKER= line
in .env and real mqttSources in config.json would switch to that single env
source on the next restart, so the migration note, DEPLOY.md and .env.example
now say so and point at ./manage.sh broker-check, which prints the sources in
the ingestor's own precedence.

TestDeploymentDocs_CarryTheComposePullMigrationNote is also scoped to the
migration section instead of the whole document, so a mention elsewhere no
longer satisfies it, and it now requires the MQTT_BROKER warning. Removing
that paragraph turns it red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator

Rapport — CS-Macmini PR#431 runde-2 — head fd0ce7c

Review feedback addressed (commit 90fe927d, plus parser guards in 912a155c and a disclosure fix in fd0ce7c0). origin/master (09b6385a) is merged in with a merge commit (904473e3); no rebase, amend or force-push. PR stays a draft.

Evidence tags: [T] = I ran it and saw the result. [A] = analysis, not executed. [K] = known from CI or GitHub metadata.

Findings

1 — Blocker: IPv4/IPv6 equivalence. Fixed. Both bind variables are now interpolated conditionally, with an empty shipped default, so an unset variable renders no host interface at all:

- "${PROD_MQTT_BIND:+${PROD_MQTT_BIND}:}${PROD_MQTT_PORT:-1883}:1883"
- "${PPROF_BIND:+${PPROF_BIND}:}6060:6060"

.env.example, DEPLOY.md and docs/deployment.md now say the default is empty, state that an explicit 0.0.0.0 is IPv4-only and must not be used to mean "everywhere", and the Migration note gained a paragraph on it.

Verified locally with Docker [T]:

Compose shape docker port for the published port
master, unqualified "P:1883" 0.0.0.0:P and [::]:P
old head, ${X:-0.0.0.0}:P:1883, unset 0.0.0.0:P only — the regression
new head, ${X:+${X}:}P:1883, unset 0.0.0.0:P and [::]:P — identical to master
new head, PROD_MQTT_BIND=127.0.0.1 127.0.0.1:P only

And on the real repo files, docker-compose config master vs head: the rendered host_ip/published/target lines are identical for all five compose files with nothing set — docker-compose.yml, .no-mosquitto.yml, .staging.yml, .staging.no-mosquitto.yml, .example.yml [T]. The only remaining rendered difference is the new empty environment keys from finding 3.

2 — Major: broker-check mis-predicted the mode. Fixed. bundled_broker_enabled() is now ! is_true "${DISABLE_MOSQUITTO:-false}" — manage.sh is the authority for its own hosts, because dc_prod/dc_staging pick the compose file with is_true() and the no-mosquitto files hard-code DISABLE_MOSQUITTO=true. The external-mode line now prints the actual raw value instead of a hard-coded =true, and the gap warning was rewritten: it names the launcher that really reads the value differently (a bare docker compose up against docker-compose.yml) instead of claiming manage.sh believes it disabled the broker. A second helper, disable_mosquitto_is_unrecognised(), keeps the old coverage for a value neither launcher accepts (a typo, or " true"), which keeps the bundled broker everywhere.

Verified against manage.sh's own dc_prod with a stub docker that logs its argv [T]:

DISABLE_MOSQUITTO compose file dc_prod runs broker-check says exit
(unset) docker-compose.yml bundled 0
false docker-compose.yml bundled 0
true docker-compose.no-mosquitto.yml external 1
1 docker-compose.no-mosquitto.yml external 1
yes docker-compose.no-mosquitto.yml external 1
on docker-compose.no-mosquitto.yml external 1
TRUE docker-compose.no-mosquitto.yml external 1

The reported mode matches the selected compose file for every value, and the review's repro (.env with DISABLE_MOSQUITTO=yes, config.json = {}) now exits 1 with the missing-source error instead of passing. DEPLOY.md and docs/deployment.md were corrected in the same way, and the ingestor's ambiguity diagnostic was rewritten (it is still correct that inside a container that can see yes the bundled broker runs — that container was not started by manage.sh).

3 — Major: .env variables never reached the container. Fixed. Every app compose file — docker-compose.yml, .no-mosquitto.yml, both staging files and .example.yml — now forwards the in-process settings through environment: with an empty default: MQTT_BROKER, MQTT_TOPIC, MQTT_SOURCE_STRICT, ENABLE_PPROF (not on the staging files, which hard-code ENABLE_PPROF=true), PPROF_HOST, PPROF_HOST_SERVER, PPROF_HOST_INGESTOR, PPROF_PORT, PPROF_PORT_SERVER, PPROF_PORT_INGESTOR. MQTT_BROKER, MQTT_TOPIC, the two PPROF_HOST_<ROLE> keys and PPROF_PORT were also missing from .env.example and are documented there now.

Inertness when unset is the load-bearing claim, so it is pinned on both sides [T]:

  • Delivery: a real container started from the edited docker-compose.yml prints MQTT_BROKER=, MQTT_SOURCE_STRICT=, PPROF_HOST= … (empty) with no .env, and MQTT_BROKER=mqtt://broker.example:1883, MQTT_SOURCE_STRICT=true, PPROF_HOST=127.0.0.1, PPROF_PORT_SERVER=16060 with them in .env.
  • Consumption: the locally built ingestor with all of those keys present and empty logs no [broker] and no [pprof] line at all; the server likewise, and /api/healthz, /api/health, /api/stats, /api/mqtt/status, /api/nodes, /api/observers, /api/packets are all 200 on a CI-prepared e2e-fixture.db.
  • End to end: DISABLE_MOSQUITTO=true + MQTT_BROKER=mqtt://broker.example:1883 now connects to tcp://broker.example:1883 with no [broker] error — the documented one-liner works. MQTT_SOURCE_STRICT=true with no source exits 1; without it the ingestor logs the error and continues.

DEPLOY.md and docs/deployment.md gained a paragraph saying plainly that these settings do reach the process, so the docs no longer over- or under-claim.

One non-inert consequence I found while doing this, and disclosed rather than hidden [A]. MQTT_BROKER overrides mqttSources in config.json (LoadConfig precedence). Before the compose files forwarded it, a value in .env was dead and config.json decided; now it decides. A host with a stale MQTT_BROKER= line in .env and real mqttSources would switch to that single env source on the next restart. This is the only behaviour an existing install can see change in this PR. The migration note in docs/deployment.md, DEPLOY.md, .env.example and the PR body all say so and point at ./manage.sh broker-check, which prints the sources in the ingestor's own precedence so an operator can see which one wins before restarting. TestDeploymentDocs_CarryTheComposePullMigrationNote was scoped to the migration section (a mention elsewhere in the document no longer satisfies it) and now requires that warning.

4 — Minor: mutant M2 survived. Fixed. TestCredentialedLoopbackOnlySourceIsStillFlagged pins the diagnostic, not only the absence of a leak: four credentialed loopback URLs (user:pass@localhost, @127.0.0.1, @[::1], user-only) must each produce a SeverityError naming loopback, must not be downgraded to "unusable", and must still carry no secret. Dropping the user-info strip in brokerHost now turns it red [T].

5 — Nit: legacy mqtt.broker double-counted. Fixed. broker_sources now extracts the mqttSources array first — bracket-matched and string-aware, so a ] inside a topic pattern cannot truncate it — and falls back to the whole-file grep only when mqttSources is absent or yields no broker, which is exactly when LoadConfig promotes the legacy block. With config.example.json and external mode, manage.sh now reports "1 of 3", matching CheckBrokerSources [T].

6 — Nit: preflight ran before migrate_config. Fixed. In cmd_start, broker_check_report || true moved below migrate_config and ensure_config "$PROD_DATA". cmd_update already ran it after migrate_config auto; cmd_restore already ran it after the config restore. cmd_setup calls neither helper (it writes .env and leaves config.json to the operator), so its step-5 position is unchanged. A source-order test pins the cmd_start ordering.

7 — Nit: trim mismatch. Fixed by matching the entrypoint. ResolveBrokerMode no longer trims: docker/entrypoint-go.sh compares "${DISABLE_MOSQUITTO:-false}" = "true" raw, so " true" keeps the bundled broker, and trimming made the ingestor report the opposite of the mode its own container was in. The test table now pins " true ", " true", "true " and "\ttrue" as bundled and ambiguous. I also established where such a value can come from [T]: both manage.sh's .env reader and docker compose's dotenv parser trim, so DISABLE_MOSQUITTO= true in .env arrives as true; only the process environment (DISABLE_MOSQUITTO=' true' docker compose up) delivers it verbatim — and docker compose config confirms it passes through as ' true'. manage.sh warns on it through disable_mosquitto_is_unrecognised, so both readers now agree and say so.

8 — Nit: PR title. Fixed. Retitled to ops(runtime): make bundled MQTT and pprof exposure explicit settings (#372), matching the commit headline. Nothing else about the PR state changed — still a draft, not merged, no closing keywords.

"Left for the owner" — unchanged in substance. All five choices remain deferred with the same recommendations. Two factual corrections only: the preamble now notes that each one-line .env change actually reaches the process (finding 3), and item 5's rationale is corrected — a manage.sh-managed host set to yes is already external, so the host that would move on normalisation is one started with a bare docker compose up. The recommendation ("once no host uses such a value") is untouched.

Tests

Red before, green after [T]:

Finding Test Red before
1 TestComposeBindDefaultsStayDualStack (new), TestProdCompose_BundledMQTTHostPortBindIsExplicit, TestStagingComposes_PprofHostPortsBindExplicitlyAndStayDistinct, TestEnvExample_DocumentsEveryNewKnob 4 Go tests red on the old compose shape; the new one named all 5 offending mappings
2 broker-check reports the mode manage.sh actually selects, for every DISABLE_MOSQUITTO value, an is_true value with no source fails the check…, a DISABLE_MOSQUITTO value the entrypoint does not accept is reported, a value neither launcher recognises…, TestAmbiguousDiagnostic_DoesNotMisattributeTheModeToManageSh 4 JS + 1 Go red
3 TestComposeFilesPassEveryProcessKnobToTheContainer (new, driven off .env.example's Used by: labels so a future knob cannot be forgotten) red on all 5 compose files
4 TestCredentialedLoopbackOnlySourceIsStillFlagged green on head by design; red under mutant M2 (see below)
5 the legacy mqtt block is ignored when mqttSources is present, config.example.json reports exactly the sources the ingestor will use 2 JS red
6 cmd_start runs the preflight after the config is migrated and ensured 1 JS red
7 TestResolveBrokerMode_MatchesTheEntrypointSpelling (4 new rows) 8 assertions red
3 (disclosure) TestDeploymentDocs_CarryTheComposePullMigrationNote, now section-scoped and requiring the MQTT_BROKER override warning 1 Go test red

Inertness guards (green by design, kept as regression guards): TestEmptyEnvironmentIsInert (pprofbind), TestEmptyBrokerEnvironmentIsInert (ingestor), an empty mqttSources array falls back to the legacy block…, a "]" inside a topic pattern does not truncate the mqttSources array, plus the pre-existing TestNoComposeFileHardcodesALoopbackBind.

Suites [T]:

  • cd cmd/server && go vet ./... && go test ./... → ok (40s)
  • cd cmd/ingestor && go vet ./... && go test ./... → ok (107s)
  • cd internal/pprofbind && go vet ./... && go test -race ./... → ok
  • sh test-all.sh → 242 passed, 0 failed
  • node test-issue-372-broker-check.js → 24 passed, 0 failed (was 15 cases, now 24)
  • node test-frontend-helpers.js → 713 passed, 0 failed
  • bash scripts/check-xss-sinks.sh --diff origin/master → "no public/**/*.{js,html} changes to scan"
  • gofmt -l reports none of the files this round touched
  • Fork guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml. No new map[string]interface{} outside tests. No frontend file touched, so no colour literals. cmd/server read-only invariant passes; the only cmd/server change this round is a test file.

Live checks [T], locally built binaries on a CI-prepared e2e-fixture.db (freshen → corescope-migrate → seeds 2073/199/245):

Check Result
All #372 knobs present but EMPTY No [broker] line, no [pprof] line, 6060 refused
/api/healthz /api/health /api/stats /api/mqtt/status /api/nodes /api/observers /api/packets all 200
ENABLE_PPROF=true, empty host/port knobs tcp46 *.6061 — dual-stack, unchanged default
ENABLE_PPROF=true PPROF_HOST=127.0.0.1 PPROF_PORT_SERVER=13860 loopback 200; 192.168.1.239:13860 refused
ENABLE_PPROF=1 off, with the reason logged
DISABLE_MOSQUITTO=true, no source, non-strict [broker] error + "continuing anyway"
DISABLE_MOSQUITTO=true MQTT_SOURCE_STRICT=true, no source exit 1, "refusing to start"
DISABLE_MOSQUITTO=true MQTT_BROKER=mqtt://broker.example:1883 connects to tcp://broker.example:1883, no [broker] error
DISABLE_MOSQUITTO=yes / ' true' (process env) [broker] warn, bundled, naming the bare-docker compose launcher

Mutants

One per finding, each red on exactly the intended test [T]:

# Mutant Result
F1 revert the bind to ${PROD_MQTT_BIND:-0.0.0.0}: Killed: TestComposeBindDefaultsStayDualStack + TestProdCompose_BundledMQTTHostPortBindIsExplicit
F2 bundled_broker_enabled back to the entrypoint's != "true" Killed: 2 JS cases
F2b restore the old "manage.sh may believe" wording in the Go diagnostic Killed: TestAmbiguousDiagnostic_DoesNotMisattributeTheModeToManageSh
F3 drop MQTT_SOURCE_STRICT from docker-compose.yml's environment: Killed: TestComposeFilesPassEveryProcessKnobToTheContainer/docker-compose.yml
F4 drop the user-info strip in brokerHost (the review's surviving M2) Killed: TestCredentialedLoopbackOnlySourceIsStillFlagged, 4 sub-cases
F5 broker_sources greps the whole file again Killed: 2 JS cases
F5b mqtt_sources_region without bracket depth Killed: config.example.json reports exactly the sources…
F5c mqtt_sources_region without string-awareness Killed: a "]" inside a topic pattern…
F6 preflight back before migrate_config Killed: the cmd_start ordering case
F7 re-add strings.TrimSpace to ResolveBrokerMode Killed: TestResolveBrokerMode_MatchesTheEntrypointSpelling
F3b delete the MQTT_BROKER override paragraph from the migration note Killed: TestDeploymentDocs_CarryTheComposePullMigrationNote

CI per job

Run 38404492174, head fd0ce7c0 — all green on the first run, no rerun needed [K]:

Job Result
✅ Go Build & Test pass (24m)
🎭 Playwright E2E Tests pass (27m)
🧱 Validate Docker Image pass
🏗️ Build & Publish Docker Image (gate) pass
📦 Release Artifacts / 🚀 Deploy Staging / 📝 Publish Badges skipped (fork guards, as expected)

Neither known flake (#271, #301) fired, so nothing was rerun. The earlier runs for 90fe927d and 912a155c were cancelled by the concurrency group when the next commit landed, which is why only the head run is reported.

The Go Build & Test pass also covers the one portability question in this round: mqtt_sources_region is a POSIX awk program and test-issue-372-broker-check.js exercises it, so it is proven on CI's Linux awk as well as the BSD awk I ran it against locally.

Not verified

  • No staging or production access was used; nothing was run against either.
  • Playwright is not installed in this environment, so no E2E ran locally. My two commits touch no public/** file and no API handler (only compose files, .env.example, DEPLOY.md, docs/deployment.md, manage.sh, cmd/ingestor/broker_mode*.go, cmd/server/compose_bind_372_test.go, internal/pprofbind/pprofbind_test.go, test-issue-372-broker-check.js), so there is no affected E2E; the CI job covers them.
  • scripts/check-dockerfile-internal-pkgs.sh needs declare -A, which macOS's bash 3.2 does not have, so it could not run here. The Dockerfile is unchanged this round, so its invariant is unaffected; CI runs the script.
  • manage.sh update, setup and promote were not driven against a real Docker daemon — they need a git remote pull, an interactive session and a staging container. They are covered by the ordering tests, and broker-check plus dc_prod's compose-file selection were driven for real with a stub docker.
  • The staging compose files were not brought up as containers (they need the external meshcore-net network). Their IPv6 fix rests on the rendered docker compose config plus the same docker mechanism proved on the prod shape with real containers.

@dborup-agent

Copy link
Copy Markdown
Collaborator

Review — CS-pve-agent2 PR#431 — head fd0ce7c

Dom: APPROVE med nits

Evidence tags: [T] = I ran it and saw the result. [A] = analysis of the code or docs, not executed. [K] = known from CI or GitHub metadata.

This is an independent re-review of round 2, checked against the round-1 review (CS-pve-agent1) and the round-2 report (CS-Macmini). All eight round-1 findings are resolved, and I re-verified each one myself (Docker, a manage.sh matrix, mutants). The compose defaults are now byte-identical to master and publish on both IPv4 and IPv6. broker-check agrees with the mode the container really ends up in for every DISABLE_MOSQUITTO value delivered through .env or the process environment. The forwarded settings reach the container and are inert when unset.

What remains is one Minor gap and some nits. None is a regression against master.

Findings

# Sev Finding Evidence
1 Minor broker-check does not see the data-volume .env that the entrypoint sources. docker/entrypoint-go.sh runs set -a; . /app/data/.env after compose has set the container environment, so values there override compose. docs/deployment.md documents that file as working "with any launch method (… compose, or manage.sh)", with DISABLE_MOSQUITTO=true and MQTT_BROKER= as its example. broker_check_report reads only the repo-root .env and the process environment. Repro: no repo .env, $PROD_DATA/.env containing only DISABLE_MOSQUITTO=true, and config.json = {}. broker-check prints "Broker mode: bundled mosquitto … Broker check passed" and exits 0. The container, however, has DISABLE_MOSQUITTO=false in its compose env, logs internal MQTT broker disabled, and the ingestor logs [broker] error: … no mqttSources are configured. This is the same false-PASS class as round-1 #2, on a path that predates this PR. The ingestor's own diagnostic and MQTT_SOURCE_STRICT still catch it, so I don't consider it blocking. Suggested fix: overlay $PROD_DATA/.env (for DISABLE_MOSQUITTO / MQTT_BROKER / MQTT_TOPIC) after the repo .env, mirroring the entrypoint. Failing that, warn when that file sets those keys, or name the limitation in the docs. The migration note's "a value in .env was dead" is likewise true only for the repo-root .env. [T] real container from the head image plus manage.sh broker-check on the same layout. [A] for MQTT_BROKER in the data .env (not run): broker-check would not see it, and in external mode would false-fail.
2 Nit The disclosure "MQTT_BROKER … is the only behaviour an existing install can see change" is slightly too narrow. Any forwarded key already present in the repo-root .env becomes live on the next up -d. For example, a stale ENABLE_PPROF=true now starts an in-container pprof listener on prod, which docker-compose.yml does not publish to the host but which is reachable on the container network. These keys were undocumented for .env on master, so this is unlikely, but the migration note could say "any of these keys present in .env" and keep MQTT_BROKER as the headline. [T] the container picked up ENABLE_PPROF=true + PPROF_HOST=127.0.0.1 + PPROF_PORT_SERVER=16060 from .env and listened on 127.0.0.1:16060 / :6061.
3 Nit (test gap) Preflight ordering is pinned only for cmd_start. My mutant R4c moves cmd_restore's broker_check_report to before the config restore, so it reads the old config. All 24 JS cases stay green. The current code order is correct in start, update (after checkout + migrate_config auto), restore (after the config copy) and setup (after the exports, with PROD_DATA reassigned). [T] mutant; [A] order.
4 Nit (test gap) The "clearly false" value sets in Go isClearlyFalse and manage.sh disable_mosquitto_is_unrecognised are identical today, but the tests pin only ""/false/true as quiet. Dropping "False" from either list survives (R5, R8), and the two lists can drift apart silently. The impact would only be a spurious warning. A table-driven case over the full set, on both sides, would close it. [T] mutants.
5 Nit The comment on bundled_broker_enabled says dc_staging resolves the mode the same way. However, docker-compose.staging.yml defaults DISABLE_MOSQUITTO to true, so on staging an unset value is external. broker_check_report is only ever called for prod, so no output is wrong today; only the comment overgeneralises. [A]

Answers to the re-review points

  1. IPv4/IPv6 — OK. With nothing set, docker compose config renders port mappings identical to origin/master for all five compose files, with no host_ip anywhere [T]. The only rendered difference is the new empty environment keys. Runtime check with the image built from head and a real Mosquitto [T]:

    Compose Setting docker port / ss -ltn MQTT CONNECT
    master docker-compose.yml unset 0.0.0.0:P and [::]:P CONNACK on 127.0.0.1 and ::1
    head docker-compose.yml unset 0.0.0.0:P and [::]:P CONNACK on 127.0.0.1 and ::1
    head docker-compose.yml PROD_MQTT_BIND=127.0.0.1 127.0.0.1:P only CONNACK on v4, ::1 refused
    master docker-compose.staging.yml unset 6060/6061 on 0.0.0.0 and [::] /debug/pprof/ 200 on v4 and v6
    head docker-compose.staging.yml unset 6060/6061 on 0.0.0.0 and [::] 200 on v4 and v6
    head docker-compose.staging.yml PPROF_BIND=127.0.0.1 127.0.0.1 only 200 on v4, ::1 refused

    I ran the staging files as real containers against a locally created stand-in for their external network. staging.no-mosquitto.yml was checked at the config level only, and renders the same as staging.yml [T].

  2. broker-check vs. what manage.sh selects — OK for every value delivered through the repo .env or the process environment. I drove manage.sh's own dc_prod with real docker compose config, and compared broker-check's mode against the DISABLE_MOSQUITTO value the container actually receives, under the entrypoint's rule [T]. The table covers 16 rows: unset, false, true, 1, yes, on, TRUE, True, enabled, " true " from .env (trimmed → external), " true" from the process env (bundled), yes from the process env, "true" and 'true' with literal quotes, true # comment, and "false". All 16 match. The quoted and inline-comment cases match because manage.sh exports the raw value and compose's process env wins over its own dotenv parsing. Exit codes are 1 in external mode with no source and 0 when bundled. The one gap is the data-volume .env (finding 1).

  3. .env reaches the container, no new effect when unset — OK. Unset [T]: the container env shows the keys as empty, and the logs of the master compose and the head compose (same image) are identical apart from the random MQTT client id. There is no [broker] or [pprof] line and no pprof listener. The Dockerfile has no ENV for these keys, so an empty override replaces nothing [A]. All readers treat empty and unset alike (os.Getenv + != ""/TrimSpace) [A]. Set via .env [T]: MQTT_SOURCE_STRICT=true with no source makes the ingestor exit 1 ("refusing to start") in a supervisord restart loop. MQTT_BROKER=mqtt://u:<pw>@broker.example:1883 connects to tcp://****@broker.example:1883, with 0 occurrences of the password in the logs. The pprof knobs bind as set. Caveats are findings 1 and 2.

  4. M2 killed — OK. Removing the user-info strip in brokerHost turns TestCredentialedLoopbackOnlySourceIsStillFlagged red [T].

  5. Legacy broker counted correctly — OK. With config.example.json in external mode, manage.sh reports "1 of 3 … cannot connect", and the legacy block is not counted. {"mqtt":{"broker":…},"mqttSources":[]} falls back to the legacy source, as LoadConfig does [T]. Mutant R1 (no fallback for an empty array) is killed.

  6. Preflight order — OK in code, with only cmd_start pinned by a test (finding 3).

  7. Trim parsing — OK. ResolveBrokerMode compares the raw value, exactly like the entrypoint's [ "${DISABLE_MOSQUITTO:-false}" = "true" ], and the clearly-false sets on both sides are identical [A]. The " true" process-env row in the point 2 matrix is bundled in both [T].

"Left for the owner" — unchanged in substance. I diffed the section against the round-1 PR body (from the edit history). The same five items carry the same recommendations. The only changes are the "reaches the process" sentence in the preamble and the corrected rationale in item 5, and that rationale matches my matrix: yes is already external under manage.sh [T].

Always-checks:

  • Every acceptance item that can be tested in the repo has a test that is red before and green after (see below). The staging-like rollout is explicitly deferred.
  • No behaviour change beyond the stated purpose, apart from the disclosed MQTT_BROKER precedence and finding 2. pprof now trims PPROF_PORT and disables itself with a reason on an unusable value; before, it tried to listen. That fits the purpose [A].
  • cmd/server changes only main.go (the pprofbind wiring) and go.mod; there are no write paths [T].
  • 0 new map[string]interface{} outside tests [T].
  • No public/** changes, so no colours. bash scripts/check-xss-sinks.sh --diff origin/master reports "no public/**/*.{js,html} changes to scan" [T].
  • Fork guards: 9 in deploy.yml and 1 in release-fast-path.yml, identical to master. No closing keywords in the title, body or commits [T].
  • All five commits have author and committer dborup <kontakt@meshview.dk> [T].

Tests and mutants

On the merged tree (git merge-tree --write-tree origin/master fd0ce7c0… → 146b86bc, base 4c7a1ca5) [T]:

  • cmd/server: go vet + go test ./... → ok
  • cmd/ingestor: go vet + go test ./... → ok
  • internal/pprofbind: go vet + go test -race → ok
  • sh test-all.sh → 243 passed, 0 failed
  • node test-frontend-helpers.js → 713 passed, 0 failed
  • node test-issue-372-broker-check.js → 24 passed, 0 failed
  • Docker image built from head; it is used for all the runtime checks above.
  • E2E: local Go server on e2e-fixture.db, prepared as in CI (freshen, inline seed SQL, corescope-migrate, seeds 2073 and 199, plus 245 as CI does). test-e2e-playwright.js with fail-fast disabled in a scratch copy gave 131/135 passed, 3 skipped and 1 failed, identically on origin/master. The one failure ("Version info lives on Perf dashboard") is environmental here and passes in CI. The PR has no E2E of its own and touches no frontend, so no other E2E is affected. I stopped the server by the pid holding the port.

Red before → green after [T]:

Runtime files from JS broker-check (24) Go compose/docs tests
origin/master 5 pass, 19 fail 6 red: BundledMQTTHostPortBindIsExplicit, NoUnboundMQTTMappingRemains, PprofHostPorts…, EnvExample…, DeploymentDocs…, ComposeFilesPassEveryProcessKnob…
round 1 c5a1d640 16 pass, 8 fail 6 red, including the new TestComposeBindDefaultsStayDualStack and TestComposeFilesPassEveryProcessKnobToTheContainer
round 1 broker_mode.go — TestResolveBrokerMode_MatchesTheEntrypointSpelling, TestAmbiguousDiagnostic_DoesNotMisattributeTheModeToManageSh red
head 24/24 all green

The new ingestor and pprofbind tests do not compile against master, which counts as red.

My own mutants [T]:

Mutant Result
M2 (round 1) drop the user-info strip in brokerHost Killed: TestCredentialedLoopbackOnlySourceIsStillFlagged
R1 an empty mqttSources no longer falls back to legacy Killed: 1 JS case
R2 docker-compose.example.yml stops forwarding ENABLE_PPROF Killed: TestComposeFilesPassEveryProcessKnobToTheContainer
R3 staging.no-mosquitto.yml 6061 back to ${PPROF_BIND:-0.0.0.0}: Killed: TestComposeBindDefaultsStayDualStack + TestStagingComposes_…
R4c cmd_restore preflight moved before the config restore Survived (finding 3)
R5 Go isClearlyFalse drops "False" Survived (finding 4)
R6 manage.sh is_loopback_host drops 127.* Killed: 1 JS case
R7 per-role PPROF_HOST_<ROLE> ignored Killed: TestPerRoleHostOverridesSharedHost
R8 manage.sh unrecognised-set drops False Survived (finding 4)

CI [K]: run 38404492174 on fd0ce7c0. Go Build & Test, Playwright E2E, Validate Docker Image and the Build & Publish gate all passed. Release, Deploy Staging and Badges were skipped by the fork guards. Neither known flake (#256, #267) was involved. That run predates master's #432 and #423; the merged-tree results above cover them. The head ref was unchanged before and after this review.

Not verified

  • No staging-like rollout and no host access. Nothing was run against staging or production, as instructed.
  • manage.sh update, setup (interactive) and promote were not driven against Docker. Their preflight order was checked by reading only.
  • The staging compose files ran only locally, against a stand-in for their external network, not against a real staging topology.
  • MQTT_BROKER in the data-volume .env vs. broker-check is analysis only (finding 1).
  • I don't know whether any real host has a stale MQTT_BROKER / ENABLE_PPROF line in its repo .env, or broker settings in its data-volume .env; the owner should check before pulling.
  • mqtt_sources_region was run with GNU awk only; the author reports BSD awk.

@dborup
dborup marked this pull request as ready for review October 13, 2026 17:18
@dborup
dborup merged commit cbab738 into master Oct 13, 2026
7 checks passed
dborup-agent pushed a commit that referenced this pull request Oct 13, 2026
Resolve the one conflict in docker-compose.example.yml by keeping both
sides: #431's forwarded in-process settings (MQTT_BROKER, MQTT_TOPIC,
MQTT_SOURCE_STRICT, ENABLE_PPROF, PPROF_*) stay active, and #339's
commented opt-in non-root lines follow them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

3 participants