MQTT and OpenGate operations

Axiom Border carries its own MQTT broker, so a probe is a message bus as well as a monitoring probe. Three independent pieces make up the messaging layer:

Piece Role
Embedded broker The MQTT broker running on the probe. TCP and WebSocket listeners, optional TLS
Event client Publishes execution lifecycle events. Publish-only
Operations client Subscribes to OpenGate operations and answers them

On top of that sits the optional OpenGate integration, which reports inventory upward (collect) and registers devices (provision).

The local bus, first: scans publish their lifecycle, and anything that can connect to the broker can follow it.

flowchart TB
    SCAN["Security scans"] -->|"started<br>finishedok<br>failed"| EVC["Event client"]
    EVC --> BROKER["Embedded broker<br>TCP 1883 ยท WS 1888<br>TLS 8883"]
    BROKER --> UI["Embedded web UI"]
    BROKER --> SUB["External<br>subscribers"]:::ext
    classDef ext fill:#e9edfa,stroke:#486ac9,color:#101010

Then the platform side, where OpenGate drives scans and receives inventory.

flowchart TB
    PLAT["OpenGate platform"]:::ext -->|"operation"| OPS["Operations client<br>odm/operation"]
    OPS -->|"launches"| SCAN2["Security scan"]
    OPS -->|"response"| PLAT
    INV["Provision and collect<br>inventory reports"] -->|"HTTP or MQTT"| PLAT
    classDef ext fill:#e9edfa,stroke:#486ac9,color:#101010

The embedded broker

Enabled by default on TCP port 1883, with a WebSocket listener on 1888 at path /ws that the web UI uses. TLS is available on 8883 when configured, including mutual TLS with client certificate verification.

Secure the broker before exposing port 1883

The broker does not apply per-topic authorisation, and with mqtt.broker.username and password both empty it accepts anonymous connections. A client that can reach the port can therefore use any topic, including the operations topic that triggers scans.

On any network you do not fully control: restrict access to the port at the network level, set broker credentials, and enable TLS. After changing the TLS settings, confirm the broker came back up โ€” if the certificates cannot be read, it does not start, and the log says so.

Configuration lives under mqtt.broker; see Configuration. Note that the WebSocket listener always binds on all interfaces.

Execution lifecycle events

Every security scan publishes its lifecycle to a single topic, mqtt.client.topic, which defaults to axiom-border/executions. This is the alternative to polling the executions API.

{
  "mode": "manual",
  "uuid": "4a79214d-3deb-4ae8-933f-9874cbdc1128",
  "event": "finishedok",
  "type": "networkscan",
  "elapsedTime": "5m18s",
  "details": "success"
}
Field Values
event started, finishedok, failed
type networkscan, vulns, snmp, snmpwalk, sniffing
mode manual, auto, process
elapsedTime, details Present on completion

Note that type here is networkscan, while the REST API reports the same scan as nmap. Map the two values if you correlate the event stream with the executions API.

Events are published on scan start, on completion, and on startup recovery for executions interrupted by a restart.

Events are dropped, not queued, when disconnected

While the connection to the broker is down, or when the topic is left empty, lifecycle events are not buffered for later delivery โ€” they are simply not published. Do not treat this topic as an audit trail. The authoritative record is the execution history, readable through GET /security/executions; the MQTT stream is a convenience for live UIs.

Events are published with the configured QoS (default 1) and retain flag (default true), and the client reconnects every 5 seconds while the broker is unreachable.

OpenGate operations over MQTT

The operations client lets the OpenGate platform trigger scans on the probe remotely. It subscribes to mqtt.ops.topicSubscribe (default odm/operation) and replies on mqtt.ops.topicPublish (default odm/response/{device-id}, with the device ID substituted at runtime).

Request envelope

{
  "operation": {
    "request": {
      "timestamp": 1730000000000,
      "name": "hostVulnScan",
      "id": "<operation-id>",
      "deviceId": "<device-id>",
      "parameters": { }
    }
  }
}

name, id and deviceId are all mandatory. A request missing any of them, or one that does not parse, is logged and silently ignored โ€” no response is published. An unknown operation name does get a response, with ERROR_PROCESSING and Unsupported operation.

Response envelope

{
  "operation": {
    "response": {
      "name": "hostVulnScan",
      "timestamp": 1730000001000,
      "resultCode": "SUCCESSFUL",
      "resultDescription": "Success",
      "deviceId": "<device-id>",
      "id": "<operation-id>"
    }
  },
  "version": "1.0"
}

There are exactly two result codes: SUCCESSFUL and ERROR_PROCESSING.

SUCCESSFUL means accepted, not completed

The operation response is published as soon as the scan is launched, not when it finishes โ€” and it does not carry the execution UUID. There is no field for it in the envelope.

To follow a scan triggered over MQTT, subscribe to the execution events topic or poll GET /security/executions. In particular, an intrusive OT scan that the configuration lock rejects is still answered with SUCCESSFUL; the rejection shows up afterwards as a failed execution.

The four operations

Operation REST equivalent
hostNetworkScan POST /security/scan/network
hostVulnScan POST /security/scan/vulns
hostSnmpScan POST /security/scan/snmp
hostSnmpWalkScan POST /security/scan/snmp/walk

Both surfaces drive the same scan engine, so they share the same locking, history and safety gates. The differences are in what each surface exposes.

hostVulnScan takes targets, timeout, level, severity and intrusive โ€” all optional. It does not expose templatesDir, so the configured resolution cascade always applies. The intrusive flag is gated by securityProbes.vulnScan.allowIntrusive exactly as over REST.

{
  "targets": ["192.168.1.0/24"],
  "level": "ot",
  "severity": "high,critical",
  "timeout": "10m",
  "intrusive": false
}

hostNetworkScan takes targets, timeout, timing and portFilter. Four options are fixed for MQTT-launched scans: port scanning is always on, OS detection is always on (which requires privileges), service detection is always off, and UDP is always off. There is no force equivalent either, so a scan launched while another manual network scan is running is rejected with ERROR_PROCESSING and manual network scan already running.

hostSnmpScan and hostSnmpWalkScan take targets, port, timeout, credentials in an snmp object, and either oids (mandatory for the GET) or walkRoot (defaulting to the enterprises subtree .1.3.6.1.4.1).

{
  "targets": ["192.168.1.50"],
  "port": 161,
  "timeout": "5m",
  "oids": ["sysDescr", "sysName"],
  "snmp": {
    "snmpVersion": "v2c",
    "community": "<snmp-community>"
  }
}
The SNMP field names differ from REST

The MQTT snmp object uses different names than the REST customParams: user rather than username, authType rather than authProtocol, privKey rather than privPass, and privType rather than privProtocol. The version field is snmpVersion.

Also: only the first entry of targets is used, and these credentials form an ephemeral profile that ignores stored profiles and associations entirely. mib and profileName are not exposed over MQTT.

Request payloads are not logged

Because operation parameters can carry SNMP community strings and v3 passphrases, request payloads are deliberately not written to the log โ€” only the operation name, ID and device ID. Responses, which carry no credentials, are logged in full.

OpenGate provision and collect

Separate from operations, the integration reports upward on a schedule. The whole section is inert unless opengate.enabled is true.

Scheduling and throttling

A five-field cron expression (default */30 * * * *) drives both, with descriptors such as @hourly accepted. An empty or invalid expression means the integration does not run at all โ€” check the log for a warning if nothing is happening.

minPeriod (default 30 minutes) is a floor: if the cron expression would fire more often than that, the schedule falls back to a fixed interval of minPeriod and logs a warning. Cycles never overlap โ€” a cycle due while the previous one is still running is skipped and logged.

Provision

Provisioning registers discovered hosts with the platform. It builds an Excel workbook โ€” one row per host, with device ID, addressing, state and the location metadata from opengate.collect.address โ€” and uploads it to the bulk endpoint.

The upload requires exactly 201 Created; anything else aborts the provision. The result is then polled up to pollMaxAttempts times (default 10, every 5 seconds), and only counts as success when the platform reports every submitted host as successful.

Provision is HTTP only โ€” there is no MQTT variant.

Collect

Collect sends the accumulated per-host inventory: identity, ports, vulnerabilities and SNMP entries, with timestamps.

{
  "deviceId": "192.168.1.50-00.11.22.33.44.55",
  "ipAddress": "192.168.1.50",
  "macAddress": "00:11:22:33:44:55",
  "specificType": "HOST",
  "hostname": "plc-line-1",
  "os": "Linux 4.x",
  "manufacturer": "Siemens AG",
  "status": "up",
  "ports": [ { "portNumber": 502, "protocol": "tcp", "service": "mbap", "status": "open" } ],
  "vulnerabilities": { "hasVulns": true, "catList": "medium,high", "highDetect": "high", "vulnList": [ ] },
  "snmp": [ { "oid": ".1.3.6.1.2.1.1.1.0", "name": "sysDescr", "value": "..." } ]
}

Transport is chosen by collect.mode: mqtt, or http for anything else including an empty value. Over HTTP the payload is posted with the X-ApiKey header; over MQTT it is published to collect.mqtt.topic, falling back to opengate.apiKey as the password when no MQTT password is set.

Chunking. With collect.sendByParts: true, each host is split into several messages: a header with the scalar fields plus one chunked block at a time โ€” ports, then SNMP, then vulnerabilities โ€” sized by collect.partSize.* (default 100 each). This matters on metered or constrained links where a host with hundreds of findings would otherwise produce one oversized message. With chunking off, each host is a single message.

A failed chunk is logged and the cycle continues with the next one, so one bad message does not abort the whole report.

Device identity

The device ID is derived per host as <ip>-<MAC with dots>, for example 192.168.1.50-00.11.22.33.44.55. Hosts without a discoverable MAC get the placeholder AA.BB.CC.DD.EE.FF. This is the same format accepted by GET /security/results/last?deviceId=, so an ID seen in the platform can be looked up on the probe directly.

Before each cycle, hosts missing a MAC or hostname get a quick nmap ping scan to fill the gaps, bounded by macDiscoveryTimeout (default 10 s).

A fixed collect.deviceId behaves differently per transport

Setting collect.deviceId forces one identifier for all hosts over HTTP, but the MQTT path always uses the per-host derived ID. If you rely on this override, use HTTP transport โ€” or better, leave it empty and let each host keep its own identity, which is almost always what you want.

Configuration reference

Everything here is configured under the mqtt and opengate blocks. See Configuration for the field-by-field tables, including the TLS options and the retry parameters for both provision and collect.