Case studiesReference integration: plant edge to cloud

Plant edge to hosted OpexFlow: records survive an outage

How a plant’s OpexFlow node sends machine records to a hosted OpexFlow without opening an inbound port, and what happens to those records when the WAN or the hosted side is down: they wait on disk, arrive in order, are stored once, and a wrong secret never loses one.

Why the plant link needs a queue

A plant has machines that cannot stop and a WAN link that sometimes does. Records that go to a cloud system have to survive that gap, arrive in the order they were made, and not be counted twice when a sender is unsure whether its last attempt worked. The plant network also should not accept connections from outside.

The reference deployment solves this with one rule at each end. The edge writes every record to disk before it tries to send it, and only dials out. The hosted side verifies a signature on every request and drops a record it has already stored.

The flow

Nodes in the edge flow

  1. modbus devices
  2. local history
  3. push to hosted

Nodes in the hosted intake flow, in order

  1. signed webhook
  2. once per record
  3. alarm?
  4. tag
  5. history
  6. live feed (alarms)

On the edge, every record from the devices goes to local history and to the push sink. The push sink stores the record in a SQLite queue on disk (push_queue.db) before the first attempt, then sends records in order as an HMAC-signed POST to the hosted hook. A restart of the edge keeps the queue.

edge.yaml, connector section
push_endpoint: "${HOSTED_URL}/api/hooks/edge"
hmac_secret: "${EDGE_HMAC_SECRET}"
hmac_key_id: "plant-sg-01"
push_queue:
  enabled: true
  max_quota_mb: 2000
  on_full: evict_oldest   # use block for business records that must never be dropped

On the hosted side, a signed webhook source checks the signature, a dedupe node drops a record it has seen, a branch routes alarm records, and the sinks store the record to history. Alarms also go to the live feed. The hook answers after the flow has run, so a record only counts as delivered once it is stored.

flow.hosted.json, source node
{
  "match": "webhook:edge",
  "webhook": {
    "signature": {
      "scheme": "hmac_sha256",
      "header": "X-Opex-Signature",
      "timestamp_header": "X-Opex-Timestamp",
      "tolerance_secs": 300,
      "secret": "${EDGE_HMAC_SECRET}"
    },
    "respond": "on_done"
  }
}
  1. The edge signs "{timestamp}.{body}" and sends X-Opex-Signature and X-Opex-Timestamp. Every retry is signed again with a fresh timestamp, so a record that waited out a long outage is not rejected as old.
  2. The hosted source refuses a bad signature or a timestamp outside five minutes with 401, and counts it.
  3. The dedupe node keys on the sender’s key id, the machine and the record timestamp, and remembers keys for two days.
  4. A branch tags the record as alarm or normal, and the record is stored.

Tested cases

The integration tests run a durable edge pusher configured as in edge.yaml against the shipped hosted flow behind the real router, over real TCP on one machine.

Test cases and results
CaseWhat happenedResult
Hosted side is downThree records were queued, two machines, one of them sent twiceThe edge kept all three, showed the outage in its push status, and dropped none
Hosted side returnsThe queue drained by itselfNothing was dead-lettered, the resent record was stored once, and no signature was refused
Routing on the hosted sideAn alarm record and a normal recordEach was tagged with its own route and stored with its state
Wrong secret on the edgeThe hosted hook answered 401 and counted a bad signatureThe record stayed queued at the edge, the edge reported the 401, and nothing reached the flow
Secret fixedThe edge was reconfigured without a restartThe same record drained and was stored
Replayed captureA valid signature with a timestamp an hour old401. The same body with a fresh timestamp was accepted and stored
Shipped edge flowDeployed to a fresh executorAll four nodes were present: devices, test source, local history, push

The wrong-secret case is the one that matters in operation. A misconfigured edge does not lose data and does not get any into the flow. It fills its queue and says why, and fixing the secret drains it.

Watching the link

  • On the edge, GET /api/push/status reports queued records, consecutive failures, the last error and the age of the oldest record. Alert on a growing age.
  • On the hosted side, GET /api/status lists per webhook the records received and the bad signatures. GET /api/ready is the public health check used by the container.
  • A queue has a size limit. On the edge the reference uses evict_oldest, which deletes the oldest telemetry at the limit and counts and alerts it. The hosted node uses block, which refuses new records instead of dropping any. Business records should use block at both ends.
  • check-link.sh in deploy/hosted injects a record at the edge and waits for it on the hosted side.

Scope

  • The tests run both ends in one process on loopback. They do not cross a real WAN or Cloudflare.
  • The hosted node needs a VPS or VM with a persistent volume. A Cloudflare Worker cannot run the binary, and a Cloudflare Container has an ephemeral disk that would lose the databases on restart.
  • The deployment guide puts a Cloudflare Tunnel in front of the node and a Bypass policy on the hook path alone. The hook has no bearer token, and the signature is its only authentication. A service token is the alternative.
  • Disk use was measured on 2,008 records: about 0.43 KB per record in the history database. A 2,000-record loop took 24 seconds on a debug build. That is a smoke result, not a capacity figure.
  • The arm64 image is not exercised.

Related

From download to first record in minutes.

Run one binary, connect a PLC, a CNC or a webhook, and watch the records arrive in the editor.

Builds for Linux (x86_64 and ARM64), Windows and macOS.

  • One binary
  • Raspberry Pi to rack server
  • Signed delivery with retries

Sizing questions or an air-gapped install? Email marketing@opexcg.com or read the quick start.