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
Monitors the hosts and devices around it, collecting metrics and keeping the history locally.
Raises alarms on its own, from rules you define, with no need to reach a server first.
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.
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.
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.
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.
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:
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.
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.
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.
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:
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. Authenticatecurl -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 filecurl -X PUT http://localhost:8083/config \
-H "Authorization: Bearer <jwt-token>"\
-H "Content-Type: application/x-yaml"\
--data-binary @configuration.yaml
# 4. Applysudo 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:
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
""
T1–T5. 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
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: truelevel: "medio"enableOT: true# continuous read-only OT visibilityallowIntrusive: false# write layer bolted shutallowTemplateUpdates: false# air-gapped: never reach for the networkdefaultTemplatesDir: "/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
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
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
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.
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.
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.
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.
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.
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.
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:
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:
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:
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.
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:
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:
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:
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.
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.
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.
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:
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:
functionprocess(metrics) {
for (vari=0; i<metrics.length; i++) {
varmetric=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.
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:
varRISKY_PORTS= { 23:"telnet", 21:"ftp", 3389:"rdp", 445:"smb", 5900:"vnc" };
functionprocess(metrics) {
for (vari=0; i<metrics.length; i++) {
varm=metrics[i];
m.SetT0();
if (m.GetTagByName("kind") ==="port"&&m.GetFieldByName("portStatus") ==="open") {
varport=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 eventscurl -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 deployedcurl -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.
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 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:
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:
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.
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.
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.
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.
Rather than passing credentials with every request, store them once as a profile and bind them to
hosts with an association:
# Create a v3 profilecurl -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 hostcurl -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.
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:
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.
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
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:
Per-request opt-in — the POST /security/scan/vulns body must contain "intrusive": true.
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.
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
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.
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: truelevel: "medio"enableOT: true# adds the read-only OT layer to scheduled scansallowIntrusive: 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.
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.
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:
A small built-in table of the universal system OIDs — sysDescr, sysObjectID, sysName,
sysLocation, sysContact.
Anything already numeric passes through untouched.
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:
The mib field of the scan request.
securityProbes.snmp.mib from configuration.
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:
The built-in table of universal system OIDs.
The vendor’s modules, trimming up to two trailing segments — which is how indexed OIDs such as
ifDescr.3 resolve to ifDescr.
The SNMPv2 module.
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:
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:
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 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 diskcurl -s http://192.168.1.10:8083/containerd/listdiskiaimages \
-H "Authorization: Bearer <jwt-token>"# Import it, bound to an agent typecurl -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:
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.
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.
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:
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.
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.
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:
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
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.
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).
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.
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.
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).
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.
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:
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.
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 global — SystemMaxUse=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.
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 internetnuclei -update-templates -ut /tmp/nuclei-templates
tar -czf nuclei-templates-$(date +%F).tar.gz -C /tmp nuclei-templates
# On the probesudo 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.
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.shrestores 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.
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:
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:
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: