feat(enroll): opt-in IPvlan per-agent for Wi-Fi-bridged VMs

Wi-Fi APs bind one MAC per associated station, so VirtualBox/VMware
guests bridged over Wi-Fi rotate the VM's DHCP lease when Docker's
macvlan starts emitting container-MAC frames through the vNIC. Adds a
`use_ipvlan` toggle on the Agent Enrollment tab (mirrors the updater
daemon checkbox): flips the flag on SwarmHost, bakes `ipvlan=true` into
the agent's decnet.ini, and `_worker_config` forces ipvlan=True on the
per-host shard at dispatch. Safe no-op on wired/bare-metal agents.
This commit is contained in:
2026-04-19 17:57:45 -04:00
parent 6d7567b6bb
commit 5df995fda1
6 changed files with 109 additions and 6 deletions

View File

@@ -87,6 +87,15 @@ class EnrollBundleRequest(BaseModel):
default=True,
description="Include updater cert bundle and auto-start decnet updater on the agent",
)
use_ipvlan: bool = Field(
default=False,
description=(
"Run deckies on this agent over IPvlan L2 instead of MACVLAN. "
"Required when the agent is a VirtualBox/VMware guest bridged over Wi-Fi — "
"Wi-Fi APs bind one MAC per station, so MACVLAN's extra container MACs "
"rotate the VM's DHCP lease. Safe no-op on wired/bare-metal hosts."
),
)
services_ini: Optional[str] = Field(
default=None,
description="Optional INI text shipped to the agent as /etc/decnet/services.ini",
@@ -166,13 +175,15 @@ def _is_excluded(rel: str) -> bool:
return False
def _render_decnet_ini(master_host: str) -> bytes:
def _render_decnet_ini(master_host: str, use_ipvlan: bool = False) -> bytes:
ipvlan_line = f"ipvlan = {'true' if use_ipvlan else 'false'}\n"
return (
"; Generated by DECNET agent-enrollment bundle.\n"
"[decnet]\n"
"mode = agent\n"
"disallow-master = true\n"
"log-directory = /var/log/decnet\n"
f"{ipvlan_line}"
"\n"
"[agent]\n"
f"master-host = {master_host}\n"
@@ -197,6 +208,7 @@ def _build_tarball(
issued: pki.IssuedCert,
services_ini: Optional[str],
updater_issued: Optional[pki.IssuedCert] = None,
use_ipvlan: bool = False,
) -> bytes:
"""Gzipped tarball with:
- full repo source (minus excludes)
@@ -216,7 +228,7 @@ def _build_tarball(
continue
tar.add(path, arcname=rel, recursive=False)
_add_bytes(tar, "etc/decnet/decnet.ini", _render_decnet_ini(master_host))
_add_bytes(tar, "etc/decnet/decnet.ini", _render_decnet_ini(master_host, use_ipvlan))
for unit in _SYSTEMD_UNITS:
_add_bytes(
tar,
@@ -329,11 +341,15 @@ async def create_enroll_bundle(
"cert_bundle_path": str(bundle_dir),
"enrolled_at": datetime.now(timezone.utc),
"notes": "enrolled via UI bundle",
"use_ipvlan": req.use_ipvlan,
}
)
# 3. Render payload + bootstrap.
tarball = _build_tarball(req.master_host, req.agent_name, issued, req.services_ini, updater_issued)
tarball = _build_tarball(
req.master_host, req.agent_name, issued, req.services_ini, updater_issued,
use_ipvlan=req.use_ipvlan,
)
token = secrets.token_urlsafe(24)
expires_at = datetime.now(timezone.utc) + BUNDLE_TTL