Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/agents/mqtt.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

Deep subsystem reference for AI assistants. Open this when a task touches Meshtastic/MeshCore MQTT ingest, channel key mapping, or the sticky BLE suppress. Hard rules live in [`AGENTS.md`](../../AGENTS.md).

Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); **outbound** (`publishEncryptedData` and its JSON mirror, `publishDecodedJsonMirror`) stamps `channel` with `computeMeshtasticChannelHash(channelName, psk)` from `src/shared/meshtasticChannelHash.ts` (XOR-fold of name bytes ^ XOR-fold of key bytes, mirroring firmware `Channels::generateHash`) — **not** the local slot index, which is "meaningless to send between nodes" per the field's own protobuf doc and is silently dropped by real radios/gateways that rely on it (there's no MQTT topic on RF to fall back to, unlike inbound). This hashes whatever key `resolvePskForChannel` resolves (including its `DEFAULT_PSK` fallback). **PSK shorthand aliases** (a single decoded byte, e.g. `AQ==` = `0x01`) are firmware-defined channel-key presets, not raw key material — `parsePsk()` and `normalizeMeshtasticPskTo16Bytes()` expand them via `expandMeshtasticPskAlias()` in `src/shared/meshtasticDefaultPublicPsk.ts` (alias `1` → firmware `defaultpsk` `d4f1bb3a20290759f0bcffabcf4e6901`; `2`-`10` → that key's last byte `+ (index - 1)`; `0` / undefined aliases zero-pad, matching "no encryption"/unknown). Zero-padding the raw alias byte instead (the pre-fix behavior) silently produces the wrong AES key and the wrong channel hash for every default/simple-preset-PSK channel — always fix both `MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES` and main-process `DEFAULT_PSK` together (doc-commented as required to match) if this ever needs to change again; Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport.
Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); **outbound** (`publishEncryptedData` and its JSON mirror, `publishDecodedJsonMirror`) stamps `channel` with `computeMeshtasticChannelHash(channelName, psk)` from `src/shared/meshtasticChannelHash.ts` (XOR-fold of name bytes ^ XOR-fold of key bytes, mirroring firmware `Channels::generateHash`) — **not** the local slot index, which is "meaningless to send between nodes" per the field's own protobuf doc and is silently dropped by real radios/gateways that rely on it (there's no MQTT topic on RF to fall back to, unlike inbound). This hashes whatever key `resolvePskForChannel` resolves (including its `DEFAULT_PSK` fallback). **PSK shorthand aliases** (a single decoded byte, e.g. `AQ==` = `0x01`) are firmware-defined channel-key presets, not raw key material — `parsePsk()` and `normalizeMeshtasticPskTo16Bytes()` expand them via `expandMeshtasticPskAlias()` in `src/shared/meshtasticDefaultPublicPsk.ts` (alias `1` → firmware `defaultpsk` `d4f1bb3a20290759f0bcffabcf4e6901`; `2`-`10` → that key's last byte `+ (index - 1)`; `0` / undefined aliases zero-pad, matching "no encryption"/unknown). Zero-padding the raw alias byte instead (the pre-fix behavior) silently produces the wrong AES key and the wrong channel hash for every default/simple-preset-PSK channel — always fix both `MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES` and main-process `DEFAULT_PSK` together (doc-commented as required to match) if this ever needs to change again; Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). **`updateChannelKeys` topic→index and radio PSKs are merge-safe** across incremental RF channel packets (partial OnTrail-only push must not wipe `LongFast@1` or private-channel decrypt keys); slot takeover evicts another name on the same index; complete-cover pushes drop absent radio names. Pass **`radioSessionId`** (`rf:<myNodeNum>` / `rf:none`) so a new RF identity clears the prior radio's maps before merging. Runtime also **debounces** channel-key pushes (`MESHTASTIC_MQTT_CHANNEL_KEYS_DEBOUNCE_MS` via `createDebouncedMqttChannelKeysPush`) while configs stream. MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport.
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -1743,7 +1743,7 @@ On **Windows**, unread messages use a red taskbar overlay. On **Linux**, launche

MQTT ingest must map inbound text to the **receiver's** local channel slot using the MQTT topic channel name (`LongFast`, regional names, etc.) via `channelNameToIndex`. `MeshPacket.channel` in the ServiceEnvelope is the **sender's** local RF slot and must not drive attribution — remote gateways often use a different slot layout (e.g. LongFast on slot 1 while you use slot 0).

Mis-filed messages also occur when `channelNameToIndex` is stale or incomplete: unnamed default-public on slot 1 without radio sync, MQTT-only without `ChannelName@index=` manual PSK lines, or MQTT connecting before RF channel configs arrive (cold-start empty map).
Mis-filed messages also occur when `channelNameToIndex` is stale or incomplete: unnamed default-public on slot 1 without radio sync, MQTT-only without `ChannelName@index=` manual PSK lines, MQTT connecting before RF channel configs arrive (cold-start empty map), or (fixed in current builds) a mid-stream radio sync that temporarily wiped `LongFast` while channel packets arrived one-by-one after cycling radios — topic→index and radio PSKs are now merge-safe so a partial `OnTrail=0` push cannot drop `LongFast=1` or private decrypt keys, and `radioSessionId` clears prior radio maps when the RF identity changes.

**Fix**

Expand Down
15 changes: 13 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,12 @@ function validateMqttUpdateChannelKeysArgs(args: unknown): void {
}
}
}
if (a.radioSessionId !== undefined) {
if (typeof a.radioSessionId !== 'string')
throw new Error('mqtt:updateChannelKeys: radioSessionId must be a string');
if (a.radioSessionId.length > 64)
throw new Error('mqtt:updateChannelKeys: radioSessionId too long');
}
}

function validateMqttUpdateTopicPrefixArgs(args: unknown): void {
Expand Down Expand Up @@ -3252,8 +3258,13 @@ ipcMain.handle('mqtt:updateChannelKeys', (event, args) => {
try {
console.debug('[IPC] mqtt:updateChannelKeys');
validateMqttUpdateChannelKeysArgs(args);
const a = args as { entries: { name: string; pskBase64: string }[] };
mqttManager.updateChannelKeys(a.entries);
const a = args as {
entries: { name: string; pskBase64: string; index?: number }[];
radioSessionId?: string;
};
mqttManager.updateChannelKeys(a.entries, {
radioSessionId: a.radioSessionId,
});
} catch (err) {
console.error(
'[IPC] mqtt:updateChannelKeys failed:',
Expand Down
231 changes: 231 additions & 0 deletions src/main/mqtt-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1534,6 +1534,237 @@ describe('updateChannelKeys', () => {
debugSpy.mockRestore();
});

it('streaming RF channel sync: OnTrail-only partial push keeps LongFast@1 for topic ingest', () => {
// Bundle repro: channelNameToIndex updated (1): OnTrail=0 wiped LongFast before @1 arrived.
const manager = new MQTTManager();
const access = mqttChannelTestAccess(manager);
stubMqttConnect(manager);
const onTrailPsk = Buffer.alloc(16, 0x11);
manager.connect({
server: 'localhost',
port: 1883,
username: '',
password: '',
topicPrefix: 'msh/US/CO/',
autoLaunch: false,
});

manager.updateChannelKeys([
{ name: 'OnTrail', pskBase64: onTrailPsk.toString('base64'), index: 0 },
{ name: 'LongFast', pskBase64: 'AQ==', index: 1 },
{ name: 'cm-west-slp', pskBase64: Buffer.alloc(16, 0x22).toString('base64'), index: 2 },
]);
expect(manager.getChannelNameToIndex()).toEqual({
OnTrail: 0,
LongFast: 1,
'cm-west-slp': 2,
});

// Mid-stream re-push with only primary (as configs arrive one-by-one).
manager.updateChannelKeys([
{ name: 'OnTrail', pskBase64: onTrailPsk.toString('base64'), index: 0 },
]);
expect(manager.getChannelNameToIndex().LongFast).toBe(1);
expect(manager.getChannelNameToIndex().OnTrail).toBe(0);
expect(manager.getChannelNameToIndex()['cm-west-slp']).toBe(2);

const nodeId = 0xa6d7c69b;
const packetId = 0x4b2a1c01;
const dataBytes = toBinary(
DataSchema,
create(DataSchema, {
portnum: PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode('within long fast'),
}),
);
const payload = buildEnvelope({
nodeId,
packetId,
dataBytes,
psk: DEFAULT_PSK,
channelName: 'LongFast',
channel: 0,
});

const messages: unknown[] = [];
manager.on('message', (m) => messages.push(m));
access.onMessage('msh/US/CO/2/e/LongFast/!a6d7c69b', payload);

expect(messages).toHaveLength(1);
expect((messages[0] as { channel: number }).channel).toBe(1);

manager.updateChannelKeys([
{ name: 'OnTrail', pskBase64: onTrailPsk.toString('base64'), index: 0 },
{ name: 'LongFast', pskBase64: 'AQ==', index: 1 },
{ name: 'cm-west-slp', pskBase64: Buffer.alloc(16, 0x22).toString('base64'), index: 2 },
]);
expect(manager.getChannelNameToIndex().LongFast).toBe(1);
});

it('layout change: LongFast@1 then LongFast@0 updates topic map to slot 0', () => {
const manager = new MQTTManager();
stubMqttConnect(manager);
manager.connect({
server: 'localhost',
port: 1883,
username: '',
password: '',
topicPrefix: 'msh/',
autoLaunch: false,
});

manager.updateChannelKeys([
{ name: 'OnTrail', pskBase64: Buffer.alloc(16, 1).toString('base64'), index: 0 },
{ name: 'LongFast', pskBase64: 'AQ==', index: 1 },
]);
expect(manager.getChannelNameToIndex().LongFast).toBe(1);

manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 0 }]);
expect(manager.getChannelNameToIndex().LongFast).toBe(0);
// Slot takeover: OnTrail must not keep slot 0 once LongFast claims it.
expect(manager.getChannelNameToIndex().OnTrail).toBeUndefined();
});

it('slot takeover: OnTrail@0 evicts prior radio LongFast@0 from topic map', () => {
const manager = new MQTTManager();
stubMqttConnect(manager);
manager.connect({
server: 'localhost',
port: 1883,
username: '',
password: '',
topicPrefix: 'msh/',
autoLaunch: false,
});

manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 0 }]);
expect(manager.getChannelNameToIndex().LongFast).toBe(0);

manager.updateChannelKeys([
{ name: 'OnTrail', pskBase64: Buffer.alloc(16, 3).toString('base64'), index: 0 },
]);
expect(manager.getChannelNameToIndex().OnTrail).toBe(0);
expect(manager.getChannelNameToIndex().LongFast).toBeUndefined();
});

it('complete-cover push drops radio names no longer present', () => {
const manager = new MQTTManager();
stubMqttConnect(manager);
manager.connect({
server: 'localhost',
port: 1883,
username: '',
password: '',
topicPrefix: 'msh/',
autoLaunch: false,
});

manager.updateChannelKeys([
{ name: 'OnTrail', pskBase64: Buffer.alloc(16, 1).toString('base64'), index: 0 },
{ name: 'LongFast', pskBase64: 'AQ==', index: 1 },
{ name: 'OldChan', pskBase64: Buffer.alloc(16, 4).toString('base64'), index: 2 },
]);

manager.updateChannelKeys([
{ name: 'OnTrail', pskBase64: Buffer.alloc(16, 1).toString('base64'), index: 0 },
{ name: 'LongFast', pskBase64: 'AQ==', index: 1 },
{ name: 'cm-west-slp', pskBase64: Buffer.alloc(16, 5).toString('base64'), index: 2 },
]);

expect(manager.getChannelNameToIndex().OldChan).toBeUndefined();
expect(manager.getChannelNameToIndex()['cm-west-slp']).toBe(2);
expect(manager.getChannelNameToIndex().LongFast).toBe(1);
});

it('radioSessionId change clears prior radio topic names before merge', () => {
const manager = new MQTTManager();
stubMqttConnect(manager);
manager.connect({
server: 'localhost',
port: 1883,
username: '',
password: '',
topicPrefix: 'msh/',
autoLaunch: false,
});

manager.updateChannelKeys(
[
{ name: 'OnTrail', pskBase64: Buffer.alloc(16, 1).toString('base64'), index: 0 },
{ name: 'LongFast', pskBase64: 'AQ==', index: 1 },
{ name: 'cm-west-slp', pskBase64: Buffer.alloc(16, 2).toString('base64'), index: 2 },
],
{ radioSessionId: 'rf:111' },
);
expect(manager.getChannelNameToIndex()['cm-west-slp']).toBe(2);

// Replacement radio: only primary so far — must not keep prior radio's cm-west-slp.
manager.updateChannelKeys(
[{ name: 'Primary', pskBase64: Buffer.alloc(16, 9).toString('base64'), index: 0 }],
{ radioSessionId: 'rf:222' },
);
expect(manager.getChannelNameToIndex()).toEqual({ Primary: 0 });
expect(manager.getChannelNameToIndex().LongFast).toBeUndefined();
expect(manager.getChannelNameToIndex()['cm-west-slp']).toBeUndefined();
});

it('partial push keeps private-channel PSK so inbound decrypt still works', () => {
const manager = new MQTTManager();
const access = mqttChannelTestAccess(manager);
stubMqttConnect(manager);
const privatePsk = Buffer.alloc(16, 0xab);
manager.connect({
server: 'localhost',
port: 1883,
username: '',
password: '',
topicPrefix: 'msh/US/CO/',
autoLaunch: false,
});

manager.updateChannelKeys([
{ name: 'OnTrail', pskBase64: privatePsk.toString('base64'), index: 0 },
{ name: 'LongFast', pskBase64: 'AQ==', index: 1 },
]);

// Mid-stream: only LongFast arrives again — OnTrail PSK must remain decryptable.
manager.updateChannelKeys([{ name: 'LongFast', pskBase64: 'AQ==', index: 1 }]);
expect(access.channelKeysByName.get('OnTrail')?.equals(privatePsk)).toBe(true);
expect(
(manager as unknown as { allDecryptKeys: Buffer[] }).allDecryptKeys.some((k) =>
k.equals(privatePsk),
),
).toBe(true);

const nodeId = 0x11223344;
const packetId = 0x00000077;
const dataBytes = toBinary(
DataSchema,
create(DataSchema, {
portnum: PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode('private after partial'),
}),
);
const payload = buildEnvelope({
nodeId,
packetId,
dataBytes,
psk: privatePsk,
channelName: 'OnTrail',
channel: 0,
});

const messages: unknown[] = [];
manager.on('message', (m) => messages.push(m));
access.onMessage('msh/US/CO/2/e/OnTrail/!11223344', payload);

expect(messages).toHaveLength(1);
expect((messages[0] as { payload: string; channel: number }).payload).toBe(
'private after partial',
);
expect((messages[0] as { channel: number }).channel).toBe(0);
});

it('Nathan/Colorado: radio LongFast@1 overrides manual LongFast@0 for topic attribution', () => {
const manager = new MQTTManager();
const access = mqttChannelTestAccess(manager);
Expand Down
Loading
Loading