Repository navigation
Releases: clement880101/sd-mqtt
Release list
sd-mqtt 0.8.0
Changed
-
The licence is now the Apache License 2.0. It was MIT. Apache 2.0
is as permissive and adds an explicit patent grant from contributors.
Releases before this one remain available under MIT for anyone who
has them; this release and everything after it are Apache-2.0. Every
crate, the web package, the image label and the notices headers say
so. -
The documentation is split into features and a setup guide. The
README is the licence, a table of features, a quick start and the
configuration reference. Each feature has a page under
docs/features/saying what it does, how to configure it and what it
reports;docs/setup.mdgoes from nothing to a three-node cluster;
docs/README.mdindexes it all.scripts/test_docs_links.pyfollows
every relative link. -
The working documents are gone from the repository. The milestone
plans, the reference-broker analyses and the milestone 3 demonstration
were how it was built, not what ships, and the decision records'
conclusions were already in the design's sections 15 and 17. The design
is nowdocs/design.mdand the component selectiondocs/components.md.
Before you upgrade a cluster
- Two replication frames are new,
SnapshotandSnapshotFrom.
They are sent only to a node that is behind its peers' log
truncation, which is a new node joining or one rebuilt from an empty
store. A node from an earlier release cannot read them and closes the
link, so upgrade every existing node before adding or rebuilding one.
No other frame changed and the store format did not change. - A peer whose clock is more than sixty seconds ahead is refused.
Its replication frames are logged and dropped rather than letting it
win every conflict. Run NTP on every node.
Fixed
- At the node a session is moving to, a forward that arrives right
behind the drain, before the session is installed, is held for up to two
seconds and tried again, rather than dropped:sdmqtt_forwards_held_total.
Only after a drain, so a new subscriber is never given a message published
before it subscribed.
Found by a review of every crate, each fixed with a test that failed
first.
- Security.
- A will is authorised as a publication at CONNECT. A client refused
a topic could publish to it by setting it as its will and dropping
the connection. - An access rule for
fleet/+no longer admits a subscription to
fleet/#. A subscribe is covered only by a rule whose filter
covers every topic the requested filter does. - A password login for an unknown user costs what a wrong password
costs, so timing no longer tells which user names exist. The decoy
it is checked against is made on the first such login, not at
startup, so an idle broker does not hold argon2's working memory. - SCRAM data with a multi-byte character where an attribute name
belongs is refused as malformed instead of panicking the
connection. - A WebSocket message or frame is bounded by the packet limit. It was
bounded by the library's 64 MiB default. - The admin
Authorizationscheme is read without regard to case. On
a loopback listener with no token,bearer <key>was treated as no
key and admitted with every right.
- A will is authorised as a publication at CONNECT. A client refused
- Connections.
- A connection displaced by a takeover stops acting on the session.
One whose writer was blocked could subscribe, and receive
acknowledgements, on behalf of the client that replaced it. - A write that makes no progress ends the connection, so a client
that stops reading cannot hold its task, descriptor and connection
slot for ever. See the listeners page. - A session's outbox exists from the moment the session does, so a
publication arriving in between is no longer dropped. - Packets the shell answers itself, a refused publication during a
partition or an AUTH step, count as keep-alive activity. - A takeover of a connection whose session expiry is zero starts a
new session, as the specification says, instead of resuming one
that ended. - A PUBCOMP or PUBACK for an identifier not outstanding is ignored
instead of closing the connection without a reason it could encode. - A connection the broker closed is counted
closed, notclean.
- A connection displaced by a takeover stops acting on the session.
- Clustering.
- Peers' stamps advance the local clock, so a node with a fast clock
no longer wins every conflict, session ownership included. - A node joining after its peers truncated their logs is sent the
registers it missed, and the exchange can no longer loop at link
speed. - After a session moves, followers of the old owner start over under
the new one instead of discarding its stream while acknowledging
synchronous publications. - A drain is complete only when every message it announced arrived.
One whose first chunk was lost was counted complete, with its queue
and subscriptions gone. - An old owner that no longer holds a session says so, and the
claimer pulls from a copy, instead of being told the drain was
complete and empty. - The pull after a short drain waits under its own key, and a drain
it makes whole keeps the drain's subscriptions, inflight window and
inbound identifiers. - A follower stalled behind a missed change is repaired with a
snapshot, and a stopped session's synchronous waiters are answered. - A late forward is redirected at most once and never back to the
node that sent it. - Dialling and accepting a cluster link are bounded by the dead
window. - A batch that removes a session carries no stream position for it,
which the store would refuse along with the batch. - A repair snapshot is stamped with the position its queue was read
at, from the same read, and a follower ignores one from its owner
that is no newer than its copy. A snapshot cut a batch behind could
replace a copy holding the newest message with one that lacked it,
and a synchronous publish acknowledged against that copy was lost
when the owner died. Found by CI. - A repair snapshot that reaches a follower ahead of a stream frame it
already holds no longer makes the follower drop the copy. Frames go
out once the store has taken their batch and repairs read the store,
so the snapshot can overtake; the follower took the frame for a
stream starting over and waited thirty seconds for another repair.
A follower that has answered and fallen behind is repaired again
after five seconds rather than thirty.
- Peers' stamps advance the local clock, so a node with a fast clock
- Rules and the admin API.
- Replacing or re-enabling a running rule leaves no window in which
matching publications miss it. - Changing a rule's action kind is refused with
409while its outbox
holds envelopes, which would otherwise be stranded and make the
rule undeletable. - A tap on a broker that refuses anonymous clients is answered
403
instead of panicking the request. - A replicated rule this node refuses still stores its register.
- The events stream tags each event with the run it came from, so a
browser reconnecting after a restart is told to resynchronise
instead of being replayed another run's events. A bare number is
still accepted.
- Replacing or re-enabling a running rule leaves no window in which
- Protocol.
- A client PUBLISH carrying a Subscription Identifier is a protocol
error. - A property not permitted on its packet type is a Malformed Packet,
0x81, as the specification says. It was0x82. - A Response Topic with a wildcard is refused.
- An invalid filter in a SUBSCRIBE is refused on its own,
0x8F,
and the other filters in the packet are granted. It closed the
connection. - A DISCONNECT followed by the socket closing no longer stamps the
session's expiry and will delay twice.
- A client PUBLISH carrying a Subscription Identifier is a protocol
- Storage and configuration.
- A stream position or replica mark for a session the store does not
hold is refused, as every other per-session record is. - Finishing an outbox envelope after its rule was removed is a no-op
instead of refusing the batch and leaking the payload. - A store from a later format is refused with the version message
before any table is opened. max_connectionsabove what the runtime can count, and cluster
waits of zero milliseconds, are refused by name at startup instead
of panicking or looping.- A second SIGINT or SIGTERM during shutdown ends the process at
once.
- A stream position or replica mark for a session the store does not
- The interface. Editing one rule and then another edits the
second. It kept the first rule's values and saved them over it.
sd-mqtt 0.7.1
Fixed
-
A publication forwarded to a session's node while the session was
moving could be lost. Section 8.3 forwards a publication to the node
whose register holds the subscriber; section 8.4 moves the session when
its client reconnects elsewhere; the two arrive over different links.
A forward that landed after the hand-over found no subscriber and was
dropped with a debug line. The node now remembers, for thirty seconds,
which filters each session it gave up held and where it went, and sends
such a forward on to that node.sdmqtt_forwards_redirected_total
counts those;sdmqtt_forwards_unroutable_totalcounts a forward
nothing wanted and nothing could be done with, which was uncounted
before. Found by a cluster test failing once on GitHub's runners; a new
test makes the race happen on purpose by stalling the link. -
CI: the nightly fuzz job installed no fuzzer and used the wrong
toolchain; the cluster tests' repair counts are measured from the kill;
the cluster tests' ports are below Linux's ephemeral range and start
apart under nextest; the development image hasprocps.
sd-mqtt 0.7.0
Before you upgrade: a store written before this milestone is refused
A broker built from this milestone will not open a store written by
milestone 6. The format moved from 10 to 11 for the user records, and
the refusal says so:
store format version 10 is not readable by this build, which reads version 11.
What to do is what every version move asks: stop the broker, move or
delete the store directory, start the new build. What that throws away
is the milestone 5 list plus, from milestone 6, the replica copies and
stream positions. On a cluster, one store at a time, letting the node
rejoin and be repaired before the next.
Added
-
A directory of users, section 12.1, in a crate of its own. A user
is durable metadata like a rule: written throughPOST /api/v1/users
on any node, replicated as a register to every other, removed by a
tombstone. The password is hashed with argon2id (PHC string) and,
when the request enrols the user with"scram": true, derived into a
SCRAM-SHA-256 verifier at the one moment the password is in hand,
because a verifier cannot be made from an argon2 hash. Enrolment is
opt-in because section 12.2 treats a verifier as a private key's
equal. Neither form is ever returned by the API, and the record'sDebugsays
<redacted>. The directory is a section 9.3 hook —authenticate—
and the broker's connect path does not know users exist. A wrong
password and an unknown name are one answer.auth.anonymousis an
explicit switch, on by default, that creating a user does not flip;
auth.scram_iterationssets the verifier's rounds and refuses fewer
than a thousand.ClientInfocarries the authenticated username, for
the access control rules that follow.argon2is the one new direct
dependency. -
SCRAM-SHA-256 over the AUTH exchange, section 12.2, with
re-authentication. The arithmetic is pure functions in the server
crate, pinned by RFC 7677's one published exchange byte for byte;
the connection shell runs it before the broker is asked to connect
anybody, and the CONNACK carries the server's final message so the
client can verify the server knew its key too. An unknown name gets a
challenge with a salt of its own and fails at the proof, the same
answer at the same step as a wrong password.=2Cand=3Din a
name are unescaped per RFC 5802. Channel binding is not offered; a
client asking for it, or for any other method, is told 0x8C, and so
is one on a node with no directory. Re-authentication is the same
exchange in AUTH packets, answered withSuccessand the server's
final; a wrong proof, or a proof as another user, disconnects with
0x87. The user name a SCRAM client proves is what the session
remembers, whatever the CONNECT's username field said. -
Access control rules, section 12.1, deny by default once one
exists.[[acl]]tables in the file andPOST /api/v1/aclthrough
the API take the same shape — identifier, order, subject (anyone,
user:<name>,client:<id>, where a name may end in*to cover a
prefix anduser:*is every authenticated connection), filter, and an opinion on publishing
and on subscribing — and are consulted together by order, first
match deciding; the API's replicate as registers, the file's are the
node's own. Filters match the way subscriptions do, through the topic
crate, and a shared subscription is judged by the filter it shares. A
refused subscribe is 0x87 (0x80 on 3.1.1), a refused publish 0x87 on
its acknowledgement, and an at-most-once publish that was refused —
by a rule or by the reserved namespace — is now dropped and counted
asrefused, where before nothing counted it. Paho's
test_subscribe_failurepasses on 3.1.1 now that the runner
configures the rule it needs; on version 5 it stays in the baseline
as an assumption, because the suite asserts 0x80 where the protocol
lists 0x87. -
A client cannot publish into the dollar namespace, section
12.3's other half, deferred since milestone 3. Judged in the broker
before the authorizing chain and told to the hooks as areserved
refusal; 0x87 on an acknowledged publish, dropped and counted
otherwise. Only the first level is reserved. -
A landing page,
site/index.html, published with GitHub Pages by
.github/workflows/pages.yml.scripts/test_site.py, in CI and before
every deployment, holds it to its sources: every version it names is
the workspace's, every figure it quotes is one the spec, a decision or
the README records, and every link names this repository. -
Install documentation, checked. The README opens with installing
from a release archive, the image, and Compose, and carries a table of
every configuration section and everySDMQTT_*variable;
tests/documented.rsfails naming any variable the configuration reads
or any section it accepts that the README does not mention, which found
ten variables and two sections missing. The store-format refusal no
longer says nothing is released: it says releases before 1.0 do not
migrate a store. -
Release archives for
x86_64andaarch64Linux (static musl,
cut from the image's own builder) and Intel and Apple silicon macOS:
scripts/package.sh <target>writesdist/sdmqtt-<version>-<target>.tar.gz
with the binary, LICENSE, README and both notices, and its.sha256.
scripts/test_package.pyunpacks one and runs it — natively, under
Rosetta, or in a container of its architecture — and requires the
version, a ready broker and the embedded interface. It caught macOS
tarshipping AppleDouble._files in the first archive. -
A container image.
services/broker/Dockerfilecross-compiles the
broker as a static musl binary with cargo-zigbuild and ships it on
scratchwith its licence and notices: about 10 MB, uid 65532, store
in/data, read-only root works. The admin listener binds every
interface in the image, so it refuses to start without
SDMQTT_ADMIN_TOKEN.scripts/test_image.py, in CI, checks a running
container, including that a durable session and a message queued for
it survivedocker stopanddocker start. The notices generator now
covers every release target and audits a given--binary. -
Four decisions recorded in
docs/decisions/, which section 17
always said they would be: the SCRAM iteration count stays 4,096 by
measurement (0002);SCRAM-SHA-256-PLUSis not offered (0003); the
admin listener stays HTTP/1.1 (0004); release artifacts are Linux musl
and macOS, not Windows (0005). -
The connection target is met. 100,000 MQTT connections opened in
2.5 seconds and held for a minute on one node, at about 32 KB each;
scripts/connection-target.shmeasures it in the development image
withsdmqtt-load, a generator that is a workspace member and never
ships. Seedocs/decisions/0001-connection-target.md. Running out of
file descriptors is now counted:sdmqtt_accept_pauses_total. -
mimalloc is the global allocator, section 10.4, on every target
but Windows, on by default and removable with
--no-default-features; static musl builds use its local dynamic TLS
mode.sdmqtt_build_info{version, allocator}names the build a node
runs. -
Two rate limiters, section 10.3, both the generic cell rate
algorithm on the broker's coarse clock. Repeated authentication
failures from one address are slowed: after
auth.max_failures_per_minute(10 by default) a connect from it is
refused with 0x9F before its credentials are read, until the minute
drains; IPv6 is counted by /64, and the tick sweeps the map. A
listener'smax_publish_rateandpublish_burstcap one session's
publishes, off by default; over it, version 5 is disconnected with
0x96 and 3.1.1 closed.sdmqtt_authentications_total{outcome}, a
series registered at zero since milestone 3, now moves;
sdmqtt_rate_limited_total{limiter}and
sdmqtt_auth_failure_addressesare new. -
The Settings page, section 11.2's last: paste a key, then manage
users, access control rules and API keys, and read this node's
listeners, publish cap included. The key is kept in session storage
for the tab's life and sent with every request the interface makes.
GET /api/v1/listenersgainsmax_publish_rateandpublish_burst. -
API keys, section 11.1, with
read,writeandadminscopes.
POST /api/v1/keysmints one and shows it once; the broker stores a
SHA-256 digest;DELETE /api/v1/keys/{id}revokes, and keys and
revocations replicate as registers. Every mutating endpoint names the
scope it needs. The configuredadmin.tokenis now the bootstrap key
withadminscope. Awritekey cannot manage users, access
control rules, keys or nodes, and is answered with the newforbidden
error code and403. -
A listener's maximum QoS and retain availability, section 5.4's
two deferred properties.listener.max_qos(0, 1 or 2) is advertised
to version 5 clients and enforced on every publish: above the cap is
a protocol error, 0x9B on version 5 and a closed socket on 3.1.1,
which has no code for it. A subscription above the cap is granted at
the cap, as 3.8.4 allows.listener.retain_available = falserefuses
a retained publish with 0x9A and refuses the connect of a client whose
will is retained, rather than accepting it and dropping the flag the
client asked for. The defaults advertise nothing and refuse nothing,
so the conformance baseline is unchanged. -
A durability mode per listener, section 8.6's override, deferred
from milestone 6.listener.durability = "sync"makes publishes on
that listener wait for a follower while the node's mode stays
async, or the reverse. The timeout is the node's in both cases: a
second timeout would be a second answer to "how long is too long" on
one node. Refused at startup on a no...