Axiom Border

Axiom Border

Axiom Border is a network and asset monitoring probe you install at the edge, close to the equipment it watches. It works fully disconnected, and it does four jobs that normally take four separate tools:

flowchart TB
    A["Your network<br>servers · devices · PLCs"]:::ext --> P["<b>Axiom Border</b>"]
    P --> M["Metrics<br>and alarms"]
    P --> S["Security<br>assessment"]
    P --> C["Web console<br>and API"]
    M --> OG["OpenGate<br><i>optional</i>"]:::ext
    S --> OG

    classDef ext fill:#e9edfa,stroke:#486ac9,color:#101010
  1. Monitors the hosts and devices around it, collecting metrics and keeping the history locally.
  2. Raises alarms on its own, from rules you define, with no need to reach a server first.
  3. Assesses security — discovers assets, scans ports and services, detects vulnerabilities, queries SNMP and observes traffic. Including industrial OT/ICS protocols, which is what sets it apart from a general-purpose scanner.
  4. Integrates with OpenGate when you want a fleet-wide view, over HTTP or MQTT — and keeps working exactly the same when the link is down.

Two fronts: the hosts and the network

Axiom Border watches on two levels at once, and the console gives each one its own view. This is what lets a single probe cover an IT/OT boundary rather than just one side of it.

The hosts it monitors — Current status

Every node running an oda-lite monitoring agent reports what happens on that machine: SSH sessions, USB devices attached, the state of its physical network interfaces, and its reporting-health heartbeat. Current status shows one block per node, with a status dot per monitor, the last event each one delivered, and the alarms currently open against it.

Current status — per-node monitors and the alarms open against each node

The value of this view is that a problem is visible two ways at once. In the capture above the METRIC-Guard dot is red on all three nodes, and the alarm table beside it reads Node stopped reporting — the missing telemetry and the alarm it raised, side by side.

The network it is plugged into — Network status

Point the probe at a network segment and it builds an inventory of what lives there: hosts discovered, their open ports and services, the operating system it could fingerprint, and the vulnerabilities found on each — industrial protocols included.

Network status — discovered assets with their ports, vulnerabilities and severity breakdown

The header names the last assessment and how it ended, the table lists every asset with per-host actions, and the charts underneath break findings down by severity and hosts by state.

Aspect Current status Network status
Looks at The machines running an agent The network segment the probe sits on
Answers What is happening on my hosts? What is out there, and is it exposed?
Sees SSH, USB, interfaces, reporting health Hosts, ports, services, OS, vulnerabilities
Needs An oda-lite agent installed on each host Only the probe, pointed at a target range
Documented in Metrics and monitoring Security assessment

Neither front is mandatory. A probe with no agents reporting is still a full network scanner, and a probe that never scans is still a monitoring collector — most deployments end up using both.

Documented version

This documentation describes Axiom Border as of August 2026. Where an example depends on a specific field or option, check it against the version you have installed.

Why it exists

Conventional monitoring assumes the collector can always reach the backend. Conventional vulnerability scanning assumes it can always reach a feed of vulnerability definitions. Both assumptions break at the edge — and they break hardest exactly where the equipment matters most, in industrial networks that are isolated on purpose.

Axiom Border inverts that. Everything it needs to do its job is installed with it: the web console, the industrial vulnerability checks, the SNMP definition catalogue, and the local storage for history. A probe placed in an isolated network is useful immediately, with no feed to update and no service to call out to.

Where it fits

Situation What Axiom Border gives you
Industrial or OT segment Asset discovery and vulnerability assessment over Modbus, IEC-104, DNP3, BACnet and OPC UA — read-only by default
Remote site on a poor link Local metrics, local alarms, and reporting to OpenGate when the link allows
IT/OT boundary Passive traffic observation plus active scanning, with a full record of every assessment run
A fleet of sites One aggregating node collecting from several remote probes

How you interact with it

Two interfaces, covering the same capabilities:

  • The web console, served by the probe itself. Point a browser at the probe’s address and log in. This is the normal way to operate it.
  • The REST API, for integrating with your own systems, scripting routine work, or driving the probe from OpenGate.

The console has six views, and they map onto the sections of this documentation:

Console view What it covers Documented in
Current status The probe at a glance This page
Alerts Raised alarms and the rules behind them Alarms and rules
Network status Discovered assets, ports and vulnerabilities Security assessment
Supervisions The monitoring agents reporting in Metrics and monitoring
AI capabilities Anomaly-detection models AI capabilities
Configuration The probe’s settings, edited and applied from the browser Configuration

Wherever a task can be done either way, the pages show both paths side by side.

What it needs to run

Axiom Border installs from a single self-contained package that carries everything it depends on, so the target machine needs no internet access and no manual preparation.

Requirement
Operating system Ubuntu 22.04 or 24.04, Debian 12 or 13 — 64-bit
Machine 2 CPU cores and 2 GB RAM minimum; 4 cores and 4 GB recommended
Disk 4 GB free minimum, 20 GB recommended for history
Network Access to the network you want to monitor
Internet Not required

Depending on how you deploy it, the package also installs the metrics database and the components for the optional anomaly-detection feature. The deployment types differ in which of those they include.

Where to go next

Once the probe is installed, Configuration is where you adapt it to your environment.

Getting a running probe to do something useful: Metrics and monitoring to bring data in, Security assessment to examine what is around it, and Alarms and rules to react automatically.

Integrating with other systems: MQTT and OpenGate.

Before you scan industrial equipment

Axiom Border can perform checks that write to industrial devices. This is switched off by default and needs two separate confirmations to enable — but the responsibility for authorising it is yours. Read OT/ICS vulnerability scanning before enabling anything against a live plant.

Index

Subsections of Axiom Border

Configuration

Configuration

Axiom Border reads a single file, configuration.yaml, from the directory given by the AXIOM_CONFIG_DIR environment variable (default: config). An example file, configuration_example.yaml, is supplied alongside it — copy it rather than editing it in place, so you always keep an untouched reference.

cp config/configuration_example.yaml config/configuration.yaml

You will rarely edit the file by hand: the console’s Configuration view and the REST API write this same file — see Changing the configuration below.

No hot reload — restarts are mandatory

Every field in this file requires a service restart to take effect. There are no exceptions. The file is read once, when the service starts. If you change a value and nothing happens, you have not restarted the service. Saving from the console on a managed deployment restarts the service for you; everywhere else the restart is yours to run:

sudo systemctl restart axiom-border
An invalid file stops the service

If configuration.yaml is missing or is not valid YAML, Axiom Border refuses to start rather than running with defaults. This is intentional — a monitoring probe silently running on the wrong configuration is worse than one that will not come up. Check the log if the service does not start.

Changing the configuration

Three paths write the same file. The web console is the recommended one: it edits the settings a running deployment actually tunes, validates them before writing, and on a managed deployment restarts the service for you. The REST API covers automation and driving a probe without shell access. Editing the file over a shell always works — it is just the least guarded of the three.

Open Configuration in the side menu. The view edits configuration.yaml itself, one group of settings per tab:

The Configuration view, on the Automatic scans tab, with the restart and credentials notices above the fields

Each field is labelled in plain language rather than by its YAML key — nmap: period for schedule, vulnScan: depth for level — and the reference further down this page maps them to the keys they write. The two notices at the top are permanent, not the result of saving: they are there to tell you before you edit that nothing applies until the service restarts, and that the file holds credentials which are preserved for you.

Tab What it edits
Automatic scans The securityProbes switches and intervals: enable and schedule for nmap, vulnScan and snmp; the vulnerability scan’s level, severity and OT switches; the interfaces and flushInterval of sniffing
OpenGate The whole opengate block — connection, collect and provision
MQTT The whole mqtt block — the embedded broker with its WebSocket and TLS listeners, the internal publisher and the operations client

Everything else — logger, login, influxdb, apiPort, pagination and the per-probe details not listed above, scan targets included — is changed through the API or the file.

Network status has a shortcut straight here: its Automatic scans action opens this view on the first tab, which is where you land when a scheduled scan looks stale or too frequent.

The forms are a window onto the file, not a copy of it. Saving rewrites the whole file but preserves everything the forms do not manage: comments, credentials, and every key outside the three tabs. Leaving a field blank removes its key from the file, returning that setting to its unset behaviour. Durations, ports and cron expressions are checked as you edit, and Save stays disabled while any field holds an invalid value — an invalid document never reaches the probe.

What happens after Save depends on the deployment:

  • On a managed deployment, the console asks the deployment manager (Keystone) to restart the service and waits until it reports healthy again — when the green confirmation appears, the change is already live. The console is unresponsive for the few seconds the restart takes.
  • On a plain systemd installation, the file is written and an amber banner stays on screen until you restart the service yourself: sudo systemctl restart axiom-border.
  • If the automatic restart fails, the write has still succeeded. Restart by hand — keystonectl restart axiom-border, or sudo systemctl restart axiom-border — and the saved configuration applies.

Changing apiPort or the login credential raises an explicit lockout warning: the file is written anyway, so before restarting make sure you can reach the new port or know the new password.

GET /config returns the current YAML and PUT /config replaces it. This exists so a probe can be configured for a customer environment without shell access to the host.

# 1. Authenticate
curl -X POST http://localhost:8083/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"<user>","password":"<your-password>"}'

# 2. Download the current file as a starting template (authenticated — it contains secrets)
curl -H "Authorization: Bearer <jwt-token>" \
  http://localhost:8083/config -o configuration.yaml

# 3. Edit it, then upload the COMPLETE file
curl -X PUT http://localhost:8083/config \
  -H "Authorization: Bearer <jwt-token>" \
  -H "Content-Type: application/x-yaml" \
  --data-binary @configuration.yaml

# 4. Apply
sudo systemctl restart axiom-border

The PUT response reports what happened:

{
  "restartRequired": true,
  "message": "configuration written; restart axiom-border to apply",
  "backup": "/opt/axiom-border/config/configuration.yaml.bak",
  "warnings": ["apiPort changes 8083 -> 9083 (may affect API access after restart)"]
}
Field Meaning
restartRequired Always true — configuration only takes effect at startup
message Confirmation text
backup Path of the previous file’s backup. Omitted when there was no previous file
warnings Present only when non-empty. Raised when apiPort or the login credentials change, because either can lock you out

Console and API end in the same validated write, with three guarantees:

  • Validation before writing. The document is validated exactly as at startup and checked for the six mandatory fields. Invalid YAML or a missing field returns HTTP 400 and nothing is written.
  • Atomic replacement. The file is written to a temporary file in the same directory and renamed.
  • Backup of the previous file, forced to 0600 because it contains secrets.
The write is a full replacement, and the backup is a single level

PUT /config expects the complete file, not a patch — fetch, edit, send back whole. The console handles this for you and sends the full document with your edits applied.

The backup is always the same filename, configuration.yaml.bak, and it is overwritten on every write. There is only one level of history. If you need more, copy it aside yourself before saving. To recover from a lockout, restore that file and restart.

Where the file lives

Deployment Path
Installed with install.sh (systemd) /opt/axiom-border/config/configuration.yaml
Managed deployment /var/lib/axiom-border/config/configuration.yaml — a stable path outside the per-version working directory

Overriding any field with an environment variable

Every key can be overridden from the environment by upper-casing it and replacing dots with underscores. A .env file in the working directory is also loaded.

Configuration key Environment variable
logger.logLevel LOGGER_LOGLEVEL
influxdb.token INFLUXDB_TOKEN
login.pass LOGIN_PASS

This is the recommended way to handle secrets: keep the tokens and passwords out of the YAML file entirely and inject them through the environment or your secret manager.

Required fields

Only six fields are validated as mandatory. If any is missing, the configuration is rejected:

login.user · login.pass · influxdb.url · influxdb.token · influxdb.org · apiPort

Everything else is optional and has a documented default.

logger

Backend logging, to console and to size-rotated files.

Field Type Default Description
directory string ./logs Destination directory for rotated log files
inFile bool true Write to file
inConsole bool true Echo to stdout
colorInConsole bool true ANSI colour codes on stdout. Disable when piping to a file or to journald
logLevel string DEBUG DEBUG | INFO | WARN | ERROR
processId string "" Process label added to every line; empty means no label
fileName string egprobe.log Base log filename; rotation appends suffixes
maxSize int (MB) 20 File size before rotation
maxBackups int 20 Number of rotated files kept
compress bool true gzip rotated backups
Tip

DEBUG is the shipped default and it is verbose enough to flood a journal on a busy probe. For production, INFO is the sane choice.

login

The single credential that protects the whole API except /auth/login.

Field Type Required Description
user string Yes API username
pass string Yes SHA256 hex digest of the password, not the password itself

Generate the digest before writing it:

echo -n "yourPassword" | sha256sum

At login, the probe hashes the submitted password and compares it against this value.

Change the shipped credential

The example file carries a placeholder digest so that a fresh installation can log in. It must be replaced before the probe is reachable by anything. Treat a deployment still carrying the example digest as unauthenticated.

influxdb

Connection to the metrics database (InfluxDB 2.x) that stores metrics, audit records, scan results and alarms.

Field Type Default Required Description
url string http://127.0.0.1:8086 Yes Metrics database endpoint
org string axiom Yes Organisation
token string Yes Token with read/write permission on the organisation. Prefer injecting via INFLUXDB_TOKEN
buckets []string see below No Buckets created at startup if they do not already exist

Default bucket set: network_bucket, ssh_bucket, metrics_bucket, usb_bucket, eg_alarms, audit_logs, networktraffic, availability, scanmetrics. The four per-channel event buckets are populated by the oda-lite collector, the rest by the probe itself — see Metrics ingestion.

The probe starts even when the metrics database is unreachable, and every feature that depends on it is inoperative until it recovers. This is deliberate for edge deployments where the database may be a separate node that boots later.

alerts and audit

Block Field Type Default Description
alerts offset duration 10s Tolerance window applied to the alarm evaluation query range
alerts bucket string eg_alarms Bucket where alarms are written and read
audit bucket string audit_logs Bucket for audit events: logins, configuration changes, executions

Top-level keys

Field Type Default Required Description
apiPort string 8083 Yes HTTP service port. Binds on 0.0.0.0
outputParquetPath string ./ No Destination directory for on-demand Parquet exports
dbPath string "" No Local state database file. Empty resolves to ./db.dat, relative to the working directory
gojascriptsDir string "" No Directory holding rule scripts. Empty resolves to resources/gojascripts
gojaTimeout duration 8s No, but set it Maximum execution time for a rule script per event
Two settings worth pinning down

Always set dbPath to an absolute path in production (for example /var/lib/axiom-border/db.dat). Deployments that use per-version working directories will otherwise create a fresh, empty database on every upgrade and appear to have lost all state.

Always set gojaTimeout explicitly. Without a time limit, rule scripts do not run at all.

pagination

Controls the two read modes over the metrics database. Every field has a default, so the block can be omitted entirely.

Field Type Default Description
maxRecentEvents int 1000 Number of most recent events kept per stream, which is also the cap of the /recent/* feed
rangePageSize int 100 Default page size for range queries
rangeMaxPageSize int 500 Maximum accepted pageSize
rangeMaterializeMaxRows int 40000 Row threshold. Below it, the whole range is cached, which allows jumping to any page and reporting an exact total. Above it, results are delivered sequentially, page after page
rangeCacheTTL duration 60s Lifetime of a cached range entry
rangeCacheMaxEntries int 8 Maximum ranges cached at the same time. Beyond it, the least recently used range is discarded
rangeExportMaxRows int 500000 Row cap for the streaming CSV export. A range exceeding it returns HTTP 400 asking you to narrow the window

trainerDetails

HTTP client settings for the AI container. Only relevant on Linux with a container engine available — see AI capabilities.

Field Type Default Description
serviceTlsCert string "" Client TLS certificate towards the AI container
serviceTlsKey string "" Matching private key
trainerContainerName string trainer Base container name. The effective name is <trainerContainerName>-<agent>

Three sub-blocks — metrics, healthCheck and predict — share the same four fields. The {port} literal in each URL is substituted at runtime with the port of the corresponding AI agent.

Sub-block url timeout retries timeBetweenRetries Purpose
metrics https://127.0.0.1:{port}/api/metrics 2s 1 2s Collect metrics from the AI container
healthCheck https://127.0.0.1:{port}/health 2s 20 5s Wait for container startup — 20 attempts at 5 s gives roughly 100 s of margin
predict https://127.0.0.1:{port}/api/predict 10s 1 2s Inference, invoked from rule scripts
Note

Use whole seconds for timeBetweenRetries; fractions of a second are not honoured.

securityProbes

Security probes run two ways: automatically on an interval (schedule), and manually from the API or UI. Results are stored in local state and in the metrics database — scanmetrics for aggregates, availability for up/down.

Probes ship disabled, and the first scan runs at startup

nmap, vulnScan and snmp all default to enabled: false. Enable them only after confirming their dependencies are present on the target host, because the first scan of an enabled probe runs as soon as the service starts, not after the schedule interval has elapsed. A probe enabled without its dependencies — the nmap binary on PATH, a readable templates directory, a valid capture interface — will log errors on every boot.

securityProbes.nmap

Host discovery, port scanning and optional fingerprinting. Requires the nmap binary on PATH.

Field Type Default Description
enabled bool false Enable the scheduled probe
schedule duration 2h Interval between sweeps. Zero or negative falls back to 2h
targets []string ["192.168.1.0/24"] Hosts and CIDR ranges to scan
portFilter string "" Port list such as "22,80,443". Empty means nmap’s top 1000
udp bool false Add a UDP scan. Slow
udpPorts string common UDP ports UDP ports probed when udp: true
timing string "" T1T5. T2 is cautious, T4 is reasonable on healthy networks
scanPorts bool true When false, ping scan only (-sn)
service bool false Service and version detection (-sV)
os bool false OS fingerprinting (-O). Implies a SYN scan, which needs raw sockets and therefore root
timeout duration 5m Abort if nmap does not finish

securityProbes.vulnScan

Template-based vulnerability scanning, including the embedded OT/ICS suite. The scan engine ships inside the product — there is no scanner binary to install.

Field Type Default Description
enabled bool false Enable the scheduled probe
schedule duration 2h Sweep interval. Also re-triggered by event, with debounce, when new hosts are recorded
severity string (CSV) info,low,medium,high,critical Severity filter, given as one comma-separated string
level string profundo Depth, translated to template tags — see below
templatesDir string "" Templates directory override for the scheduled probe
defaultTemplatesDir string ./vulnscan-templates Deployment-wide fallback
timeout duration 5m Scan cut-off. A request may override it
allowTemplateUpdates bool true Whether the probe may refresh templates from the network
allowIntrusive bool false Master lock for OT/ICS intrusive mode
enableOT bool false Add read-only OT templates to the scheduled sweep
userAgent string "" HTTP User-Agent for the web checks. Empty keeps a neutral, randomised browser value per request; set it only when a deployment needs a deterministic one

level to tag mapping:

level value Tags applied
ligero, rapido, fast tech,ssl
medio, medium cve,misconfig,default-login,tech,ssl
profundo, full, deep cve,misconfig,default-login,exposure,network,ssl,tech,ot
ot, ics, industrial ot only
Use one of the listed level values

A level value not in the table above applies no tag filter at all, so the scan walks the entire template tree with only the severity filter. This is rarely what anyone intends and is dramatically slower. Check for typos.

Templates directory resolution order: the templatesDir field of the scan request, then securityProbes.vulnScan.templatesDir, then defaultTemplatesDir, then a vulnscan-templates directory next to the product binary. Relative paths resolve against the working directory.

allowTemplateUpdates behaviour:

  • true (default, including when the key is absent): best-effort refresh to the latest template release. Being offline or having GitHub blocked does not abort the scan — local templates are kept. An empty directory triggers a full download.
  • false: strict offline kill-switch. The network is never touched, even when the directory is empty. If nothing is on disk the scan fails with a clear error.
The OT suite never depends on this

The OT/ICS templates ship inside the product and the bundled set is restored after every template update, so they survive a wipe-and-replace by the template manager and work with allowTemplateUpdates: false on an air-gapped network. See OT/ICS vulnerability scanning.

allowIntrusive governs the write/control layer of the OT suite over both REST and MQTT. A request asking for intrusive mode while this is false is rejected, and the execution ends as failed with an explicit message. The scheduled probe is never intrusive regardless of this flag.

enableOT adds the read-only ot tag to the scheduled sweep even when level would not include it. It never enables the intrusive layer. Some older PLCs are fragile in the face of unexpected connections; enable it only if the OT network tolerates periodic probing.

Recommended steady state for a probe on an industrial segment:

securityProbes:
  vulnScan:
    enabled: true
    level: "medio"
    enableOT: true            # continuous read-only OT visibility
    allowIntrusive: false     # write layer bolted shut
    allowTemplateUpdates: false   # air-gapped: never reach for the network
    defaultTemplatesDir: "/opt/axiom-border/vulnscan-templates"

securityProbes.snmp

Field Type Default Description
enabled bool false Enable the scheduled probe
schedule duration 2h Interval. Zero or negative falls back to 2h
targets []string ["192.168.1.0/24"] Hosts and CIDR ranges to interrogate
mibDir string MIBS/JSON-FORMAT MIB catalogue in JSON format
mib string synology Default MIB when no device match is found
port int 161 SNMP destination port
timeout duration 2m SNMP operation cut-off
oids []string sysDescr, sysName, sysObjectID OIDs fetched by GET
walkRoot string "" Root OID for the walk. Empty means sysDescr
workers int 32 Walk parallelism. Zero or negative becomes 1
Host pre-discovery has a fixed 30 s limit

This probe uses nmap for host pre-discovery, with a fixed 30 second limit. A /24 range at T2 timing will exhaust it. Narrow the range or raise the main nmap timing value.

With neither oids nor walkRoot set there is nothing for the probe to do.

securityProbes.sniffing

Continuous traffic capture with periodic persistence to the networktraffic bucket.

Field Type Default Description
interfaces []string [] Interfaces to capture. An empty list disables sniffing — there is no separate enabled switch for this block
bpf string "" BPF filter. Empty captures all IPv4
promiscuous bool true Put the NIC in promiscuous mode
backend string pcap Capture mode. pcap is the supported value
duration duration 10m Default window for a manual capture
flushInterval duration 5m Persistence interval for continuous capture. Zero or negative becomes 1 minute

Interface naming is platform-specific and is the most common source of a probe that captures nothing:

Platform Format Example
Linux Simple name eth0, enp3s0, wlan0
macOS BSD name en0
Windows Npcap NPF device path \\Device\\NPF_{GUID}

On Windows, the friendly name (Ethernet, Wi-Fi) does not work. Discover the NPF path with nmap --iflist and read the WINDEVICE column.

opengate

Optional cloud integration: inventory reporting (collect) and provisioning (provision). The whole section is inert when enabled: false.

Field Type Default Description
enabled bool false Master switch
apiKey string "" Secret. Sent as the X-ApiKey header over HTTP, and used as the default MQTT password when collect.mqtt.password is empty
cron string */30 * * * * Five-field cron expression for provision and collect. Descriptors such as @hourly are accepted. Empty or invalid means the integration does not run
minPeriod duration 30m Throttle. If the cron interval is shorter than this, the cron is ignored and a plain ticker at minPeriod is used instead
macDiscoveryTimeout duration 10s Limit for resolving the local host MAC

opengate.collect

Field Type Default Description
enabled bool false Enable collected-data reporting
mode string mqtt http or mqtt
urlTemplate string OpenGate south collect endpoint URL template; {{deviceID}} is substituted
deviceId string "" Force a fixed device ID. Empty derives one per host from IP and MAC
sendByParts bool false Split the payload into components: ports, SNMP, vulnerabilities
partSize.ports int 100 Port rows per chunk
partSize.snmp int 100 SNMP entries per chunk
partSize.vulnerabilities int 100 Vulnerabilities per chunk
retryCount int 3 Retries for the collect publish or POST
retrySleep duration 5s Wait between retries
mqtt.broker string "" OpenGate broker URL. Required for mode: mqtt; empty skips sending
mqtt.username string "" MQTT username
mqtt.password string "" Secret. Empty falls back to opengate.apiKey
mqtt.topic string "" Publish topic, accepts {{deviceID}}. Required for mode: mqtt
address.* string Asset location metadata: country, region, province, town, postal, address

opengate.provision

Field Type Default Description
enabled bool false Enable bulk provisioning
bulkUrlTemplate string OpenGate north bulk endpoint Template with {organizationName} and {provisionProcessorId} placeholders
searchUrl string OpenGate north bulk search endpoint Endpoint for querying bulk status
organizationName string "" Target OpenGate organisation. Needed when enabled
provisionProcessorId string "" Provision processor ID. Needed when enabled
retryCount int 3 Retries for the bulk file upload
retrySleep duration 5s Wait between retries
pollMaxAttempts int 10 Maximum bulk result polls
pollSleep duration 5s Wait between polls

mqtt

Three independent blocks: broker is the MQTT broker embedded in the product, client is the internal publisher of executions and alarms, and ops is the client that listens for OpenGate operations and answers them.

mqtt.broker

Field Type Default Description
enabled bool true Start the embedded broker
host string 0.0.0.0 TCP listener interface
port int 1883 MQTT TCP port
username string "" Broker authentication. Empty username and password together allow anonymous access
password string "" Secret
ws.enabled bool true Secondary WebSocket listener, used by the web UI
ws.host string 0.0.0.0 Not configurable — the WebSocket listener always binds on all interfaces
ws.port int 1888 WebSocket port
ws.path string /ws WebSocket endpoint path. A leading slash is added if missing
ws.tls bool false TLS on the WebSocket listener
tls.enabled bool false TLS on the main MQTT listener
tls.host string "" TLS listener interface. Empty inherits broker.host
tls.port int 8883 MQTT-over-TLS port
tls.verify bool false Verify client certificates (mTLS)
tls.caFile string "" CA used to validate clients. Needed when verify is on
tls.certFile string "" Server certificate. Needed when TLS is enabled
tls.keyFile string "" Server private key. Needed when TLS is enabled
Anonymous by default

With username and password both empty the embedded broker accepts anonymous connections. On any network you do not fully control, set credentials and enable TLS. If you do enable TLS, supply a complete and valid certificate set: an incomplete TLS block prevents the broker from starting at all.

mqtt.client

Field Type Default Description
enabled bool true Start the internal publisher
broker string tcp://127.0.0.1:1883 Broker URL, by default the embedded one. Empty disables the client with a warning
clientId string "" Empty generates a unique client ID automatically
username string "" Only sent when non-empty
password string "" Secret, only sent when non-empty
topic string axiom-border/executions Execution-event publish topic. Empty publishes nothing
qos int 1 Publish QoS
retain bool true Retain flag on published messages

Auto-reconnect is always on, retrying every 5 seconds, with a 10 second initial connection timeout. Neither is configurable.

mqtt.ops

Field Type Default Description
enabled bool true Start the operations listener
broker string tcp://127.0.0.1:1883 Broker to subscribe against
username string "" MQTT username
password string "" Secret
topicSubscribe string odm/operation Incoming OpenGate operations topic
topicPublish string odm/response/{device-id} Response topic; {device-id} is substituted at runtime
qos int 1 QoS for both subscribe and publish
retain bool false Retain flag on responses

If broker, topicSubscribe or topicPublish is empty, the operations module does not start.

Metrics Ingestion and Audit

Metrics ingestion and audit

Axiom Border’s data comes from two origins. The first one the probe captures by itself: the traffic it sniffs and the scheduled scans it runs against the network around it, with no cooperation from the equipment it watches. The second one is reported to it: every monitored machine runs an oda-lite agent that watches the machine from the inside and sends what it sees.

flowchart TB
    NET["The network around<br>the probe"]:::ext
    MACH["The monitored machines<br><i>an oda-lite agent on each</i>"]:::ext
    NET -->|"sniffing and<br>scheduled scans"| P["<b>Axiom Border</b>"]
    MACH -->|"SSH · USB · interface<br>· health events"| P
    P --> ST[("Metrics store<br><i>history and exports</i>")]
    P --> LP["Live pipeline<br><i>rules · alarms · audit</i>"]
    classDef ext fill:#e9edfa,stroke:#486ac9,color:#101010

Whatever the origin, the data ends in the same two places: the metrics store, which keeps the history behind queries and exports, and the live pipeline, where rules are evaluated, alarms are raised and the audit trail is written. This page follows that flow — the two origins first, then the pipeline, then how to read everything back.

What the probe captures by itself

The network-facing half needs nothing installed on the monitored equipment:

  • Traffic capture — continuous sniffing of the configured interfaces, persisted periodically to the networktraffic bucket. Configured under securityProbes.sniffing.
  • Scheduled assessments — the discovery, vulnerability and SNMP probes run on their configured intervals, keep the asset inventory current, and record per-scan aggregates (scanmetrics) and host up/down state (availability).

Both are documented in Security assessment. What matters on this page is that their results are data like any other: they land in the store, their executions are audited, and the audit log’s agent filter accepts the probe names (network, vulns, snmp, sniff) alongside the agent channels.

What the agents report

The host-facing half is delivered by oda-lite, a lightweight monitoring agent that ships alongside Axiom Border as a separate component with its own release cycle — the version you have is the one your package includes. It is the whole of the monitoring deployment role: a machine with that role runs only the agent, reporting to a central probe. The probe’s own machine runs one too, so the central host is watched exactly like every other node.

An agent watches four things, and each maps to a channel — the “Guards” of the console views:

Channel In the console What arrives
ssh SSH-Guard SSH activity: connections, successful and failed logins, logouts, per-session byte counters — tagged with user, ip and port
iface IFACE-Guard Network interfaces appearing, disappearing or changing state, with the interface name, type and MAC address
usb USB-Guard USB devices connected and disconnected, with device names, ID and manufacturer
metrics METRIC-Guard The node’s reporting-health heartbeat: ram_usage, prepared_vars and sent_vars. Its silence is what a node stopped reporting alarm keys on

Those four names are what the API calls {agentType}: they appear in the read paths (/recent/{agentType}), in the audit log’s agent filter, and as the group routing tag on every event.

flowchart TB
    AG["oda-lite agents<br>on each machine"]:::ext
    OTH["Other reporters<br><i>optional</i>"]:::ext
    AG -->|":9090/agents<br>line protocol"| COL["oda-lite on the probe host<br><i>central profile</i>"]
    OTH -->|":9091/metrics<br>JSON"| COL
    COL -->|"one bucket<br>per channel"| ST[("Metrics store")]
    COL -->|"POST /telegraf"| LP["Live pipeline<br>rules · alarms · audit"]
    classDef ext fill:#e9edfa,stroke:#486ac9,color:#101010

On the probe host, oda-lite runs in its central profile: besides watching its own host, it listens for everyone else on two ports —

Listener Format Who posts there
:9090/agents InfluxDB line protocol The oda-lite agents on the monitored machines
:9091/metrics JSON Other reporting processes. A metric arriving without a channel tag that carries the reporting-health fields is routed to the metrics channel automatically

Everything it receives — its own guards’ events included — goes two ways at once: each channel is written to its bucket in the metrics store, and every event is forwarded to the probe’s live pipeline, so rules, alarms, audit and the recent feeds see it immediately.

Two operational handles worth knowing:

  • The agent is a systemd service on every node: systemctl status oda-lite. Its configuration is generated by the installer at /etc/oda-lite/oda-lite.conf — this is also where the listener ports move if 9090 or 9091 collide with something else.
  • Agents buffer briefly and flush every few seconds, so an event appears in the console with at most a few seconds of delay — instantly is the wrong expectation, but a minute is a problem.
SSH events need verbose sshd logging

The SSH monitor reads authentication logs, which means sshd must log at LogLevel VERBOSE and rsyslog must be populating /var/log/auth.log. The installer configures both for the roles that need them. On a host where SSH events never appear, check those two things first.

Seeing what is reporting in

Supervisions in the console lists the channels the probe is receiving data from. Each card is one channel, with a status dot, the last event it delivered, and the nodes it is monitoring.

The Supervisions view, with one card per channel: the interface, SSH and USB monitors reporting in, and the metrics one with no data

This is the first place to look when data is not arriving: a channel with no recent event means the agent has either stopped, or never reached the probe. In the capture above METRIC-Guard is in exactly that state — there is no data to display — which is the same silence that raised the node stopped reporting alarms on Current status. All events opens the full history for that channel.

The live pipeline: rules, alarms and audit

Events reach the probe itself through a single door, POST /telegraf — the same endpoint the collector forwards into. It accepts no authentication, because the processes reporting into it are unattended.

Protect the ingestion port

Anyone who can reach the API port can inject measurements, which means they can also trigger rules and raise alarms. Bind the API to a management network or firewall it. This is a deployment responsibility, not something the probe can enforce for you.

Payload format

The body is {"metrics": [...]} and every metric requires all four fields:

Field Type Meaning
name string Becomes the measurement in the store. In practice the machine ID of the reporting node
timestamp int64 Epoch in seconds, not milliseconds
tags map of string Must include group, which routes the metric to a channel
fields map The event’s values, numeric or string

The group tag is the routing key:

group value Channel History bucket
SSH ssh ssh_bucket
USBS usb usb_bucket
IFACES iface network_bucket
METRICS metrics metrics_bucket
SECSCAN Routed by the additional probe tag scanmetrics

Beyond group, the conventional tags are eventType, ip, port and user — these are what rules and queries filter on.

curl -s -X POST http://192.168.1.10:8083/telegraf \
  -H 'Content-Type: application/json' \
  -d '{
    "metrics": [
      {
        "name": "<machine-id>",
        "timestamp": 1746535617,
        "tags": {
          "group": "SSH",
          "eventType": "conn",
          "ip": "192.168.1.80",
          "port": "56390",
          "user": "unknown"
        },
        "fields": { "authevent": 1 }
      },
      {
        "name": "<machine-id>",
        "timestamp": 1746535623,
        "tags": {
          "group": "SSH",
          "eventType": "login_attempt_fail",
          "ip": "192.168.1.80",
          "port": "56390",
          "user": "operator"
        },
        "fields": { "authevent": 1 }
      }
    ]
  }'

The response is 200 with an empty body, or 400 if the JSON does not parse.

A 200 does not mean the rules ran

The recent feeds are updated immediately, so a /recent/* query straight after ingestion will show the event. Rule evaluation, auditing and alarm generation are asynchronous, so the 200 confirms acceptance, not evaluation. If you are testing a rule, allow a moment and check the alarms feed rather than inferring from the ingestion response.

This endpoint feeds the pipeline, not the history

POST /telegraf drives the recent feeds, the rules engine and the audit trail — it does not write the event itself to the metrics store. History is written by the collector. An event posted directly here can raise alarms and will show in the recent feeds, but it leaves no history and will not appear in range queries or exports. To feed your own data in fully, post to the collector’s listeners — :9090/agents in line protocol or :9091/metrics in JSON — and let it fan out to both places.

Reading data back

There are three ways to read, and choosing the right one is the difference between a responsive UI and a slow one.

Recent feeds — for live views

These answer in microseconds, serving the most recent events the probe already holds without running a historical query. Feed depth is capped by pagination.maxRecentEvents, default 1000.

curl -s -X POST http://192.168.1.10:8083/recent/ssh \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"limit": 50, "orderBy": "desc"}'
{
  "count": 50,
  "warming": false,
  "elements": [ { "_measurement": "<machine-id>", "_time": "2026-05-06T13:27:03.000Z", "eventType": "login_attempt_fail", "user": "operator", "authevent": 1 } ]
}

Optional request fields: limit, uuids to filter by node, and orderBy for presentation order. Note that orderBy only affects how the returned page is sorted — the feed always yields the most recent events, so asc does not page backwards through history.

The warming flag is true while the feed is still being seeded after a restart. Surface it in a UI as a loading state rather than presenting a partial feed as complete.

Three feeds exist: /recent/{agentType} for events, /recent/alarms for alarms, and /recent/auditlog for audit entries. The audit variant takes timeOrder instead of orderBy and adds eventsId and agent filters — the latter also accepting the probe names network, vulns, snmp and sniff.

There is no total or pages in the response. The client paginates locally over what it received.

Paginated ranges — for historical queries

curl -s -X POST http://192.168.1.10:8083/datapagination/ssh \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "from": "2026-08-01T00:00:00Z",
        "to": "2026-08-03T00:00:00Z",
        "page": 1,
        "pageSize": 100,
        "orderBy": "desc"
      }'

A range read is answered in one of two ways, and the response metadata tells you which one you got:

meta field Meaning
randomAccess true: page numbers work, so you can jump to any page. false: follow nextCursor instead
exact true: total and pages are exact. false: total is an upper bound

Narrow ranges answer with full random access. Wide ones switch to sequential reading, and nextCursor carries the position of the following page.

{
  "elements": [ "..." ],
  "meta": {
    "total": 3412, "pages": 35, "page": 1, "pageSize": 100,
    "randomAccess": true, "exact": true,
    "range": { "from": "2026-08-01T00:00:00Z", "to": "2026-08-03T00:00:00Z" }
  }
}

To continue a sequential read, send only the cursor field — no from, no to, no page.

Read randomAccess before drawing a pager

When randomAccess is false, page numbers are meaningless and total is an upper bound, not a count. A UI that renders “page 12 of 340” from that metadata will be wrong. Read meta.randomAccess on every response and switch between a numbered pager and a “load more” control accordingly.

Pagination limits are configurable — see Configuration.

Exports — for taking data elsewhere

Streaming CSV, which is the one to use:

curl -s -X POST http://192.168.1.10:8083/data/ssh \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "uuids": ["<machine-id>"],
        "orderby": "desc",
        "range": { "initTime": "2026-08-01T00:00:00Z", "finishTime": "2026-08-03T00:00:00Z" }
      }' -o export.csv

The range is mandatory here, and a range exceeding pagination.rangeExportMaxRows (default 500 000) is rejected with 400 asking you to narrow it. That guard is deliberate.

Parquet, which behaves differently:

curl -s -X POST http://192.168.1.10:8083/parquet/ssh \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"uuids": ["<machine-id>"]}'
Parquet export writes server-side and overwrites

This endpoint returns 200 with an empty body — it does not return the file. It writes to a fixed path, <outputParquetPath>/influxquery.parquet, so every call overwrites the previous one. Unlike the CSV export, the range is optional and no row-count limit applies, so a broad query can produce a very large export. Always pass a range on a populated bucket, and collect the file from the server before the next call.

Other read helpers

Endpoint Purpose
GET /lastevent/{agentType} Latest event per device as semicolon-separated CSV. Sets X-Recent-Warming: true while the feed is still seeding
GET /uuids/{agentType} Node UUIDs present in one channel’s bucket
GET /uuids Map of UUID to the channels it appears in

Aliases — making UUIDs readable

Nodes are identified by machine ID, which is unreadable. An alias maps one to a name, and every read endpoint accepts ?alias=true to perform the translation:

curl -s -X POST http://192.168.1.10:8083/alias \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"alias": "plc-line-1", "uuid": "<machine-id>"}'

With ?alias=true, the uuids filter in a request body also accepts alias names, so a client can work entirely in readable names. Note that DELETE /alias takes the alias name, as {"name": "..."}, not the UUID.

Audit

Every significant action is audited to the audit_logs bucket. Two ways to read it:

# Live tail
curl -s -X POST http://192.168.1.10:8083/recent/auditlog \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"limit": 100, "timeOrder": "desc"}'

# Historical, filtered to vulnerability scans
curl -s -X POST http://192.168.1.10:8083/auditlog \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "from": "2026-08-01T00:00:00Z",
        "to": "2026-08-03T00:00:00Z",
        "agent": "vulns",
        "pageSize": 100
      }'

The agent filter accepts the four channel names and the four probe names. Filtering by vulns is how you answer “what was scanned, when, and by whom” — including the warning entry that every intrusive OT scan generates.

Auditing follows the rule script

An agent’s or probe’s activity is audited when its rule script is enabled. Disabling a script therefore costs you the audit records for that agent or probe as well as its rules — the events are still ingested and stored, but they leave no audit trail.

If you need an audit trail for a particular agent, check the enabled state with GET /scripts and make sure its script exists and is enabled, even if the script itself does nothing. See Alarms and the rules engine.

Storage layout

Where each kind of data ends up. The four channel buckets are written by the collector; everything else is written by the probe itself:

Bucket Contents
ssh_bucket, usb_bucket, network_bucket, metrics_bucket Per-channel agent events
eg_alarms Raised alarms
audit_logs Audit trail
networktraffic Passive sniffing flow data
availability Host up/down state from the probes
scanmetrics Aggregated scan metrics

Buckets are created on first start if absent. Note that no retention policy is applied by default — configure retention in InfluxDB according to your disk budget, or the metrics buckets will grow without bound. An Influx bucket with no retention is a common cause of a probe slowly filling its disk.

Alarms and the Rules Engine

Alarms and the rules engine

Axiom Border decides locally. A probe that only forwards data is useless the moment the uplink drops, so alarm evaluation runs on the device, against data it already holds, with no dependency on the platform.

There are two ways to express what should raise an alarm:

Mechanism Use it for Defined via
Checks — declarative rules Thresholds, event matches, dead-machine detection /checks API
Scripts — rules written in JavaScript Anything with logic: correlation, state, arithmetic, calls to the AI model /scripts API

Checks cover most of what monitoring needs and need no code. Scripts exist for the cases checks cannot express.

Where you work with alarms

Alerts in the console is the operator’s view: the alarms currently raised, the history of what has been logged, and the Rules configuration button that opens the editor for the rules described below. An autoreload toggle keeps the list current while you watch an incident develop.

The Alerts view listing raised alarms with their severity and originating rule

Each row names the alarm, the check type behind it, the node it came from, the agent, the severity and a description that includes when the condition was first seen. ACK acknowledges the selected alarm.

Two tabs sit above the table. Recent reads the live feed — the most recent alarms the probe already holds, answered immediately. Range queries a time window from history instead, which is what you want when reconstructing an incident after the fact:

The Range tab, querying alarm history over a time window

Rules configuration opens the Alarm config screen, which is where both kinds of rule live — the declarative alarm rules in the upper table, and the JavaScript Expert System at the bottom with its own enable switch:

The Alarm config screen, listing alarm rules and the Expert System section

Everything on this page can be done from that screen or from the API, and both paths are shown together wherever they differ.

Checks — declarative rules

A check watches one event type from one agent over a time window, and raises an alarm when its condition holds.

flowchart TB
    EV["Event arrives"] --> MATCH{"Matches<br>a check?"}
    MATCH -->|no| DROP["Stored only"]
    MATCH -->|yes| TYPE{"Check type"}
    TYPE -->|count| CNT{"Count over<br>threshold?"}
    TYPE -->|deadmachine| SILENT{"Silent for<br>the window?"}
    CNT -->|no| WAIT["Keep counting"]
    CNT -->|yes| FIRE["Raise alarm"]
    SILENT -->|yes| FIRE
    FIRE --> COAL{"Alarm<br>already open?"}
    COAL -->|yes| INC["Increment count"]
    COAL -->|no| NEW["New alarm"]

The two check types

ctype Fires when threshold
count The event occurs at least threshold times within freqs Required
deadmachine No events arrive from the node within freqs Not used, send 0

deadmachine is the one worth calling out: it alarms on absence, which is how you detect a node that stopped reporting rather than a node reporting something bad.

Alarming on every occurrence

To raise an alarm whenever an event happens at all, use a count check with "threshold": 1 and a short freqs. The first matching event trips the condition immediately.

Creating a check

From Alerts → Rules configuration, use New alarm rule. The wizard walks three steps — Rule config, Alarm config and Summary — and collects exactly the fields described above:

Step one of the new alarm rule wizard

Rule type is the check type, Agent and event choose what to watch, Trigger sets the window and the count, and UUIDs selects the nodes. Only agents actually reporting in are offered, which is why a rule cannot be created before its agent has sent data.

curl -s -X POST http://192.168.1.10:8083/checks \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "checkName": "ssh-bruteforce",
        "agent": "ssh",
        "ctype": "count",
        "event": "login_attempt_fail",
        "freqs": "5m",
        "threshold": 10,
        "level": "critical",
        "alarmName": "SSH brute force attempt",
        "alarmDescription": "More than 10 failed SSH logins in 5 minutes",
        "uuids": ["<machine-id>"],
        "enable": true
      }'

Every field is required on create. freqs is a duration string — 30s, 5m, 1h. level is info, low, medium, high or critical. uuids lists the nodes the check applies to, and accepts alias names when the request carries ?alias=true.

A dead-machine check for the same node:

curl -s -X POST http://192.168.1.10:8083/checks \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "checkName": "node-silent",
        "agent": "metrics",
        "ctype": "deadmachine",
        "event": "cpu",
        "freqs": "10m",
        "threshold": 0,
        "level": "critical",
        "alarmName": "Node stopped reporting",
        "alarmDescription": "No metrics received from the node in 10 minutes",
        "uuids": ["<machine-id>"],
        "enable": true
      }'

Managing checks

Method Path Notes
GET /checks Lists all checks. Returns 204 when there are none
POST /checks Create. Returns 201
PUT /checks Update
DELETE /checks Body {"name": "<checkName>"}
Three fields cannot be updated

PUT /checks keeps the stored ctype, agent and event. To change what a check watches or how it evaluates, delete it and create it again. Everything else — threshold, window, level, alarm text, nodes, enablement — updates normally.

To pause a check without losing its definition, set enable: false rather than deleting it.

Alarm coalescing

A brute-force attempt that trips a threshold every five minutes for an hour should be one alarm with a count, not twelve identical alarms. Axiom Border coalesces repeated firings of the same rule against the same node into a single open alarm:

Field Meaning
firstSeen When the condition first held
lastSeen The most recent firing
count How many times it has fired

The alarm stays open and accumulating until someone acknowledges it. Acknowledgement is what closes the coalescing window — the next occurrence after an acknowledgement opens a fresh alarm, so the operator gets a new signal rather than a silent increment on something they already dealt with.

Acknowledging

curl -s -X PATCH 'http://192.168.1.10:8083/alarms?alarmid=<alarm-id>' \
  -H "Authorization: Bearer <jwt-token>"

This sets ack=1 and records ackTime. Acknowledging an already-acknowledged alarm returns 400, so a UI can treat that as “someone else got there first” rather than an error worth surfacing loudly.

Reading alarms

# Live feed
curl -s -X POST http://192.168.1.10:8083/recent/alarms \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"limit": 50, "orderBy": "desc"}'

# Historical, paginated
curl -s -X POST http://192.168.1.10:8083/alarms \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"from": "2026-08-01T00:00:00Z", "to": "2026-08-03T00:00:00Z", "pageSize": 100}'

An alarm carries alarmId, alarmName, alarmDescription, criticality, eventType, agent, agentUuid, time, ackTime, and the coalescing trio.

Alarms are stored in the eg_alarms bucket. Evaluation applies a tolerance window set by alerts.offset (default 10 s) to absorb clock skew and ingestion latency — without it, an event landing milliseconds outside a window boundary would be missed.

Rule scripts

For logic that a declarative check cannot express, Axiom Border runs rule scripts written in JavaScript. They are evaluated on the device, server-side, on events you did not trigger.

In the console these are the Expert System, edited from Alerts → Rules configuration → Edit script. The editor opens on a working skeleton, so you can see the shape a script must have before writing one:

The Expert System script editor, showing the process(metrics) skeleton

Note the Enable switch beside the editor on the Alarm config screen: a saved script does nothing until it is turned on.

How scripts are organised

A script is identified by name, and the name determines when it runs. Scripts named after an agent — ssh, iface, usb, metrics — run on events for that agent. Scripts named after a probe — network, vulns, snmp, sniff — run on that probe’s findings. A general-purpose rulesengine script runs over every ingested batch.

Every script has an enabled state, set with the ?enabled= query parameter when you upload it and reported by GET /scripts. A disabled script is stored but never invoked.

The script contract

A script must define a function called process, which receives the batch of metrics:

function process(metrics) {
  for (var i = 0; i < metrics.length; i++) {
    var metric = metrics[i];
    metric.SetT0();
    // your logic here
    metric.SetT1();
  }
}
metrics is not a real JavaScript array

Iterate it by index with .length, as above. .filter(), .map() and .forEach() are not available and will throw. This is the single most common mistake when writing a first rule.

SetT0() and SetT1() bracket your processing and are what populate the timing fields in the audit record. They are not required for the rule to work, but including them is the convention and it makes slow rules visible.

Available globals

Function Purpose
newAlarm(metric, criticality, description, alarmName, eventType) Raise an alarm
newPredict(payloadJSON, agentType) Call the AI model for inference
newGetMetrics(agentType) Fetch the AI model’s own metrics
filterByIp(metrics, ip) Subset of the batch matching an IP
filterByProbe(metrics, probe) Subset matching a probe name
distinctIps(metrics) Distinct IPs, in first-appearance order
print(...) Write to the log at debug level

Each metric exposes: GetTagByName(key), GetFieldByName(key), ExistsField(key), GetMetricName(), GetAliasName(), GetAgentType(), GetMonitoredEvents(), GetEventValues(), GetTime(), SetT0(), SetT1() and String().

A worked example, raising an alarm when a scan finds a risky port open:

var RISKY_PORTS = { 23: "telnet", 21: "ftp", 3389: "rdp", 445: "smb", 5900: "vnc" };

function process(metrics) {
  for (var i = 0; i < metrics.length; i++) {
    var m = metrics[i];
    m.SetT0();

    if (m.GetTagByName("kind") === "port" && m.GetFieldByName("portStatus") === "open") {
      var port = m.GetFieldByName("portNumber");
      if (RISKY_PORTS[port]) {
        newAlarm(m, "critical",
          "Risky service " + RISKY_PORTS[port] + " exposed on port " + port,
          "Risky port open", "port_discovery");
      }
    }

    m.SetT1();
  }
}

The tags and fields available per probe are documented in the header comment of each deployed script, so read the script with GET /scripts/{scriptname} before writing rules against it.

What probe rules can react to

Probe rules react to appearances and changes: a new host, a new open port, a changed SNMP value. To alarm on a node that stops responding, use a deadmachine check against that node’s agent data instead — that is exactly what checks on absence are for.

Managing scripts

Method Path Notes
GET /scripts Names and enabled state. 204 when there are none
GET /scripts/{scriptname} Source, as text/plain
POST /scripts/{scriptname}?enabled=<bool> Create. Body is the JavaScript source as text/plain
PUT /scripts/{scriptname}?enabled=<bool> Replace the source
DELETE /scripts/{scriptname} Delete
# Upload a rule for SSH events
curl -s -X POST 'http://192.168.1.10:8083/scripts/ssh?enabled=true' \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: text/plain' \
  --data-binary @ssh-rule.js

# Read back what is deployed
curl -s http://192.168.1.10:8083/scripts/ssh \
  -H "Authorization: Bearer <jwt-token>"

Uploading through the API is the recommended path — it works on a probe you have no shell access to, and it is the same mechanism the web console uses.

Always set a script timeout

Each script invocation is bounded by gojaTimeout. Define it explicitly: without a value the limit is zero and no script runs at all, which shows up as missing alarms rather than as an error. The shipped value is 8s.

gojaTimeout: "8s"

Scripts can call the AI model

A script can invoke inference against a deployed AI capability, using the predict endpoint configured under trainerDetails.predict. This is what connects anomaly detection to alarm generation: the model scores an event, and the script decides whether that score warrants an alarm.

Timeouts and retries for that call come from trainerDetails.predict — default 10 s, one retry. Note that the call is bounded by gojaTimeout as well, so a predict timeout longer than the script timeout cannot complete. Keep gojaTimeout comfortably above the predict timeout if your rules use inference.

See AI capabilities for deploying a model in the first place.

Security probe results reach the rules engine too

Security probe findings are evaluated by the rules engine and audited exactly like agent metrics. A scan finding can therefore raise an alarm the same way a metric can — a newly discovered host, a critical vulnerability, a port that opened when it should not have. See Security probes.

Choosing between a check and a script

Reach for a check when the condition is “this event, this many times, this window”. It is declarative, visible in the API, and cannot fail in interesting ways.

Reach for a script when you need to remember something between events, combine two signals, compute a value, or ask the model. The cost is that scripts are code: they need a timeout that works, they fail in ways checks do not, and they are harder to audit at a glance.

If a check can express it, use the check.

Where alarms go next

Locally, alarms land in eg_alarms and surface through the feeds above and in the web console.

If OpenGate integration is enabled, alarms and executions also publish over the embedded MQTT broker and are forwarded to the platform, which is how a fleet of probes becomes a single operational picture. See MQTT and OpenGate operations.

Security Probes

Security probes

Axiom Border assesses the network it sits on with four probes. They share one execution model, one audit trail and one accumulated view of results, so the findings of each one reinforce the others instead of living in separate reports.

Probe What it answers Requires
Network discovery What is out there, and what is it listening on? nmap 7.80+ on PATH
Vulnerability scanning Which of those services are vulnerable? A templates directory (OT/ICS checks are included)
SNMP What does the device say about itself? Reachable SNMP agent, credentials or a profile
Traffic capture What is actually crossing the wire? libpcap, a valid capture interface, root

Each runs two ways: automatically on a configurable interval, and manually on demand from the API or the web UI. Results are kept locally as probe state and written to InfluxDB as metrics, and every execution is audited.

flowchart TB
    DISC["Network discovery<br><i>live hosts and ports</i>"]
    DISC --> VULN["Vulnerabilities"]
    DISC --> SNMP["SNMP identity"]
    DISC --> CAP["Traffic capture"]
    VULN --> INV
    SNMP --> INV
    CAP --> INV
    DISC --> INV[("Consolidated<br>inventory")]
    INV --> ALARM["Alarms and<br>audit trail"]

Network discovery establishes the baseline and the other three enrich it. That ordering matters: a vulnerability scan with no targets scans every host in the baseline, so a discovery scan must have run first for it to have anything to do. Newly discovered hosts also trigger a vulnerability scan on their own, grouped together after a short delay, so new assets get assessed without waiting for the next interval.

Where you see the results

Network status in the console is the operational view of everything on this page: the discovered hosts with their addresses, when each was first and last seen, and per-host actions for its open ports, its vulnerabilities and its SNMP data. The charts below summarise findings by severity, hosts by status, and how many hosts carry vulnerabilities at all.

The Network status view, listing discovered hosts with their ports and vulnerabilities

The header names the last assessment that ran and how it finished, so you can tell at a glance whether what you are looking at is current.

Each row carries its own actions. Scan opens the Ports panel for that host — what it is listening on, with the service the scan identified and when the port was first and last seen:

The Ports panel for a single host

Vulnerabilities opens the findings for that host. Each row names the check that fired and explains what it confirmed, which is what makes a finding actionable rather than just a label:

The Vulnerabilities panel, listing the checks that fired against one host

Both panels carry a re-scan button, so you can reassess a single host without launching a sweep of the whole range.

How to read a finding

Name says what was confirmed — Modbus/TCP Diagnostics Function Exposed Without Authentication — and Description explains how it was confirmed and why it matters. Two more columns sit to the right of the description: CVE, empty whenever the finding is an exposure rather than a published vulnerability, and Template ID, which always identifies the check (modbus-diagnostics-exposure and the like). Since not every finding has a CVE, the template identifier is the stable way to refer to one.

Checks from the write/control layer are marked twice over: the name ends in [INTRUSIVE] and the description opens with INTRUSIVE / GATED. Those only ever run when a scan explicitly asks for intrusive mode — see OT/ICS vulnerability scanning.

The shared execution model

Every probe launch is asynchronous: the request is accepted, you get an identifier back immediately, and the work continues in the background.

Open Network status and use Edit to set the scan parameters, then Reload to refresh the view once the run finishes. The header keeps showing the state of the most recent execution while it progresses.

curl -s -X POST http://192.168.1.10:8083/security/scan/network \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"targets": ["192.168.1.0/24"], "service": true}'
{ "uuid": "4a79214d-3deb-4ae8-933f-9874cbdc1128", "status": "inprogress" }

Poll GET /security/executions/{uuid} until the status leaves inprogress, landing on finished or failed. An execution record carries two payloads that are easy to confuse: currentOutput is the result of that run alone, while currentStatus is the consolidated view of the affected hosts. To list completed runs, filter by finished.

Executions also publish over MQTT

If you would rather not poll, the embedded broker publishes started, finishedok and failed events for every execution. See MQTT and OpenGate operations.

Network discovery and port scanning

The discovery probe. It finds live hosts, enumerates ports, and optionally identifies services and operating systems.

curl -s -X POST http://192.168.1.10:8083/security/scan/network \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "targets": ["192.168.1.0/24"],
        "portFilter": "1-1024,502,2404,20000,47808",
        "timing": "T3",
        "service": true,
        "os": false,
        "timeout": "30m"
      }'

The portFilter above is worth copying if you have industrial equipment: it covers the common IT range plus the Modbus, IEC-104, DNP3 and BACnet ports, so the OT assets show up in the baseline that the vulnerability probe will later work from.

Practical notes:

  • os: true needs root, because OS fingerprinting implies a SYN scan over raw sockets. Without it, leave OS detection off.
  • udp: true is slow. Restrict udpPorts rather than sweeping.
  • Only one discovery scan runs at a time. A second request returns 409 Conflict unless you pass ?force=true, which cancels the running one and marks it failed with cancelled due to forced run.
  • The default portFilter is the full 1-65535 range. On a /24 at cautious timing that is a long scan — narrow it.

Scheduled behaviour is configured under securityProbes.nmap — from the console’s Configuration view, or in the file. See Configuration.

Vulnerability scanning

Template-based scanning, in two distinct halves. The web and CVE half uses the standard upstream template tree and needs that tree present on disk. The OT/ICS half uses Axiom Border’s own suite of 57 industrial protocol checks, which are installed with the product and work with no internet access.

curl -s -X POST http://192.168.1.10:8083/security/scan/vulns \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"targets": ["192.168.1.50"], "level": "medio", "severity": "high,critical"}'

Depth is chosen with level, which takes one of four values — ligero, medio, profundo or ot. Always use one of those four: each selects a different set of checks, and the mapping table in Configuration is worth reading before you pick one.

Industrial protocol scanning has its own page, because the safety model deserves the space:

OT/ICS vulnerability scanning — Modbus/TCP, IEC 60870-5-104, DNP3, BACnet/IP and OPC UA, with the two-lock model that governs write operations.

Check the templates directory before trusting an empty report

A web scan that reports nothing looks exactly like a web scan that had no templates to run. Confirm the directory is populated:

ls /opt/axiom-border/vulnscan-templates/

You should see per-protocol directories such as http/, network/ and ssl/. CVE templates live under http/cves/, not at the root. OT/ICS findings are unaffected, because those checks are installed with the product.

SNMP — interrogation and profiles

Two operations: a GET of specific OIDs, and a walk from a root OID.

Symbolic names work as well as numeric OIDs — sysDescr is resolved through the bundled MIB catalogue, which matters on an air-gapped probe where you cannot look an OID up.

curl -s -X POST http://192.168.1.10:8083/security/scan/snmp \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "target": "192.168.1.50",
        "oids": ["sysDescr", "sysName", "sysObjectID"],
        "timeout": "15s"
      }'

Profiles and associations

Rather than passing credentials with every request, store them once as a profile and bind them to hosts with an association:

# Create a v3 profile
curl -s -X POST http://192.168.1.10:8083/security/snmp/profiles \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "name": "plc-floor-v3",
        "version": "3",
        "username": "<snmp-user>",
        "securityLevel": "authPriv",
        "authProtocol": "SHA",
        "authPass": "<auth-passphrase>",
        "privProtocol": "AES",
        "privPass": "<priv-passphrase>"
      }'

# Bind it to a host
curl -s -X POST http://192.168.1.10:8083/security/snmp/associations \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"ipAddress": "192.168.1.50", "profileName": "plc-floor-v3"}'

Resolution order when a scan runs: the request’s profileName wins, then the per-IP association, then the configured default. A target that resolves to no profile fails with snmp omitted targets due to missing profile: <ip>.

Supported versions are v1, v2c and v3. For v3, defaults when fields are left empty are authPriv with SHA and AES.

Prefer profiles over inline credentials

Credentials sent inline as customParams are persisted with the execution record and returned in its detail. Profiles keep them out of execution history. Use customParams for one-off diagnostics only.

Note

The scheduled SNMP probe runs a discovery pass first, with a fixed budget of 30 seconds. A /24 at cautious timing will exhaust it, so narrow the range or raise the discovery timing value.

Traffic capture

Two modes, both capturing IPv4 traffic through libpcap:

  • Continuous, driven by configuration. When securityProbes.sniffing.interfaces is non-empty, capture starts automatically and persists flow counters to the networktraffic bucket every flushInterval.
  • On demand, via the API, for a bounded duration, merged into the latest scan result.
curl -s -X POST http://192.168.1.10:8083/security/scan/sniff \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "interfaces": ["eth0"],
        "bpf": "tcp port 502",
        "duration": "5m",
        "promiscuous": true
      }'

The BPF filter above captures Modbus traffic only, which is a good way to confirm an industrial segment is actually active before scanning it.

Capture contributes bytesIn/bytesOut and packetsIn/packetsOut counters plus samples to each host in the merged result.

The interface name is the usual culprit

Capture failing silently almost always comes down to the interface. It must be a pcap name: eth0 or enp3s0 on Linux, en0 on macOS, and an Npcap NPF device path such as \\Device\\NPF_{GUID} on Windows — the friendly name Ethernet does not work there. Confirm with nmap --iflist.

Also confirm the service runs as root, and that there is traffic to see: sudo tcpdump -i <iface> -c 10.

The merged result view

Individual executions tell you what one probe found. GET /security/results/last tells you what is known about a host, merged across all four probes:

curl -s 'http://192.168.1.10:8083/security/results/last?ipAddress=192.168.1.50' \
  -H "Authorization: Bearer <jwt-token>"

Each host carries ipAddress, hostname, macAddress, os, manufacturer, status, firstSeen and updatedAt, then the per-probe contributions: ports from discovery, vulnerabilities from the vulnerability scan, snmp entries from the SNMP probe, and sniffing counters from traffic capture. This is the view worth putting on a dashboard.

The firstSeen and updatedAt pair is what turns the baseline into change detection: a host with a recent firstSeen is new to the network.

Feeding results into rules

Probe results are available to the rules engine, so a scan finding can raise an alarm the same way a metric can — a new host appearing, a critical vulnerability, a port opening that should not be open. See Alarms and rules.

Results also flow to InfluxDB as metrics under the SECSCAN group with a probe tag, and are queryable through POST /auditlog with agent set to network, vulns, snmp or sniff.

Index

Subsections of Security Probes

OT/ICS Vulnerability Scanning

OT/ICS vulnerability scanning

Generic vulnerability scanners are built for IT. Point one at an industrial segment and two things go wrong: it probes for a web server that a PLC does not have and drops the host from the scan, and when it does find something it has no idea what a coil or a Common Address is.

Axiom Border ships its own suite of 57 checks for five industrial protocols, written specifically for this problem. They are installed with the product and made available to every scan automatically, so they travel in every deployment role and work with no internet access and no upstream template feed.

Industrial equipment is not a web application

A write to the wrong register on a live PLC has physical consequences. Axiom Border defaults to read-only and requires two independent switches before it will send a single write frame. Read the safety model before you enable anything, and never enable intrusive mode without written authorisation for the segment you are testing.

Protocol coverage

Protocol Port Checks Of which intrusive
Modbus/TCP 502/TCP 19 6
IEC 60870-5-104 2404/TCP 11 4
DNP3 20000/TCP 10 3
BACnet/IP 47808/UDP 10 1
OPC UA 4840/TCP 7 2
Total 57 16

Every check is labelled with the protocol it targets, and the ones in the write/control layer are additionally labelled as intrusive. That intrusive label is what the safety model keys on.

What each layer detects

The suite is organised in four layers of increasing invasiveness:

flowchart TB
    L2["<b>Layer 2 — Detection</b><br>Is this protocol here?<br>Identify vendor and version"]
    L3["<b>Layer 3 — Exposure</b><br>Structural weaknesses reachable<br>without authentication"]
    L3B["<b>Layer 3b — Recon</b><br>Read process data and object lists<br>without writing anything"]
    L4["<b>Layer 4 — Intrusive</b><br>Write / control confirmation<br><i>gated behind two locks</i>"]
    CVE["<b>CVE checks</b><br>Vendor-specific, only where<br>fingerprinting is reliable"]

    L2 --> L3 --> L3B --> L4
    L2 --> CVE

    L4:::danger

    classDef danger fill:#fff0ed,stroke:#ff664e,color:#101010

Detection confirms the protocol is listening and extracts identity where the protocol allows it — Modbus device identification, DNP3 Object Group 0 device attributes, BACnet vendor identifier, OPC UA BuildInfo software version.

Exposure reports structural problems that need no credentials to observe: Modbus and DNP3 responding to unauthenticated requests, IEC-104 accepting a station interrogation, BACnet/IP being usable as a reflection and amplification source, OPC UA offering endpoints with None security policy or anonymous authentication.

Recon reads real process data — Modbus holding registers via FC03, Modbus diagnostics via FC08 sub-function 0, IEC-104 counter interrogation and read commands, DNP3 event classes and class-0 integrity polls, BACnet Who-Is discovery and object enumeration. These checks read; they never write.

Intrusive confirms a write is actually possible. It is described in detail below.

CVE checks exist only for the cases where the protocol itself reveals enough to be sure: Schneider Modicon over Modbus/UMAS, Delta enteliBUS (CVE-2019-9569) and Contemporary Controls (CVE-2025-13926) over BACnet, plus a multi-vendor Modbus fingerprint that maps identity to known advisories.

Why some known CVEs are deliberately absent

Axiom Border only reports what it can actually confirm. Three known CVEs cannot be confirmed over the industrial protocol itself, so no check claims to detect them: DNP3 CVE-2020-6996 (the Triangle MicroWorks stack version is not observable over DNP3), OPC UA stack versions below 1.5.374.158 (only the Basic128Rsa15 precondition is observable, and that is already covered by an exposure check), and the IEC-104 device CVEs (IEC-104 carries no native device identifier). For those, use SNMP interrogation to fingerprint the device and correlate the version externally.

Scan depth and how OT checks get selected

The level field of a vulnerability scan decides which checks run. OT checks are never included by accident — you either ask for a deep scan, ask for OT explicitly, or opt in for scheduled scans:

level Web templates OT read-only checks Notes
ligero Yes No Unless enableOT: true for the scheduled probe
medio Yes No Unless enableOT: true for the scheduled probe
profundo Yes (full) Yes Full web scan plus a separate OT pass
ot No Yes OT/ICS checks only — for dedicated industrial segments
flowchart TB
    REQ["Scan request"] --> LVL{"level"}
    LVL -->|"ligero<br>medio"| W["Web templates<br>only"]
    LVL -->|"profundo"| WOT["Web scan +<br>OT read-only"]
    LVL -->|"ot"| OT["OT/ICS<br>checks only"]

Scheduled scans with enableOT: true add the read-only OT layer to whatever level they run at.

The OT pass runs separately, on purpose

When a scan includes OT checks, that part runs as a pass of its own that does not discard hosts without a web server. A general-purpose scan pre-filters targets by HTTP reachability, which would drop a PLC or RTU before a single Modbus request was ever sent. Running the OT pass separately is what makes industrial assets visible at all.

The safety model: two locks

Intrusive checks send write or control frames. Enabling them requires two independent switches that live in different places, so neither an API caller nor a configuration mistake can unlock them alone:

  1. Per-request opt-in — the POST /security/scan/vulns body must contain "intrusive": true.
  2. Deployment kill-switch — the configuration must have securityProbes.vulnScan.allowIntrusive: true. The default is false.

If the configuration lock is closed, a request asking for intrusive mode is rejected — over REST and over MQTT alike. There is no path that bypasses it.

flowchart TB
    REQ["Vulnerability<br>scan request"] --> Q1{"intrusive: true<br>in the request?"}
    Q1 -->|no| RO["Read-only scan<br><i>write layer excluded</i>"]
    Q1 -->|yes| Q2{"allowIntrusive<br>in configuration?"}
    Q2 -->|no| REJ["Request rejected<br><i>REST and MQTT</i>"]
    Q2 -->|yes| INT["Write layer enabled<br><i>audited as WARN</i>"]

    REJ:::danger
    INT:::danger

    classDef danger fill:#fff0ed,stroke:#ff664e,color:#101010

Three further guarantees hold regardless:

  • The scheduled probe never runs the intrusive layer. Automatic periodic scans are always read-only, whatever the configuration says. Intrusive checks only ever happen because someone asked for one, explicitly, right now.
  • Every intrusive execution is audited. A WARN entry goes to the log and a notice is attached to the details field of the execution record.
  • Intrusive means “no net change”, not “no writes”. The approach is read-then-write-back — read the current value, write the same value back — or SELECT-only for command protocols, issuing the select phase without the execute phase. Genuinely destructive actuation is excluded from the suite entirely.

What the intrusive layer actually does, per protocol

Protocol Intrusive checks Technique
Modbus/TCP write-single-register (FC03→FC06), write-single-coil (FC01→FC05), write-multiple-registers (FC03→FC16), write-multiple-coils (FC0F), mask-write-register (FC22), read-write-multiple-registers (FC17) Read current value, write the identical value back
IEC 60870-5-104 control-select (C_SC), double-command-select (C_DC), setpoint-select (C_SE), regulating-step-select (C_RC) SELECT phase only, S/E=1, execute phase never sent
DNP3 analog-output-writeback (g40→g41), binary-output-crob (g10v2→g12v1), crob-select-only Write-back of the read value; CROB in SELECT-only form
BACnet/IP writeproperty-noauth Writes present-value back verbatim, preserving the original tag encoding
OPC UA anonymous-session, node-write-back Establishes an anonymous session; writes a read value back
Real physical risk

“No net change” is a design goal, not a law of physics. A PLC may react to the act of being written to — some stacks latch, some log, some fault. A SELECT without an execute leaves a control point reserved on some IEC-104 implementations. Treat intrusive mode as an operation on live plant, because that is what it is: schedule it, get authorisation, and have someone watching the process while it runs.

Examples

A dedicated OT segment scan, read-only

The common case: assess an industrial segment without touching a single register.

curl -X POST https://probe.example.com:8083/security/scan/vulns \
  -H "Authorization: Bearer <jwt-token>" \
  -H "Content-Type: application/json" \
  -d '{
        "target": "192.168.1.0/24",
        "level": "ot"
      }'

The response returns immediately with an execution UUID — scans are asynchronous:

{
  "uuid": "3f7c1a92-5b64-4e0d-9a11-8c2de4f07b35",
  "status": "inprogress"
}

A deep scan covering both web and OT

curl -X POST https://probe.example.com:8083/security/scan/vulns \
  -H "Authorization: Bearer <jwt-token>" \
  -H "Content-Type: application/json" \
  -d '{
        "target": "192.168.1.0/24",
        "level": "profundo"
      }'

An intrusive confirmation scan

Only after the configuration lock is open and the operation is authorised:

curl -X POST https://probe.example.com:8083/security/scan/vulns \
  -H "Authorization: Bearer <jwt-token>" \
  -H "Content-Type: application/json" \
  -d '{
        "target": "192.168.1.42",
        "level": "ot",
        "intrusive": true
      }'

Note the narrowed target — a single host rather than a subnet. That is deliberate, and it is the recommended practice for intrusive runs.

If allowIntrusive is false, the same request is rejected rather than silently downgraded to read-only, so you always know which mode actually ran.

Enabling OT checks on the scheduled probe

To have the periodic automatic scan include the OT read-only layer without changing its level:

securityProbes:
  vulnScan:
    enabled: true
    level: "medio"
    enableOT: true          # adds the read-only OT layer to scheduled scans
    allowIntrusive: false   # keep the master lock closed

This is the recommended steady-state configuration for a probe sitting on an industrial segment: continuous read-only OT visibility, with the write layer bolted shut.

Reading the results

OT findings surface through the same execution model as every other probe — poll GET /security/executions/{uuid} for status, and read the findings from the execution record once the status reaches finished. See Security probes for the shared asynchronous execution model.

Findings identify the protocol, the affected host and port, the check that fired, and the severity. For fingerprint checks the extracted identity (vendor, model, firmware or software version) is part of the finding, which is what makes the CVE correlation useful downstream.

Operational guidance

Start with level: "ot" on a narrow target. A /24 sweep of an industrial segment generates traffic that some networks are not used to. Validate against one host, confirm the findings make sense, then widen.

Keep allowIntrusive: false as the normal state. Open it for the duration of an authorised test window and close it again. It is a configuration change and requires a service restart, which is a feature here rather than an inconvenience — it makes the unlock deliberate and visible.

Expect true negatives. The multi-vendor Modbus fingerprint reports nothing when no listed vendor is present. That is correct behaviour, not a missed detection.

Segment scans do not need internet. The OT checks are installed with the product and run offline. If your OT network is air-gapped — and it should be — nothing about this capability degrades.

MIB Catalogue and OID Resolution

MIB catalogue and OID resolution

An OID like .1.3.6.1.4.1.6574.2.1.1.5 means nothing on its own. Turning it into diskTemperature requires a MIB, and on an air-gapped probe you cannot look one up.

Axiom Border therefore ships the catalogue with the product: over 12,500 MIB modules in JSON format covering the vendors you are likely to meet in the field. Symbolic names work anywhere numeric OIDs work, and returned values come back with readable names attached.

Directory Contents Used by
MIBS/JSON-FORMAT/ ~12,500 .json modules Yes — this is the catalogue the probe reads
MIBS/ASN1-FORMAT/ ~15,900 .mib modules No — provided for the host’s own SNMP tooling

The JSON tree is what the probe uses, and its location is configurable through securityProbes.snmp.mibDir (default MIBS/JSON-FORMAT). The ASN.1 tree is a convenience: copy it to /usr/share/snmp/mibs if you want snmpwalk and friends on the host to resolve names too.

One operation is slower than the rest

Listing vendors, inspecting a module and resolving a symbol are all fast, whatever the size of the catalogue. The one operation that is noticeably slower is a symbol search without a vendor, because it has to cover every module in the catalogue. Pass vendor whenever you know it.

Vendors

Vendor is derived from the module name: everything before the first hyphen, lowercased. So SYNOLOGY-DISK-MIB belongs to vendor synology, and A3COM-HUAWEI-DEVICE-MIB to a3com. A module name with no hyphen becomes its own vendor. This is a heuristic, not metadata from the file — it works because MIB naming conventions are near-universal, but do not expect it to be perfect on unusual modules.

Browsing the catalogue

List vendors and modules

curl -s http://192.168.1.10:8083/security/mibs \
  -H "Authorization: Bearer <jwt-token>"
{
  "root": "MIBS/JSON-FORMAT",
  "vendors": [
    { "vendor": "synology", "modules": ["SYNOLOGY-DISK-MIB", "SYNOLOGY-SYSTEM-MIB"], "moduleCount": 7 }
  ]
}

This is inexpensive regardless of catalogue size.

Search for a symbol

curl -s 'http://192.168.1.10:8083/security/mibs/search?q=diskTemp&vendor=synology&limit=50' \
  -H "Authorization: Bearer <jwt-token>"
{
  "query": "diskTemp",
  "vendor": "synology",
  "limit": 50,
  "matches": [
    {
      "vendor": "synology",
      "module": "SYNOLOGY-DISK-MIB",
      "name": "diskTemperature",
      "oid": "1.3.6.1.4.1.6574.2.1.1.6",
      "class": "objecttype",
      "description": "Temperature of the disk"
    }
  ]
}

q is mandatory and matched as a case-insensitive substring against object names. Always pass vendor when you can — with it the search is limited to that vendor’s modules; without it the search covers the whole catalogue and takes considerably longer.

limit of zero or less means unlimited. Results are ordered by vendor, then module, then name.

Inspect a module

curl -s http://192.168.1.10:8083/security/mibs/SYNOLOGY-DISK-MIB \
  -H "Authorization: Bearer <jwt-token>"
{
  "module": "SYNOLOGY-DISK-MIB",
  "vendor": "synology",
  "objects": [
    { "name": "diskID", "oid": "1.3.6.1.4.1.6574.2.1.1.2", "class": "objecttype", "description": "..." }
  ]
}

Module names are exact and case-sensitive — they are the filename without the .json extension. An unknown module returns 404.

How resolution works

Two directions matter, and they behave differently: symbols become OIDs before the request goes out, and OIDs get names again on the way back.

Inbound: symbols become OIDs before the request

When a scan request contains symbolic names, they are resolved before anything is sent on the wire:

  1. A small built-in table of the universal system OIDs — sysDescr, sysObjectID, sysName, sysLocation, sysContact.
  2. Anything already numeric passes through untouched.
  3. Everything else is looked up in the vendor’s modules.
flowchart TB
    S["Requested name<br>sysDescr"] --> C1{"Built-in<br>system OID?"}
    C1 -->|"no"| C2{"Already<br>numeric?"}
    C2 -->|"no"| C3{"In the vendor's<br>modules?"}
    C3 -->|"no"| FAIL["Execution fails<br>unresolved oids"]:::danger
    C1 -->|"yes"| OK["Numeric OID<br>sent on the wire"]
    C2 -->|"yes"| OK
    C3 -->|"yes"| OK
    classDef danger fill:#fff0ed,stroke:#ff664e,color:#101010
An unresolved symbol fails the whole execution

If any requested symbol cannot be resolved, the execution ends as failed with snmp: unresolved oids for vendor=<vendor>: <list> — it does not silently skip the unknown ones and query the rest. Verify symbols with /security/mibs/search before relying on them, or pass numeric OIDs.

The vendor hint

Symbol resolution needs to know which vendor’s modules to search. Axiom Border works it out in order:

  1. The mib field of the scan request.
  2. securityProbes.snmp.mib from configuration.
  3. Inferred from the host’s manufacturer as recorded by a previous nmap scan.

The third is the useful one: run a network scan first, and the manufacturer discovered from the MAC address becomes the vendor hint automatically. Explicit rules exist for common vendors — Synology, Cisco, HP, Huawei, Juniper, D-Link — with a fallback to the first token of the manufacturer name.

Note that the hint is only consulted when a request actually contains non-numeric symbols. All-numeric requests need no vendor at all.

Outbound: OIDs become names on the response

Every OID that comes back is put through the same four steps, in order, and the first match wins:

  1. The built-in table of universal system OIDs.
  2. The vendor’s modules, trimming up to two trailing segments — which is how indexed OIDs such as ifDescr.3 resolve to ifDescr.
  3. The SNMPv2 module.
  4. A set of generic modules — if, ip, tcp, udp, host, entity, sysappl, ucd, net — by exact match.

If nothing matches, the name field of the result is literally unknown. The value is still returned — only the label is missing.

Ambiguity yields no name rather than a wrong one

When trimming produces more than one candidate match, resolution stops and returns no name. This is deliberate: an OID labelled with a plausible-but-wrong symbol is worse than an unlabelled one, because it silently misleads whoever reads the report.

Using symbols in scans

Symbolic names work in both SNMP operations. A GET with symbols:

curl -s -X POST http://192.168.1.10:8083/security/scan/snmp \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "target": "192.168.1.50",
        "mib": "synology",
        "oids": ["sysDescr", "sysName", "diskTemperature"]
      }'

And a walk from a symbolic root:

curl -s -X POST http://192.168.1.10:8083/security/scan/snmp/walk \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"target": "192.168.1.50", "mib": "synology", "walkRoot": "diskTable"}'

A walkRoot symbol that does not resolve fails that target with snmp: walkRoot symbol not found vendor=<vendor> walkRoot=<root>.

oids always wins over walkRoot

If a request contains both oids and walkRoot, the walkRoot is silently ignored and only the listed OIDs are fetched. A request must contain at least one of the two, or it fails with snmp: no oids or walkRoot provided. If you meant to walk, do not send oids.

Values in results

Each SNMP result entry carries the numeric oid, the resolved name (or unknown), the formatted value and a status.

Values are formatted by type: numbers as numbers, IP addresses in dotted form, and byte strings as text when they are printable UTF-8, otherwise as 0x<hex> — so binary values are legible rather than mangled. The status is up, or error carrying the SNMP condition name for NoSuchObject, NoSuchInstance and EndOfMibView.

Walks mark disappearances, GETs do not

A walk covers a whole subtree, so an OID that was previously known and is now absent is genuinely gone, and gets marked as down. A GET only asks about specific OIDs, so absence proves nothing and nothing is marked. This is why change detection over SNMP inventory should use walks.

Extending the catalogue

To add a vendor’s MIB, drop its JSON module into the configured mibDir. New modules are picked up automatically, and changing mibDir reloads the catalogue from the new location.

If the catalogue fails to load — a bad path, unreadable files — the probe logs a warning and continues with an empty catalogue. It does not refuse to start. Symbol resolution will then fail for everything except the built-in system OIDs, which is a good signature for this particular misconfiguration: numeric OIDs work, sysDescr works, everything else does not.

AI Capabilities

AI capabilities

Axiom Border can run machine-learning models on the probe, in containers, to detect anomalies in the metrics it ingests. A model trains locally on local data, exposes an inference endpoint on loopback, and rules call it to decide whether an event is worth an alarm.

The point is the same as everywhere else in the product: no cloud round trip, no data leaving the site.

Linux and the central role are required

This feature requires Linux and the container engine that the central role installs, so deploy AI capabilities on that role.

Anywhere else the rest of the probe runs normally — every other subsystem is unaffected — but these endpoints report that the container engine is unavailable:

POST /iacap/{agentType} · GET /iacap/{agentType}/execute · DELETE /iacap/{agentType} · POST /containerd/loadImage · POST /containerd/deployImage

Anomaly detection covers system metrics

Deploy the capability with agent type metrics, which detects anomalies in the system metrics the probe ingests. The other agent types — ssh, iface, usb — appear in the API but are not supported yet.

Where you manage it

AI capabilities in the console lists the models configured on the probe. On a fresh installation it is empty, because model images are supplied separately from the product:

The AI capabilities view before any model has been deployed

From here you create a capability, follow its training state, and read the quality metrics the model reports about itself. Create new AI trainer opens the wizard that collects the same parameters as the API call shown further down:

The new AI trainer wizard

The equivalent API calls are shown alongside each step below.

The lifecycle

flowchart TB
    TAR["Model image tarball<br>supplied separately"]:::ext -->|"stage on disk"| IMG["Image imported<br>for one agent type"]
    IMG -->|"POST /iacap"| EXPORT["Training data<br>exported"]
    EXPORT --> DEPLOY["Container deployed"]
    DEPLOY --> HEALTH{"Health check"}
    HEALTH -->|"not yet"| HEALTH
    HEALTH -->|"responds"| READY["status: ready"]
    READY -->|"retrain due"| TRAIN["status: training"]
    TRAIN --> READY
    READY -->|"predict"| INFER["Inference<br>from rules"]
    classDef ext fill:#e9edfa,stroke:#486ac9,color:#101010

Each agent type has a fixed loopback port, which is how a rule reaches the right model:

Agent Port Container name
iface 5555 <trainerContainerName>-iface
ssh 5556 <trainerContainerName>-ssh
usb 5557 <trainerContainerName>-usb
metrics 5558 <trainerContainerName>-metrics

Each model listens on the host’s loopback address, so rules reach it at 127.0.0.1:<port>.

Loading an image

Model images are not part of the installation bundle — they are distributed separately as OCI tarballs, and resources/iaimages/ ships empty as a placeholder.

Place the tarball in that directory, then import it:

# See what is on disk
curl -s http://192.168.1.10:8083/containerd/listdiskiaimages \
  -H "Authorization: Bearer <jwt-token>"

# Import it, bound to an agent type
curl -s -X POST 'http://192.168.1.10:8083/containerd/loadImage?agent=metrics&fileondisk=model-metrics.tar.gz' \
  -H "Authorization: Bearer <jwt-token>"

You can also upload the tarball directly as a multipart form field named file, without staging it on disk first.

On import the image is tagged axiom-border:<agent>. Loading from the host with nerdctl works too, and is the documented path in Operation and maintenance:

sudo nerdctl --namespace axiom-border image load -i model-metrics.tar.gz
Loading an image removes the other agents’ images

loadImage keeps only the image for the agent you named and deletes the other loaded images. If you intend to run capabilities for more than one agent type, be aware that importing sequentially will not accumulate them.

Deploying a capability

curl -s -X POST http://192.168.1.10:8083/iacap/metrics \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "retrainFreqMinutes": 1440,
        "ruleEnabled": true
      }'
Field Type Meaning
retrainFreqMinutes int64 Retraining interval. Minimum 10 minutes. Zero means no retraining
ruleEnabled bool Enables the rule script for this agent

Deployment does four things in order: exports the training data from the metrics database to a Parquet file, deploys the container with that data available to it, waits for the container’s health endpoint, then marks the capability ready, collects the model metrics and enables the rule script.

A retrainFreqMinutes below 10 is rejected with freq must be at least 10m0s. A capability already existing for that agent is rejected too — remove it first.

The health wait uses trainerDetails.healthCheck, which defaults to 20 attempts at 5 second intervals, giving roughly 100 seconds for the container to come up. A slow-starting model may need that raised; see Configuration.

Checking status

curl -s http://192.168.1.10:8083/iacap \
  -H "Authorization: Bearer <jwt-token>"

Returns 204 when no capability is deployed. Otherwise, per capability: iaName, enabled, status, retraining, retrainFreqMinutes, lastExecution, nextExecution, fileFrom (the tarball it came from) and metrics — the model’s own quality metrics, fetched from the container.

Status values

Status Meaning
ready Trained and serving inference
training Training in progress
ready (last training cancel, waiting next one) A training run was cancelled; the schedule continues
canceled, waiting to resume Cancelled with no retraining scheduled
error Something failed; details accompany the status

Training

Retraining runs automatically on the configured interval. To force a run:

curl -s http://192.168.1.10:8083/iacap/metrics/execute \
  -H "Authorization: Bearer <jwt-token>"

This is rejected while a training run is already in progress, and it waits for any scheduled run to finish rather than colliding with it. An on-demand run updates the last-executed timestamp but does not shift the schedule — the next scheduled run happens when it was always going to.

To cancel a run in progress:

curl -s http://192.168.1.10:8083/iacap/metrics/cancel \
  -H "Authorization: Bearer <jwt-token>"

Cancellation stops the health-check retry loop rather than killing the container mid-computation. The capability lands in one of the two cancelled states above depending on whether retraining is scheduled.

Each training run re-exports the data, restarts the container, and waits for health again — so a training run means a brief window where inference is unavailable. Rules calling predict during that window will get an error, which they should handle rather than treating as an anomaly.

Changing the schedule

curl -s -X PUT http://192.168.1.10:8083/iacap/metrics \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"retrainFreqMinutes": 720, "ruleEnabled": true}'

Rejected while training is in progress. The same 10-minute minimum applies, and zero disables retraining.

Removing a capability

curl -s -X DELETE http://192.168.1.10:8083/iacap/metrics \
  -H "Authorization: Bearer <jwt-token>"

Best-effort cleanup: the container, the image, the rule script, the exported data directory and the database record, plus stopping the retraining schedule. Note that this returns 200 even when parts of the cleanup failed, with the errors in the response body — read it rather than assuming success.

Calling inference from rules

This is where the capability earns its place. A rule script fetches recent metrics, builds a feature vector, asks the model to score it, and raises an alarm when the model says anomaly:

function process(metrics) {
  for (var i = 0; i < metrics.length; i++) {
    var metric = metrics[i];
    metric.SetT0();

    var payload = JSON.stringify({
      sent_vars:     metric.GetFieldByName("sent_vars"),
      prepared_vars: metric.GetFieldByName("prepared_vars"),
      ram_usage:     metric.GetFieldByName("ram_usage")
    });

    var response = newPredict(payload, "metrics");
    var result   = JSON.parse(response);

    if (result.prediction === 1) {
      newAlarm(metric, "critical", "New anomaly detected", "Anomaly", "AI Capability");
    }

    metric.SetT1();
  }
}

Two globals are available to scripts for this: newPredict(payloadJSON, agentType) calls the model’s inference endpoint, and newGetMetrics(agentType) fetches the model’s own metrics. Both target the loopback port for that agent, and both are configured under trainerDetails.

Keep the script time limit above the predict timeout

The predict call has its own timeout (default 10 s), but the entire script invocation is bounded by gojaTimeout (default 8 s). With those two defaults, a slow inference call cannot complete — the script is stopped first.

If your rules call inference, set gojaTimeout comfortably above trainerDetails.predict.timeout, or lower the predict timeout. Always define gojaTimeout: without a time limit, scripts do not run at all.

The rule script for an agent is enabled through ruleEnabled when deploying the capability, and can be managed directly through the /scripts endpoints. See Alarms and rules.

Surviving a restart

Capabilities are part of the probe’s local state, and startup restores them: containers are resumed, model metrics are re-fetched, and retraining schedules resume from their stored next-run time.

One case is treated deliberately: a capability that was training when the process stopped is considered cancelled rather than resumed, because the training run did not finish. It moves to the appropriate cancelled state and waits for its next scheduled run.

Failures during this restoration are logged and do not prevent startup.

Configuration reference

The relevant block is trainerDetails — container name, TLS material, and the three endpoint definitions for health, metrics and inference. See Configuration.

The health, metrics and inference endpoints are meant to be reached over loopback only. Keep them bound to 127.0.0.1 and do not expose those ports beyond the host.

MQTT and OpenGate Operations

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.

Operation and Maintenance

Operation and maintenance

Service commands

Each role installs a different set of units. The standard systemd verbs apply to all of them:

sudo systemctl status axiom-border
sudo systemctl restart axiom-border
sudo systemctl is-active axiom-border
sudo systemctl is-enabled axiom-border

The units, depending on role: axiom-border, axiom-influxdb, containerd, oda-lite.

Where the logs live

This is the single most useful thing to know when something misbehaves, because the important detail is not in the journal:

Service Journal Own file Rotation
axiom-border systemd events and early startup errors only — with the shipped default, very little /opt/axiom-border/logs/axiom-border.logall the detail: probes, MQTT, scans, rules Automatic, tunable in configuration.yaml
axiom-influxdb Everything: startup, crashes, slow queries, compactions None journald drop-in: 500 MB, 1 day per file
containerd Everything: runtime, namespaces, OCI errors None journald drop-in
oda-lite Startup, crashes and early warnings only — 3 to 5 lines in normal operation /var/log/oda-lite/oda-lite.log — plugin activity, HTTP errors towards central, emitted metrics Automatic: every 24 h or at 100 MB, 7 archives kept
Lifecycle scripts /var/log/axiom-border-install.log — install, upgrade and uninstall appended together Not rotated; clear it by hand if it grows
The rule of thumb

To diagnose application logic, read the file. To diagnose a crash or a failure to start, read the journal. Looking for probe results in journalctl is the most common wasted hour.

Everything at once, which is often what you want:

sudo journalctl -u axiom-border -u axiom-influxdb -u containerd -u oda-lite \
  --since "5 minutes ago" --no-pager

Log levels

Axiom Border — the logger block of configuration.yaml. Set logLevel to DEBUG, INFO, WARN or ERROR, then restart the service. See Configuration.

oda-lite agent/etc/oda-lite/oda-lite.conf, [agent] section: log_level and debug. Restart the service afterwards.

InfluxDB and containerd — the level comes from binary flags in the unit file. Changing it means editing the unit, and is not recommended.

Turn DEBUG back off

DEBUG on a busy probe generates enough volume to fill a disk. Raise it to reproduce a problem, then put it back to INFO. Do not leave it on “just in case”.

Rotation and disk usage

Axiom Border’s own log rotates through the logger settings: maxSize (MB per file, default 20), maxBackups (default 20) and compress (gzip the rotated files).

The journald drop-in installed by the bundle is globalSystemMaxUse=500M and MaxFileSec=1day apply to the entire host journal, not only to Axiom Border. On a host sharing other workloads, adjust it or replace it with per-unit drop-ins.

journalctl --disk-usage
sudo journalctl --vacuum-size=500M
sudo journalctl --vacuum-time=7d

To see what is consuming space:

du -sh /var/log/oda-lite /opt/axiom-border/logs /var/log/journal/

Backup and restore

Resource Path Suggested frequency
Configuration /opt/axiom-border/config/configuration.yaml On every change
Local state — rules, aliases, executions /opt/axiom-border/db.dat Daily
InfluxDB data — metrics, alarms /var/lib/influxdb2/ Per retention policy
Logs /opt/axiom-border/logs/ On demand

Stop the service first, for a consistent snapshot:

sudo systemctl stop axiom-border
sudo tar -czf /path/backup-axiom-border-$(date +%F).tar.gz \
  /opt/axiom-border/config /opt/axiom-border/db.dat /var/lib/influxdb2
sudo systemctl start axiom-border

Restore is the inverse:

sudo systemctl stop axiom-border
sudo tar -xzf backup-axiom-border-<date>.tar.gz -C /
sudo systemctl start axiom-border

Offline maintenance

Refreshing the web and CVE vulnerability templates

The OT/ICS suite ships with the product and needs no maintenance. The web and CVE templates do, and on an air-gapped probe the refresh is manual: generate a snapshot of the upstream nuclei-templates tree on a machine with internet, transfer it, then swap it in.

# On a machine with internet
nuclei -update-templates -ut /tmp/nuclei-templates
tar -czf nuclei-templates-$(date +%F).tar.gz -C /tmp nuclei-templates

# On the probe
sudo systemctl stop axiom-border
sudo mv /opt/axiom-border/vulnscan-templates /opt/axiom-border/vulnscan-templates.old
sudo tar -xzf nuclei-templates-<date>.tar.gz -C /tmp
sudo mv /tmp/nuclei-templates /opt/axiom-border/vulnscan-templates
sudo systemctl start axiom-border

Keep .old until you have confirmed a few successful scans, then delete it. Templates are read when the service starts, so a restart is required after swapping them.

Loading AI images

AI images are not part of the bundle — they are distributed separately as OCI tarballs, and resources/iaimages/ ships empty as a placeholder.

sudo nerdctl --namespace axiom-border image load -i <image>.tar
sudo nerdctl --namespace axiom-border image ls

Upgrading

Extract the new bundle of the same role on the target and run upgrade.sh. It aborts if the bundle role does not match the installed role — changing role requires uninstall followed by install.

sudo ./upgrade.sh
Flag Meaning
--force-conf-regen Regenerate the oda-lite configuration from the new template, saving the current one as .bak-<timestamp>
-h, --help Print help and exit
Variable Default Meaning
HEALTHCHECK_RETRIES 12 Attempts before declaring failure
HEALTHCHECK_INTERVAL 5 Seconds between attempts — 12 × 5 s gives a 60 s window
AXIOM_API_PORT 8083 API port used for the health check

What it does: verifies the previous installation and role match, stops the role’s services (leaving InfluxDB and containerd running, so data in flight is not disturbed), takes timestamped backups of the binary and db.dat, replaces the binary and updatable assets, starts the service, and health-checks it.

Preserved across upgrade: config/, db.dat, .influx-creds, parquet/, logs/, /var/lib/influxdb2/, resources/iaimages/ and the oda-lite configuration.

Replaced: the binary, MIBS/, resources/gojascripts/, vulnscan-templates/, the documentation and the lifecycle scripts.

Failed upgrades roll back automatically

If the health check fails all 12 attempts, upgrade.sh restores the previous binary, confirms the old version answers with HTTP 200, and exits with code 2. The probe is left operational on the old version and the backup is kept at /opt/axiom-border/axiom-border.bak-<timestamp>. An upgrade that reports a rollback has not broken anything — diagnose, then retry with a corrected bundle.

Back up db.dat before upgrading

The local state file db.dat is not migrated automatically. If a new version changes its format, the file may have to be regenerated, and that loses local state — rules, aliases and execution history. Always take a copy before upgrading, and if the new version will not start cleanly, restore the copy and revert the binary.

Uninstalling

sudo /opt/axiom-border/uninstall.sh

It stops and disables the role’s services, removes only the units it created, removes the journald and sshd drop-ins, and asks whether to keep the data — InfluxDB data, db.dat, logs and configuration.yaml. Answer non-interactively with AXIOM_KEEP_DATA=true|false.

It removes shared binaries only where the manifest marks them as installed by this deployment. It does not remove nmap, rsyslog or network-manager even if it installed them — those are host infrastructure and are left to the operator. It does not revert the netplan switch by default.

Variable Effect
AXIOM_KEEP_DATA=true|false Answers the data-retention question without prompting
AXIOM_RESTORE_NETPLAN=1 Restores /etc/netplan/ from the backup, re-applies it, and switches back to systemd-networkd
AXIOM_FORCE_CLEAN=1 Destructive. Without a manifest, claims every detected artefact as its own — including global binaries that may belong to other software such as Docker or Kubernetes

Running it on a clean system is safe and idempotent: it reports there is nothing to uninstall and exits 0.

AXIOM_FORCE_CLEAN can remove another product’s binaries

Use it only after confirming nothing else on the host depends on containerd, runc, nerdctl or the CNI plugins. Without the flag, a manifest-less uninstall stays conservative and touches only artefacts that are unmistakably Axiom Border’s.

AXIOM_RESTORE_NETPLAN=1 warns about possible connectivity loss. In practice an established SSH session usually survives, since the TCP connection is not torn down and the IP is retained — but do not count on it over a link you cannot recover from out-of-band.

Troubleshooting by symptom

Quick triage

Symptom Look here first
Service will not start journalctl -u <service> -n 50 --no-pager
Service active but no results /opt/axiom-border/logs/axiom-border.log
Metrics not reaching InfluxDB journalctl -u axiom-influxdb plus the agent log
Monitoring node not reaching central tail -f /var/log/oda-lite/oda-lite.log | grep "outputs.http"
SSH, USB or interface events missing The agent log, filtering by plugin name
Service restart-looping journalctl -u <service> --since "5 min ago", looking for start-limit
Install or upgrade behaved oddly tail -200 /var/log/axiom-border-install.log
Disk filling up du -sh /var/log/oda-lite /opt/axiom-border/logs /var/log/journal/

Detailed cases

The service will not start. Three usual causes: the binary lost its execute bit (chmod +x /opt/axiom-border/axiom-border), a required port is occupied, or the YAML is invalid. Validate the configuration directly:

python3 -c "import yaml; yaml.safe_load(open('/opt/axiom-border/config/configuration.yaml'))"

Login fails with the correct password. login.pass must be the SHA-256 digest, not the plaintext. Recalculate and restart:

echo -n "<your-password>" | sha256sum | awk '{print $1}'

A port is in use. Identify the holder, then either stop it or move the Axiom Border port:

sudo ss -tlnp | grep -E ':(8083|1883|1888|8086|9090|9091) '

Ports 8083, 1883 and 1888 move via apiPort, mqtt.broker.port and mqtt.broker.ws.port. Ports 9090 and 9091 move in /etc/oda-lite/oda-lite.conf.

Vulnerability scans return zero findings, suspiciously. The web template directory is probably empty or incomplete:

ls /opt/axiom-border/vulnscan-templates/ | head

It should list per-protocol directories such as http/, network/ and ssl/. CVE templates live under http/cves/, not at the root. Refresh them as described above. OT/ICS findings are unaffected, because that suite ships with the product.

Sniffing captures nothing. Check the interface exists (ip -o link show), that the service runs as root (systemctl show axiom-border -p User), and that there is actually traffic (sudo tcpdump -i <iface> -c 10).

The UI loads but every API call returns 401. The JWT is missing or expired. Clear the browser’s localStorage and log in again.

nmap probes hang or take very long. A large target range with poor reachability, a high timeout, or udp: true with many ports. Narrow the range, lower the timeout, disable UDP.

High memory usage. Check for an AI capability working a large dataset, an Influx bucket with no retention policy, or logLevel: DEBUG left on.

“Orphaned installation detected.” Bundle artefacts exist without an .install-manifest. Three ways out: AXIOM_CLEAN_RESIDUALS=1 sudo bash install.sh moves the leftovers aside, sudo bash upgrade.sh preserves data, sudo bash uninstall.sh cleans best-effort.

upgrade.sh exits with code 2 reporting a rollback. The new binary failed its health check and the old one is back in service. Diagnose with the journal, the application log and the install log, then retry with a corrected bundle.

The monitoring node floods the journal with “connection refused” to port 9090. The central node is not up, a firewall blocks it, or the URL is wrong. The agent keeps retrying and buffers metrics while it waits, so a short outage loses nothing — a long one eventually will.

Error executing 'netstat': exit status 1. Minimal Ubuntu Server images lack net-tools. Not blocking — the other plugins continue:

sudo apt-get install -y net-tools && sudo systemctl restart oda-lite

NetworkManager is active but interfaces show as unmanaged. The netplan renderer was not switched over. Re-run install.sh --switch-to-network-manager; it detects the current state and completes the switch without reinstalling NetworkManager.

/etc/oda-lite/oda-lite.conf.new contains literal ${VARIABLE} placeholders. The file is an unexpanded template. Do not copy it over the live configuration — the agent would not start. Delete it and re-run the upgrade with the current bundle.

scp or tar -xzf fail with Permission denied in /tmp. Some base images ship /tmp with wrong permissions. It should be drwxrwxrwt:

ls -ld /tmp
sudo chmod 1777 /tmp