Skip to content

Commit c74d64a

Browse files
committed
fix: improve MQTT client proxy — per-channel subscriptions, zero-hop injection, security fixes
- Subscribe per downlink-enabled channel (prefix/2/e/<name>/+) instead of the # wildcard; always include the PKI channel regardless of downlink state, matching Android behavior - Append a random UUID suffix to the MQTT client ID to prevent SESSION_TAKEN_OVER disconnections when multiple clients share the same node ID - Clamp hop_limit to 0 on downlink ServiceEnvelopes before forwarding to the connected device, preventing MQTT-bridged packets from being re-broadcast over RF (hop_start preserved for distance-travelled computation) - Replace plaintext password TextField with SecureField + show/hide toggle - Remove the JSON mode toggle — the firmware marks json_enabled deprecated and ignores it; the field is no longer sent on save - Make the "Connect via Proxy" toggle react to external disconnects by observing mqttProxyConnected rather than only updating on screen appear - Move setMqttValues to a private extension to satisfy type_body_length lint rule
1 parent 9e11586 commit c74d64a

5 files changed

Lines changed: 86 additions & 86 deletions

File tree

Meshtastic/Accessory/Accessory Manager/AccessoryManager+MQTT.swift

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,12 @@ extension AccessoryManager {
5555
func onMqttConnected() {
5656
mqttProxyConnected = true
5757
mqttError = ""
58-
if mqttManager.shouldSubscribe {
59-
Logger.services.info("📲 [MQTT Client Proxy] onMqttConnected now subscribing to \(self.mqttManager.topic, privacy: .public).")
60-
mqttManager.mqttClientProxy?.subscribe(mqttManager.topic)
61-
} else {
62-
Logger.services.info("📲 [MQTT Client Proxy] onMqttConnected not subscribing since downlink is not on")
58+
for topic in mqttManager.topics {
59+
Logger.services.info("📲 [MQTT Client Proxy] onMqttConnected subscribing to \(topic, privacy: .public).")
60+
mqttManager.mqttClientProxy?.subscribe(topic, qos: .qos1)
61+
}
62+
if mqttManager.topics.isEmpty {
63+
Logger.services.info("📲 [MQTT Client Proxy] onMqttConnected - no topics to subscribe to")
6364
}
6465
}
6566

@@ -72,13 +73,27 @@ extension AccessoryManager {
7273
if message.topic.contains("/stat/") {
7374
return
7475
}
76+
77+
// Clamp hop_limit to 0 on downlink ServiceEnvelopes before forwarding to
78+
// the device. Packets with hop_limit > 0 would be re-broadcast over RF,
79+
// flooding the mesh with traffic that arrived via MQTT. hop_start is
80+
// preserved so receivers can still compute how far the packet travelled.
81+
let rawData = Data(message.payload)
82+
let forwardData: Data
83+
if var envelope = try? ServiceEnvelope(serializedData: rawData),
84+
envelope.hasPacket, envelope.packet.hopLimit > 0 {
85+
envelope.packet.hopLimit = 0
86+
forwardData = (try? envelope.serializedData()) ?? rawData
87+
} else {
88+
forwardData = rawData
89+
}
90+
7591
var proxyMessage = MqttClientProxyMessage()
7692
proxyMessage.topic = message.topic
77-
proxyMessage.data = Data(message.payload)
93+
proxyMessage.data = forwardData
7894
proxyMessage.retained = message.retained
7995

80-
var toRadio: ToRadio!
81-
toRadio = ToRadio()
96+
var toRadio = ToRadio()
8297
toRadio.mqttClientProxyMessage = proxyMessage
8398
Task {
8499
try? await self.send(toRadio)

Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ class MqttClientProxyManager {
2323
private static let defaultKeepAliveInterval: Int32 = 60
2424
weak var delegate: MqttClientProxyManagerDelegate?
2525
var mqttClientProxy: CocoaMQTT?
26-
var topic = "msh"
26+
/// Per-channel subscription topics built in `connectFromConfigSettings`.
27+
/// Mirrors Android: one `prefix/2/e/<channelName>/+` per downlink-enabled channel,
28+
/// plus `prefix/2/e/PKI/+` which is always subscribed.
29+
var topics: [String] = []
2730
var debugLog = false
28-
var shouldSubscribe = true
2931
func connectFromConfigSettings(node: NodeInfoEntity) {
3032
let originalAddress = node.mqttConfig?.address ?? "mqtt.meshtastic.org"
3133
let defaultServerAddress = "mqtt.meshtastic.org"
@@ -44,24 +46,34 @@ class MqttClientProxyManager {
4446
let port = defaultServerPort
4547
let root = node.mqttConfig?.root?.count ?? 0 > 0 ? node.mqttConfig?.root : "msh"
4648
let prefix = root!
47-
let hasAnyDownlinkEnabled = node.myInfo?.channels.contains { $0.downlinkEnabled } ?? false
48-
49-
shouldSubscribe = hasAnyDownlinkEnabled
50-
51-
topic = prefix + "/2/e" + "/#"
49+
50+
// Build per-channel subscription topics (mirrors Android MQTTRepositoryImpl):
51+
// - one topic per downlink-enabled channel: prefix/2/e/<channelName>/+
52+
// - PKI channel is always subscribed regardless of downlink settings
53+
var newTopics: [String] = []
54+
for channel in node.myInfo?.channels ?? [] {
55+
if channel.downlinkEnabled, let name = channel.name, !name.isEmpty {
56+
newTopics.append(prefix + "/2/e/" + name + "/+")
57+
}
58+
}
59+
newTopics.append(prefix + "/2/e/PKI/+")
60+
topics = newTopics
61+
5262
// Require opt in to map report terms to connect
5363
if node.mqttConfig?.mapReportingEnabled ?? false && UserDefaults.mapReportingOptIn || !(node.mqttConfig?.mapReportingEnabled ?? false) {
54-
connect(host: host, port: port, useSsl: useSsl, topic: topic, node: node)
64+
connect(host: host, port: port, useSsl: useSsl, node: node)
5565
} else {
5666
delegate?.onMqttError(message: "MQTT Map Reporting Terms need to be accepted.")
5767
}
5868
}
59-
func connect(host: String, port: Int, useSsl: Bool, topic: String?, node: NodeInfoEntity) {
69+
func connect(host: String, port: Int, useSsl: Bool, node: NodeInfoEntity) {
6070
guard !host.isEmpty else {
6171
delegate?.onMqttDisconnected()
6272
return
6373
}
64-
let clientId = "MeshtasticAppleMqttProxy-" + (node.user?.userId ?? String(ProcessInfo().processIdentifier))
74+
// UUID suffix prevents SESSION_TAKEN_OVER when multiple clients share the same node ID
75+
// (mirrors Android: "MeshtasticAndroidMqttProxy-<nodeId>-<uuid>")
76+
let clientId = "MeshtasticAppleMqttProxy-" + (node.user?.userId ?? String(ProcessInfo().processIdentifier)) + "-" + UUID().uuidString
6577
mqttClientProxy = CocoaMQTT(clientID: clientId, host: host, port: UInt16(port))
6678
if let mqttClient = mqttClientProxy {
6779
mqttClient.enableSSL = useSsl

Meshtastic/Views/Connect/Connect.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ struct Connect: View {
395395
deviceConnected: accessoryManager.isConnected,
396396
name: accessoryManager.activeConnection?.device.shortName ?? "?",
397397
mqttProxyConnected: accessoryManager.mqttProxyConnected,
398-
mqttTopic: accessoryManager.mqttManager.topic
398+
mqttTopic: accessoryManager.mqttManager.topics.first ?? ""
399399
)
400400
}
401401
}

Meshtastic/Views/Messages/ChannelMessageList.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ struct ChannelMessageList: View {
420420
mqttProxyConnected: accessoryManager.mqttProxyConnected && (channel.uplinkEnabled || channel.downlinkEnabled),
421421
mqttUplinkEnabled: channel.uplinkEnabled,
422422
mqttDownlinkEnabled: channel.downlinkEnabled,
423-
mqttTopic: accessoryManager.mqttManager.topic
423+
mqttTopic: accessoryManager.mqttManager.topics.first(where: { $0.contains("/2/e/\(channel.name ?? "")") }) ?? accessoryManager.mqttManager.topics.first ?? ""
424424
)
425425
}
426426
}

Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift

Lines changed: 39 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ struct MQTTConfig: View {
2424
@State var username = ""
2525
@State var password = ""
2626
@State var encryptionEnabled = true
27-
@State var jsonEnabled = false
2827
@State var tlsEnabled = false
28+
@State private var showPassword = false
2929
@State var root = "msh"
3030
@State var selectedTopic = ""
3131
@State var mqttConnected: Bool = false
@@ -83,14 +83,6 @@ struct MQTTConfig: View {
8383
Label("Encryption Enabled", systemImage: "lock.icloud")
8484
}
8585
.tint(.accentColor)
86-
87-
if !proxyToClientEnabled {
88-
Toggle(isOn: $jsonEnabled) {
89-
Label("JSON Enabled", systemImage: "ellipsis.curlybraces")
90-
Text("JSON mode is a limited, unencrypted MQTT output for locally integrating with home assistant")
91-
}
92-
.tint(.accentColor)
93-
}
9486
}
9587

9688
Section(header: Text("Map Report")) {
@@ -213,19 +205,30 @@ struct MQTTConfig: View {
213205
.keyboardType(.default)
214206
HStack {
215207
Label("Password", systemImage: "wallet.pass")
216-
TextField("Password", text: $password)
217-
.foregroundColor(.gray)
218-
.autocapitalization(.none)
219-
.disableAutocorrection(true)
220-
.onChange(of: password) {
221-
var totalBytes = password.utf8.count
222-
// Only mess with the value if it is too big
223-
while totalBytes > 31 {
224-
password = String(password.dropLast())
225-
totalBytes = password.utf8.count
226-
}
208+
Group {
209+
if showPassword {
210+
TextField("Password", text: $password)
211+
} else {
212+
SecureField("Password", text: $password)
227213
}
228-
.foregroundColor(.gray)
214+
}
215+
.foregroundColor(.gray)
216+
.autocapitalization(.none)
217+
.disableAutocorrection(true)
218+
.onChange(of: password) {
219+
var totalBytes = password.utf8.count
220+
while totalBytes > 31 {
221+
password = String(password.dropLast())
222+
totalBytes = password.utf8.count
223+
}
224+
}
225+
Button {
226+
showPassword.toggle()
227+
} label: {
228+
Image(systemName: showPassword ? "eye.slash" : "eye")
229+
.foregroundColor(.secondary)
230+
}
231+
.buttonStyle(.plain)
229232
}
230233
.keyboardType(.default)
231234
.listRowSeparator(/*@START_MENU_TOKEN@*/.visible/*@END_MENU_TOKEN@*/)
@@ -270,7 +273,6 @@ struct MQTTConfig: View {
270273
mqtt.password = self.password
271274
mqtt.root = self.root
272275
mqtt.encryptionEnabled = self.encryptionEnabled
273-
mqtt.jsonEnabled = self.jsonEnabled
274276
mqtt.tlsEnabled = self.tlsEnabled
275277
mqtt.mapReportingEnabled = self.mapReportingEnabled
276278
mqtt.mapReportSettings.shouldReportLocation = UserDefaults.mapReportingOptIn
@@ -284,9 +286,6 @@ struct MQTTConfig: View {
284286
if oldEnabled != newEnabled && newEnabled != node?.mqttConfig?.enabled { hasChanges = true }
285287
}
286288
.onChange(of: proxyToClientEnabled) { oldProxy, newProxyToClientEnabled in
287-
if newProxyToClientEnabled {
288-
jsonEnabled = false
289-
}
290289
if oldProxy != newProxyToClientEnabled && newProxyToClientEnabled != node?.mqttConfig?.proxyToClientEnabled { hasChanges = true }
291290
}
292291
.onChange(of: address) { oldAddress, newAddress in
@@ -317,12 +316,6 @@ struct MQTTConfig: View {
317316
.onChange(of: encryptionEnabled) { oldEncryption, newEncryptionEnabled in
318317
if oldEncryption != newEncryptionEnabled && newEncryptionEnabled != node?.mqttConfig?.encryptionEnabled { hasChanges = true }
319318
}
320-
.onChange(of: jsonEnabled) { oldJson, newJsonEnabled in
321-
if newJsonEnabled {
322-
proxyToClientEnabled = false
323-
}
324-
if oldJson != newJsonEnabled && newJsonEnabled != node?.mqttConfig?.jsonEnabled { hasChanges = true }
325-
}
326319
.onChange(of: tlsEnabled) { oldTls, newTlsEnabled in
327320
if defaultServer && accessoryManager.checkIsVersionSupported(forVersion: "2.7.3") {
328321
tlsEnabled = true
@@ -363,10 +356,14 @@ struct MQTTConfig: View {
363356
request: accessoryManager.requestMqttModuleConfig
364357
)
365358
}
359+
.onChange(of: accessoryManager.mqttProxyConnected) { _, connected in
360+
mqttConnected = connected
361+
}
366362
}
367-
363+
}
364+
365+
private extension MQTTConfig {
368366
func setMqttValues() {
369-
370367
nearbyTopics = []
371368
let geocoder = CLGeocoder()
372369
if LocationsHandler.shared.locationsArray.count > 0 {
@@ -377,69 +374,45 @@ struct MQTTConfig: View {
377374
Logger.services.error("Failed to reverse geocode location: \(error.localizedDescription, privacy: .public)")
378375
return
379376
}
380-
381377
if let placemarks = placemarks, let placemark = placemarks.first {
382-
/// Country Topic unless your region is a country
383378
if !(region?.isCountry ?? false) {
384379
let countryTopic = defaultTopic + "/" + (placemark.isoCountryCode ?? "")
385-
if !countryTopic.isEmpty {
386-
nearbyTopics.append(countryTopic)
387-
}
380+
if !countryTopic.isEmpty { nearbyTopics.append(countryTopic) }
388381
}
389382
let stateTopic = defaultTopic + "/" + (placemark.administrativeArea ?? "")
390-
if !stateTopic.isEmpty {
391-
nearbyTopics.append(stateTopic)
392-
}
383+
if !stateTopic.isEmpty { nearbyTopics.append(stateTopic) }
393384
let countyTopic = defaultTopic + "/" + (placemark.administrativeArea ?? "") + "/" + (placemark.subAdministrativeArea?.lowercased().replacingOccurrences(of: " ", with: "") ?? "")
394-
if !countyTopic.isEmpty {
395-
nearbyTopics.append(countyTopic)
396-
}
385+
if !countyTopic.isEmpty { nearbyTopics.append(countyTopic) }
397386
let cityTopic = defaultTopic + "/" + (placemark.administrativeArea ?? "") + "/" + (placemark.locality?.lowercased().replacingOccurrences(of: " ", with: "") ?? "")
398-
if !cityTopic.isEmpty {
399-
nearbyTopics.append(cityTopic)
400-
}
401-
let neightborhoodTopic = defaultTopic + "/" + (placemark.administrativeArea ?? "") + "/" + (placemark.subLocality?.lowercased()
387+
if !cityTopic.isEmpty { nearbyTopics.append(cityTopic) }
388+
let neighborhoodTopic = defaultTopic + "/" + (placemark.administrativeArea ?? "") + "/" + (placemark.subLocality?.lowercased()
402389
.replacingOccurrences(of: " ", with: "")
403390
.replacingOccurrences(of: "'", with: "") ?? "")
404-
if !neightborhoodTopic.isEmpty {
405-
nearbyTopics.append(neightborhoodTopic)
406-
}
391+
if !neighborhoodTopic.isEmpty { nearbyTopics.append(neighborhoodTopic) }
407392
} else {
408393
Logger.services.debug("No Location")
409394
}
410395
})
411396
}
412-
413397
self.enabled = node?.mqttConfig?.enabled ?? false
414398
self.proxyToClientEnabled = node?.mqttConfig?.proxyToClientEnabled ?? false
415399
self.address = node?.mqttConfig?.address ?? ""
416-
if address.lowercased().contains("mqtt.meshtastic.org") {
417-
defaultServer = true
418-
} else {
419-
defaultServer = false
420-
}
400+
defaultServer = address.lowercased().contains("mqtt.meshtastic.org")
421401
self.username = node?.mqttConfig?.username ?? ""
422402
self.password = node?.mqttConfig?.password ?? ""
423403
self.root = node?.mqttConfig?.root ?? "msh"
424404
self.encryptionEnabled = node?.mqttConfig?.encryptionEnabled ?? false
425-
self.jsonEnabled = node?.mqttConfig?.jsonEnabled ?? false
426405
if defaultServer && accessoryManager.checkIsVersionSupported(forVersion: "2.7.3") {
427406
self.tlsEnabled = true
428407
} else {
429408
self.tlsEnabled = node?.mqttConfig?.tlsEnabled ?? false
430409
}
431410
self.mqttConnected = accessoryManager.mqttProxyConnected
432411
self.mapReportingEnabled = node?.mqttConfig?.mapReportingEnabled ?? false
433-
if node?.mqttConfig?.mapPublishIntervalSecs ?? 0 < 3600 {
434-
self.mapPublishIntervalSecs = UpdateInterval(from: 3600)
435-
} else {
436-
self.mapPublishIntervalSecs = UpdateInterval(from: Int(node?.mqttConfig?.mapPublishIntervalSecs ?? 3600))
437-
}
412+
self.mapPublishIntervalSecs = UpdateInterval(from: max(3600, Int(node?.mqttConfig?.mapPublishIntervalSecs ?? 3600)))
438413
self.mapPositionPrecision = Double(node?.mqttConfig?.mapPositionPrecision ?? 14)
439414
self.mapReportingOptIn = UserDefaults.mapReportingOptIn
440-
if mapPositionPrecision < 11 || mapPositionPrecision > 14 {
441-
self.mapPositionPrecision = 14
442-
}
415+
if mapPositionPrecision < 11 || mapPositionPrecision > 14 { self.mapPositionPrecision = 14 }
443416
self.hasChanges = false
444417
}
445418
}

0 commit comments

Comments
 (0)