@matter/protocol 0.16.7 → 0.16.8-alpha.0-20260125-38e62bc3e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/cjs/action/client/ClientInteraction.d.ts +4 -4
  2. package/dist/cjs/action/client/ClientInteraction.d.ts.map +1 -1
  3. package/dist/cjs/action/client/ClientInteraction.js +48 -6
  4. package/dist/cjs/action/client/ClientInteraction.js.map +1 -1
  5. package/dist/cjs/action/client/QueuedClientInteraction.d.ts +0 -1
  6. package/dist/cjs/action/client/QueuedClientInteraction.d.ts.map +1 -1
  7. package/dist/cjs/action/client/QueuedClientInteraction.js +0 -1
  8. package/dist/cjs/action/client/QueuedClientInteraction.js.map +1 -1
  9. package/dist/cjs/action/client/subscription/ClientSubscriptionHandler.d.ts.map +1 -1
  10. package/dist/cjs/action/client/subscription/ClientSubscriptionHandler.js +5 -2
  11. package/dist/cjs/action/client/subscription/ClientSubscriptionHandler.js.map +1 -1
  12. package/dist/cjs/action/server/AttributeWriteResponse.d.ts +1 -1
  13. package/dist/cjs/action/server/AttributeWriteResponse.d.ts.map +1 -1
  14. package/dist/cjs/action/server/AttributeWriteResponse.js +0 -6
  15. package/dist/cjs/action/server/AttributeWriteResponse.js.map +1 -1
  16. package/dist/cjs/action/server/DataResponse.d.ts +5 -0
  17. package/dist/cjs/action/server/DataResponse.d.ts.map +1 -1
  18. package/dist/cjs/action/server/DataResponse.js +7 -0
  19. package/dist/cjs/action/server/DataResponse.js.map +1 -1
  20. package/dist/cjs/action/server/ServerInteraction.js.map +1 -1
  21. package/dist/cjs/interaction/InteractionMessenger.d.ts +30 -30
  22. package/dist/cjs/interaction/InteractionMessenger.d.ts.map +1 -1
  23. package/dist/cjs/interaction/InteractionMessenger.js +81 -12
  24. package/dist/cjs/interaction/InteractionMessenger.js.map +1 -1
  25. package/dist/cjs/mdns/MdnsClient.d.ts.map +1 -1
  26. package/dist/cjs/mdns/MdnsClient.js +157 -100
  27. package/dist/cjs/mdns/MdnsClient.js.map +1 -1
  28. package/dist/cjs/mdns/MdnsServer.d.ts +2 -0
  29. package/dist/cjs/mdns/MdnsServer.d.ts.map +1 -1
  30. package/dist/cjs/mdns/MdnsServer.js +45 -5
  31. package/dist/cjs/mdns/MdnsServer.js.map +1 -1
  32. package/dist/cjs/peer/ControllerCommissioningFlow.d.ts.map +1 -1
  33. package/dist/cjs/peer/ControllerCommissioningFlow.js +3 -1
  34. package/dist/cjs/peer/ControllerCommissioningFlow.js.map +1 -1
  35. package/dist/cjs/protocol/MessageExchange.js +1 -1
  36. package/dist/cjs/protocol/MessageExchange.js.map +1 -1
  37. package/dist/esm/action/client/ClientInteraction.d.ts +4 -4
  38. package/dist/esm/action/client/ClientInteraction.d.ts.map +1 -1
  39. package/dist/esm/action/client/ClientInteraction.js +49 -6
  40. package/dist/esm/action/client/ClientInteraction.js.map +1 -1
  41. package/dist/esm/action/client/QueuedClientInteraction.d.ts +0 -1
  42. package/dist/esm/action/client/QueuedClientInteraction.d.ts.map +1 -1
  43. package/dist/esm/action/client/QueuedClientInteraction.js +0 -1
  44. package/dist/esm/action/client/QueuedClientInteraction.js.map +1 -1
  45. package/dist/esm/action/client/subscription/ClientSubscriptionHandler.d.ts.map +1 -1
  46. package/dist/esm/action/client/subscription/ClientSubscriptionHandler.js +5 -2
  47. package/dist/esm/action/client/subscription/ClientSubscriptionHandler.js.map +1 -1
  48. package/dist/esm/action/server/AttributeWriteResponse.d.ts +1 -1
  49. package/dist/esm/action/server/AttributeWriteResponse.d.ts.map +1 -1
  50. package/dist/esm/action/server/AttributeWriteResponse.js +0 -6
  51. package/dist/esm/action/server/AttributeWriteResponse.js.map +1 -1
  52. package/dist/esm/action/server/DataResponse.d.ts +5 -0
  53. package/dist/esm/action/server/DataResponse.d.ts.map +1 -1
  54. package/dist/esm/action/server/DataResponse.js +7 -0
  55. package/dist/esm/action/server/DataResponse.js.map +1 -1
  56. package/dist/esm/action/server/ServerInteraction.js.map +1 -1
  57. package/dist/esm/interaction/InteractionMessenger.d.ts +30 -30
  58. package/dist/esm/interaction/InteractionMessenger.d.ts.map +1 -1
  59. package/dist/esm/interaction/InteractionMessenger.js +82 -12
  60. package/dist/esm/interaction/InteractionMessenger.js.map +1 -1
  61. package/dist/esm/mdns/MdnsClient.d.ts.map +1 -1
  62. package/dist/esm/mdns/MdnsClient.js +157 -100
  63. package/dist/esm/mdns/MdnsClient.js.map +1 -1
  64. package/dist/esm/mdns/MdnsServer.d.ts +2 -0
  65. package/dist/esm/mdns/MdnsServer.d.ts.map +1 -1
  66. package/dist/esm/mdns/MdnsServer.js +45 -5
  67. package/dist/esm/mdns/MdnsServer.js.map +1 -1
  68. package/dist/esm/peer/ControllerCommissioningFlow.d.ts.map +1 -1
  69. package/dist/esm/peer/ControllerCommissioningFlow.js +3 -1
  70. package/dist/esm/peer/ControllerCommissioningFlow.js.map +1 -1
  71. package/dist/esm/protocol/MessageExchange.js +1 -1
  72. package/dist/esm/protocol/MessageExchange.js.map +1 -1
  73. package/package.json +6 -6
  74. package/src/action/client/ClientInteraction.ts +62 -6
  75. package/src/action/client/QueuedClientInteraction.ts +0 -1
  76. package/src/action/client/subscription/ClientSubscriptionHandler.ts +5 -2
  77. package/src/action/server/AttributeWriteResponse.ts +4 -16
  78. package/src/action/server/DataResponse.ts +8 -0
  79. package/src/action/server/ServerInteraction.ts +2 -2
  80. package/src/interaction/InteractionMessenger.ts +113 -15
  81. package/src/mdns/MdnsClient.ts +207 -102
  82. package/src/mdns/MdnsServer.ts +79 -6
  83. package/src/peer/ControllerCommissioningFlow.ts +5 -1
  84. package/src/protocol/MessageExchange.ts +1 -1
@@ -63,6 +63,19 @@ const logger = Logger.get("MdnsClient");
63
63
 
64
64
  const MDNS_EXPIRY_GRACE_PERIOD_FACTOR = 1.05;
65
65
 
66
+ /**
67
+ * Protection window for out-of-order goodbye packets (RFC 6762).
68
+ * If a record was discovered within this window, ignore TTL=0 goodbye packets
69
+ * as they likely arrived out of order (goodbye sent before an announcement but received after).
70
+ */
71
+ const GOODBYE_PROTECTION_WINDOW = Millis(1000);
72
+
73
+ /**
74
+ * Minimum TTL for PTR records to prevent DoS attacks with very short TTLs.
75
+ * Value based on python-zeroconf/bonjour implementation.
76
+ */
77
+ const RECORD_MIN_TTL = Seconds(15);
78
+
66
79
  type MatterServerRecordWithExpire = ServerAddressUdp & AddressLifespan;
67
80
 
68
81
  /** Type for commissionable Device records including Lifespan details. */
@@ -891,6 +904,13 @@ export class MdnsClient implements Scanner {
891
904
  answersList.forEach(answers =>
892
905
  answers.forEach(answer => {
893
906
  const { name, recordType } = answer;
907
+
908
+ // Enforce minimum TTL for records to prevent DoS attacks with very short TTLs
909
+ // But don't modify TTL=0 goodbye packets - those need to be processed for record removal
910
+ if (answer.ttl > 0 && answer.ttl < RECORD_MIN_TTL) {
911
+ answer = { ...answer, ttl: RECORD_MIN_TTL };
912
+ }
913
+
894
914
  if (name.endsWith(MATTER_SERVICE_QNAME)) {
895
915
  structuredAnswers.operational = structuredAnswers.operational ?? {};
896
916
  structuredAnswers.operational[recordType] = structuredAnswers.operational[recordType] ?? [];
@@ -926,8 +946,32 @@ export class MdnsClient implements Scanner {
926
946
  return structuredAnswers;
927
947
  }
928
948
 
949
+ /**
950
+ * Merge a record into a map with goodbye protection.
951
+ * Returns true if the record was processed (added or deleted), false if skipped due to protection.
952
+ */
953
+ #mergeRecordWithGoodbyeProtection(
954
+ targetMap: Map<string, AnyDnsRecordWithExpiry>,
955
+ key: string,
956
+ record: AnyDnsRecordWithExpiry,
957
+ now: number,
958
+ ): void {
959
+ const existingRecord = targetMap.get(key);
960
+ if (!existingRecord || existingRecord.discoveredAt < record.discoveredAt) {
961
+ if (record.ttl === 0) {
962
+ // Apply goodbye protection - ignore if the existing record is young
963
+ if (existingRecord && now - existingRecord.discoveredAt < GOODBYE_PROTECTION_WINDOW) {
964
+ return;
965
+ }
966
+ targetMap.delete(key);
967
+ } else {
968
+ targetMap.set(key, record);
969
+ }
970
+ }
971
+ }
972
+
929
973
  #combineStructuredAnswers(...answersList: StructuredDnsAnswers[]): StructuredDnsAnswers {
930
- // Special type for easier combination of answers
974
+ // Special type for an easier combination of answers
931
975
  const combinedAnswers: {
932
976
  operational?: Record<number, Map<string, AnyDnsRecordWithExpiry>>;
933
977
  commissionable?: Record<number, Map<string, AnyDnsRecordWithExpiry>>;
@@ -935,7 +979,9 @@ export class MdnsClient implements Scanner {
935
979
  addressesV6?: Record<string, Map<string, AnyDnsRecordWithExpiry>>;
936
980
  } = {};
937
981
 
982
+ const now = Time.nowMs;
938
983
  for (const answers of answersList) {
984
+ // Process operational records
939
985
  if (answers.operational) {
940
986
  combinedAnswers.operational = combinedAnswers.operational ?? {};
941
987
  for (const [recordType, records] of Object.entries(answers.operational) as unknown as [
@@ -943,18 +989,18 @@ export class MdnsClient implements Scanner {
943
989
  AnyDnsRecordWithExpiry[],
944
990
  ][]) {
945
991
  combinedAnswers.operational[recordType] = combinedAnswers.operational[recordType] ?? new Map();
946
- records.forEach(record => {
947
- const existingRecord = combinedAnswers.operational![recordType].get(record.name);
948
- if (!existingRecord || existingRecord.discoveredAt < record.discoveredAt) {
949
- if (record.ttl === 0) {
950
- combinedAnswers.operational![recordType].delete(record.name);
951
- } else {
952
- combinedAnswers.operational![recordType].set(record.name, record);
953
- }
954
- }
955
- });
992
+ for (const record of records) {
993
+ this.#mergeRecordWithGoodbyeProtection(
994
+ combinedAnswers.operational[recordType],
995
+ record.name,
996
+ record,
997
+ now,
998
+ );
999
+ }
956
1000
  }
957
1001
  }
1002
+
1003
+ // Process commissionable records
958
1004
  if (answers.commissionable) {
959
1005
  combinedAnswers.commissionable = combinedAnswers.commissionable ?? {};
960
1006
  for (const [recordType, records] of Object.entries(answers.commissionable) as unknown as [
@@ -963,18 +1009,18 @@ export class MdnsClient implements Scanner {
963
1009
  ][]) {
964
1010
  combinedAnswers.commissionable[recordType] =
965
1011
  combinedAnswers.commissionable[recordType] ?? new Map();
966
- records.forEach(record => {
967
- const existingRecord = combinedAnswers.commissionable![recordType].get(record.name);
968
- if (!existingRecord || existingRecord.discoveredAt < record.discoveredAt) {
969
- if (record.ttl === 0) {
970
- combinedAnswers.commissionable![recordType].delete(record.name);
971
- } else {
972
- combinedAnswers.commissionable![recordType].set(record.name, record);
973
- }
974
- }
975
- });
1012
+ for (const record of records) {
1013
+ this.#mergeRecordWithGoodbyeProtection(
1014
+ combinedAnswers.commissionable[recordType],
1015
+ record.name,
1016
+ record,
1017
+ now,
1018
+ );
1019
+ }
976
1020
  }
977
1021
  }
1022
+
1023
+ // Process IPv6 addresses
978
1024
  if (answers.addressesV6) {
979
1025
  combinedAnswers.addressesV6 = combinedAnswers.addressesV6 ?? {};
980
1026
  for (const [name, records] of Object.entries(answers.addressesV6) as unknown as [
@@ -982,18 +1028,18 @@ export class MdnsClient implements Scanner {
982
1028
  Map<string, AnyDnsRecordWithExpiry>,
983
1029
  ][]) {
984
1030
  combinedAnswers.addressesV6[name] = combinedAnswers.addressesV6[name] ?? new Map();
985
- records.forEach(record => {
986
- const existingRecord = combinedAnswers.addressesV6![name].get(record.value);
987
- if (!existingRecord || existingRecord.discoveredAt < record.discoveredAt) {
988
- if (record.ttl === 0) {
989
- combinedAnswers.addressesV6![name].delete(record.value);
990
- } else {
991
- combinedAnswers.addressesV6![name].set(record.value, record);
992
- }
993
- }
994
- });
1031
+ for (const record of records.values()) {
1032
+ this.#mergeRecordWithGoodbyeProtection(
1033
+ combinedAnswers.addressesV6[name],
1034
+ record.value,
1035
+ record,
1036
+ now,
1037
+ );
1038
+ }
995
1039
  }
996
1040
  }
1041
+
1042
+ // Process IPv4 addresses
997
1043
  if (this.#socket.supportsIpv4 && answers.addressesV4) {
998
1044
  combinedAnswers.addressesV4 = combinedAnswers.addressesV4 ?? {};
999
1045
  for (const [name, records] of Object.entries(answers.addressesV4) as unknown as [
@@ -1001,16 +1047,14 @@ export class MdnsClient implements Scanner {
1001
1047
  Map<string, AnyDnsRecordWithExpiry>,
1002
1048
  ][]) {
1003
1049
  combinedAnswers.addressesV4[name] = combinedAnswers.addressesV4[name] ?? new Map();
1004
- records.forEach(record => {
1005
- const existingRecord = combinedAnswers.addressesV4![name].get(record.value);
1006
- if (!existingRecord || existingRecord.discoveredAt < record.discoveredAt) {
1007
- if (record.ttl === 0) {
1008
- combinedAnswers.addressesV4![name].delete(record.value);
1009
- } else {
1010
- combinedAnswers.addressesV4![name].set(record.value, record);
1011
- }
1012
- }
1013
- });
1050
+ for (const record of records.values()) {
1051
+ this.#mergeRecordWithGoodbyeProtection(
1052
+ combinedAnswers.addressesV4[name],
1053
+ record.value,
1054
+ record,
1055
+ now,
1056
+ );
1057
+ }
1014
1058
  }
1015
1059
  }
1016
1060
  }
@@ -1065,43 +1109,60 @@ export class MdnsClient implements Scanner {
1065
1109
  }
1066
1110
 
1067
1111
  /**
1068
- * Update the discovered matter relevant IP records with the new data from the DNS message.
1112
+ * Update IP address records in a target map with goodbye protection.
1113
+ * Returns true if any records were updated.
1069
1114
  */
1070
- #updateIpRecords(answers: StructuredDnsAnswers, netInterface: string) {
1071
- const interfaceRecords = this.#discoveredIpRecords.get(netInterface);
1072
- if (interfaceRecords === undefined) {
1073
- return;
1115
+ #updateIpAddressRecords(
1116
+ sourceAddresses: Record<string, Map<string, AnyDnsRecordWithExpiry>> | undefined,
1117
+ targetAddresses: Record<string, Map<string, AnyDnsRecordWithExpiry>> | undefined,
1118
+ now: number,
1119
+ ): boolean {
1120
+ if (!sourceAddresses || !targetAddresses) {
1121
+ return false;
1074
1122
  }
1075
1123
  let updated = false;
1076
- if (answers.addressesV6) {
1077
- for (const [target, ipAddresses] of Object.entries(answers.addressesV6)) {
1078
- if (interfaceRecords.addressesV6?.[target] !== undefined) {
1079
- for (const [ip, record] of Object.entries(ipAddresses)) {
1080
- if (record.ttl === 0) {
1081
- interfaceRecords.addressesV6[target].delete(ip);
1082
- } else {
1083
- interfaceRecords.addressesV6[target].set(ip, record);
1084
- }
1085
- updated = true;
1086
- }
1087
- }
1124
+ for (const [target, ipAddresses] of Object.entries(sourceAddresses)) {
1125
+ const targetMap = targetAddresses[target];
1126
+ if (targetMap === undefined) {
1127
+ continue;
1088
1128
  }
1089
- }
1090
- if (this.#socket.supportsIpv4 && answers.addressesV4) {
1091
- for (const [target, ipAddresses] of Object.entries(answers.addressesV4)) {
1092
- if (interfaceRecords.addressesV4?.[target] !== undefined) {
1093
- for (const [ip, record] of Object.entries(ipAddresses)) {
1094
- if (record.ttl === 0) {
1095
- interfaceRecords.addressesV4[target].delete(ip);
1096
- } else {
1097
- interfaceRecords.addressesV4[target].set(ip, record);
1129
+ for (const [ip, record] of Object.entries(ipAddresses)) {
1130
+ if (record.ttl === 0) {
1131
+ const existingRecord = targetMap.get(ip);
1132
+ if (existingRecord) {
1133
+ const recordAge = now - existingRecord.discoveredAt;
1134
+ if (recordAge < GOODBYE_PROTECTION_WINDOW) {
1135
+ // Record was recently added - ignore goodbye (likely out-of-order packet)
1136
+ continue;
1098
1137
  }
1138
+ targetMap.delete(ip);
1099
1139
  updated = true;
1100
1140
  }
1141
+ } else {
1142
+ targetMap.set(ip, record);
1143
+ updated = true;
1101
1144
  }
1102
1145
  }
1103
1146
  }
1104
- if (updated) {
1147
+ return updated;
1148
+ }
1149
+
1150
+ /**
1151
+ * Update the discovered matter relevant IP records with the new data from the DNS message.
1152
+ */
1153
+ #updateIpRecords(answers: StructuredDnsAnswers, netInterface: string) {
1154
+ const interfaceRecords = this.#discoveredIpRecords.get(netInterface);
1155
+ if (interfaceRecords === undefined) {
1156
+ return;
1157
+ }
1158
+ const now = Time.nowMs;
1159
+
1160
+ const updatedV6 = this.#updateIpAddressRecords(answers.addressesV6, interfaceRecords.addressesV6, now);
1161
+ const updatedV4 =
1162
+ this.#socket.supportsIpv4 &&
1163
+ this.#updateIpAddressRecords(answers.addressesV4, interfaceRecords.addressesV4, now);
1164
+
1165
+ if (updatedV6 || updatedV4) {
1105
1166
  this.#discoveredIpRecords.set(netInterface, interfaceRecords);
1106
1167
  }
1107
1168
  }
@@ -1197,20 +1258,37 @@ export class MdnsClient implements Scanner {
1197
1258
  );
1198
1259
  }
1199
1260
 
1261
+ /**
1262
+ * Handle goodbye (TTL=0) for an operational device record with protection against out-of-order packets.
1263
+ * Returns true if the goodbye was processed (record deleted or protected), false if no action needed.
1264
+ */
1265
+ #handleOperationalDeviceGoodbye(matterName: string, netInterface: string, now: number): boolean {
1266
+ const existingRecord = this.#operationalDeviceRecords.get(matterName);
1267
+ if (!existingRecord) {
1268
+ return false;
1269
+ }
1270
+ const recordAge = now - existingRecord.discoveredAt;
1271
+ if (recordAge < GOODBYE_PROTECTION_WINDOW) {
1272
+ // Record was recently added - ignore goodbye (likely out-of-order packet)
1273
+ return true;
1274
+ }
1275
+ logger.debug(
1276
+ `Removing operational device ${matterName} from cache (interface ${netInterface}) because of ttl=0`,
1277
+ );
1278
+ this.#operationalDeviceRecords.delete(matterName);
1279
+ return true;
1280
+ }
1281
+
1200
1282
  #handleOperationalTxtRecord(record: DnsRecord<any>, netInterface: string) {
1201
1283
  const { name: matterName, value, ttl } = record as DnsRecord<string[]>;
1202
- const discoveredAt = Time.nowMs;
1284
+ const now = Time.nowMs;
1203
1285
 
1204
- // we got an expiry info, so we can remove the record if we know it already and are done
1286
+ // we got expiry info, so we can remove the record if we know it already and are done
1205
1287
  if (ttl === 0) {
1206
- if (this.#operationalDeviceRecords.has(matterName)) {
1207
- logger.debug(
1208
- `Removing operational device ${matterName} from cache (interface ${netInterface}) because of ttl=0`,
1209
- );
1210
- this.#operationalDeviceRecords.delete(matterName);
1211
- }
1288
+ this.#handleOperationalDeviceGoodbye(matterName, netInterface, now);
1212
1289
  return;
1213
1290
  }
1291
+ const discoveredAt = now;
1214
1292
  if (!Array.isArray(value)) return;
1215
1293
 
1216
1294
  // Existing records are always updated if relevant, but no new are added if they are not matching the criteria
@@ -1256,14 +1334,11 @@ export class MdnsClient implements Scanner {
1256
1334
  value: { target, port },
1257
1335
  } = record;
1258
1336
 
1337
+ const now = Time.nowMs;
1338
+
1259
1339
  // We got device expiry info, so we can remove the record if we know it already and are done
1260
1340
  if (ttl === 0) {
1261
- if (this.#operationalDeviceRecords.has(matterName)) {
1262
- logger.debug(
1263
- `Removing operational device ${matterName} from cache (interface ${netInterface}) because of ttl=0`,
1264
- );
1265
- this.#operationalDeviceRecords.delete(matterName);
1266
- }
1341
+ this.#handleOperationalDeviceGoodbye(matterName, netInterface, now);
1267
1342
  return;
1268
1343
  }
1269
1344
 
@@ -1276,7 +1351,7 @@ export class MdnsClient implements Scanner {
1276
1351
  return;
1277
1352
  }
1278
1353
 
1279
- const discoveredAt = Time.nowMs;
1354
+ const discoveredAt = now;
1280
1355
  const device = this.#operationalDeviceRecords.get(matterName) ?? {
1281
1356
  deviceIdentifier: matterName,
1282
1357
  addresses: new Map<string, MatterServerRecordWithExpire>(),
@@ -1288,10 +1363,18 @@ export class MdnsClient implements Scanner {
1288
1363
  if (ips.length > 0) {
1289
1364
  for (const { value: ip, ttl } of ips) {
1290
1365
  if (ttl === 0) {
1291
- logger.debug(
1292
- `Removing IP ${ip} for operational device ${matterName} from cache (interface ${netInterface}) because of ttl=0`,
1293
- );
1294
- addresses.delete(ip);
1366
+ const existingAddress = addresses.get(ip);
1367
+ if (existingAddress) {
1368
+ const addressAge = now - existingAddress.discoveredAt;
1369
+ if (addressAge < GOODBYE_PROTECTION_WINDOW) {
1370
+ // Address was recently added - ignore goodbye (likely out-of-order packet)
1371
+ continue;
1372
+ }
1373
+ logger.debug(
1374
+ `Removing IP ${ip} for operational device ${matterName} from cache (interface ${netInterface}) because of ttl=0`,
1375
+ );
1376
+ addresses.delete(ip);
1377
+ }
1295
1378
  continue;
1296
1379
  }
1297
1380
  const address = addresses.get(ip) ?? ({ ip, port, type: "udp" } as MatterServerRecordWithExpire);
@@ -1321,6 +1404,25 @@ export class MdnsClient implements Scanner {
1321
1404
  return;
1322
1405
  }
1323
1406
 
1407
+ /**
1408
+ * Handle goodbye (TTL=0) for a commissionable device record with protection against out-of-order packets.
1409
+ * Returns true if the goodbye should be skipped (record is protected), false if processed or no record exists.
1410
+ */
1411
+ #handleCommissionableDeviceGoodbye(name: string, netInterface: string, now: number): boolean {
1412
+ const existingRecord = this.#commissionableDeviceRecords.get(name);
1413
+ if (!existingRecord) {
1414
+ return false;
1415
+ }
1416
+ const recordAge = now - existingRecord.discoveredAt;
1417
+ if (recordAge < GOODBYE_PROTECTION_WINDOW) {
1418
+ // Record was recently added - ignore goodbye (likely out-of-order packet)
1419
+ return true;
1420
+ }
1421
+ logger.debug(`Removing commissionable device ${name} from cache (interface ${netInterface}) because of ttl=0`);
1422
+ this.#commissionableDeviceRecords.delete(name);
1423
+ return false;
1424
+ }
1425
+
1324
1426
  #handleCommissionableRecords(
1325
1427
  answers: StructuredDnsAnswers,
1326
1428
  formerAnswers: StructuredDnsAnswers,
@@ -1341,17 +1443,14 @@ export class MdnsClient implements Scanner {
1341
1443
 
1342
1444
  const queryMissingDataForInstances = new Set<string>();
1343
1445
 
1446
+ const now = Time.nowMs;
1447
+
1344
1448
  // First process the TXT records
1345
1449
  const txtRecords = commissionableRecords[DnsRecordType.TXT] ?? [];
1346
1450
  for (const record of txtRecords) {
1347
1451
  const { name, ttl } = record;
1348
1452
  if (ttl === 0) {
1349
- if (this.#commissionableDeviceRecords.has(name)) {
1350
- logger.debug(
1351
- `Removing commissionable device ${name} from cache (interface ${netInterface}) because of ttl=0`,
1352
- );
1353
- this.#commissionableDeviceRecords.delete(name);
1354
- }
1453
+ this.#handleCommissionableDeviceGoodbye(name, netInterface, now);
1355
1454
  continue;
1356
1455
  }
1357
1456
  const txtRecord = this.#parseCommissionableTxtRecord(record);
@@ -1392,10 +1491,8 @@ export class MdnsClient implements Scanner {
1392
1491
  ttl,
1393
1492
  } = record as DnsRecord<SrvRecordValue>;
1394
1493
  if (ttl === 0) {
1395
- logger.debug(
1396
- `Removing commissionable device ${record.name} from cache (interface ${netInterface}) because of ttl=0`,
1397
- );
1398
- this.#commissionableDeviceRecords.delete(record.name);
1494
+ // Handle goodbye - either deletes or protects the record
1495
+ this.#handleCommissionableDeviceGoodbye(record.name, netInterface, now);
1399
1496
  continue;
1400
1497
  }
1401
1498
 
@@ -1405,15 +1502,23 @@ export class MdnsClient implements Scanner {
1405
1502
  if (ips.length > 0) {
1406
1503
  for (const { value: ip, ttl } of ips) {
1407
1504
  if (ttl === 0) {
1408
- logger.debug(
1409
- `Removing IP ${ip} for commissionable device ${record.name} from cache (interface ${netInterface}) because of ttl=0`,
1410
- );
1411
- storedRecord.addresses.delete(ip);
1505
+ const existingAddress = storedRecord.addresses.get(ip);
1506
+ if (existingAddress) {
1507
+ const addressAge = now - existingAddress.discoveredAt;
1508
+ if (addressAge < GOODBYE_PROTECTION_WINDOW) {
1509
+ // Address was recently added - ignore goodbye (likely out-of-order packet)
1510
+ continue;
1511
+ }
1512
+ logger.debug(
1513
+ `Removing IP ${ip} for commissionable device ${record.name} from cache (interface ${netInterface}) because of ttl=0`,
1514
+ );
1515
+ storedRecord.addresses.delete(ip);
1516
+ }
1412
1517
  continue;
1413
1518
  }
1414
1519
  const matterServer =
1415
1520
  storedRecord.addresses.get(ip) ?? ({ ip, port, type: "udp" } as MatterServerRecordWithExpire);
1416
- matterServer.discoveredAt = Time.nowMs;
1521
+ matterServer.discoveredAt = now;
1417
1522
  matterServer.ttl = ttl;
1418
1523
 
1419
1524
  storedRecord.addresses.set(ip, matterServer);
@@ -24,10 +24,14 @@ import {
24
24
  ObserverGroup,
25
25
  Time,
26
26
  Timer,
27
+ Timestamp,
27
28
  } from "#general";
28
29
 
29
30
  const logger = Logger.get("MdnsServer");
30
31
 
32
+ /** RFC 6762 §7.3 - Window for duplicate question suppression (999ms per python-zeroconf) */
33
+ export const QUESTION_SUPPRESSION_WINDOW = Millis(999);
34
+
31
35
  export class MdnsServer {
32
36
  #lifetime: Lifetime;
33
37
  #observers = new ObserverGroup();
@@ -51,6 +55,14 @@ export class MdnsServer {
51
55
  );
52
56
  readonly #recordLastSentAsMulticastAnswer = new Map<string, number>();
53
57
  readonly #truncatedQueryCache = new Map<string, { message: MdnsSocket.Message; timer: Timer }>();
58
+ /** RFC 6762 §7.3 - Tracks recently answered queries for duplicate suppression */
59
+ readonly #recentlyAnsweredQueries = new Map<
60
+ string,
61
+ {
62
+ knownAnswerHashes: Set<string>;
63
+ timestamp: Timestamp;
64
+ }
65
+ >();
54
66
 
55
67
  readonly #socket: MdnsSocket;
56
68
 
@@ -135,6 +147,11 @@ export class MdnsServer {
135
147
  );
136
148
  if (answers.length === 0) continue; // Nothing to send
137
149
 
150
+ // RFC 6762 §7.3 - Check for duplicate question suppression
151
+ if (this.#shouldSuppressResponse(queries, knownAnswers, sourceIntf, answers)) {
152
+ continue; // Another responder already answered
153
+ }
154
+
138
155
  answers.forEach(answer =>
139
156
  this.#recordLastSentAsMulticastAnswer.set(this.buildDnsRecordKey(answer, sourceIntf), now),
140
157
  );
@@ -181,7 +198,6 @@ export class MdnsServer {
181
198
  for (const [service, serviceRecords] of records) {
182
199
  if (services.length && !services.includes(service)) continue;
183
200
 
184
- // TODO: try to combine the messages to avoid sending multiple messages but keep under 1500 bytes per message
185
201
  await this.#announceRecordsForInterface(netInterface, serviceRecords);
186
202
  await Time.sleep("MDNS delay", Millis(20 + Math.floor(Math.random() * 100))); // as per DNS-SD spec wait 20-120ms before sending more packets
187
203
  }
@@ -198,15 +214,12 @@ export class MdnsServer {
198
214
  const records = await this.#records.get(netInterface);
199
215
  for (const [service, serviceRecords] of records) {
200
216
  if (services.length && !services.includes(service)) continue;
201
- const instanceSet = new Set<string>();
217
+
218
+ // Set TTL to Instant for all records to expire them
202
219
  serviceRecords.forEach(record => {
203
220
  record.ttl = Instant;
204
- if (record.recordType === DnsRecordType.TXT) {
205
- instanceSet.add(record.name);
206
- }
207
221
  });
208
222
 
209
- // TODO: try to combine the messages to avoid sending multiple messages but keep under 1500 bytes per message
210
223
  await this.#announceRecordsForInterface(netInterface, serviceRecords);
211
224
  this.#recordsGenerator.delete(service);
212
225
  await Time.sleep("MDNS delay", Millis(20 + Math.floor(Math.random() * 100))); // as per DNS-SD spec wait 20-120ms before sending more packets
@@ -220,12 +233,14 @@ export class MdnsServer {
220
233
  async setRecordsGenerator(service: string, generator: MdnsServer.RecordGenerator) {
221
234
  await this.#records.clear();
222
235
  this.#recordLastSentAsMulticastAnswer.clear();
236
+ this.#recentlyAnsweredQueries.clear();
223
237
  this.#recordsGenerator.set(service, generator);
224
238
  }
225
239
 
226
240
  async #resetServices() {
227
241
  await this.#records.clear();
228
242
  this.#recordLastSentAsMulticastAnswer.clear();
243
+ this.#recentlyAnsweredQueries.clear();
229
244
  }
230
245
 
231
246
  async close() {
@@ -237,6 +252,7 @@ export class MdnsServer {
237
252
  }
238
253
  this.#truncatedQueryCache.clear();
239
254
  this.#recordLastSentAsMulticastAnswer.clear();
255
+ this.#recentlyAnsweredQueries.clear();
240
256
  }
241
257
 
242
258
  #getMulticastInterfacesForAnnounce() {
@@ -252,6 +268,63 @@ export class MdnsServer {
252
268
  }
253
269
  }
254
270
 
271
+ /**
272
+ * RFC 6762 §7.3 - Checks if we should suppress a response because another responder
273
+ * has recently answered the same question with answers that cover what we'd send.
274
+ * Also, lazily cleans up expired entries from the cache.
275
+ */
276
+ #shouldSuppressResponse(
277
+ queries: { name: string; recordType: DnsRecordType }[],
278
+ knownAnswers: DnsRecord<any>[],
279
+ sourceIntf: string,
280
+ answers: DnsRecord<any>[],
281
+ ): boolean {
282
+ const now = Time.nowMs;
283
+
284
+ // Clean up expired entries
285
+ for (const [key, entry] of this.#recentlyAnsweredQueries) {
286
+ if (now - entry.timestamp >= QUESTION_SUPPRESSION_WINDOW) {
287
+ this.#recentlyAnsweredQueries.delete(key);
288
+ }
289
+ }
290
+
291
+ // Build query signature
292
+ const queryKey =
293
+ queries
294
+ .map(q => `${q.name}-${q.recordType}`)
295
+ .sort()
296
+ .join("|") + `-${sourceIntf}`;
297
+
298
+ const cached = this.#recentlyAnsweredQueries.get(queryKey);
299
+
300
+ if (cached && now - cached.timestamp < QUESTION_SUPPRESSION_WINDOW) {
301
+ // Check if all our answers are already in the known answers from the cached response
302
+ const answerHashes = answers.map(a => this.buildDnsRecordKey(a, sourceIntf));
303
+ const allAnswersKnown = answerHashes.every(h => cached.knownAnswerHashes.has(h));
304
+
305
+ if (allAnswersKnown) {
306
+ return true; // Suppress - another responder already answered with our records
307
+ }
308
+ }
309
+
310
+ // Record that we're answering this question
311
+ const knownAnswerHashes = new Set<string>();
312
+ for (const answer of knownAnswers) {
313
+ knownAnswerHashes.add(this.buildDnsRecordKey(answer, sourceIntf));
314
+ }
315
+ // Also add our answers to the known set
316
+ for (const answer of answers) {
317
+ knownAnswerHashes.add(this.buildDnsRecordKey(answer, sourceIntf));
318
+ }
319
+
320
+ this.#recentlyAnsweredQueries.set(queryKey, {
321
+ knownAnswerHashes,
322
+ timestamp: now,
323
+ });
324
+
325
+ return false;
326
+ }
327
+
255
328
  async #processTruncatedQuery(key: string) {
256
329
  const { message, timer } = this.#truncatedQueryCache.get(key) ?? {};
257
330
  this.#truncatedQueryCache.delete(key);
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { ClientInteraction } from "#action/client/ClientInteraction.js";
8
8
  import { ClientRead } from "#action/client/ClientRead.js";
9
+ import { WriteResult } from "#action/index.js";
9
10
  import { Invoke } from "#action/request/Invoke.js";
10
11
  import { Read } from "#action/request/Read.js";
11
12
  import { Write } from "#action/request/Write.js";
@@ -1673,7 +1674,7 @@ export class ControllerCommissioningFlow {
1673
1674
  for (const requestorEndpoint of this.collectedCommissioningData.otaRequestorList) {
1674
1675
  try {
1675
1676
  // Fabric scoped attribute, so we just overwrite our value
1676
- await this.interaction.write(
1677
+ const writeResult = await this.interaction.write(
1677
1678
  Write(
1678
1679
  Write.Attribute({
1679
1680
  endpoint: requestorEndpoint,
@@ -1689,6 +1690,9 @@ export class ControllerCommissioningFlow {
1689
1690
  }),
1690
1691
  ),
1691
1692
  );
1693
+
1694
+ WriteResult.assertSuccess(writeResult);
1695
+
1692
1696
  success = true;
1693
1697
  logger.debug(`Added default OTA provider on endpoint ${endpoint}`);
1694
1698
  } catch (error) {
@@ -493,7 +493,7 @@ export class MessageExchange {
493
493
  options?.expectedProcessingTime,
494
494
  );
495
495
  }
496
- return this.#messagesQueue.read(timeout);
496
+ return this.#messagesQueue.read({ timeout });
497
497
  }
498
498
 
499
499
  async #sendStandaloneAckForMessage(message: Message) {