Config Lifecycle¶
Storage¶
A gateway's configuration is one JSON document. Every save inserts a new row in gateway_configs. Rows are never edited in place, apart from their status:
gateway_configs (
id BIGSERIAL PRIMARY KEY,
device_id VARCHAR(64) NOT NULL REFERENCES devices(id),
version BIGINT NOT NULL,
config_data JSONB NOT NULL,
config_hash VARCHAR(64) NOT NULL, -- sha256 of config_data
created_by VARCHAR(128), -- Zitadel sub
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status VARCHAR(16) NOT NULL,
applied_at TIMESTAMPTZ,
comment VARCHAR,
UNIQUE (device_id, version)
)
devices.desired_config_versionis the version the platform wants on the device.devices.config_versionis the version that was last confirmed.
SaveGatewayConfig refuses a config in any of these cases:
- it is oversized
- it still contains template placeholders
- it has an unusable FQDN alias
- the caller's
base_versionis stale, meaning someone else saved in between
Status transitions¶
stateDiagram-v2
[*] --> draft : SaveGatewayConfig
draft --> pending : ApplyGatewayConfig
failed --> pending : ApplyGatewayConfig
pending --> awaiting_confirm : agent reports applied
pending --> failed : agent reports an error
awaiting_confirm --> applied : ConfirmGatewayConfig
awaiting_confirm --> rolled_back : no confirm within 60 s
| Status | Meaning |
|---|---|
draft |
Saved on nexus, not pushed. |
pending |
desired_config_version is set and the apply_config push was sent. It stays pending while the device is offline, and the device picks it up on its next GetConfig. |
awaiting_confirm |
The agent applied it and armed its rollback guard. |
applied |
Confirmed. applied_at is set and devices.config_version is moved to this version. |
failed |
The agent could not apply it. It can be applied again. |
rolled_back |
No confirmation reached the agent in time, so it restored the previous state. |
ConfirmGatewayConfig refuses a version whose status is failed or rolled_back, because that version is not running on the device.
The sequence across portal, nexus, realtime and agent is drawn in How It Works.
Confirmation¶
The portal starts a 60 s countdown when the push succeeds. The operator confirms explicitly; the portal never confirms on its own. Confirmation sends a confirm_config command to the agent.
The push can be lost, for example when the new config cut the socket it would arrive on. To cover that, the agent polls GetDesiredState while its guard is armed. A config_status of applied counts as a confirmation.
Do not bypass the rollback
The rollback guard is what keeps a bad rule from locking the operator out of a remote box. Do not disable it, and do not make confirmation automatic.
Rollback on the gateway¶
The guard is edge/gateway/internal/rollback, created with a 60 s timeout in cmd/agent/serve.go.
- Before applying, it snapshots the connectivity-critical state to disk (mode 0600). That covers
pf.conf,/etc/mygate, the IPv4 default route, the generatediked.conf,dhcpd.confandunbound.conf, and the list of virtual interfaces. If the snapshot cannot be written, the config is not applied. - It starts the timer.
- On
confirm_configfor that version, or on a confirmed status seen by polling, it stops the timer and drops the snapshot. - On timeout, it restores the snapshot and removes any virtual interface the change created. It re-syncs static routes from the restored config, then sends a
config_reportwithstatus: rolled_back.
The snapshot carries a deadline, so an agent that dies inside the window finishes the rollback when it comes back (internal/rollback/resume.go).
Rolling back from the portal¶
RollbackConfig(version) changes nothing on the device. It copies an older version's config_data into a new draft with the next version number, which the operator then applies and confirms as usual. History stays append-only.
Payload shape¶
The agent decodes the blob into DeviceConfig (edge/gateway/internal/config/device.go). Top-level keys:
| Key | Contents |
|---|---|
hostname |
Device hostname |
interfaces |
Physical, VLAN, bridge, trunk, PPPoE, WireGuard, IPsec, cellular; addressing and per-interface DHCP |
firewall |
rules (structured, or raw pf for system rules) |
nat |
rules, see below |
aliases |
host, network, port, group, fqdn |
routes |
Static routes (destination, gateway, optional interface) |
services, schedules |
Objects referenced by rules |
dns_filter, detection, syslog, flow_export |
Filtering, Suricata, log forwarding, flow export |
dynamic_routing |
OSPF |
ip_forwarding |
Absent means on |
published_services |
name, scheme, address, port, require_code, label, disabled |
NAT rules¶
There is one rule type. Source and destination translation are each optional, and the pf construct depends on which ones are present (internal/pf/generate.go):
{
"id": "nat-…", "enabled": true, "name": "…", "description": "…", "log": false,
"match": {
"in_interface": "", // set for destination NAT (match in on …)
"out_interface": "egress", // set for source NAT (match out on …)
"protocol": "tcp",
"source": "lan_net", "source_port": "",
"destination": "", "destination_port": ""
},
"translated_source": { "address": "egress", "port": "", "randomize_port": false,
"method": "", "sticky": false }, // nat-to
"translated_destination": { "address": "10.0.1.50", "port": "8443",
"method": "", "sticky": false }, // rdr-to
"bidirectional": false // binat-to
}
| Rule shape | pf |
|---|---|
translated_source only |
match out on <out_interface> … nat-to <addr> [port] [method] [sticky-address] |
translated_destination only |
match in on <in_interface> … rdr-to <addr> [port] [method] [sticky-address] |
bidirectional |
match on <interface> from <internal> binat-to <addr> |
address: "egress"means the interface's own address.methodis one ofround-robin,random,source-hashorbitmask.- A translated address that is not on the interface is not added as an alias automatically. The operator adds the alias.