Skip to content

Connections

A Connection is the core authoring unit of the Fabric subsystem. You pick a source event from the catalog, add optional conditions to filter which events matter, then build a step chain of operations that fires whenever a matching event arrives on the live bus. Read operations run immediately; write operations stage a pending change and wait for an explicit operator sign-off before touching any device.

This page covers every phase of authoring a Connection: the schema fields, event sources, condition syntax, templating rules, the Negotiator’s at-most-once semantics, the write-staging contract, and worked examples.


{
"name": "Omada change applied → snapshot Proxmox VM",
"source_event": "controller.change.applied",
"description": "Before any staged Omada write goes live, snapshot the gateway VM.",
"conditions": {
"logic": "and",
"conditions": [
{ "field": "vendor", "operator": "eq", "value": "omada" }
]
},
"cooldown_seconds": 60,
"enabled": true,
"steps": [
{
"operation_id": "hypervisor.vm.snapshot",
"params": {
"controller_id": "{{trigger.controller_id}}",
"node": "pve",
"vmid": 100,
"snapname": "pre-change-{{trigger.change_id}}",
"description": "Pre-change snapshot - feature: {{trigger.feature}}"
},
"continue_on_error": false
}
]
}
Field Required Type Constraints Purpose
name Yes string 1-255 chars Display label in the UI
source_event Yes string 1-255 chars Event type from the Fabric catalog
description No string - Free text; shown in the UI
conditions No object max 32 conditions, max depth 10 Filter events before running the chain
cooldown_seconds No integer 0-86 400 Minimum seconds between two firings of this Connection
enabled No boolean default true Disabled Connections are inert on the bus
steps Yes array 1-25 steps Ordered list of operations to execute

source_event must match an event_type exposed in the Fabric catalog (GET /api/v1/fabric/catalog). You can also use pattern wildcards:

Pattern form Matches
exact string, e.g. device.status.changed Only that event type
a.b.* One additional segment: a.b.X but not a.b.X.Y
a.b.# Any depth: a.b, a.b.X, a.b.X.Y
* Every event on the bus

Use * or # patterns with a cooldown_seconds value; a Connection wired to every event with no cooldown can saturate the bus under high-velocity device activity.

These are always available regardless of which modules or adapters are active:

Event type When it fires Key payload fields
controller.change.applied A staged device write was successfully applied change_id, controller_id, feature, operation, vendor, status, site_id, actor_id
controller.change.staged A write landed in the pending-changes queue awaiting sign-off same shape
controller.change.failed A staged write failed at the device same shape
device.status.changed A managed device went online or offline device_id, site_id, data.name, data.old_status, data.new_status, data.reason
device.discovered A new device was discovered or adopted on a controller device_id, site_id, data.*
device.updated A managed device record changed (rename, re-IP, firmware) device_id, site_id, data.*
ingest.external An external system called POST /fabric/ingest name, data (caller-supplied)
Event type Provider Carries artifact
cameras.event.motion Video Surveillance image/jpeg snapshot handle
cameras.event.person Video Surveillance image/jpeg snapshot handle
camera.status.online / camera.status.offline Video Surveillance -
camera.snapshot.captured Video Surveillance image/jpeg snapshot handle
network.vlan.created / .updated / .deleted Network Management -
network.wifi.created / .updated / .deleted Network Management -
gateway.sync.completed Firewall / Orchestration -
gateway.brain.offline Firewall / Orchestration -
storage.pool.degraded / storage.capacity.warning Storage -
backup.validation.failed Configuration Backup -
ai.budget.warning AI Assistant -

Events that carry an artifact (e.g. cameras.event.motion) let you wire a downstream step that accepts image/jpeg - the Negotiator threads the artifact reference through without inlining binary data into the event bus. The suggest endpoint (GET /api/v1/fabric/connections/suggest?source_event=cameras.event.motion) annotates each compatible operation with "match": "artifact" so the UI can show the paperclip hint.


Conditions gate which events actually proceed to the step chain. Omit conditions entirely to fire on every matching event.

{
"logic": "and",
"conditions": [
{ "field": "vendor", "operator": "eq", "value": "opnsense" },
{ "field": "feature", "operator": "contains", "value": "firewall" }
]
}

You can nest condition groups:

{
"logic": "or",
"conditions": [
{
"logic": "and",
"conditions": [
{ "field": "vendor", "operator": "eq", "value": "omada" },
{ "field": "data.new_status", "operator": "eq", "value": "offline" }
]
},
{ "field": "data.reason", "operator": "contains", "value": "power" }
]
}

Maximum nesting depth is 10; maximum conditions per group is 32.

Operator Meaning
eq / ne Equal / not equal
gt / gte / lt / lte Numeric comparison
contains / not_contains String substring check
matches Regex match (ReDoS-guarded; input capped at 4 KB)
in / not_in Value in a list (list ≤ 64 items, each string ≤ 1 024 chars)
exists / not_exists Field presence check

field is a dot-path into the event payload (data.new_status, vendor, site_id). Use the field picker in the Connection builder to browse available paths for a chosen event type.


Each step names an operation_id from the Fabric catalog and a params object. Steps run in order. If a step with "continue_on_error": false fails, the run stops and subsequent steps are skipped. There is no partial rollback.

Field Required Default Description
operation_id Yes - Dotted operation id from the catalog
params Yes - Object; values may use {{...}} template expressions
continue_on_error No true If false, a failure in this step aborts the chain

These three operations are always available and have no side-effect on devices:

Operation id What it does Required params
fabric.notify Dispatches a multi-channel notification (email, Slack, in-app, webhook) channels (required - JSONB channel config; an empty object {} dispatches nothing); title (default: "FreeSDN Fabric") and body (default: "") are optional
fabric.log Writes a structured Fabric log entry (audit sink; useful for wiring validation) message
fabric.webhook HTTP POST to an external URL (n8n, Zapier, Make, Node-RED) url (required), method (default POST), payload, headers

fabric.notify truncates title to 200 characters and body to 4 000 characters. fabric.webhook enforces a 30-second timeout, caps the response body at 16 KB, and routes every request through the SSRF guard (safe_http_request) - it can never reach loopback, cloud-metadata addresses, or private ranges unless the deploy owner has explicitly added the host to FABRIC_WEBHOOK_ALLOWED_HOSTS. See Fabric overview for webhook security details.

Operation id Provider Write? Permission required
cameras.snapshot Video Surveillance No (read)
storage.health Storage No storage.view
storage.store_blob Storage Yes storage.write
hypervisor.vm.snapshot Compute Yes hypervisor.manage_snapshots
hypervisor.vm.start / .stop / .shutdown / .reboot Compute Yes hypervisor.manage_vms
network.client.list Network Management No network.view
firewall.search_alerts Firewall No firewall.view_logs
voip.phone.live_status VoIP & Telephony No voip.view

Step params values may embed {{...}} expressions resolved at runtime. The template engine is a dotted-path lookup only - there is no expression evaluation, no arithmetic, no function calls, and no attribute access of any kind. Server-side template injection is impossible by construction.

{
"trigger": <the source event payload>,
"steps": [
{ "output": <step 0 output>, "artifact": <ArtifactRef | null>, "success": true/false },
{ "output": <step 1 output>, "artifact": <ArtifactRef | null>, "success": true/false },
...
]
}
Expression Resolves to
{{trigger}} The full source event payload (object, preserved as native type)
{{trigger.vendor}} A top-level field from the event payload
{{trigger.data.new_status}} A nested field (dot-separated path)
{{steps.0.output.change_id}} Output from step 0 (zero-indexed)
{{steps.1.artifact}} The ArtifactRef produced by step 1

A reference that is the only content of a string value preserves the native type ({{trigger.count}} → integer, not "5"). A reference embedded in a longer string renders to a string: "Device {{trigger.device_id}} is offline".

A missing path resolves to null (not an error). The chain does not abort on a missing reference - check required fields with a condition if downstream steps depend on them.

Limit Value
Maximum path depth 12 segments
Maximum rendered string length 16 KiB
Maximum referenced object size 256 KiB (truncated if over)

The Negotiator is the Connection runtime. It subscribes to the live event bus under a * (all-events) fan-out, applies source-pattern matching and org-scoping to determine which Connections a given event triggers, then executes the step chain for each match.

Under a multi-worker deployment, every gunicorn worker’s Negotiator instance sees every event. To prevent duplicate firings, the Negotiator claims each (connection_id, event_id) pair with a Redis SET NX EX 300 before running:

fabric:fired:{connection_id}:{event_id} NX EX 300

Only one worker wins the claim. The others skip silently. This is the primary cluster-wide deduplication guard.

A cluster-wide cooldown key suppresses repeated firings of the same Connection within cooldown_seconds:

fabric:cooldown:{connection_id} NX EX {cooldown_seconds}

If Redis is down, the Negotiator falls back to an in-process timestamp. Cooldown is particularly important for Connections that could create feedback loops (e.g. ingest.externalfabric.webhook whose target posts back to /fabric/ingest).

Every step’s Operation has a declared permission. Before executing, the Negotiator re-resolves the author’s current permissions from the DB. If the author has since been deactivated, moved to a different org, or had permissions reduced, the step fails with PERMISSION_DENIED rather than proceeding under stale cached grants.

A Connection only fires on events from its own organization. The org check is fail-closed: a missing org field on an event causes the Connection to skip, not fire.

A semaphore caps concurrent chain executions at 16 per API process. An event burst that arrives faster than chains can complete queues at the semaphore rather than spawning unbounded asyncio tasks or exhausting the DB connection pool.

Every firing writes a ConnectionRun row: source event type, trigger payload, per-step results, success flag, and duration in milliseconds. View runs at GET /api/v1/fabric/connections/{id}/runs or in the UI under Fabric → Connections → {name} → Runs.


Any step whose operation_id resolves to a write operation (write: true in the catalog) never touches a device directly. The Negotiator routes it through AdapterStagingService.stage_change, which creates a pending change row and returns the change_id. The step output is { "staged": true, "change_id": "...", "feature": "...", "operation": "..." }.

The device is not contacted until an operator navigates to Pending Changes and explicitly approves the change through the dual-gate (ADAPTER_READ_ONLY=false at the system level AND the individual per-change sign-off).

This is the same pipeline that governs all adapter writes in FreeSDN - there is no special bypass path for Fabric-initiated writes.

What happens if a write step is in the middle of a chain?

Section titled “What happens if a write step is in the middle of a chain?”

The chain continues with the staged step’s change_id available as {{steps.N.output.change_id}}. A downstream step can reference it (for example, log it). The device-side action is deferred; the chain itself completes.

If a write step also needs a binary artifact (for example, storage.store_blob accepting a camera snapshot), the artifact is copied from the transient broker to the durable store at staging time. The staged change payload carries only a reference (token + sha256 + size). If the durable store write fails after staging, the staged change row is rolled back to avoid an orphan.

Plugin operations (tier: plugin) are categorically refused by the executor with PLUGIN_WRITE_FORBIDDEN. Device writes may only flow from native, operator-authored steps. Plugin ops reach the platform through their own bounded runtime, not through the Fabric write pipeline.


Creating, updating, and deleting Connections requires an unscoped org-admin role: the API key or session must be scoped to at least org_admin and must not be narrowed by a custom scope set that excludes org-admin authority. Viewers, operators, and site-admins can list Connections and view runs but cannot author them.

At create time the service validates that the authoring user holds every permission required by every step’s operation. A step referencing hypervisor.manage_vms from a user without that permission is rejected with ConnectionPermissionError before the Connection is stored.

Non-authors (including operators and viewers) see step params redacted:

{ "operation_id": "fabric.webhook", "params": { "__redacted__": "hidden - author-only" } }

The operation_id is preserved for visibility; the params blob (which may contain a webhook URL or Slack hook) is dropped entirely. This is a whole-blob redaction, not a key-name heuristic.


Method Path Purpose Permission
POST /api/v1/fabric/connections Create a Connection Unscoped org-admin
GET /api/v1/fabric/connections List Connections (enabled? filter) Any org member
GET /api/v1/fabric/connections/suggest Compatible operations for a source_event Any org member
GET /api/v1/fabric/connections/{id} Get one Connection Any org member
PATCH /api/v1/fabric/connections/{id} Update a Connection Unscoped org-admin
DELETE /api/v1/fabric/connections/{id} Delete a Connection Unscoped org-admin
GET /api/v1/fabric/connections/{id}/runs Run history (max 200) Any org member
POST /api/v1/fabric/connections/{id}/test Fire once with an explicit payload Unscoped org-admin

The suggest endpoint is the matchmaking surface for the step builder. Pass ?source_event=cameras.event.motion and it returns compatible operations annotated with "match": "artifact" (the op accepts the event’s media type) or "match": "data" (params-only, no artifact), plus "allowed": true/false based on whether the calling user holds the required permission. Results are sorted: authorable artifact-match → authorable data-match → alphabetical.


1. Device goes offline → in-app notification + email

Section titled “1. Device goes offline → in-app notification + email”
{
"name": "Device offline → notify ops team",
"source_event": "device.status.changed",
"conditions": {
"logic": "and",
"conditions": [
{ "field": "data.new_status", "operator": "eq", "value": "offline" }
]
},
"cooldown_seconds": 120,
"steps": [
{
"operation_id": "fabric.notify",
"params": {
"channels": {
"in_app": { "enabled": true },
"email": { "enabled": true }
},
"title": "Device offline: {{trigger.data.name}}",
"body": "Device {{trigger.device_id}} at site {{trigger.site_id}} went offline. Previous status: {{trigger.data.old_status}}."
}
}
]
}

The cooldown_seconds: 120 prevents a flapping device from sending one email every second.

2. Camera motion event → capture snapshot → store blob (staged)

Section titled “2. Camera motion event → capture snapshot → store blob (staged)”
{
"name": "Motion detected → store snapshot",
"source_event": "cameras.event.motion",
"cooldown_seconds": 30,
"steps": [
{
"operation_id": "cameras.snapshot",
"params": {
"camera_id": "{{trigger.camera_id}}"
},
"continue_on_error": false
},
{
"operation_id": "storage.store_blob",
"params": {
"controller_id": "{{trigger.controller_id}}",
"label": "motion-{{trigger.device_id}}"
}
}
]
}

Step 0 (cameras.snapshot) runs immediately and produces an image/jpeg artifact. Step 1 (storage.store_blob) accepts image/jpeg and receives that artifact automatically via the Negotiator’s artifact threading. Because storage.store_blob is a write operation, step 1 stages a pending change - an operator must sign off before the blob is committed to storage.

3. External webhook ingest → route by name → notify

Section titled “3. External webhook ingest → route by name → notify”
{
"name": "n8n alert → in-app",
"source_event": "ingest.external",
"conditions": {
"logic": "and",
"conditions": [
{ "field": "name", "operator": "eq", "value": "n8n-critical" }
]
},
"cooldown_seconds": 10,
"steps": [
{
"operation_id": "fabric.notify",
"params": {
"channels": { "in_app": { "enabled": true } },
"title": "n8n alert",
"body": "{{trigger.data.message}}"
}
}
]
}

The external system calls POST /api/v1/fabric/ingest with { "name": "n8n-critical", "payload": { "message": "..." } } using an API key scoped to event:write. The ingest.external event fires; the condition on name routes only this Connection. Rate limit: 120 ingests per 60-second window, cluster-wide.

4. Staged Omada write applied → snapshot Proxmox VM

Section titled “4. Staged Omada write applied → snapshot Proxmox VM”
{
"name": "Omada change applied → Proxmox snapshot",
"source_event": "controller.change.applied",
"conditions": {
"logic": "and",
"conditions": [
{ "field": "vendor", "operator": "eq", "value": "omada" },
{ "field": "status", "operator": "eq", "value": "applied" }
]
},
"cooldown_seconds": 300,
"steps": [
{
"operation_id": "hypervisor.vm.snapshot",
"params": {
"controller_id": "{{trigger.controller_id}}",
"node": "pve",
"vmid": 100,
"snapname": "post-change",
"description": "Post-change snapshot - {{trigger.feature}} / change {{trigger.change_id}}"
},
"continue_on_error": false
},
{
"operation_id": "fabric.log",
"params": {
"message": "VM snapshot staged for change {{trigger.change_id}}. Pending change id: {{steps.0.output.change_id}}"
}
}
]
}

Step 0 stages the VM snapshot (a write); step 1 logs the change_id returned in step 0’s output. Both run within the same Connection firing. The VM snapshot itself waits for a separate operator sign-off in Pending Changes.

5. IDS/IPS critical alert → webhook to SIEM

Section titled “5. IDS/IPS critical alert → webhook to SIEM”
Terminal window
curl -sX POST https://<freesdn>/api/v1/fabric/connections \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "IDS critical → SIEM webhook",
"source_event": "firewall.event.ids_critical",
"cooldown_seconds": 5,
"steps": [
{
"operation_id": "fabric.webhook",
"params": {
"url": "https://siem.example.com/api/events",
"method": "POST",
"headers": { "Authorization": "Bearer siem-token-here" },
"payload": {
"source": "freesdn",
"rule": "{{trigger.rule_name}}",
"src_ip": "{{trigger.src_ip}}",
"dst_ip": "{{trigger.dst_ip}}"
}
}
}
]
}'

The SIEM URL must be a public destination or listed in FABRIC_WEBHOOK_ALLOWED_HOSTS. If FABRIC_WEBHOOK_SIGNING_SECRET is set, the POST carries X-Fabric-Signature and X-Fabric-Timestamp for replay-protected verification on the SIEM side.


Before wiring a Connection to live events, use the test endpoint to fire it with a synthetic payload:

Terminal window
curl -sX POST https://<freesdn>/api/v1/fabric/connections/{id}/test \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"payload": {
"device_id": "aabbcc112233",
"site_id": "00000000-0000-0000-0000-000000000001",
"data": { "name": "test-switch", "new_status": "offline", "old_status": "online" }
}
}'

The test run applies the same per-step permission gate and write-staging path as a live firing. Write steps stage a real pending change; they do not auto-apply. The run is recorded in the run history identically to a live firing - there is no distinguishing marker stored on the run record.


Environment variable Default Effect
FABRIC_WEBHOOK_ALLOWED_HOSTS "" Comma-separated hostnames or IPs that fabric.webhook may reach even if private/LAN/tailnet. Deploy-owner controlled; still DNS-pinned and TLS-verified.
FABRIC_WEBHOOK_SIGNING_SECRET "" When set, every fabric.webhook POST carries X-Fabric-Signature: sha256=<hmac> over the body plus X-Fabric-Timestamp. Rotate via env.
FABRIC_ARTIFACT_DURABLE_DIR /data/fabric_artifacts Durable blob storage for staged write steps. Must be a persistent volume in production.
REDIS_URL - Required for cluster-wide at-most-once, cooldown, and ingest-throttle guards. Without Redis the guards fall back to per-process behavior.

Item Limit
Steps per Connection 25
Conditions per group 32
Condition group nesting depth 10
Cooldown range 0-86 400 seconds
ingest.external rate 120 per 60-second window per org
Ingest request body cap 64 KiB
Webhook response cap 16 KiB
Webhook timeout 30 seconds
Plugin artifact cap 16 MiB
Transient artifact cap 64 MiB, TTL 1 hour
Concurrent chains per API worker 16

  • Fabric Catalog - browse the full operation and event catalog, understand the tier model, and see executor security details.
  • n8n Integration - wire Connections to external automation using the n8n community node or the fabric.webhook / POST /fabric/ingest bridge.
  • Plugin SDK - register custom trigger event sources and callable operations from a plugin, then reference them in Connections.
  • Marketplace - install signed community plugins that add new operations and event sources to the catalog.

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.