Skip to content

Rollback Guard

internal/rollback. A config change that cuts the device off must undo itself, because nobody can reach the device to undo it by hand.

Applying a config arms a timer. If the platform does not confirm the version before it expires, the device puts itself back.

The shape

guard := rollback.NewGuard(pfApplier, dataDir, 60*time.Second, logger) // serve.go
err := guard.Arm(rollback.CaptureSnapshotWith(pfApplier, ifApplier), version)
guard.Confirm(version)   // cancel the timer, discard the snapshot
guard.IsPending() bool

The trial window is 60 seconds, fixed in cmd/agent/serve.go. The portal's countdown is driven by Nexus; the device only waits for confirm_config.

Arm returns an error, and the caller does not apply if it does. Arming writes the snapshot to disk (pf.conf.rollback and pending.json in the data directory, mode 0600, because the snapshot can hold the IPsec pre-shared key). A change that could not be undone is never made.

What a snapshot holds

Field Why
PFConf The firewall ruleset
Mygate, DefaultGateway The management path: a restored ruleset is useless if the route out is gone
Files Other generated files, each with its content, whether it existed, and how to reload it
VirtualInterfaces Destroyable interfaces present before the change

Files covers /etc/iked.conf, /etc/dhcpd.conf, /var/unbound/etc/unbound.conf, /etc/dhcpleased.conf, /etc/ifstated.conf and every /etc/hostname.<if> (except lo, enc, pflog).

A file that did not exist is removed, not emptied. Restoring an empty string is not the same as removing a file the failed config created.

VirtualInterfaces is nil when nothing was inspected, and that is deliberate: nil means "never looked" and is skipped; an empty slice means "looked, there were none" and is acted on. Confusing the two would destroy interfaces on a device the guard knows nothing about.

The guard restores the kernel as well as files because a rolled-back change can otherwise leave something running that the platform believes is gone: a tunnel whose config was removed, a wg0 described by no configuration, a route that broke connectivity. The rule is that whatever the failed version created, in a file or in the kernel, is taken away again.

On expiry

The guard (rollback.go):

  1. restores pf.conf and reloads it, so filtering is back first;
  2. writes /etc/mygate and resets the kernel default route;
  3. destroys virtual interfaces created since the snapshot, before restoring files, so a restored hostname.if cannot collide with an address still held by the interface being removed;
  4. restores the generated files and reloads their daemons (rcctl reload iked, restart dhcpd / dhcpleased / ifstated, reload unbound, sh /etc/netstart <if>). A daemon that will not reload does not stop the rest; a service that is switched off is logged as routine.

Then the SetOnRollback callback in serve.go:

  1. restores the config store to the previous version and updates live state;
  2. reconciles static routes against the abandoned config, removing what it added;
  3. sends a rollback report to Nexus.

Surviving a reboot

pending.json holds {version, deadline, snapshot}. At startup, before the connector runs, ResumePending():

  • with no record, does nothing;
  • with a deadline already passed, rolls back immediately;
  • with a deadline ahead, re-arms for the remaining time.

A malformed record is discarded rather than treated as fatal. A box that rebooted mid-trial therefore still rolls back an unconfirmed change.

Confirming

Confirm(version) cancels the timer and discards the snapshot. A confirm for a different version than the armed one is logged (confirm version mismatch, accepting anyway) and still accepted.