Conversation
Configured clientId used verbatim; default corescope-<name>-<8 hex> with broker-host and plain fallbacks; uniqueness across 5000 constructions and with crypto/rand failing; the same ID on first connect, paho auto-reconnect and the watchdog force-reconnect against a loopback broker (a second client differs); other options unchanged; a connect log line without credentials; no literal clientId in config.example.json; the legacy mqtt block. Does not compile on master (MQTTSource has no ClientID; buildMQTTOpts never sets one). Relates to #118 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
- mqttSources[].clientId (optional) is used verbatim; documented as needing to be unique among the broker's concurrent clients. - Otherwise buildMQTTOpts sets corescope-<name>-<8 hex>: sanitized source name ([a-z0-9-], capped at 32), else broker hostname, else none; 32 random bits from crypto/rand, with a clock+counter fallback should that ever fail. Generated once per client construction, so paho's auto-reconnect and the watchdog force-reconnect reuse it. - The connect log line names the client ID; the broker URL in it is logged without user-info. - config.example.json and the ingestor README document clientId and the MQTT 3.1 23-character limit without setting a literal value. - Topics, credentials, CleanSession, TLS and the watchdog are unchanged. The random-failure test now pins a coarse clock (clientIDNow) so the fallback's counter is exercised. Relates to #118 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
Independent review of
|
| Source | Length |
|---|---|
default (legacy block) |
26 |
local (no config) |
24 |
env |
22 |
| name of 4 characters | 23 |
Default IDs also contain -, which is outside the 1–23 [0-9a-zA-Z] set that MQTT 3.1.1 servers must accept ([MQTT-3.1.3-5]). Longer IDs and other characters are only a "MAY". The code header mqtt_client_id.go:30-35 states this correctly. README.md:94 frames the limit as a strict-MQTT-3.1 issue only. Mosquitto, EMQX, HiveMQ and VerneMQ accept these IDs [A]. Staging is listed as not verified [T].
nit. The legacy mqtt block and the MQTT_BROKER env source cannot set clientId cmd/ingestor/config.go:44-47,310-320. The docs tell strict-broker users to set a short clientId. Legacy-block and env users can only do that by moving to mqttSources, and the docs do not say so [F].
No P1 or P2 findings.
Metadata
-
CI on
b493dadais complete: Go Build & Test, Playwright E2E and Docker are SUCCESS; the rest are SKIPPED [F]. -
Head at start and at end:
b493dada6236be2d539e4840703d6a610e80e6c5(gh pr view 141 --json headRefOid) [F]. -
Commits in
origin/master..head[F]. Both have author and committerdborup <kontakt@meshview.dk>:78fd9c56test(ingestor): pin explicit, collision-resistant MQTT client IDs (ingestor: assign explicit, collision-resistant MQTT client IDs #118)b493dadafix(ingestor): explicit, collision-resistant MQTT client IDs (ingestor: assign explicit, collision-resistant MQTT client IDs #118)
-
Files changed [F]:
cmd/ingestor/README.md(+1)cmd/ingestor/config.go(+4)cmd/ingestor/main.go(+6/-2)cmd/ingestor/mqtt_client_id.go(new, 118 lines)cmd/ingestor/mqtt_client_id_118_test.go(new, 319 lines)config.example.json(+1/-1)
-
.github/workflows/deploy.ymlandcmd/serverare not touched [F]. -
The merge-base is
d264716c, 3 commits behindorigin/masterad011021. Those commits touch onlypublic/,test-all.sh,deploy.ymland one JS test [F]. -
git merge-tree --write-tree origin/master b493dadais clean (tree4cdc1d0c) [F]. -
Stacked with PR fix(ingestor): warn, throttled and bounded, when observerIATAWhitelist drops a region #134 (
origin/codex/issue-110-iata-whitelist-warningd9f4c328) [F]:- master → fix(ingestor): warn, throttled and bounded, when observerIATAWhitelist drops a region #134 → this PR is clean.
- master → this PR → fix(ingestor): warn, throttled and bounded, when observerIATAWhitelist drops a region #134 is clean.
- Both orders give the identical tree
263ab6a4. - On that tree,
go vetis clean and all_118and IATA tests from both PRs pass.config.example.jsonstill parses, becauseTestConfigExampleHasNoLiteralClientID_118passes.
-
PR body [F]:
- It says "Relates to ingestor: assign explicit, collision-resistant MQTT client IDs #118", with no closing keyword, and
closingIssuesReferencesis empty. - No @mentions.
- Upstream appears only as code-formatted
Kpa-clawbot/CoreScope#2016, with nogithub.com/Kpa-clawbotURL. - The PR is a draft.
- It says "Relates to ingestor: assign explicit, collision-resistant MQTT client IDs #118", with no closing keyword, and
Acceptance criteria (issue #118)
| Criterion | Result |
|---|---|
Optional mqttSources[].clientId, used verbatim, documented as unique |
Met [F]. config.go:28-31, mqtt_client_id.go:50-52, README.md:94, config.example.json:373. A whitespace-only value counts as unset (M4 caught). A value with padding such as " edge " is kept verbatim, spaces included, which matches the criterion. |
Omitted: non-empty, collision-resistant ID from sanitized name → broker hostname → corescope, plus crypto/rand |
Met [F]. mqtt_client_id.go:53-99. The suffix is 32 bits of crypto/rand. On a failure, a clock+counter fallback kicks in (M7 caught). The hostname comes from u.Hostname(), with no port and no user-info (M8 and M9 caught). No credentials reach the ID (test at _test.go:53). Collision odds with 1,000 concurrent same-name instances are about 1.2e-4 (birthday bound) [F, calculated]. |
| Generated once per construction; stable across paho auto-reconnect and watchdog force-reconnect; unique across sources and processes | Met [F]. The ID is set in buildMQTTOpts (main.go:554), and paho copies the options into the client (client.go:154). The loopback test covers first connect, auto-reconnect, force-reconnect and a second client. Mutant M10, which regenerates the ID in OnReconnecting, is caught. I also ran -race -count=10 on that test: 10/10 pass. The ID changes on restart. That is harmless because CleanSession=true stays and subscriptions are QoS 0 (main.go), so no persistent session is lost [F]. |
| Log the selected client ID on connect without credentials or tokens | Partially met [F]. The client ID is logged and user-info is stripped from the broker part. Credentials still leak through the tag for unnamed sources and through scheme-less or query-token URLs (finding 1). |
No literal default ID in config.example.json |
Met [F]. The file only documents the key in _comment_mqttSources, and a test enforces this. |
| Topics, credentials, clean-session, TLS and watchdog preserved | Met [F]. Only the SetClientID link was added to the chain. TestMQTTClientIDKeepsOtherOptions_118 covers this (M13 CleanSession=false caught). The existing TestBuildMQTTOpts_* and ForceReconnect tests pass. |
| Document legacy MQTT 3.1 length constraints | Met [F]. mqtt_client_id.go:30-35, README.md:94, config.example.json:373. For wording nits, see findings 3 and 4. I checked paho v1.5.0 client.go:412-424: it tries 3.1.1, falls back to 3.1 only when the first handshake fails, then pins the version. That matches the PR text. |
Test-first and mutants
- Commit A (
78fd9c56), which is master plus the new test file, does not compile:unknown field ClientID in struct literal of type MQTTSourceand more [F]. - A behavioural red comes from mutant M1, a revert of
SetClientID. It fails 6 of the 11 new tests, including the loopback test ("the client connected with an empty client id") [F]. - Tests changed between A and head [F].
TestMQTTClientIDRandomFailureStillUnique_118was strengthened: it now freezes the clock through the newclientIDNowhook and checks 2000 constructions instead of 2. That is justified, because it now tests the counter fallback on a coarse clock. - All mutants were run on a copy of the head with
-run '_118|BuildMQTTOpts|ForceReconnect'. Afterwards, each file was restored and checked withshasumagainstgit show b493dada:<path>: they match formain.go,config.goandmqtt_client_id.go[F].
| # | Mutant | Result |
|---|---|---|
| M1 | Remove SetClientID (revert the fix) |
Caught (6 tests, including the loopback test) |
| M2 | Constant suffix 00000000 |
Caught (Unique, StableAcrossReconnects, RandomFailure) |
| M3 | Configured clientId ignored |
Caught (ConfiguredIsVerbatim) |
| M4 | Whitespace-only clientId used verbatim |
Caught (BlankConfiguredIsGenerated) |
| M5 | u.User = nil removed (user-info logged) |
Caught (ConnectedLogLine) |
| M6 | Length cap removed | Caught (LongNameIsCapped) |
| M7 | Fallback counter removed | Caught (RandomFailureStillUnique) |
| M8 | Hostname fallback removed | Caught (DefaultShape) |
| M9 | u.Host instead of u.Hostname() |
Caught (DefaultShape) |
| M10 | ID regenerated on every reconnect (OnReconnecting) |
Caught (StableAcrossReconnects) |
| M11 | main.go connect log reverted to the old line |
Survived: a real test gap (finding 2); main() wiring is not covered |
| M12 | No - separators in the sanitizer |
Caught (DefaultShape) |
| M13 | SetCleanSession(false) added |
Caught (KeepsOtherOptions) |
Suites run locally
- Head:
cd cmd/ingestor && go test -race -count=1 -timeout 60m ./...returnedok github.com/corescope/ingestor 776.731s(exit 0) [F]. The PR reports 1074 s on its machine [T]. - Master: I did not run the full suite on master, because nothing failed on the head.
- Stacked tree (fix(ingestor): warn, throttled and bounded, when observerIATAWhitelist drops a region #134 + this PR):
go vetis clean, and the_118, IATA and Drop tests pass [F]. go vetandgofmt -lon the changed Go files are clean [F].
Performance and security
- Performance. The ID is built once per client construction, at startup: at most 4 bytes of
crypto/randplus string work. The ingest path is not touched, so no proof of performance is needed [F]. - Bounded state. The only new global state is an
atomic.Uint64counter and two test hooks (clientIDRandom,clientIDNow). There are no maps, timers or goroutines [F]. - The loopback test broker closes its listener, its connections and its WaitGroup in
t.Cleanup[F]. - Interfaces. No new
map[string]interface{}(0 occurrences in the diff) [F]. - Read-only invariant.
cmd/serveris not touched [F]. - What the ID contains. It holds only the operator-chosen source name or the broker hostname, and it is sent only to that broker. No credentials end up in it: tested, and M8/M9 were caught [F]. The source name can become visible to other clients where a broker exposes client IDs, for example through
$SYSor a dashboard. I consider that acceptable, since the name is a label and not a secret [A]. - Log redaction. For the remaining gaps, see finding 1.
Not verified
- Behaviour against the real staging brokers: ID acceptance, ACLs,
clientid_prefixes-style restrictions and reconnects. No broker other than the in-test loopback broker was contacted [K], as the PR itself states [T]. - Behaviour against a strict MQTT 3.1 or strict 3.1.1 broker [K].
- The full
cmd/ingestorsuite on master, which was not needed because the head was green. - The PR's "Not verified" section is honest. It leaves out the unnamed-source tag leak and the scheme-less case in finding 1, and its "24 characters" figure is wrong (finding 3).
An unnamed source used its raw broker URL as log tag, so user:pass in the URL reached every "MQTT [tag]" line. The disconnected, reconnecting and connection-attempt lines and the watchdog lines (via the liveness state) also logged the raw URL, and brokerForLog passed a broker without a scheme through unchanged. - mqttSourceTag: the name, or for an unnamed source brokerForLog(broker); used by main() and buildMQTTOpts. - brokerForLog: reads a scheme-less broker as tcp:// (as paho does) and drops user-info, query and fragment; for unparseable input it keeps only the part after the last '@', without query or fragment. - main() logs, and hands the watchdog, only brokerForLog(broker). The source-status registry still gets the raw broker (not logged). Tests (mqtt_log_credentials_118_test.go): tag and brokerForLog for URLs with and without a scheme; a loopback connect of unnamed sources with credentials in the URL, with every captured log line checked; and a go/ast check of main.go (no log call with a raw X.Broker, tag and liveness Broker not the raw URL). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FcyXW5RdFzLZhuL1ntAsY
|
Review feedback addressed (commit
Generated by Claude Code |
…118) master (#134, iata_drop_log_test.go) already declares captureLog, so the package test build failed on the PR's merge with master. The #118 helper is now captureLog118; no behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FcyXW5RdFzLZhuL1ntAsY
|
Review feedback addressed (commit
Generated by Claude Code |
…118) TestMQTTClientIDUniqueAcrossConstructions_118 drew 5000 IDs whose suffix is 32 random bits. By the birthday bound that repeats one about once in 350 runs (reproduced with -count=200). 200 constructions keep the chance near 5e-6 and still catch a constant or low-entropy suffix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mm9BK6mmVDjykf3B1JuP1C
The public /api/mqtt/status (no API key) served the ingestor's raw broker URL with only `scheme://user:pass@` masked. A broker without a scheme, a lone user name or token, a query or fragment, and URLs url.Parse rejects went out unmasked; brokerForLog could also log part of a password holding an unescaped '/', '?' or '#'. - internal/brokerurl (new, shared by ingestor and server): Strip, Mask and MaskText, without url.Parse. Everything up to the last '@' is user-info, query and fragment are dropped; on doubt it shows too little. - ingestor: brokerForLog and the client-ID host use it. The status registry stores the stripped broker whoever registers it, main passes the stripped one, and a disconnect error is masked before it is logged or stored. The stats file therefore carries no credentials; its tmp file is forced to 0o600 (a stale one kept its own mode). - ingestor: unnamed sources whose tag is taken get " (2)", " (3)", … so two sources on one host with different credentials no longer lose watchdog tracking or share counters. Named sources are unchanged. - ingestor: main's per-source wiring moved to prepareMQTTSource, so a test runs its connect, disconnect and reconnect handlers against a loopback broker with credentials in the URL. - server: masks broker, name and lastError again, all user-info included, and the /api/healthz ingest_liveness keys (an older ingestor tagged an unnamed source with its raw broker). Existing tests that expected the user name to be shown now expect it masked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mm9BK6mmVDjykf3B1JuP1C
|
Review feedback addressed (commits
Also found and fixed:
Verification:
The staging test against both brokers follows after this round. Generated by Claude Code |
Review round 3 on the MQTT credential masking: - Server: a source name or /api/healthz liveness key is masked only when it is a raw broker URL, i.e. one of the stats file's source_statuses brokers or a name holding "://". Names the operator chose, such as "obs@north" or "Feed @ CPH", are served unchanged and no longer collide into " (2)". - Ingestor: brokers in logs, tags, the status registry and the stats file use brokerurl.Mask, so cut user-info shows as "****@" and a host that is only what followed an '@' reads as cut short. brokerurl.Strip is gone with its last caller. - errForLog first replaces the source's known secrets literally (password, user name, and the broker URL's user-info, query and fragment, via the new brokerurl.Secrets), so a query token quoted without its scheme no longer reaches the log or lastError. - Stats file: a stale tmp file owned by another user is refused before it is touched, also as root, where chmod alone succeeds. README and code comments now say so. - Test: all four random bytes reach the client-ID suffix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNatSvATYtHfy9XDaFaC1d
Two mutants survived the round-3 tests: errForLog without its longest-first order (a user name that starts the URL's user-info left the password behind) and writeStatsAtomic without the Truncate that replaced O_TRUNC (a longer stale tmp file left a tail after the JSON). Both are now caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNatSvATYtHfy9XDaFaC1d
|
Review feedback addressed (commit
Verification
Generated by Claude Code |
|
CI on I don't think this failure comes from this PR:
No fix exists for it yet. I don't want to widen this PR, so here is a proposal for a separate change: in I have re-run the failed job once. Generated by Claude Code |
An ingestor built 2026-06-07..06-12 writes source_liveness but no source_statuses. The raw-broker list is then empty, and a scheme-less raw broker tag such as "user:secret@host:1883" reaches /api/healthz unmasked. Pin that it is masked then (absent and empty statuses), that a chosen name like "obs@north" stays as it is when statuses exist, and that tags masking to the same value keep an entry each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…118) /api/healthz masked an ingest_liveness key only when it was one of the stats file's raw brokers or held "://". An ingestor built 2026-06-07.. 06-12 writes source_liveness without source_statuses, so the raw-broker list was empty and a scheme-less tag such as "user:pass@host:1883" was served as it was. When source_statuses is absent or empty, mask every key holding '@' as a broker URL. With statuses present nothing changes, so names such as "obs@north" stay as they are; colliding keys still get " (2)", " (3)". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review feedback addressed (commit Relates to #118. Round 4, finding R1 only; R2, R3, R4 and the FIFO hang are left for separate issues.
|
Relates to #118
Plan and design
The user asked for autonomous work, so the plan is written here instead of waiting for sign-off (AGENTS.md rule 5).
Commits:
78fd9c56: tests (they do not compile on master, becauseMQTTSourcehas noClientID).b493dada: the fix.a0f190cf(review round 1): no MQTT log line contains credentials from the broker URL, including the tag of an unnamed source and a broker without a scheme.8698783d: test helper rename.0f40395(review round 2): the client-ID uniqueness test no longer flakes (see "Tests").07f3481(review round 2): credentials out of/api/mqtt/status,/api/healthz, the stats file and the tags; see "Credentials in status data" below.2721749(review round 3): names the operator chose are kept, masked credentials show as****@, known secrets are masked in errors, and a foreign stats tmp file is refused, also as root. See "Review round 3".3ff6050(review round 3): two tests that catch the mutants that survived2721749.Claims in the issue, verified against master
d264716cMQTTSourcehas noclientIdfield (cmd/ingestor/config.go).buildMQTTOpts()never callsSetClientID(cmd/ingestor/main.go). paho v1.5.0 therefore connects with a zero-length client ID andCleanSession=true.The change
Configured ID. New optional
mqttSources[].clientId, used verbatim. It is documented as needing to be unique among the broker's concurrent clients. A whitespace-only value counts as unset, so a blank template value cannot become a shared ID.Generated default (new file
cmd/ingestor/mqtt_client_id.go):corescope-<name>-<8 hex>.<name>is the sanitized source name: lowercased,[a-z0-9]kept, every other run of characters turned into one-, capped at 32 characters.<name>is the host of the masked broker (no port). Since round 2 the host comes from the masked broker, becauseurl.Parsetakes the user name for the host when a password contains an unescaped/,?or#.corescope-<8 hex>.crypto/rand. Go 1.22 is pinned ingo.mod, socrypto/randcan still return an error. In that case a clock + process-counter fallback keeps IDs unique within the process.Stable across reconnects. The default is generated once per client construction, inside
buildMQTTOpts:buildForceReconnectFn) reuses the same client.So the ID is stable for the process lifetime but differs between sources and processes.
Logging (credential-free).
MQTT [tag] connected to <broker> as client <id>.brokerForLogreplaces user-info with****and drops query and fragment (brokerurl.Mask, since round 3). A broker without a scheme is read astcp://, as paho'sAddBrokerdoes.connected,disconnected,reconnectingandconnection attempt #Nlines and the watchdog lines (via the liveness state'sBroker) all use the masked broker. Disconnect and connect errors are masked too, in case they quote a URL or one of the source's secrets.Documentation.
config.example.jsondocumentsclientIdin_comment_mqttSourceswithout a literal value, because copied deployments must not share an ID.cmd/ingestor/README.mddocuments the key, the tag of an unnamed source and what the stats file contains.Unchanged. Topics, credentials,
CleanSession, TLS, keepalive and the watchdog.Credentials in status data (review round 2)
The public
/api/mqtt/status(no API key) served the ingestor's raw broker URL. The server only maskedscheme://user:pass@, so these went out unmasked:user:secret@host:1883(no scheme),tcp://token@host(user name or token only),wss://host/mqtt?token=abc(query), andtcp://user:p%zz@host(bad %-escape,url.Parsefails). The problem existed before this PR. It is fixed at both layers:internal/brokerurl(new;Mask,MaskText, and since round 3Secrets;Stripwas removed in round 3 together with its last caller), used by the ingestor and the server.url.Parse. Everything up to the last@is user-info, and the query and fragment are dropped.@in its path or query shows only what follows that@, after****@.go.modfiles, both Dockerfile builder stages (check-dockerfile-internal-pkgs.shpasses) and the CI step that tests the shared packages.RegisterSourceStatusstores the masked broker (brokerForLog, the same function the log uses), whoever calls it.mainpasses the masked one as well.source_statuses[].broker/name/lastErrorand thesource_livenesskeys) carries no credentials.TestSourceStatus_BasicLifecyclepinned raw passthrough ("server masks"). It now expects the masked broker, with the reason in a comment.broker: all user-info becomes****(a lone user name too), and the query and fragment are dropped, also without a scheme and on parse errors.nameand the/api/healthzingest_livenesskeys: an older ingestor tagged an unnamed source with its raw broker. Since round 3 only such raw-broker names are masked (see below). Masked keys that coincide get(2), so no entry is lost.lastError: each broker URL or user-info in the text is masked, and the rest of the message is kept.mqtt://u:****@host). They now expectmqtt://****@host: the endpoint is public, and a user name alone is often the credential.cmd/server. Only/api/mqtt/statusand/api/healthz(tags as keys) show broker data./api/perf/write-sourcesshows counters only, and no endpoint exposes themqttSourcesconfig.0o600. A stale tmp file used to keep its own mode through the rename, so the writer now forces0o600. Since round 3 a tmp file owned by another user is refused before anything is changed, also as root (see below).brokerForLogused to leak when an unescaped password contained/,?or#and the part before it was digits only.tcp://user:2024/secret@host:1883was logged unchanged; it now becomestcp://****@host:1883.tcp://user:1234?abc@hostbecametcp://user:1234; it now becomestcp://****@host.@is still in the result after parsing" would not catch the second case, because its@ends up in the dropped query. That is why the package always cuts at the last@.(2),(3), … Tags of named sources are reserved first.nameis left alone: that stays a config error, reported as before.main's per-source setup (options, status, liveness, the connect, connection-lost and reconnecting handlers) moved unchanged toprepareMQTTSource.fmt.Sprint(source.Broker)in the disconnect handler.Review round 3
Names the operator chose are kept (N1). Round 2 masked every name with
@or://, soobs@northbecame****@north,Feed @ CPHbecame****@ CPH, and in/api/healthznames that masked to the same value got(2). Now a name or liveness key is masked only when it is a raw broker URL:source_statuses(an older ingestor wrote the raw broker as the tag of an unnamed source), or://./api/mqtt/statusand/api/healthz(on a cache refresh) build the same set of raw brokers from the stats file (rawBrokerSet).****@instead of a silent cut (N2). The ingestor's logs, tags, status registry and stats file usebrokerurl.Maskinstead ofStrip:mqtt://u:p@broker:1883becomesmqtt://****@broker:1883.wss://host/mqtt?u=me@x.orgbecomeswss://****@x.orginstead ofwss://x.org.The operator can see that the URL carries credentials and that the host may be cut short.
Striphad no caller left and was removed.Known secrets masked in errors.
errForLogfirst replaces the source's secrets with****, longest first, then runsMaskText. The secrets arepassword,username, and the user-info, query and fragment of the broker URL as configured (newbrokerurl.Secrets). Only non-empty values count. The disconnect handler (log andlastError) and main's "connection failed" line pass them. This closes the gap where a query token quoted without its scheme passedMaskText.Stats tmp file as root (N3). The round-2 text said the writer "gives up for a tmp file it does not own". That held only because
chmodfailed, which does not happen as root, and both binaries run as root in Docker. The writer now:O_TRUNC,geteuid(fileOwnerUID, with a Unix and a Windows file),chmod 0o600and truncates.The code comment and
cmd/ingestor/README.mdnow describe this.Nit. A deterministic test injects
clientIDRandomwithde ad be efand requires the suffixdeadbeef.Perf. No hot path is touched:
MarkPacketis unchanged. The ingestor masks once per source at startup. Each disconnect makes one sort and one replace over a handful of secrets. The server masks per/api/mqtt/statusrequest: one entry per source, typically 1–5, plus one set of the same size. For/api/healthzit masks only when the liveness cache is refreshed, not per probe; it decodes the already-read file a second time for the raw brokers.Legacy Mosquitto and MQTT 3.1
mqttblock becomes sourcedefaultand getscorescope-default-<8 hex>(26 characters).clientId. The length is not capped automatically, because that would cut the readable source name to 6 characters for everyone.How this differs from upstream
Kpa-clawbot/CoreScope#2016Upstream is read as a reference only; nothing was cherry-picked.
clientIdcrypto/randfailure****, no query or fragment in any line, tag, status entry or stats fileBoth use a loopback broker test with auto-reconnect and watchdog force-reconnect. This PR additionally checks that a second client for the same source gets a different ID.
Acceptance criteria
mqttSources[].clientId, used verbatim and documented as uniqueTestMQTTClientIDConfiguredIsVerbatim_118, README,config.example.jsoncorescope, pluscrypto/randTestMQTTClientIDDefaultShape_118(8 cases),TestMQTTClientIDUniqueAcrossConstructions_118,TestMQTTClientIDRandomFailureStillUnique_118,TestMQTTClientIDBaseHasNoCredentials_118,TestMQTTClientIDSuffixUsesAllRandomBytes_118TestMQTTClientIDStableAcrossReconnects_118(loopback broker)TestMQTTConnectedLogLine_118,TestMQTTLogLinesHaveNoCredentials_118,TestMQTTSourceWiringLeaksNoCredentials_118,TestMainLogsNoRawBroker_118,TestBrokerForLogAmbiguousPassword_118,TestBrokerForLogMarksRemovedUserinfo_118config.example.jsonTestConfigExampleHasNoLiteralClientID_118TestMQTTClientIDKeepsOtherOptions_118; existingTestBuildMQTTOpts_*and force-reconnect tests passmqtt_client_id.goheader, README,config.example.json/api/mqtt/status,/api/healthzor the stats file; unique tagsinternal/brokerurltests,mqtt_status_118_test.go(server),mqtt_status_credentials_118_test.go(ingestor)****@marker, known secrets masked in errors, foreign tmp refused as rootTestMqttStatusKeepsChosenNames_118,TestIngestLivenessKeepsChosenNames_118,TestMaskSourceName_118,TestErrForLogMasksKnownSecrets_118,TestDisconnectErrorMasksKnownSecrets_118,TestWriteStatsAtomicRefusesForeignTmp_118,TestWriteStatsAtomicRefusesForeignTmpAsRoot_118,TestWriteStatsAtomicTruncatesOwnStaleTmp_118,TestSecretsTests
New tests in round 2 (all red before the fix, for the right reasons; green after):
internal/brokerurl/brokerurl_test.go:MaskandMaskTexttables, and idempotence.cmd/server/mqtt_status_118_test.go:lastError/nametext.ingest_livenesskeys.maskSourceName.cmd/ingestor/mqtt_status_credentials_118_test.go:brokerForLogfor both P3 examples.lastError.prepareMQTTSourceruntime test against the loopback broker.StartStatsFileWriter: its content, and mode600with a stale644tmp file present.go/asttest now also checksmqtt_source.goand rejectsRegisterSourceStatus(…, X.Broker).Round 3 (
2721749,3ff6050):cmd/ingestor/mqtt_credentials_r3_118_test.go, three server tests (TestMqttStatusKeepsChosenNames_118,TestIngestLivenessKeepsChosenNames_118, a rewrittenTestMaskSourceName_118), andTestSecrets.TestStripwas folded intoTestMask, now with theMaskresults.tcp://host→tcp://****@host, and so on), plus the loopback test, which now requiresconnected to tcp://****@<addr>.TestSourceStatus_BasicLifecycle, 3 server tests andTestSecrets.maskSourceName("obs@north") = "****@north",brokerForLog("wss://host/mqtt?u=me@x.org") = "wss://x.org",errForLog("dial host/mqtt?token=abc: refused")unchanged, andwriteStatsAtomic accepted a tmp file owned by another user(as root).TestMQTTClientIDSuffixUsesAllRandomBytes_118was green on the existing code, as intended; mutants back it up.Adjusted tests:
TestMain_StartsRouteMaskBackfillAfterBufferReadyreadc.Subscribe(frommain.goas its marker for "MQTT is set up beforeReady()". That code moved toprepareMQTTSource, so the marker is now the call to it, and the test checks that the function subscribes.TestMQTTClientIDUniqueAcrossConstructions_118made 5000 draws of a 32-bit suffix. By the birthday bound that repeats in about 1 run in 350: the-race -count=10run hit it, and-count=200reproduced it 3 out of 3 times. It now makes 200 draws, which puts the chance near 5·10⁻⁶ (-count=2000: ok).-racerun flagged the test reading while paho logged.Mutants, round 2: 23, one per point at least, 22 caught by the tests.
RegisterSourceStatus(tag, source.Broker). It is equivalent, because the function masks the broker itself. The static test now rejects it as well.@used instead of the last, scheme-less user-info kept.nameorlastErrorunmasked.ingest_livenesskeys unmasked or merged.brokerForLogback onurl.Parse.url.Parse.fmt.Sprint(source.Broker)in the disconnect log.Mutants, round 3: 21, at least two per point. All are caught on
3ff6050.@rule back; no raw-broker set (://only); healthz without the set; handler with an empty setbrokerForLogdrops the****@;Maskwithout the markerSecretswithout the query; password not among the secretsO_TRUNCback before the check; noTruncate2721749, two mutants survived: no longest-first order, and noTruncate.3ff6050adds a test for each:rawBrokerSet(nil), which the tests catch.Runs on
3ff6050:origin/masterbe35eefb(clean, no conflicts;deploy.ymlfork-guard lines unchanged):go test -race -count=1 ./...: ingestorok(619 s), serverok(787 s).-race -count=10: ingestorok, serverok.internal/brokerurl:go test ./...ok,-race -count=10ok.-count=10runs on the branch itself:ok.go vetis clean for the ingestor (linux and darwin; windows builds), the server andinternal/brokerurl.GOOS=windows go vetstops at the existingchannel_proposals_test.go(syscall.Kill), which this PR does not touch.source_status.goandperf_io.gohave the same gofmt diffs as before; none of them come from this PR.TestWriteStatsAtomicRefusesForeignTmpAsRoot_118ran as root here. In CI, where it is not root, it is skipped, andTestWriteStatsAtomicRefusesForeignTmp_118(an injected euid) covers the check instead.Not verified
MaskTextlimitation: user-info without a scheme that contains whitespace cannot be told apart from free text, so only its last part is masked. This only affectslastError.brokerand a raw-brokernameare masked as whole URLs, the source's own secrets are now replaced literally first, and paho's disconnect errors do not quote the broker.[stats-file] write …: … belongs to uid N, until that file is removed. Before round 3, a root ingestor took the file over. The reverse direction (root to non-root) failed before as well.nlink). This predates the PR, andfs.protected_hardlinksusually prevents it.Overlap with other open PRs
cmd/ingestor/config.goandcmd/ingestor/main.go: PR fix(ingestor): warn, throttled and bounded, when observerIATAWhitelist drops a region #134 (ingestor: warn when observerIATAWhitelist drops a region without unbounded throttle state #110, IATA whitelist warning, since merged) edited them in different hunks. The merge with master is clean; its new IATA-drop warning logs only the tag, which is now credential-free.config.example.json: fix(ingestor): warn, throttled and bounded, when observerIATAWhitelist drops a region #134 documentsiataWarnIntervalSec; this PR extends_comment_mqttSources. Different lines.🤖 Generated with Claude Code
https://claude.ai/code/session_01UNatSvATYtHfy9XDaFaC1d