diff --git a/switch_backend.py b/switch_backend.py index eeb4966..1bbf6a9 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -2163,10 +2163,12 @@ class CtrldVlanProfile(BaseModel): # Required for router-mode multi-listener TOML class CtrldConfig(BaseModel): - mode: str # "local" | "opnsense" | "manual" - deploy_mode: Optional[str] = "proxy" # "router" (OPNsense) | "proxy" (management host) - vlan_profiles: list[CtrldVlanProfile] - opnsense_host: Optional[str] = "" + mode: str # "local" | "opnsense" | "manual" + deploy_mode: Optional[str] = "proxy" # "router" (OPNsense) | "proxy" (management host) + vlan_profiles: list[CtrldVlanProfile] + opnsense_host: Optional[str] = "" + unbound_port: Optional[int] = 5353 # port Unbound listens on after being moved off 53 + local_domain: Optional[str] = "lan" # domain suffix forwarded to Unbound (*.lan, *.local) class CtrldInstallRequest(BaseModel): token: str @@ -2310,12 +2312,19 @@ def ctrld_validate_endpoints(body: CtrldValidateRequest): def ctrld_toml_preview(): """Generate and return the ctrld.toml without installing it.""" cfg = _load_ctrld_cfg() - profiles = cfg.get("vlan_profiles", []) - deploy_mode = cfg.get("deploy_mode", "proxy") + profiles = cfg.get("vlan_profiles", []) + deploy_mode = cfg.get("deploy_mode", "proxy") + unbound_port = cfg.get("unbound_port", 5353) + local_domain = cfg.get("local_domain", "lan") if not profiles: raise HTTPException(400, "No VLAN profiles configured yet") - toml = _build_ctrld_toml(profiles, deploy_mode=deploy_mode) - return {"toml": toml, "deploy_mode": deploy_mode} + toml = _build_ctrld_toml( + profiles, + local_domain=local_domain, + local_resolver=f"127.0.0.1:{unbound_port}", + deploy_mode=deploy_mode, + ) + return {"toml": toml, "deploy_mode": deploy_mode, "local_resolver": f"127.0.0.1:{unbound_port}"} @app.post("/api/ctrld/save-config") def ctrld_save_config(body: CtrldInstallRequest): @@ -2342,21 +2351,34 @@ def ctrld_save_config(body: CtrldInstallRequest): "toml_error": validation.get("toml_error", ""), }) - deploy_mode = body.config.deploy_mode or "proxy" + deploy_mode = body.config.deploy_mode or "proxy" + unbound_port = body.config.unbound_port or 5353 + local_domain = body.config.local_domain or "lan" cfg_dict = { "mode": body.config.mode, "deploy_mode": deploy_mode, "vlan_profiles": profiles, "opnsense_host": body.config.opnsense_host, + "unbound_port": unbound_port, + "local_domain": local_domain, } _save_ctrld_cfg(cfg_dict) - toml = _build_ctrld_toml(profiles, deploy_mode=deploy_mode) + local_resolver = f"127.0.0.1:{unbound_port}" + toml = _build_ctrld_toml( + profiles, + local_domain=local_domain, + local_resolver=local_resolver, + deploy_mode=deploy_mode, + ) if body.config.mode == "local": return _ctrld_install_local(toml, profiles) elif body.config.mode == "opnsense": - return _ctrld_generate_opnsense_cmd(body.config.opnsense_host, profiles, deploy_mode) + return _ctrld_generate_opnsense_cmd( + body.config.opnsense_host, profiles, deploy_mode, + unbound_port=unbound_port, local_domain=local_domain, + ) else: # Manual — just return the toml and instructions return { @@ -2509,10 +2531,16 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict: } def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list, - deploy_mode: str = "router") -> dict: + deploy_mode: str = "router", + unbound_port: int = 5353, + local_domain: str = "lan") -> dict: """ - Generate the SSH command to install ctrld on OPNsense. - Defaults to router deploy_mode — OPNsense has per-VLAN gateway IPs. + Generate the SSH command + step-by-step instructions to install ctrld on OPNsense. + + Port-conflict fix: Unbound moves to localhost:5353 so ctrld can own :53. + ctrld.toml gets [upstream.local] pointing at 127.0.0.1: so + *.lan / *.local / *.home.arpa queries still resolve via Unbound. + Both services start cleanly after reboot with zero conflict. """ first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None) if not first_rid: @@ -2523,23 +2551,57 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list, f"-s {first_rid} forced'" ) - toml = _build_ctrld_toml(profiles, deploy_mode=deploy_mode) + local_resolver = f"127.0.0.1:{unbound_port}" + toml = _build_ctrld_toml( + profiles, + local_domain=local_domain, + local_resolver=local_resolver, + deploy_mode=deploy_mode, + ) - # For OPNsense the config path is different opnsense_cfg = "/usr/local/etc/controld/ctrld.toml" + # Shell one-liner to write the TOML file via SSH (safe for embedding) + escaped_toml = toml.replace("'", "'\\''") + write_toml_cmd = f"cat > {opnsense_cfg} << 'CTRLDEOF'\n{toml}\nCTRLDEOF" + + unbound_steps = [ + "FIRST: fix the Unbound port conflict (do this before installing ctrld)", + " OPNsense GUI → Services → Unbound DNS → General:", + f" • Listen Port: 53 → {unbound_port}", + " • Network Interfaces: All (recommended) → Localhost", + " • Click Apply / Save", + " This moves Unbound to localhost only so ctrld can own port 53.", + " After reboot: Unbound starts on 5353 (no conflict), ctrld starts on 53.", + ] + return { - "success": True, - "mode": "opnsense", - "message": "Run the install command in OPNsense shell (SSH or console)", - "install_cmd": install_cmd, - "ssh_cmd": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{install_cmd}'", - "toml": toml, - "config_path": opnsense_cfg, - "step2": f"After install, replace {opnsense_cfg} with the toml config shown below", - "step3": "Run: ctrld restart", - "step4": f"Set DNS (option 6) to {opnsense_host or 'OPNsense IP'} on each VLAN pool", - "docs": "https://docs.controld.com/docs/routers-platform", + "success": True, + "mode": "opnsense", + "message": "Step-by-step OPNsense ctrld install with Unbound coexistence", + "unbound_steps": unbound_steps, + "unbound_warning": ( + "Unbound MUST be on localhost:{} before ctrld is installed. " + "If ctrld starts first it will grab :53 and Unbound will fail — " + "then on next reboot Unbound grabs :53 first and ctrld fails. " + "Change the port first, then install ctrld.".format(unbound_port) + ), + "step1_unbound": "Change Unbound: Listen Port → {} | Network Interfaces → Localhost | Apply".format(unbound_port), + "step2_install": install_cmd, + "step2_ssh": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{install_cmd}'", + "step3_config": f"Replace {opnsense_cfg} with the TOML below, then: ctrld restart", + "step4_dns": ( + f"In OPNsense DHCP server for each VLAN, set DNS (option 6) to that VLAN's " + f"gateway IP (e.g. 192.168.10.1 for VLAN 10) — not {opnsense_host}." + ), + "step5_verify": "dig @192.168.10.1 google.com # should return via ctrld", + "step5_local": f"dig @192.168.10.1 myhost.{local_domain} # should return via Unbound", + "toml": toml, + "toml_write_cmd": write_toml_cmd, + "config_path": opnsense_cfg, + "local_resolver": local_resolver, + "local_domain": local_domain, + "docs": "https://docs.controld.com/docs/routers-platform", } @app.post("/api/ctrld/update-profiles") @@ -2550,8 +2612,15 @@ def ctrld_update_profiles(body: CtrldUpdateProfile): cfg["vlan_profiles"] = [p.dict() for p in body.vlan_profiles] _save_ctrld_cfg(cfg) - deploy_mode = cfg.get("deploy_mode", "proxy") - toml = _build_ctrld_toml(cfg["vlan_profiles"], deploy_mode=deploy_mode) + deploy_mode = cfg.get("deploy_mode", "proxy") + unbound_port = cfg.get("unbound_port", 5353) + local_domain = cfg.get("local_domain", "lan") + toml = _build_ctrld_toml( + cfg["vlan_profiles"], + local_domain=local_domain, + local_resolver=f"127.0.0.1:{unbound_port}", + deploy_mode=deploy_mode, + ) cfg_path = _ctrld_config_path() if cfg.get("mode") == "local" and cfg_path.exists():