Skip to content

Commit 757615e

Browse files
committed
add ACL support, fix wildcard routing and QoS >0 crash
This change adds Mosquitto compatible ACL file support (user/topic rules, pattern rules with %u/%c substitution). Replaces broken topic routing with trie-based index. The broker previously only matched the literal "#" and exact topic string, so subscribers on filters like foo/# or foo/+/bar never received messages. The trie resolves matches in O(topic depth) instead of O(subscription count). Replaces assert statements in onPubAck, onPubRec, onPubRel, and onPubComp with graceful checks. The asserts crash the broker under normal QoS > 0 operation when work queue state doesn't match expectations due to timing or msgId reuse.
1 parent 93bc8e5 commit 757615e

File tree

5 files changed

+420
-31
lines changed

5 files changed

+420
-31
lines changed

config/nmqtt.conf

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,23 @@ allow_anonymous = true
8585
#
8686
#password_file =
8787

88+
#
89+
# The absolute path to the ACL (Access Control List) file. This uses
90+
# Mosquitto-compatible ACL format to restrict which users can publish
91+
# and subscribe to which topics.
92+
#
93+
# The ACL file supports:
94+
# user <username> - Start a user-specific rule section
95+
# topic [read|write|readwrite] <topic> - Allow access to a topic
96+
# pattern [read|write|readwrite] <topic> - Pattern rules for all users
97+
#
98+
# Pattern topics support %u (username) and %c (client ID) substitution.
99+
# MQTT wildcards (# and +) are supported in topic filters.
100+
#
101+
# If no ACL file is specified, all authenticated users have full access.
102+
#
103+
#acl_file =
104+
88105
#
89106
# To activate a SSL connection you need a certificate and a private key.
90107
# When you specify both of them, the connection will automatic be using

nmqtt.nim

Lines changed: 72 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ when defined(broker):
1616
times,
1717
random,
1818
nmqtt/utils/passwords,
19-
nmqtt/utils/version
19+
nmqtt/utils/version,
20+
nmqtt/utils/acl,
21+
nmqtt/utils/trie
2022
from parsecfg import loadConfig, getSectionValue
2123
from os import fileExists
2224

@@ -142,6 +144,7 @@ when defined(broker):
142144
connections: Table[string, MqttCtx]
143145
retained: Table[string, RetainedMsg] # Topic, RetaindMsg
144146
subscribers: Table[string, seq[MqttCtx]]
147+
subTrie: TopicTrie # Trie index for O(depth) subscription matching
145148
version: uint8
146149
clientIdMaxLen: int
147150
clientKickOld: bool
@@ -150,6 +153,7 @@ when defined(broker):
150153
passClientId: bool
151154
maxConnections: int
152155
passwords: Table[string, string]
156+
acl: AclStore
153157

154158
RetainedMsg = object
155159
msg: string
@@ -159,7 +163,7 @@ when defined(broker):
159163

160164
when defined(broker):
161165
var
162-
mqttbroker = MqttBroker()
166+
mqttbroker = MqttBroker(acl: newAclStore(), subTrie: newTopicTrie())
163167
r = initRand(toInt(epochTime()))
164168

165169

@@ -289,6 +293,8 @@ when defined(broker):
289293
mqttbroker.subscribers[topic].insert(ctx)
290294
else:
291295
mqttbroker.subscribers[topic] = @[ctx]
296+
# First subscriber on this filter, add to the trie index
297+
mqttbroker.subTrie.subscribe(topic)
292298
except:
293299
wrn("Crash when adding a new subcriber")
294300

@@ -298,6 +304,9 @@ when defined(broker):
298304
try:
299305
if mqttbroker.subscribers.hasKey(topic):
300306
mqttbroker.subscribers[topic] = filter(mqttbroker.subscribers[topic], proc(x: MqttCtx): bool = x != ctx)
307+
if mqttbroker.subscribers[topic].len() == 0:
308+
mqttbroker.subscribers.del(topic)
309+
mqttbroker.subTrie.unsubscribe(topic)
301310
except:
302311
wrn("Crash when removing subscriber with specific topic")
303312

@@ -314,6 +323,7 @@ when defined(broker):
314323

315324
for t in delTop:
316325
mqttbroker.subscribers.del(t)
326+
mqttbroker.subTrie.unsubscribe(t)
317327

318328
when defined(broker):
319329
proc qosAlign(qP, qS: uint8): uint8 =
@@ -641,20 +651,22 @@ when defined(broker):
641651
await c.work()
642652

643653
when defined(broker):
644-
proc publishToSubscribers(seqctx: seq[MqttCtx], pkt: Pkt, topic, message: string, qos: uint8, retain: bool, senderId: string) {.async.} =
645-
## Publish async to clients
654+
proc publishToSubscribers(seqctx: seq[MqttCtx], pkt: Pkt, subFilter, pubTopic, message: string, qos: uint8, retain: bool, senderId: string) {.async.} =
655+
## Publish async to clients.
656+
## `subFilter` is the subscription key (e.g. "topic/subtopic/#") for QoS lookup.
657+
## `pubTopic` is the actual published topic (e.g. "topic/subtopic/specific") sent to the client.
646658
for c in seqctx:
647659
if c.state != Connected:
648-
asyncCheck removeSubscriber(c, topic)
660+
asyncCheck removeSubscriber(c, subFilter)
649661
continue
650662
let
651663
msgId = c.nextMsgId()
652-
qosSub = qosAlign(qos, c.subscribed[topic])
664+
qosSub = qosAlign(qos, c.subscribed[subFilter])
653665

654666
if mqttbroker.passClientId:
655-
c.workQueue[msgId] = Work(wk: PubWork, msgId: msgId, topic: topic, qos: qosSub, retain: retain, message: senderId & ":" & message, typ: Publish)
667+
c.workQueue[msgId] = Work(wk: PubWork, msgId: msgId, topic: pubTopic, qos: qosSub, retain: retain, message: senderId & ":" & message, typ: Publish)
656668
else:
657-
c.workQueue[msgId] = Work(wk: PubWork, msgId: msgId, topic: topic, qos: qosSub, retain: retain, message: message, typ: Publish)
669+
c.workQueue[msgId] = Work(wk: PubWork, msgId: msgId, topic: pubTopic, qos: qosSub, retain: retain, message: message, typ: Publish)
658670
await c.work()
659671

660672
when defined(broker):
@@ -810,12 +822,25 @@ proc onPublish(ctx: MqttCtx, pkt: Pkt) {.async.} =
810822
(message, offset) = pkt.getstring(offset, false)
811823

812824
when defined(broker):
813-
# Send message to all subscribers on "#"
814-
if mqttbroker.subscribers.hasKey("#"):
815-
await publishToSubscribers(mqttbroker.subscribers["#"], pkt, "#", message, qos, retain, ctx.clientid)
816-
# Send message to all subscribers on _the topic_
817-
if mqttbroker.subscribers.hasKey(topic):
818-
await publishToSubscribers(mqttbroker.subscribers[topic], pkt, topic, message, qos, retain, ctx.clientid)
825+
# Check ACL: does the publishing client have write access?
826+
if not mqttbroker.acl.checkPublish(ctx.username, ctx.clientId, topic):
827+
if mqttbroker.verbosity >= 1:
828+
verbose("ACL >> " & ctx.clientId & " denied publish to " & topic)
829+
# Per MQTT v3.1.1, the broker silently drops the message but still
830+
# completes the QoS handshake so the client doesn't stall.
831+
if qos == 1:
832+
ctx.workQueue[msgId] = Work(wk: PubWork, msgId: msgId, state: WorkNew, qos: 1, typ: PubAck)
833+
await ctx.work()
834+
elif qos == 2:
835+
ctx.workQueue[msgId] = Work(wk: PubWork, msgId: msgId, state: WorkNew, qos: 2, typ: PubRec)
836+
await ctx.work()
837+
return
838+
839+
# Route the published message to all matching subscribers.
840+
# Uses the trie index for O(topic_depth) matching instead of O(n) scan.
841+
for subFilter in mqttbroker.subTrie.matchingFilters(topic):
842+
if mqttbroker.subscribers.hasKey(subFilter):
843+
await publishToSubscribers(mqttbroker.subscribers[subFilter], pkt, subFilter, topic, message, qos, retain, ctx.clientid)
819844

820845
if mqttbroker.verbosity >= 1:
821846
verbose("Client >> " & ctx.clientId & " has published a message")
@@ -869,36 +894,44 @@ proc onPublish(ctx: MqttCtx, pkt: Pkt) {.async.} =
869894

870895
proc onPubAck(ctx: MqttCtx, pkt: Pkt) {.async.} =
871896
let (msgId, _) = pkt.getu16(0)
872-
assert msgId in ctx.workQueue
873-
assert ctx.workQueue[msgId].wk == PubWork
874-
assert ctx.workQueue[msgId].state == WorkSent
875-
assert ctx.workQueue[msgId].qos == 1
897+
if msgId notin ctx.workQueue:
898+
ctx.dmp "PubAck for unknown msgId: " & $msgId
899+
return
900+
if ctx.workQueue[msgId].wk != PubWork or ctx.workQueue[msgId].qos != 1:
901+
ctx.dmp "PubAck unexpected state for msgId: " & $msgId
902+
return
876903
ctx.workQueue.del msgId
877904

878905
proc onPubRec(ctx: MqttCtx, pkt: Pkt) {.async.} =
879906
let (msgId, _) = pkt.getu16(0)
880-
assert msgId in ctx.workQueue
881-
assert ctx.workQueue[msgId].wk == PubWork
882-
assert ctx.workQueue[msgId].state == WorkSent
883-
assert ctx.workQueue[msgId].qos == 2
907+
if msgId notin ctx.workQueue:
908+
ctx.dmp "PubRec for unknown msgId: " & $msgId
909+
return
910+
if ctx.workQueue[msgId].wk != PubWork or ctx.workQueue[msgId].qos != 2:
911+
ctx.dmp "PubRec unexpected state for msgId: " & $msgId
912+
return
884913
ctx.workQueue[msgId] = Work(wk: PubWork, msgId: msgId, state: WorkNew, qos: 2, typ: PubRel)
885914
await ctx.work()
886915

887916
proc onPubRel(ctx: MqttCtx, pkt: Pkt) {.async.} =
888917
let (msgId, _) = pkt.getu16(0)
889-
assert msgId in ctx.workQueue
890-
assert ctx.workQueue[msgId].wk == PubWork
891-
assert ctx.workQueue[msgId].state == WorkSent
892-
assert ctx.workQueue[msgId].qos == 2
918+
if msgId notin ctx.workQueue:
919+
ctx.dmp "PubRel for unknown msgId: " & $msgId
920+
return
921+
if ctx.workQueue[msgId].wk != PubWork or ctx.workQueue[msgId].qos != 2:
922+
ctx.dmp "PubRel unexpected state for msgId: " & $msgId
923+
return
893924
ctx.workQueue[msgId] = Work(wk: PubWork, msgId: msgId, state: WorkNew, qos: 2, typ: PubComp)
894925
await ctx.work()
895926

896927
proc onPubComp(ctx: MqttCtx, pkt: Pkt) {.async.} =
897928
let (msgId, _) = pkt.getu16(0)
898-
assert msgId in ctx.workQueue
899-
assert ctx.workQueue[msgId].wk == PubWork
900-
assert ctx.workQueue[msgId].state == WorkSent
901-
assert ctx.workQueue[msgId].qos == 2
929+
if msgId notin ctx.workQueue:
930+
ctx.dmp "PubComp for unknown msgId: " & $msgId
931+
return
932+
if ctx.workQueue[msgId].wk != PubWork or ctx.workQueue[msgId].qos != 2:
933+
ctx.dmp "PubComp unexpected state for msgId: " & $msgId
934+
return
902935
ctx.workQueue.del msgId
903936

904937
#when defined(broker):
@@ -921,6 +954,15 @@ proc onSubscribe(ctx: MqttCtx, pkt: Pkt) {.async.} =
921954
(topic, offset) = pkt.getstring(offset, parseInt($nextLen))
922955
(qos, offset) = pkt.getu8(offset)
923956

957+
# Check ACL: does the client have read access for this topic?
958+
if not mqttbroker.acl.checkSubscribe(ctx.username, ctx.clientId, topic):
959+
if mqttbroker.verbosity >= 1:
960+
verbose("ACL >> " & ctx.clientId & " denied subscribe to " & topic)
961+
# Send SubAck with failure return code (0x80) per MQTT v3.1.1 spec
962+
ctx.workQueue[msgId] = Work(wk: PubWork, msgId: msgId, state: WorkNew, qos: 0, typ: SubAck)
963+
await ctx.work()
964+
return
965+
924966
ctx.subscribed[topic] = qos
925967
await addSubscriber(ctx, topic)
926968

nmqtt/nmqtt.nim

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,12 @@ OPTIONS:
132132
ClientID in payload: $11
133133
Client kick old: $12
134134
Number of passwords: $13
135+
ACL loaded: $14
135136
136137
""".format(nmqttVersion, mb.host, mb.port, mb.sslOn, now(), mb.verbosity,
137138
mb.maxConnections, mb.clientIdMaxLen, mb.spacesInClientId,
138-
mb.emptyClientId, mb.passClientId, mb.clientKickOld, mb.passwords.len()
139+
mb.emptyClientId, mb.passClientId, mb.clientKickOld, mb.passwords.len(),
140+
mb.acl.loaded
139141
)
140142

141143

@@ -194,6 +196,16 @@ proc loadConf(mb: MqttBroker, config: string) =
194196
let passwordFile = dict.getSectionValue("","password_file")
195197
loadPasswords(passwordFile)
196198

199+
# Load ACL file if specified
200+
let aclFile = dict.getSectionValue("","acl_file")
201+
if aclFile != "":
202+
if not fileExists(aclFile):
203+
echo "\nACL file does not exist..\n - " & aclFile
204+
quit()
205+
mqttbroker.acl.loadAclFile(aclFile)
206+
if mqttbroker.verbosity >= 1:
207+
echo "ACL loaded from: " & aclFile
208+
197209

198210
proc handler() {.noconv.} =
199211
## Catch ctrl+c from user
@@ -206,6 +218,7 @@ proc handler() {.noconv.} =
206218
proc nmqttBroker(config="", host="127.0.0.1", port=1883, verbosity=0, max_conn=0,
207219
clientid_maxlen=60, clientid_spaces=false, clientid_empty=false,
208220
client_kickold=false, clientid_pass=false, password_file="",
221+
acl_file="",
209222
ssl=false, ssl_cert="", ssl_key=""
210223
) {.async.} =
211224
## CLI tool for a MQTT broker
@@ -226,6 +239,12 @@ proc nmqttBroker(config="", host="127.0.0.1", port=1883, verbosity=0, max_conn=0
226239
if password_file != "":
227240
loadPasswords(password_file)
228241

242+
if acl_file != "":
243+
if not fileExists(acl_file):
244+
echo "\nACL file does not exist..\n - " & acl_file
245+
quit()
246+
mqttbroker.acl.loadAclFile(acl_file)
247+
229248
if ssl:
230249
mqttbroker.sslOn = true
231250
mqttbroker.sslCert = ssl_cert
@@ -282,13 +301,15 @@ $options
282301
"client-kickold": "kick old client, if new client has same clientid. Defaults to false.",
283302
"clientid-pass": "pass clientid in payload {clientid:payload}. Defaults to false.",
284303
"password-file": "absolute path to the password file",
304+
"acl-file": "absolute path to the ACL file (Mosquitto-compatible format)",
285305
"ssl": "activate ssl for the broker - requires --ssl-cert and --ssl-key.",
286306
"ssl-cert": "absolute path to the ssl certificate.",
287307
"ssl-key": "absolute path to the ssl key."
288308
},
289309
short={
290310
"help": '?',
291311
"max-conn": '\0',
312+
"acl-file": '\0',
292313
"ssl": '\0',
293314
"ssl-cert": '\0',
294315
"ssl-key": '\0'

0 commit comments

Comments
 (0)