Add --ipvlan flag for WiFi-compatible deployments

MACVLAN assigns unique MACs per container; WiFi APs typically reject
frames from unregistered MACs, making deckies unreachable from other
LAN devices. IPvlan L2 shares the host's MAC, so all traffic passes
through the AP normally.

- network.py: add create_ipvlan_network, setup_host_ipvlan,
  teardown_host_ipvlan, HOST_IPVLAN_IFACE
- config.py: add ipvlan: bool = False to DecnetConfig (persisted to
  state so teardown uses the right driver)
- deployer.py: branch on config.ipvlan for create/setup/teardown
- cli.py: add --ipvlan flag, wire into DecnetConfig
- tests/test_network.py: new test module covering ips_to_range,
  create_macvlan/ipvlan, setup/teardown for both drivers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 05:16:56 -03:00
parent 8276467dbb
commit b57269f68a
5 changed files with 280 additions and 11 deletions

View File

@@ -188,6 +188,7 @@ def deploy(
log_file: Optional[str] = typer.Option(None, "--log-file", help="Write RFC 5424 syslog to this path inside containers (e.g. /var/log/decnet/decnet.log)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Generate compose file without starting containers"),
no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild all images, ignoring Docker layer cache"),
ipvlan: bool = typer.Option(False, "--ipvlan", help="Use IPvlan L2 instead of MACVLAN (required on WiFi interfaces)"),
config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to INI config file"),
) -> None:
"""Deploy deckies to the LAN."""
@@ -294,6 +295,7 @@ def deploy(
deckies=decky_configs,
log_target=effective_log_target,
log_file=effective_log_file,
ipvlan=ipvlan,
)
if effective_log_target and not dry_run:

View File

@@ -44,6 +44,7 @@ class DecnetConfig(BaseModel):
deckies: list[DeckyConfig]
log_target: str | None = None # "ip:port" or None
log_file: str | None = None # path for RFC 5424 syslog file output
ipvlan: bool = False # use IPvlan L2 instead of MACVLAN (WiFi-friendly)
@field_validator("log_target")
@classmethod

View File

@@ -15,13 +15,16 @@ from decnet.composer import write_compose
from decnet.network import (
MACVLAN_NETWORK_NAME,
allocate_ips,
create_ipvlan_network,
create_macvlan_network,
detect_interface,
detect_subnet,
get_host_ip,
ips_to_range,
remove_macvlan_network,
setup_host_ipvlan,
setup_host_macvlan,
teardown_host_ipvlan,
teardown_host_macvlan,
)
@@ -88,16 +91,27 @@ def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False)
decky_range = ips_to_range(ip_list)
host_ip = get_host_ip(config.interface)
console.print(f"[bold cyan]Creating MACVLAN network[/] ({MACVLAN_NETWORK_NAME}) on {config.interface}")
net_driver = "IPvlan L2" if config.ipvlan else "MACVLAN"
console.print(f"[bold cyan]Creating {net_driver} network[/] ({MACVLAN_NETWORK_NAME}) on {config.interface}")
if not dry_run:
create_macvlan_network(
client,
interface=config.interface,
subnet=config.subnet,
gateway=config.gateway,
ip_range=decky_range,
)
setup_host_macvlan(config.interface, host_ip, decky_range)
if config.ipvlan:
create_ipvlan_network(
client,
interface=config.interface,
subnet=config.subnet,
gateway=config.gateway,
ip_range=decky_range,
)
setup_host_ipvlan(config.interface, host_ip, decky_range)
else:
create_macvlan_network(
client,
interface=config.interface,
subnet=config.subnet,
gateway=config.gateway,
ip_range=decky_range,
)
setup_host_macvlan(config.interface, host_ip, decky_range)
# --- Compose generation ---
compose_path = write_compose(config, COMPOSE_FILE)
@@ -142,10 +156,14 @@ def teardown(decky_id: str | None = None) -> None:
ip_list = [d.ip for d in config.deckies]
decky_range = ips_to_range(ip_list)
teardown_host_macvlan(decky_range)
if config.ipvlan:
teardown_host_ipvlan(decky_range)
else:
teardown_host_macvlan(decky_range)
remove_macvlan_network(client)
clear_state()
console.print("[green]All deckies torn down. MACVLAN network removed.[/]")
net_driver = "IPvlan" if config.ipvlan else "MACVLAN"
console.print(f"[green]All deckies torn down. {net_driver} network removed.[/]")
def status() -> None:

View File

@@ -19,6 +19,7 @@ import docker
MACVLAN_NETWORK_NAME = "decnet_lan"
HOST_MACVLAN_IFACE = "decnet_macvlan0"
HOST_IPVLAN_IFACE = "decnet_ipvlan0"
# ---------------------------------------------------------------------------
@@ -157,6 +158,35 @@ def create_macvlan_network(
)
def create_ipvlan_network(
client: docker.DockerClient,
interface: str,
subnet: str,
gateway: str,
ip_range: str,
) -> None:
"""Create an IPvlan L2 Docker network. No-op if it already exists."""
existing = [n.name for n in client.networks.list()]
if MACVLAN_NETWORK_NAME in existing:
return
client.networks.create(
name=MACVLAN_NETWORK_NAME,
driver="ipvlan",
options={"parent": interface, "ipvlan_mode": "l2"},
ipam=docker.types.IPAMConfig(
driver="default",
pool_configs=[
docker.types.IPAMPool(
subnet=subnet,
gateway=gateway,
iprange=ip_range,
)
],
),
)
def remove_macvlan_network(client: docker.DockerClient) -> None:
nets = [n for n in client.networks.list() if n.name == MACVLAN_NETWORK_NAME]
for n in nets:
@@ -197,6 +227,28 @@ def teardown_host_macvlan(decky_ip_range: str) -> None:
_run(["ip", "link", "del", HOST_MACVLAN_IFACE], check=False)
def setup_host_ipvlan(interface: str, host_ipvlan_ip: str, decky_ip_range: str) -> None:
"""
Create an IPvlan interface on the host so the deployer can reach deckies.
Idempotent — skips steps that are already done.
"""
_require_root()
result = _run(["ip", "link", "show", HOST_IPVLAN_IFACE], check=False)
if result.returncode != 0:
_run(["ip", "link", "add", HOST_IPVLAN_IFACE, "link", interface, "type", "ipvlan", "mode", "l2"])
_run(["ip", "addr", "add", f"{host_ipvlan_ip}/32", "dev", HOST_IPVLAN_IFACE], check=False)
_run(["ip", "link", "set", HOST_IPVLAN_IFACE, "up"])
_run(["ip", "route", "add", decky_ip_range, "dev", HOST_IPVLAN_IFACE], check=False)
def teardown_host_ipvlan(decky_ip_range: str) -> None:
_require_root()
_run(["ip", "route", "del", decky_ip_range, "dev", HOST_IPVLAN_IFACE], check=False)
_run(["ip", "link", "del", HOST_IPVLAN_IFACE], check=False)
# ---------------------------------------------------------------------------
# Compute an ip_range CIDR that covers a list of IPs
# ---------------------------------------------------------------------------