Matrix connector protocol
The Matrix connector speaks the same envelope, hello, heartbeat and job
lifecycle as Axis agents and connectors (Agent and connector protocol),
and adds the messages and jobs of firewall management. The Go types in
entrosity-shared-go/proto/matrix (and JobProgress.Detail in
proto/jobs.go) are the single source of truth; this page describes
intent and behaviour.
Like the rest of the protocol, these types only grow: fields are added, never renamed or repurposed, so connectors in the field keep working.
Transport
- WebSocket:
wss://hub.entrosity.com/matrix/api/connector/v1/wswithAuthorization: Bearer <connector key>. JSON text frames, one envelope per frame. - The first message must be
hello(capabilities: ["firewall"], plus"sites"from builds that manage allowed sites,"update"from builds that install signed releases, andpending_job_ids); the server answershello.ack, delivers the pending jobs and may offer an update. - The connector sends
heartbeatevery minute. It is online from itshellountil the socket closes, or after 3 minutes without a message. Going offline makes its firewalls' rooms stale. - Jobs follow
job.assign→job.ack→job.progress→job.result. A job sent withoutjob.ackis sent again; a job not finished before its expiry is expired by the server (Outcomes).
HTTP endpoints (/api/connector/v1)
| Method and path | Purpose |
|---|---|
POST /enroll | {enrollment_token, hostname, domain, version} → {connector_id, connector_key, ws_url}. The token must be a Matrix connector token of an active tenant. Rate-limited per IP (MATRIX_ENROLL_RATE_PER_MINUTE). |
GET /ws | The WebSocket. |
POST /heartbeat | HTTP fallback of heartbeat. |
GET /jobs/pending, POST /jobs/{id}/ack, …/progress, …/result | HTTP polling fallback of the job lifecycle. |
POST /jobs/{id}/authorize | Write-time authorization (below). |
POST /report | HTTP fallback of matrix.report (same handler; gzip accepted). 204. |
GET /firewalls/{firewallID}/credentials | The firewall's API token (Credentials). |
GET /releases/{releaseID}/msi?t=… | A release's MSI for self-update; the link's token authorizes it (no connector key). |
All but /enroll and the release download need the connector key.
Everything a connector does is confined to its tenant and its own
firewalls.
Messages
Connector → server: matrix.report
Report is {firewalls: [FirewallReport]} (at most 50). Each
FirewallReport:
| Field | Meaning |
|---|---|
firewall_id | The firewall. |
status | online, unreachable, auth_failed, version_mismatch, direction_invalid or error, with error and error_code. |
fortios_version | As reported by the FortiGate. |
guard_state | accepted, pending or mismatch. |
rooms_fetched_at, rooms | Room {policy_id, name, room, building, status} of every room policy (at most 2,000). |
groups_hash, groups_fetched_at, groups | The address groups (AddressGroup with members); groups is omitted when unchanged since the last report with the same groups_hash. At most 500 groups of 2,000 members. |
sites_hash, sites_fetched_at, sites | Connectors with sites only. sites is a list of SitesList (Allowed sites): the shared list always (exists: false when its group is missing) and the list of every current room whose group exists; omitted when unchanged since the last report with the same sites_hash. (services, the removed Entrosity services group, is still sent by older connectors and ignored.) |
Sent every firewall's report interval, right after each write, and with groups (and sites) every 5 minutes, after a refresh of addresses and after address or sites writes.
What the server does: upserts the rooms (present, last_seen_at); rooms
missing from a successful report become present = false; stores the
groups snapshot when included, and the sites when included
(a report without sites_hash, from an older connector, clears them);
updates the firewall's status, version, guard state and last_report_at;
notifies web apps (matrix.rooms, matrix.addresses, matrix.sites).
An invalid report is refused (invalid_message).
Jobs
| Type | Payload | Result | Timeout / expiry |
|---|---|---|---|
matrix.firewall.apply | FirewallApplyJob {firewall: FirewallRef, credentials_version} | FirewallApplyResult {guard_state} | 1 min / 24 h |
matrix.firewall.remove | {firewall_id} | – | 1 min / 24 h |
matrix.firewall.check | {firewall_id, credentials_version?} | CheckResult | 60 s / 2 min |
matrix.refresh | {firewall_id, scope: rooms|addresses|all} | FirewallReport | 30 s / 1 min |
matrix.policy.set | PolicySetJob {firewall_id, policy_id, room, desired, change_id} | PolicySetResult | 30 s / 60 s |
matrix.address.update | AddressOpJob with expected_ip | AddressOpResult | 2 min / 2 min |
matrix.address.create | AddressOpJob without expected_ip | AddressOpResult | 2 min / 2 min |
matrix.sites.set | SitesSetJob {firewall_id, list, room, domains, list_version, change_id} | SitesSetResult | 2 min / 2 min |
update_agent | UpdateJob, component matrix-connector | UpdateResult | 30 min |
FirewallRefis the firewall's configuration:firewall_id,driver(fortigateorsimulator),host,port,vdom,source_interface,destination_interface,policy_pattern,hostname_suffix,marker_prefix,verify_tls,ca_pem,expected_version,report_interval_seconds,writes_enabled,address_writes_enabled,site_writes_enabled. It never carries the token. The switches are not part of the scope. (services_enabledandservice_domains, of the removed Entrosity services, are no longer sent; connectors ignore them.) Its scope (host, port, VDOM, both interfaces, pattern, suffix, marker) is what the local guard pins.- Apply stores the configuration; a first apply records the firewall
in the guard as pending, a changed scope as mismatch. When
credentials_versionis newer than the stored one, the connector fetches the token. CheckResult:version,direction_ok,rooms,groups,members,warnings(English, stable prefixes such aslater_accept_policy:),guard_state,policy_names(every policy with the configured direction, at most 2,000), and from connectors withsites:sites(the lists as in the report). Allowed-sites warnings use the prefixessites_not_set_up:,sites_policy_missing:,sites_policy_disabled:,sites_policy_not_covering:,sites_group_read_only:andsites_wildcard_dns:. A policy whose every destination is an allowed-sites group (or the oldMatrix Entrosity servicesgroup) is not reported as alater_accept_policy. Read-only.PolicySetResult:policy_id,room,name,previous,status,wrote(a write was sent),confirmed(read back).AddressOpJob:operation_id(a UUID, the same across retries),firewall_id,policy_id,group,name,ip,expected_ip(update),group_version(64 hex), andresume(the journaledAddressOpStateof an interrupted attempt, on an explicit retry; stageupdate_sent,create_sent,createdormember_sent).AddressOpResult:operation_id,state{stage, address_uuid, original_members, previous_ip},member.
A failed write job still carries its PolicySetResult /
AddressOpResult / SitesSetResult in job.result.result, so the server
knows whether something was written.
Allowed sites
Domains a room's computers can still reach while the room's policy is
disabled. Every list is a FortiGate address group with a fixed name, which
the administrator creates once together with an ACCEPT policy (same
direction as the rooms, the list's group as dstaddr, the room group(s) as
srcaddr):
List (list) | Address group |
|---|---|
shared (every room) | Matrix allowed sites |
a room code, e.g. SB1-102 | Matrix allowed sites SB1-102 |
SitesList:list,room(empty forshared),group,exists,policy(SitesPolicy{policy_id, name, status, covers}: the first enabled ACCEPT policy with the firewall's exact direction and the group in itsdstaddr;coverswhen itssrcaddrcontains every room policy's source, or that room's for a per-room list, orall),version(64 hex, fingerprint of the group and its members' objects),editable,reason,domains(SiteEntry{domain, object, owned, editable, reason}; the built-in membernoneis not listed).- Objects Matrix owns:
type fqdnaddress objects namedmatrix-site:<domain>(longer names: the first 58 characters of the domain,~and 8 hex digits of its SHA-256; at most 79) whose comment is<marker prefix> siteand whosefqdnis exactly the domain. Only those are ever removed or deleted; every other member is shown read-only (Not created by Matrix; change it on the FortiGate.) and kept. - Domains (
matrix.ValidSiteDomain): lower case, two or more labels ofa-z 0-9 -(punycode for international names), optionally*.first, at most 253 characters, not an IP address; at most 200 per list. matrix.sites.set:domainsis the full desired set of Matrix-owned domains (canonical, sorted). The connector re-reads everything; the list must be set up (group and policy, elsesites_not_set_up), editable (not used as a source or inside another group, no nested groups) and atlist_version(elseconflict); per-room lists only for current rooms. It creates missing objects (reusing its own object of the same domain from another list), sets the group's members to the kept members plus the new objects (nonewhen nothing is left), deletes its objects of removed domains that no group or policy uses any more (best effort) and confirms with another read. A domain a non-Matrix member already provides is not added again.- Entrosity services (removed):
matrix.services.sync(ServicesSyncJob), the authorization stepservices.sync, the listservicesandServicesStateremain inproto/matrixas deprecated wire types only. Servers never send the job and refuse the step (services_removed); connectors fail the job as an unsupported job type. SitesSetResult:list,domains(Matrix-owned after the job),added,removed,stage,wrote,confirmed. A failure after any write is reported asunconfirmed.
A sites job takes one slot of the connector's per-minute write cap; its further writes re-check the job's expiry and the local guard.
Job progress detail
job.progress has a product-specific detail (JobProgress.Detail).
Address jobs send an AddressOpProgress {operation_id, state} before
each write step, after journaling it locally:
| Stage | Sent before |
|---|---|
update_sent | PUT firewall/address/<name> (the new subnet) |
create_sent | POST firewall/address (the new object) |
created | – (the object was read back; the membership comes next) |
member_sent | PUT firewall/addrgrp/<group> (the members) |
done | – (confirmed) |
The server stores the stage on the address operation, so an interruption shows how far it got and a retry can resume.
Sites jobs send a SitesSetProgress {list, stage, object}
before each write step: create_sent (before POST firewall/address of an
FQDN object), created (read back), member_sent (before
PUT firewall/addrgrp/<group>), delete_sent (before
DELETE firewall/address/<object> of an unused Matrix-owned object).
Write-time authorization
Right before every write the connector sends
POST /jobs/{jobID}/authorize with an AuthorizeRequest:
| Field | For |
|---|---|
step | policy.status (room switch), address.update, address.create, address.member, sites.set |
policy_id, room | Room and address steps (room also for per-room sites.set) |
list, domains_hash | sites.set (the job's list, SitesDomainsHash of its domains) |
previous, desired | policy.status |
object_name, group | Address steps |
The answer is {allowed: true} or {allowed: false, reason}. The
connector fails closed: any other answer, an error or no answer within
5 seconds blocks the write (unauthorized).
The server allows only if all hold:
- the job belongs to this connector, is
ackedorrunning, and has not expired; - the step matches the job type (
matrix.policy.set↔policy.status;address.update↔address.update;address.create↔address.createoraddress.member;matrix.sites.set↔sites.set) and the values equal the job's payload; amatrix.services.syncjob is always refused (services_removed); - for
sites.set: the firewall'ssite_writes_enabledis on and the author may still edit the list (the shared list: tenant admin; a room's list: tenant admin or a teacher with a right for the room); - the firewall is enabled and
writes_enabled(room steps) oraddress_writes_enabled(address steps) is on; - the job's author is still an active user of the tenant whose Hub session
was not ended, and still a tenant admin (or global admin), or, for room
switches, a teacher with a right for the room; a system job (no
author, an automatic re-enable) must belong to a schedule that is
firing.
Reasons: invalid_request, unknown_job, job_not_live, expired,
step_mismatch, values_mismatch, firewall_disabled,
writes_disabled, user_inactive, session_ended, not_permitted,
room_not_granted, schedule_not_firing, services_removed (a
matrix.services.sync job of the removed Entrosity services),
not_recorded. Every call is
recorded as a step of the change (metadata.steps); if it cannot be
recorded, the write is refused.
Credentials
GET /firewalls/{firewallID}/credentials → {token, version}
(Cache-Control: no-store), only for a firewall assigned to the asking
connector; 404 no_credentials when none is stored. The connector
fetches it when an apply or check carries a newer credentials_version
and keeps it DPAPI-sealed in firewalls.json. Every fetch is recorded in
the tenant's audit log as firewall.credentials_fetch, without the token.
Tokens never appear in job payloads, results, reports, events or logs.
Job error codes
Reported in job.result.error_code:
| Code | Meaning | Outcome |
|---|---|---|
forbidden_policy | Not a manageable room policy. | denied |
policy_changed | Changed between the reads, or not confirmed without a write. | denied |
room_mismatch | The policy is no longer that room. | denied |
unconfirmed | A write was sent but not confirmed. | unconfirmed |
conflict | Group version or expected IP differ. | denied |
duplicate_ip, duplicate_name | Another managed computer has the IP; an object with the name exists. | denied |
shared_object | Used outside the room: read-only. | denied |
invalid_input | Name or IP rejected. | denied |
sites_not_set_up | The allowed-sites group or its ACCEPT policy is missing. | denied |
unauthorized | The server's write-time authorization refused. | denied |
guard_pending, guard_mismatch | The local guard is not accepted, or the scope changed. | denied |
writes_disabled | Writes switched off (server switch or guard). | denied |
busy | Another write on the same object is running. | denied |
not_found | The object was not found on the FortiGate. | denied |
rate_limited | The connector's local write cap. | denied |
expired | The job arrived too late to write. | error |
unreachable, tls, auth_failed | No connection; certificate not verified; HTTP 401/403. | error |
version_mismatch, direction_invalid, invalid_response | FortiOS version differs from the pin; interfaces or zones not found; inconsistent FortiGate data. | error |
unknown_firewall, unknown_driver | The connector does not know the firewall, or has no such driver. | error |
interrupted | The connector restarted before any write. | error |
Self-update codes (update_unsigned, signature_invalid,
download_failed, hash_mismatch, exec_failed) are as for the other
connectors (Error codes).
Outcomes
How the server maps a write job to the change's result:
| Job | Change result |
|---|---|
Succeeded (and, for rooms, confirmed) | success |
Failed with wrote: true, code unconfirmed, an address stage beyond prepared, or a sites stage (create_sent, created, member_sent, delete_sent) | unconfirmed |
| Failed without a write, with a refusal code (the denied rows above) | denied |
| Failed without a write, any other code | error |
| Expired after the connector acked or started it | unconfirmed |
| Expired without ever being acked | error |
The server never creates a write job again on its own; an unconfirmed change waits for a person (a new change, or an explicit address retry).
Limits
| Limit | Value |
|---|---|
| Firewalls per report | 50 |
| Rooms per firewall | 2,000 |
| Address groups per firewall, members per group | 500, 2,000 |
| Allowed-sites lists per firewall, domains per list | 2,001, 200 |
| Policy name pattern | 500 characters, RE2, one (?P<room>…) |
| Report interval | 15–300 s (default 30) |
| Local writes per firewall and minute | 1–600 (default 60) |
| Token | 512 characters, no whitespace |