Skip to main content

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/ws with Authorization: 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, and pending_job_ids); the server answers hello.ack, delivers the pending jobs and may offer an update.
  • The connector sends heartbeat every minute. It is online from its hello until 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 without job.ack is sent again; a job not finished before its expiry is expired by the server (Outcomes).

HTTP endpoints (/api/connector/v1)​

Method and pathPurpose
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 /wsThe WebSocket.
POST /heartbeatHTTP fallback of heartbeat.
GET /jobs/pending, POST /jobs/{id}/ack, …/progress, …/resultHTTP polling fallback of the job lifecycle.
POST /jobs/{id}/authorizeWrite-time authorization (below).
POST /reportHTTP fallback of matrix.report (same handler; gzip accepted). 204.
GET /firewalls/{firewallID}/credentialsThe 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:

FieldMeaning
firewall_idThe firewall.
statusonline, unreachable, auth_failed, version_mismatch, direction_invalid or error, with error and error_code.
fortios_versionAs reported by the FortiGate.
guard_stateaccepted, pending or mismatch.
rooms_fetched_at, roomsRoom {policy_id, name, room, building, status} of every room policy (at most 2,000).
groups_hash, groups_fetched_at, groupsThe 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, sitesConnectors 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​

TypePayloadResultTimeout / expiry
matrix.firewall.applyFirewallApplyJob {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?}CheckResult60 s / 2 min
matrix.refresh{firewall_id, scope: rooms|addresses|all}FirewallReport30 s / 1 min
matrix.policy.setPolicySetJob {firewall_id, policy_id, room, desired, change_id}PolicySetResult30 s / 60 s
matrix.address.updateAddressOpJob with expected_ipAddressOpResult2 min / 2 min
matrix.address.createAddressOpJob without expected_ipAddressOpResult2 min / 2 min
matrix.sites.setSitesSetJob {firewall_id, list, room, domains, list_version, change_id}SitesSetResult2 min / 2 min
update_agentUpdateJob, component matrix-connectorUpdateResult30 min
  • FirewallRef is the firewall's configuration: firewall_id, driver (fortigate or simulator), 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_enabled and service_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_version is newer than the stored one, the connector fetches the token.
  • CheckResult: version, direction_ok, rooms, groups, members, warnings (English, stable prefixes such as later_accept_policy:), guard_state, policy_names (every policy with the configured direction, at most 2,000), and from connectors with sites: sites (the lists as in the report). Allowed-sites warnings use the prefixes sites_not_set_up:, sites_policy_missing:, sites_policy_disabled:, sites_policy_not_covering:, sites_group_read_only: and sites_wildcard_dns:. A policy whose every destination is an allowed-sites group (or the old Matrix Entrosity services group) is not reported as a later_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), and resume (the journaled AddressOpState of an interrupted attempt, on an explicit retry; stage update_sent, create_sent, created or member_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-102Matrix allowed sites SB1-102
  • SitesList: list, room (empty for shared), group, exists, policy (SitesPolicy {policy_id, name, status, covers}: the first enabled ACCEPT policy with the firewall's exact direction and the group in its dstaddr; covers when its srcaddr contains every room policy's source, or that room's for a per-room list, or all), version (64 hex, fingerprint of the group and its members' objects), editable, reason, domains (SiteEntry {domain, object, owned, editable, reason}; the built-in member none is not listed).
  • Objects Matrix owns: type fqdn address objects named matrix-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> site and whose fqdn is 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 of a-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: domains is the full desired set of Matrix-owned domains (canonical, sorted). The connector re-reads everything; the list must be set up (group and policy, else sites_not_set_up), editable (not used as a source or inside another group, no nested groups) and at list_version (else conflict); 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 (none when 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 step services.sync, the list services and ServicesState remain in proto/matrix as 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 as unconfirmed.

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:

StageSent before
update_sentPUT firewall/address/<name> (the new subnet)
create_sentPOST firewall/address (the new object)
created– (the object was read back; the membership comes next)
member_sentPUT 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:

FieldFor
steppolicy.status (room switch), address.update, address.create, address.member, sites.set
policy_id, roomRoom and address steps (room also for per-room sites.set)
list, domains_hashsites.set (the job's list, SitesDomainsHash of its domains)
previous, desiredpolicy.status
object_name, groupAddress 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 acked or running, and has not expired;
  • the step matches the job type (matrix.policy.set ↔ policy.status; address.update ↔ address.update; address.create ↔ address.create or address.member; matrix.sites.set ↔ sites.set) and the values equal the job's payload; a matrix.services.sync job is always refused (services_removed);
  • for sites.set: the firewall's site_writes_enabled is 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) or address_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:

CodeMeaningOutcome
forbidden_policyNot a manageable room policy.denied
policy_changedChanged between the reads, or not confirmed without a write.denied
room_mismatchThe policy is no longer that room.denied
unconfirmedA write was sent but not confirmed.unconfirmed
conflictGroup version or expected IP differ.denied
duplicate_ip, duplicate_nameAnother managed computer has the IP; an object with the name exists.denied
shared_objectUsed outside the room: read-only.denied
invalid_inputName or IP rejected.denied
sites_not_set_upThe allowed-sites group or its ACCEPT policy is missing.denied
unauthorizedThe server's write-time authorization refused.denied
guard_pending, guard_mismatchThe local guard is not accepted, or the scope changed.denied
writes_disabledWrites switched off (server switch or guard).denied
busyAnother write on the same object is running.denied
not_foundThe object was not found on the FortiGate.denied
rate_limitedThe connector's local write cap.denied
expiredThe job arrived too late to write.error
unreachable, tls, auth_failedNo connection; certificate not verified; HTTP 401/403.error
version_mismatch, direction_invalid, invalid_responseFortiOS version differs from the pin; interfaces or zones not found; inconsistent FortiGate data.error
unknown_firewall, unknown_driverThe connector does not know the firewall, or has no such driver.error
interruptedThe 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:

JobChange 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 codeerror
Expired after the connector acked or started itunconfirmed
Expired without ever being ackederror

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​

LimitValue
Firewalls per report50
Rooms per firewall2,000
Address groups per firewall, members per group500, 2,000
Allowed-sites lists per firewall, domains per list2,001, 200
Policy name pattern500 characters, RE2, one (?P<room>…)
Report interval15–300 s (default 30)
Local writes per firewall and minute1–600 (default 60)
Token512 characters, no whitespace