The Application API
The Application API
Devices speak MQTT to modbee serve. Your application - a platform
backend, a gateway's own software - can speak to it a different way: over
the Application API, gRPC on a local socket, with no MQTT library at all.
The contract is proto/modbee/app/v1/app.proto.
The server answers all five of its services:
| Service | What it is for |
|---|---|
System.Hello |
What this server is, which API version it speaks, and what it can do (features) |
Messages |
Every device's messages, shared by your application's instances, and messages back to the devices - below |
Connections |
MQTT connections the server keeps to other brokers for your application - below |
Authority |
Your application decides who may log in, and what each login may do - below |
Admin |
Logins, site keys, sessions, kicks, retained messages, TLS, counters - below, and the same calls as JSON over HTTP |
Hello's features lists what a server serves (authority, messages, connections, admin), so an
application asks rather than guesses.
System.Attach is an instance's presence. Open it with the messages to
publish should the instance vanish - its Wills, as an MQTT client has one:
const attach = system.Attach(metadata());
attach.write({ open: { instance: 'backend-1', wills: [
{ topic: 'spBv1.0/STATE/host-1', payload: offline, qos: 1, retain: true } ] } });
// ... and on a clean shutdown:
attach.write({ close: {} }); attach.end();
A stream that ends without close - the process killed, the socket gone -
has its Wills published, in order. One that closes first has none.
Turning it on
api:
grpc: ["unix:/run/modbee/api.sock"]
modbee serve says where it listens:
modbee serve: Application API (gRPC) on unix:/run/modbee/api.sock
-
The socket is the lock. It is made readable and writable by the server's
own user and group only (0660): put your application in that group, or
run it as that user. A Unix socket's path is at most 107 bytes. -
From another host, add a TCP address. It must have TLS and a token -
the server refuses to start otherwise:api: grpc: ["unix:/run/modbee/api.sock", "tcp://0.0.0.0:7660"] tls: cert: /etc/modbee/api.pem key: /etc/modbee/api.key client-ca: /etc/modbee/apps-ca.pem # optional: callers present a certificate it signed token-file: /run/secrets/modbee-apiEvery call then carries the token as gRPC metadata,
authorization: Bearer <token>, on the socket as well; one without it is
refusedUNAUTHENTICATED. -
Any gRPC library reaches it:
@grpc/grpc-jsfor Node.js
(unix:/run/modbee/api.sockis a valid target),google.golang.org/grpc
for Go,grpciofor Python. The standardgrpc.health.v1.Healthservice
answers too, for a supervisor's health check. -
The server needs gRPC to serve it: a build where gRPC was not found says
so and does not start with anapi:section (Ubuntu/Debian:
libgrpc++-dev protobuf-compiler-grpc libprotobuf-dev). The published
Docker image does not have it yet: buildmodbeefrom source with those
packages installed until it does. -
An application on the same machine, on Windows as on Linux, can use TCP
on loopback with the token alone - no TLS, since nothing off the machine
reaches it:api: grpc: ["tcp://127.0.0.1:7660"] token-file: /run/secrets/modbee-apiThat is how it is reached on Windows (windows.md); the
server refuses a loopback address without a token.
Messages
Reading every device's messages: Messages.Consume
Open a stream, send open with a group name and the topic filters it
wants, and the messages arrive, one Delivery each. Acknowledge each one
(ack, by its id) once you have handled it - stored it, acted on it - not
before.
call.write({ open: { group: 'platform', filters: ['#'], maxInFlight: 100 } });
call.on('data', (down) => {
if (down.delivery) { handle(down.delivery); call.write({ ack: { ids: [down.delivery.id] } }); }
});
Run as many instances as you like with the same group: they share it.
| One device, one instance, in order | Every message is dealt to the instance its publisher's client id hashes to, so one device's messages reach one instance, in the order the server took them. An instance joining or leaving moves only its own share (pick: PICK_ROUND_ROBIN turns this off) |
| Nothing is lost when an instance stops | What it had been sent and not acknowledged - killed, crashed, restarting - goes to the others, marked redelivered. A message can therefore arrive twice: handle it so a repeat is harmless (store it under the device and its own time) |
| The group outlives its instances | With none connected, a named group keeps collecting, and what waited arrives marked replay when one is back. One nobody opens for expiry_seconds (a day by default) is dropped |
And the server, with sessions: store: file |
A named group and every message it holds that nobody has acknowledged are kept beside the sessions (groups.db), and a QoS 1 or 2 device's acknowledgement waits until its message is written - so a restart, an upgrade or modbee killed outright loses none of them. After it the group is collecting again before any instance opens it, and what it held arrives marked replay and redelivered (the server cannot know which had been sent). Without it, a restart starts every group afresh. A stream of its own (group: "") is never kept |
| The group must take it | A group holding api.max-queued messages (100,000) refuses more: a device publishing at QoS 1 or 2 is told 0x97 Quota exceeded and keeps the message to send again; QoS 0 is dropped and counted. With on_full: ON_FULL_DROP, it is dropped instead |
| Flow | An instance is given at most max_in_flight unacknowledged messages at once; acknowledge to get more |
| Not now | retry with an after_ms sends one again later, to any instance |
Each delivery says who published it, which a device cannot fake: its
publisher (client_id, the login the server checked, and the login's
tag from its scope), broker_time_ms - when the server took
it, a clock you can trust - and the MQTT 5 properties it was published with.
$topics come only when a filter names them ($modbee/provision/+/request).events: trueadds client events - connected, disconnected, subscribed,
unsubscribed - dealt like the messages: to the instance that gets that
client's messages, in order with them, acknowledged byid. The server
needsevents: enabled(events.md).exclude_originskeeps messages your application published itself
(with thatorigin) out of the group.group: ""is a stream of its own, gone when it ends.Messages.ListGroupslists the groups;Messages.DeleteGroupremoves one
nobody is connected to.
examples/app-api/nodejs/consumer.js
is a whole member: start two, kill one mid-stream, and every message still
arrives.
Sending to the devices: Messages.Publish
Publish routes a message - topic, payload, QoS, retain, MQTT 5 properties -
to the devices subscribed to it. PublishStream does the same for a stream
of them, each answered by its request_id. origin names who published it,
for exclude_origins and for the publisher.origin a group sees. When the
answer comes is up to you (confirm):
confirm |
Answered |
|---|---|
CONFIRM_ACCEPTED (the default) |
Once the server has taken it: PUBLISH_ACCEPTED |
CONFIRM_DELIVERED |
Once every subscriber it reached has acknowledged it - PUBACK at QoS 1, PUBCOMP at QoS 2 - on whichever CPU core's share of the server each one is: PUBLISH_DELIVERED, with how many it matched. Nobody subscribed: PUBLISH_NO_SUBSCRIBERS at once |
The wait is bounded by the call's gRPC deadline (ten seconds without one).
When it comes first the answer is PUBLISH_PARTIAL, with how many
acknowledged and how many are pending - a subscriber that is offline
counts as pending, and its message waits in its session. A subscriber that
never could have it (its queue full, the message expired or too large for
it) also makes it PARTIAL. Use it for a command that must have reached its
device before your application goes on; a store-and-forward outbox deletes
only what came back PUBLISH_DELIVERED.
Connections to other brokers
Your application can have the server hold an MQTT connection to another
broker for it - a customer's, the platform's (a gateway's uplink), one a
Sparkplug host or edge node lives on - with no MQTT library of its own. The
server's own client does it, so the connection stays up while your
application restarts.
Connections.Put with a ConnectionSpec: an id, the servers in order of
preference, the client id, login, TLS, keep-alive, session, Will.
| Failover | An attempt that fails moves to the next server. Connected to any but the first, it checks the first every primary_check_seconds (60) and goes back to it once it answers |
| Kept | Written to connections.json in api.state-directory (/var/lib/modbee), readable by the server's user only, and opened again when the server starts. Put again with a change reconnects; with none, nothing happens. Delete closes and forgets it |
| Messages in | A Consume stream with connection set to its id: the group's filters become the connection's subscriptions, and what the other broker sends arrives as any delivery does, its connection set |
| Messages out | Publish with connection set. CONFIRM_DELIVERED answers when the other broker has acknowledged it (PUBACK, PUBCOMP); PUBLISH_NOT_CONNECTED when the connection is down - nothing was sent |
RECONNECT_WHEN_ASKED |
A connection that drops stays down (CONNECTION_WAITING) until Connections.Reconnect, which can bring a new Will for the next session - what a Sparkplug edge node needs, its NDEATH carrying a new bdSeq each time |
| A refused login | CONNECTION_REFUSED, with what the other broker said in last_error, and not tried again until Put or Reconnect: a wrong password sent every second gets a site banned |
Get and List say each one's state, which server it is on, and its counts -
never its password or key. Watch streams every change of state.
TLS: ca_pem (empty: the system's trust store), and certificate_pem and
key_pem for mutual TLS, written beside connections.json. The name checked
is the server's host; server_name is not used yet.
A Sparkplug host on a broker you do not own, built on nothing but this:
examples/app-api/sparkplug-host,
which passes the Eclipse Sparkplug TCK's Host Application profile through the
API - its Will renewed on every connect with RECONNECT_WHEN_ASKED, its
STATE birth answered by the other broker's PUBACK.
Administration, and REST
Admin is what an operator - or the platform's own console - does to a
running server, without restarting it:
| Calls | |
|---|---|
ListLogins GetLogin PutLogin DeleteLogin ResetLogin |
The logins in the server's login database (authentication.database). PutLogin without a password generates one and returns it, once. ResetLogin gives a new one and closes the login's connections. With logins decided by your application, there is no database: FAILED_PRECONDITION |
ListKeys PutKey DeleteKey |
Site keys devices register with. A new key's secret, <name>.<secret>, is returned once |
ListSessions |
Every session on every shard, connected or not: its login, address, protocol, subscriptions, what is queued and in flight |
Kick |
Closes a client's connection, or every connection of a login, with DISCONNECT 0x98 (administrative action); its Will is published. discard_session ends the session too |
ListRetained DeleteRetained |
Retained messages under a filter, with payloads if asked; deleting is the same as a device publishing an empty retained message to each |
GetListeners PutTls |
The listeners, and the TLS certificate they serve - replaced live: new handshakes use it, open connections keep theirs |
Stats |
The server's counters: connections, sessions, messages in and out, logins asked of your application and answered from its cache |
REST
The same calls, as JSON, on the HTTP listener /metrics is on:
metrics:
address: 127.0.0.1
port: 9464
api:
grpc: ["unix:/run/modbee/api.sock"]
rest: true
modbee serve: Application API (REST) on http://127.0.0.1:9464/v1/
curl -X PUT localhost:9464/v1/logins/sensor-7 -d '{"login":{"publish":["plant/sensor-7/#"]}}'
# {"login":{"name":"sensor-7","enabled":true,...},"password":"YT4SDLSozVV1cuLkvKLnKeB6xCC49mBb"}
curl 'localhost:9464/v1/retained?filter=plant/%23&payloads=true'
curl -X POST localhost:9464/v1/sessions/pump-1:kick
| REST | gRPC |
|---|---|
GET /v1/logins?prefix=&pageSize=&pageToken= |
Admin.ListLogins |
GET · PUT · DELETE /v1/logins/{name} |
GetLogin · PutLogin · DeleteLogin |
POST /v1/logins/{name}:reset · POST /v1/logins/{name}:kick |
ResetLogin · Kick |
GET /v1/keys · PUT · DELETE /v1/keys/{name} |
ListKeys · PutKey · DeleteKey |
GET /v1/sessions?clientIdPrefix=&login=&connectedOnly= · POST /v1/sessions/{clientId}:kick |
ListSessions · Kick |
GET /v1/retained?filter=&payloads= · DELETE /v1/retained?filter= |
ListRetained · DeleteRetained |
GET /v1/listeners · PUT /v1/listeners/tls |
GetListeners · PutTls |
GET /v1/stats · GET /v1/health |
Stats · health |
GET /v1/connections · GET · PUT · DELETE /v1/connections/{id} |
Connections.* |
GET /v1/groups · DELETE /v1/groups/{group}?connection= |
Messages.ListGroups · DeleteGroup |
POST /v1/publish |
Messages.Publish (the streams are gRPC only) |
- The JSON is protobuf's own mapping: field names in lowerCamelCase,
64-bit numbers as strings,bytes(a password, a payload) as base64, enums
by name. A field the message does not have is refused,400. - Errors are the gRPC status as HTTP -
400invalid,401no token,
404not found,409exists,503unavailable - with
{"code": 404, "message": "..."}. - Who may call. With
api.token-file, every request carries
Authorization: Bearer <token>. Without one, REST must be on a loopback
address (metrics.address: 127.x); the server refuses to start otherwise.
The listener has no TLS: reach it from another host through a proxy that
adds it. - One request at a time, as
/metricsis: it is for an operator and a
console, not for traffic. Messages in volume go over gRPC.
Logins decided by your application
authentication:
username: platform # your application's own login: never asked
password: ...
authority: {} # every other login is asked of your application
api:
grpc: ["unix:/run/modbee/api.sock"]
Logins then come from your application instead of a login database
(authentication: database and authority cannot be used together). The
fixed logins in the file - username/password (an admin) and users: -
are still decided by the server itself: that is how your application, and the
modbee command, log in without asking themselves.
What is asked, and when
Your application opens Authority.Serve - a stream - and sends open. From
then on, when a device connects with a login the server has no answer for, it
sends your application a ConnectQuestion: the client id, the user name and
password, where it came from, the MQTT version, and the client certificate
when the TLS handshake verified one. Your application sends back a Decision
with the question's id.
Only CONNECT is asked, and only once per login. The answer says how long
it holds (ttl_seconds); until then the same client id, login and password
are answered from what was kept, without asking. The answer to "may it
connect" also says what the login may do - its scope - and the server
checks every PUBLISH and SUBSCRIBE against that itself, so no message ever
waits on your application.
While a CONNECT is asked, nothing waits: that connection is parked until the
answer comes, and every other connection carries on.
The answer
Decision field |
|
|---|---|
allow |
|
refusal |
When allow is false - the table below |
ttl_seconds |
How long it holds. 0: ask at every CONNECT |
scope |
What the login may do (when allowed) |
login |
Optional: the session's login, when it is not the CONNECT's user name |
replace_session |
Optional: it may take over a session another login holds under this client id |
refusal |
MQTT 5.0 | MQTT 3.1.1 |
|---|---|---|
REFUSAL_BAD_CREDENTIALS |
0x86 | 4 |
REFUSAL_NOT_AUTHORIZED |
0x87 | 5 |
REFUSAL_BANNED |
0x8A | 5 |
REFUSAL_QUOTA |
0x97 | 3 |
REFUSAL_UNAVAILABLE |
0x88 | 3 |
Say REFUSAL_UNAVAILABLE when you could not check - your database is
down - never "bad credentials". A device told its password is wrong stops
trying; one told the server is unavailable comes back. An unavailable answer
is never kept, and a login the server still holds an earlier answer for is let
in on that instead.
The scope
Scope field |
Means |
|---|---|
publish |
Topic filters it may publish to - its Will included |
subscribe |
Topic filters whose messages it may receive. A SUBSCRIBE inside one of them is granted |
subscribe_wide |
Filters it may also subscribe with, receiving only what subscribe allows |
deny |
Never, whatever the lists above say: nothing published there, nothing delivered from there |
max_sessions, max_retained |
This login's limits (absent: authentication: max-sessions-per-login / max-retained-per-login) |
tag |
Up to 255 bytes, yours: kept with each session of the login |
${username}and${clientid}in a filter are put in for each session. A
value containing/,+or#is never put into a filter: that rule then
grants nothing (so a login namedacme/pump-07gets its topics from
explicit rules, as with the login database).$topics are reached only by naming them; a filter starting with a
wildcard never reaches them.$modbee/admin/...is for the fixed admin
login only, whatever a scope says.subscribe_wideis for a subscriber that asks for more than it may
have - a Sparkplug host subscribingspBv1.0/#, which is Ignition's
default. It is granted, and each message through it is checked: on a
server shared by several customers the host receives its own groups and no
one else's, retained messages included.denykeeps a login out of levels inside a namespace it otherwise
owns -acme/+/$ota/#, every device's firmware level, for a shared login.
A subscription that reaches into one (acme/#) is granted, and what it
would deliver from there is held back.
A platform's shapes, as an example:
| Login | publish |
subscribe |
subscribe_wide |
deny |
|---|---|---|---|---|
| A device's own | acme/pump-07/# |
acme/pump-07/# |
||
| A shared login for a namespace | acme/# |
acme/# |
acme/+/$ota/# |
|
| A Sparkplug host | spBv1.0/acme/#, spBv1.0/STATE/acme-host |
spBv1.0/acme/#, spBv1.0/STATE/+ |
spBv1.0/# |
|
| A read-only key | (none) | acme/# |
When your application is away
| Situation | What happens |
|---|---|
No instance connected, or none answers within timeout-ms |
A login answered before is let in on that answer, for stale-for after it expired; any other is refused 0x88 (3.1.1: 3), which a client retries |
| Several instances connected | Questions are dealt round them |
| An instance stops with questions it has not answered | They are asked of another, once |
| It comes back | It is asked again as before |
authentication:
authority:
timeout-ms: 2000 # how long a CONNECT waits for an answer
stale-for: 86400 # seconds an expired answer still serves while your application is away
cache-size: 1000000 # answers kept, whole server
max-pending: 10000 # CONNECTs waiting for an answer at once; one more is told 0x89 Server busy (3.1.1: 3)
Changing a login while it is connected
| Call | Does |
|---|---|
Authority.Revoke(login) |
Forgets its kept answers and closes its connections (MQTT 5: 0x87). It is asked again when it reconnects |
Authority.Regrant(login, [client_id], scope) |
Its live sessions take the new scope at once, and stay up; a subscription the new scope no longer allows is removed |
Authority.Forget([login]) |
Forgets kept answers - one login's, or all - so the next CONNECT asks again; live sessions are untouched |
Each answers once every CPU core's share of the server has done it, with how
many connections, sessions and answers it touched.
An example
examples/app-api/nodejs/authority.js
is a whole authority in Node.js - Hello, Serve, and coming back after a
restart - with a small table standing in for your database:
cd examples/app-api/nodejs && npm install
MODBEE_API=unix:/run/modbee/api.sock node authority.js
modbee-mqtt 0.1.0 (API 1.0, authority) at unix:/run/modbee/api.sock
asked about "acme/pump-07" (client pump-07): allowed