Incus Setup (Linux)
This is the fully implemented backend this milestone.
Install
Follow the official Incus install instructions
for your distribution — current stable is the 7.0 LTS line (formerly the
6.23 series). agentctl shells out to the incus CLI directly, so any
install method that puts a working incus binary on $PATH and an
initialized daemon in place is sufficient.
Initialize
$ sudo incus admin init --minimal
--minimal is enough to get started; see Incus's own docs for storage/network
backend choices appropriate to your environment.
Permissions
Add your user to the incus-admin group (or whichever group your
distribution uses) so incus commands don't require sudo:
$ sudo usermod -aG incus-admin $USER
Log out and back in for the group change to take effect. agentctl assumes
this is already set up — it does not manage Incus permissions itself.
What agentctl assumes exists
- A running, initialized Incus daemon reachable via the local
incusCLI. - Enough storage/network configuration for
incus init --vmto succeed (agentctl creates VMs, not containers, for the stronger isolation boundary). - Working hardware virtualization (KVM). Since agentctl always creates
VMs, the host needs
/dev/kvmavailable — a real machine or a VM host with nested virtualization enabled. This is why.github/workflows/ci-integration.ymlcan't run on a standard GitHub-hosted Actions runner: those have no nested virtualization, so Incus fails immediately with "Instance type \"virtual-machine\" is not supported on this server: QEMU command not available for CPU architecture." That workflow needs to be pointed at a self-hosted or nested-virt-capable runner. - Network ACL support (native since early Incus 6.x; agentctl uses this for
the egress allowlist/
--deny-lanenforcement).
Fedora/RHEL: add the bridge to firewalld's trusted zone first
Do this before your first agentctl create, or instances will come up with
no IPv4 address. On distributions that ship firewalld — Fedora, RHEL and
derivatives — incusbr0 is not assigned to any zone, so it falls through to
the default one (normally public), which blocks DHCP (UDP 67/68) to the
bridge's dnsmasq. IPv6 doesn't use DHCP, so it configures fine via router
advertisements, and the result is a guest that looks half-working: an IPv6
address, IPv6 resolvers, and no IPv4 at all.
Check it — no zone is the unhealthy answer, and so is any zone other than
trusted:
$ sudo firewall-cmd --get-zone-of-interface=incusbr0
no zone
agentctl create checks this for you and warns before creating anything, so
you shouldn't have to remember it. The check is read-only and stays silent
whenever it can't get a definitive answer — firewalld absent, not running, or
the query needing root it doesn't have — so its silence is not a guarantee
that all is well. It also checks net.ipv4.ip_forward, the other way traffic
reaches the bridge and stops there.
$ sudo firewall-cmd --zone=trusted --change-interface=incusbr0 --permanent
$ sudo firewall-cmd --reload
Or tell Incus not to manage those rules for that bridge:
$ incus network set incusbr0 ipv4.firewall=false
$ incus network set incusbr0 ipv6.firewall=false
This is plain Incus behavior, not something agentctl causes — you'd hit it
with a bare incus launch too. It is called out here because the symptom
(an agent install failing at curl) looks like an agentctl network-policy
problem and isn't.
Known operational notes
- agentctl never emits ACL reject rules, deliberately. Incus re-sorts
ACL rules by action — all rejects first, then allows, first match wins — so
a reject rule shadows every allow no matter what order it was added in. An
earlier version rejected the RFC1918 ranges under
--deny-lan, which covered the bridge's own dnsmasq resolver and blackholed DNS for every instance (upstream: lxc/incus#1919). Since attaching any ACL already makes unmatched egress default to reject,--deny-lanis enforced by withholding allow rules instead, and--allow-langrants the private ranges explicitly. SeenetworkACLCommandsininternal/provider/incus/translate.go. - Incus 6.14 patched a bridge-network ACL isolation bypass that could let a
compromised instance intercept traffic from other instances — exactly the
attack class this project defends against. Keep Incus itself patched and
current;
agentctl's guarantees are only as good as the backend enforcing them. agentctl createalways passes-c security.secureboot=false. Incus VMs default to requiring UEFI Secure Boot, but most public/community images (Alpine, generic Ubuntu cloud images, ...) aren't Secure Boot signed and simply refuse to start otherwise. This doesn't weaken agentctl's actual security guarantees — Secure Boot protects a guest's own boot chain against tampering with its boot media, which is orthogonal to the host-escape/LAN-lateral-movement threat model this project defends against. If you're using an image that is Secure Boot signed and want it enforced, re-enable it by hand after creation:incus config set <name> security.secureboot=true.agentctl createalso provisions a non-root default user (see Profiles & Policies) by running a small bootstrap script inside the instance right after network policy is applied.incus exec(which the bootstrap step uses) requires a running instance, socreatebriefly starts the instance, waits for the in-guest agent, runs the bootstrap script, then stops the instance again —createstill keeps its documented contract of not leaving the instance running, it just takes a bit longer (a real boot/shutdown cycle) than it used to. The bootstrap script detectsuseradd(Debian/Ubuntu family) oradduser(Alpine/busybox family) — images using neither will fail this step with a clear error rather than silently staying root-only. It attempts a fixed UID (1500) for predictability and falls back to whatever the OS assigns if that's already taken; the actual result is recorded on the instance viaincus config get <name> user.agentctl-shell-user(colon-delimitedusername:uid:home), whichshell/execread back on every invocation — agentctl keeps no state of its own here, Incus's own per-instance config is the source of truth. Re-running the bootstrap step (e.g. on a retry) is a no-op if the user already exists.
Verify
$ agentctl config set provider=incus
$ agentctl create test-instance --image=images:alpine/edge
$ agentctl start test-instance
$ agentctl exec test-instance -- whoami # -> agent (non-root default)
$ agentctl exec --root test-instance -- whoami # -> root
$ agentctl delete test-instance --force