From 5df995fda16be30c3618da4a9955a0f874032162 Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 19 Apr 2026 17:57:45 -0400 Subject: [PATCH] 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. --- decnet/web/db/models.py | 6 ++++ decnet/web/router/swarm/api_deploy_swarm.py | 16 +++++++-- .../router/swarm_mgmt/api_enroll_bundle.py | 22 ++++++++++-- decnet_web/src/components/AgentEnrollment.tsx | 13 +++++++ tests/api/fleet/test_deploy_automode.py | 22 ++++++++++++ tests/api/swarm_mgmt/test_enroll_bundle.py | 36 +++++++++++++++++++ 6 files changed, 109 insertions(+), 6 deletions(-) diff --git a/decnet/web/db/models.py b/decnet/web/db/models.py index 9c9dca4..2dbf7f7 100644 --- a/decnet/web/db/models.py +++ b/decnet/web/db/models.py @@ -126,6 +126,11 @@ class SwarmHost(SQLModel, table=True): cert_bundle_path: str enrolled_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) notes: Optional[str] = Field(default=None, sa_column=Column("notes", Text, nullable=True)) + # Per-host driver preference. True => deckies on this host run over IPvlan + # (L2) instead of macvlan — required when the host is a VirtualBox guest + # bridged over Wi-Fi, because Wi-Fi APs only allow one MAC per station + # and macvlan's per-container MACs rotate the VM's DHCP lease. + use_ipvlan: bool = Field(default=False) class DeckyShard(SQLModel, table=True): @@ -322,6 +327,7 @@ class SwarmHostView(BaseModel): updater_cert_fingerprint: Optional[str] = None enrolled_at: datetime notes: Optional[str] = None + use_ipvlan: bool = False class DeckyShardView(BaseModel): diff --git a/decnet/web/router/swarm/api_deploy_swarm.py b/decnet/web/router/swarm/api_deploy_swarm.py index f2b7b2e..b15f4b7 100644 --- a/decnet/web/router/swarm/api_deploy_swarm.py +++ b/decnet/web/router/swarm/api_deploy_swarm.py @@ -43,8 +43,18 @@ def _shard_by_host(config: DecnetConfig) -> dict[str, list[DeckyConfig]]: return buckets -def _worker_config(base: DecnetConfig, shard: list[DeckyConfig]) -> DecnetConfig: - return base.model_copy(update={"deckies": shard}) +def _worker_config( + base: DecnetConfig, + shard: list[DeckyConfig], + host: dict[str, Any], +) -> DecnetConfig: + updates: dict[str, Any] = {"deckies": shard} + # Per-host driver opt-in (Wi-Fi-bridged VMs can't use macvlan — see + # SwarmHost.use_ipvlan). Never downgrade: if the operator picked ipvlan + # at the deploy level, keep it regardless of the per-host flag. + if host.get("use_ipvlan"): + updates["ipvlan"] = True + return base.model_copy(update=updates) async def dispatch_decnet_config( @@ -69,7 +79,7 @@ async def dispatch_decnet_config( async def _dispatch(host_uuid: str, shard: list[DeckyConfig]) -> SwarmHostResult: host = hosts[host_uuid] - cfg = _worker_config(config, shard) + cfg = _worker_config(config, shard, host) try: async with AgentClient(host=host) as agent: body = await agent.deploy(cfg, dry_run=dry_run, no_cache=no_cache) diff --git a/decnet/web/router/swarm_mgmt/api_enroll_bundle.py b/decnet/web/router/swarm_mgmt/api_enroll_bundle.py index 862f8ab..f3a7128 100644 --- a/decnet/web/router/swarm_mgmt/api_enroll_bundle.py +++ b/decnet/web/router/swarm_mgmt/api_enroll_bundle.py @@ -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 diff --git a/decnet_web/src/components/AgentEnrollment.tsx b/decnet_web/src/components/AgentEnrollment.tsx index 3977467..f538416 100644 --- a/decnet_web/src/components/AgentEnrollment.tsx +++ b/decnet_web/src/components/AgentEnrollment.tsx @@ -15,6 +15,7 @@ const AgentEnrollment: React.FC = () => { const [masterHost, setMasterHost] = useState(window.location.hostname); const [agentName, setAgentName] = useState(''); const [withUpdater, setWithUpdater] = useState(true); + const [useIpvlan, setUseIpvlan] = useState(false); const [servicesIni, setServicesIni] = useState(null); const [servicesIniName, setServicesIniName] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -49,6 +50,7 @@ const AgentEnrollment: React.FC = () => { setError(null); setAgentName(''); setWithUpdater(true); + setUseIpvlan(false); setServicesIni(null); setServicesIniName(null); setCopied(false); @@ -64,6 +66,7 @@ const AgentEnrollment: React.FC = () => { master_host: masterHost, agent_name: agentName, with_updater: withUpdater, + use_ipvlan: useIpvlan, services_ini: servicesIni, }); setResult(res.data); @@ -130,6 +133,16 @@ const AgentEnrollment: React.FC = () => { /> Install updater daemon (lets the master push code updates to this agent) +