# Configuration

## Configuration

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

```bash
cp config/configuration_example.yaml config/configuration.yaml
```

You will rarely edit the file by hand: the console's **Configuration** view and the REST API write
this same file — see [Changing the configuration](#changing-the-configuration) below.

{{% notice style="warning" title="No hot reload — restarts are mandatory" icon="triangle-exclamation" %}}
**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:

```bash
sudo systemctl restart axiom-border
```

{{% /notice %}}

{{% notice style="note" title="An invalid file stops the service" icon="exclamation-triangle" %}}
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.
{{% /notice %}}

## 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.

{{< tabs groupid="ui-or-api" >}}
{{% tab title="Web console" %}}

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

{{< staticImage "edge_products/axiom_border/console-configuration.png" "The Configuration view, on the Automatic scans tab, with the restart and credentials notices above the fields" >}}

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

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

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

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

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

What happens after **Save** depends on the deployment:

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

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

{{% /tab %}}
{{% tab title="REST API" %}}

`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.

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

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

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

# 4. Apply
sudo systemctl restart axiom-border
```

The `PUT` response reports what happened:

```json
{
  "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 |

{{% /tab %}}
{{< /tabs >}}

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](#required-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.

{{% notice style="warning" title="The write is a full replacement, and the backup is a single level" icon="triangle-exclamation" %}}
`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.
{{% /notice %}}

### Where the file lives

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

### Overriding any field with an environment variable

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

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

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

### Required fields

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

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

Everything else is optional and has a documented default.

## `logger`

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

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

{{% notice style="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.
{{% /notice %}}

## `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:

```bash
echo -n "yourPassword" | sha256sum
```

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

{{% notice style="warning" title="Change the shipped credential" icon="triangle-exclamation" %}}
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.
{{% /notice %}}

## `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](../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 |

{{% notice style="warning" title="Two settings worth pinning down" icon="triangle-exclamation" %}}
**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.
{{% /notice %}}

## `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](../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 |

{{% notice style="note" %}}
Use whole seconds for `timeBetweenRetries`; fractions of a second are not honoured.
{{% /notice %}}

## `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.

{{% notice style="warning" title="Probes ship disabled, and the first scan runs at startup" icon="triangle-exclamation" %}}
`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.
{{% /notice %}}

### `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 |

**`level` to tag mapping:**

| `level` value | Tags applied |
| --- | --- |
| `ligero`, `rapido`, `fast` | `tech,ssl` |
| `medio`, `medium` | `cve,misconfig,default-login,tech,ssl` |
| `profundo`, `full`, `deep` | `cve,misconfig,default-login,exposure,network,ssl,tech,ot` |
| `ot`, `ics`, `industrial` | `ot` only |

{{% notice style="warning" title="Use one of the listed level values" icon="triangle-exclamation" %}}
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.
{{% /notice %}}

**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.

{{% notice style="note" title="The OT suite never depends on this" icon="lightbulb" %}}
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](../security_probes/ot_ics_scanning/).
{{% /notice %}}

**`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:

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

### `securityProbes.snmp`

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

{{% notice style="note" title="Host pre-discovery has a fixed 30 s limit" icon="lightbulb" %}}
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.
{{% /notice %}}

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

### `securityProbes.sniffing`

Continuous traffic capture with periodic persistence to the `networktraffic` bucket.

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

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

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

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

## `opengate`

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

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

### `opengate.collect`

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

### `opengate.provision`

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

## `mqtt`

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

### `mqtt.broker`

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

{{% notice style="warning" title="Anonymous by default" icon="triangle-exclamation" %}}
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.
{{% /notice %}}

### `mqtt.client`

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

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

### `mqtt.ops`

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

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