From b1b2b91905f14c291d47b4a8232ba537c5b2c5b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 14:33:35 +0000 Subject: [PATCH] Fix ctrld setup: ask subnet/domain/resolver, newbie-friendly OPNsense instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup_ctrld() now asks for each VLAN's actual subnet (was hardcoded as 192.168.{id}.0/24, wrong for any non-default addressing) - Asks for local domain suffix (default: lan) with explanation of what it is and where to find it in OPNsense - Asks for local resolver address (default: 127.0.0.1:5353) with a full plain-English explanation of WHY Unbound must move off port 53, why both .lan and .local rules are needed, and what breaks without them - _ctrld_opnsense_setup() now walks through the Unbound port change step-by-step with exact OPNsense UI paths, pausing for confirmation at each stage before proceeding - All three install paths (_local, _opnsense, _manual) now pass local_domain and local_resolver through to _build_toml() - Intro text rewritten for someone unfamiliar with DNS filtering, VLANs, or Resolver IDs — explains what each thing is before asking https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6 --- Avaya_5952_setup.py | 311 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 246 insertions(+), 65 deletions(-) diff --git a/Avaya_5952_setup.py b/Avaya_5952_setup.py index ad2cc4f..f74850b 100644 --- a/Avaya_5952_setup.py +++ b/Avaya_5952_setup.py @@ -903,34 +903,47 @@ def setup_ctrld(vlans_config: list): step(21, "DNS Filtering — Control D (optional)") sep() print(f""" - {bold('Control D')} filters DNS per VLAN — ads, malware, adult content, - social media, and more. Each VLAN gets its own filtering profile. + {bold('What is DNS filtering?')} + Every time a device opens a website or app, it first asks a DNS server + "what is the address of google.com?" — before any data is sent. + DNS filtering intercepts that question and can block ads, malware, adult + content, social media, or anything else — invisibly, for every device + on your network, without installing anything on the devices themselves. - {cyan('ctrld')} is a local DNS proxy daemon that: - - Listens on port 53 for normal DNS from your devices - - Routes queries by source VLAN subnet to the right profile - - Forwards upstream via encrypted DoH3 to Control D - - Runs as a system service, starts on boot + {bold('Control D')} is a DNS filtering service. Each of your VLANs gets + its own filtering profile — so your Staff VLAN can browse freely while + your Guest VLAN blocks everything except basic browsing. - {bold('You need a Control D account:')} {dim('https://controld.com')} - Create a Device (type: Router) per VLAN to get a Resolver ID. + {cyan('ctrld')} is a small program that runs on your router or this computer. + It sits between your devices and the internet for DNS only: + Your device → ctrld (port 53) → Control D cloud → answer back + Nothing else about your traffic is touched. - {bold('Three deployment options:')} + {bold('Before you start — you need a free Control D account:')} + {cyan('https://controld.com')} + + {dim('Once logged in: go to Endpoints → Add Device → type: Router')} + {dim('Create one Device per VLAN. Each gets a short Resolver ID like "p-abc123".')} + {dim('That ID is what this script asks for below.')} + + {bold('Three ways to run ctrld:')} {cyan('A — On this management computer')} {dim('(fully automated)')} - ctrld runs here alongside the switch manager. - Pros: self-contained, no other machine needed. - Cons: if this machine is down, DNS filtering stops - (devices still get IPs, just no filtering). + ctrld runs on the same computer as the switch manager. + Good if you do not have OPNsense or just want to get started. + Downside: if this computer sleeps or shuts down, filtering stops + (your devices can still get on the internet, just unfiltered). - {cyan('B — On OPNsense')} {dim('(semi-automated — one SSH command)')} - ctrld runs on your router. - Pros: DNS filtering survives management computer outage. - Cons: requires SSH access to OPNsense. + {cyan('B — On OPNsense')} {dim('(recommended — one SSH command + two OPNsense clicks)')} + ctrld runs on your router, which is always on. + Filtering survives this computer being off or rebooted. + Requires: SSH access to OPNsense (one command to install). + Also requires moving OPNsense's built-in DNS off port 53 first + — this script explains exactly how to do that. - {cyan('C — Manual / skip')} {dim('(config generated, you install)')} - Site generates ctrld.toml and install commands. - Install wherever you want, whenever you want. + {cyan('C — Generate config only')} {dim('(copy-paste the file yourself)')} + Produces a ready-to-use ctrld.toml you can nano/copy anywhere. + Good if you want to review the file before anything is changed. """) sep() @@ -938,32 +951,105 @@ def setup_ctrld(vlans_config: list): info("Skipping — you can configure this in the DNS Filtering tab later") return - choice = ask("Option (A/B/C)", "A").strip().upper() + choice = ask("Option (A/B/C)", "B").strip().upper() - if choice not in ("A","B","C"): + if choice not in ("A", "B", "C"): warn("Invalid choice — skipping"); return - # Collect Resolver IDs per VLAN + # ── Local domain name ──────────────────────────────────────────────────── sep() - print(f" {bold('Enter your Control D Resolver ID for each VLAN.')}") - print(f" {dim('Find it at: controld.com → Endpoints → your device → Resolver ID')}") - print(f" {dim('Press Enter to skip a VLAN (it will use the first configured profile as fallback).')}") - print() + print(f""" + {bold('Local domain name')} + OPNsense gives every device on your network a name ending in your local + domain — for example "johns-laptop.lan" or "printer.lan". + The default in OPNsense is {cyan('lan')} (so devices get names like device.lan). + + {dim('You can check yours in OPNsense → System → General → Domain.')} + {dim('If you have not changed it, it is "lan". Just press Enter.')} +""") + local_domain = ask(" Your OPNsense local domain", "lan").strip().strip(".") + + # ── Local resolver (Unbound on OPNsense or skip) ───────────────────────── + sep() + print(f""" + {bold('Local device name resolution (e.g. printer.lan, devlaptop.lan)')} + ctrld will handle ALL DNS queries on port 53 — but it does not know + about your local devices. OPNsense's built-in DNS (called Unbound) + tracks those: it watches DHCP leases and knows "johns-laptop = 192.168.10.5". + + {bold('The problem:')} both Unbound and ctrld want to listen on port 53. + They cannot share it. One must move. + + {bold('The solution:')} move Unbound to port 5353 (internal only), ctrld takes 53. + ctrld then forwards any query ending in .{cyan(local_domain)} or .local back to + Unbound at 127.0.0.1:5353. Everything works exactly as before, just routed. + + {dim('Why .local too? Apple devices (iPhones, Macs, iPads) and many printers')} + {dim('use .local automatically via a protocol called mDNS/Bonjour.')} + {dim('Without this rule, your Mac would not find your printer by name.')} + + {bold('If you are NOT using OPNsense')} or want to skip local name forwarding, + press Enter to leave this blank. Device names like printer.lan will not + resolve — you would use IP addresses instead. +""") + if choice == "B": + local_resolver = ask( + f" Unbound address after port move (OPNsense → 127.0.0.1:5353)", + "127.0.0.1:5353" + ).strip() + else: + local_resolver = ask( + " Local DNS address for .lan/.local names (blank to skip)", + "" + ).strip() + + # ── Collect per-VLAN subnet + Resolver ID ──────────────────────────────── + sep() + print(f""" + {bold('VLAN subnets and Control D Resolver IDs')} + + For each VLAN you want filtered, you need two things: + + {cyan('Subnet')} — the IP address range for that VLAN. + Example: if your Staff VLAN is 192.168.10.x, the subnet is 192.168.10.0/24. + The "/24" means "all addresses from .0 to .255 in that group". + ctrld uses this to know which VLAN a query is coming from. + + {cyan('Resolver ID')} — the short code from Control D for that VLAN's profile. + Find it at: {dim('controld.com → Endpoints → your device → copy the Resolver ID')} + It looks like: {dim('p-abc123')} or {dim('abcd1234')} + + {dim('Press Enter on Resolver ID to skip a VLAN (no filtering for that VLAN).')} +""") profiles = [] - for vlan in (vlans_config or [{"id":10,"name":"Staff"},{"id":20,"name":"Servers"}, - {"id":30,"name":"IoT"},{"id":40,"name":"Guest"}, - {"id":50,"name":"Cameras"}]): - rid = ask(f" VLAN {vlan['id']} ({vlan['name']}) Resolver ID", "").strip() + for vlan in (vlans_config or [{"id": 10, "name": "Staff"}, + {"id": 20, "name": "Servers"}, + {"id": 30, "name": "IoT"}, + {"id": 40, "name": "Guest"}, + {"id": 50, "name": "Cameras"}]): + vid = vlan["id"] + name = vlan["name"] + print(f"\n {bold(f'VLAN {vid} — {name}')}") + suggested_subnet = f"192.168.{vid}.0/24" + subnet = ask( + f" Subnet (the IP range for this VLAN)", + suggested_subnet + ).strip() + rid = ask( + f" Control D Resolver ID (press Enter to skip filtering for this VLAN)", + "" + ).strip() if rid: profiles.append({ - "vlan_id": vlan["id"], - "name": vlan["name"], - "subnet": f"192.168.{vlan['id']}.0/24", + "vlan_id": vid, + "name": name, + "subnet": subnet, "resolver_id": rid, }) + ok(f"VLAN {vid} ({name}) — {subnet} → ControlD profile {rid}") else: - info(f"Skipping VLAN {vlan['id']} — will use fallback") + info(f"VLAN {vid} ({name}) — skipped (no filtering)") if not profiles: warn("No Resolver IDs entered — skipping DNS setup") @@ -972,11 +1058,11 @@ def setup_ctrld(vlans_config: list): sep() if choice == "A": - _ctrld_install_local_setup(profiles) + _ctrld_install_local_setup(profiles, local_domain, local_resolver) elif choice == "B": - _ctrld_opnsense_setup(profiles) + _ctrld_opnsense_setup(profiles, local_domain, local_resolver) else: - _ctrld_manual_setup(profiles) + _ctrld_manual_setup(profiles, local_domain, local_resolver) def _build_toml(profiles: list, local_domain: str = "lan", @@ -1088,7 +1174,9 @@ def _build_toml(profiles: list, local_domain: str = "lan", return "\n".join(lines) -def _ctrld_install_local_setup(profiles: list): +def _ctrld_install_local_setup(profiles: list, + local_domain: str = "lan", + local_resolver: str = ""): info("Installing ctrld on this machine...") first_rid = profiles[0]["resolver_id"] @@ -1107,7 +1195,7 @@ def _ctrld_install_local_setup(profiles: list): # Write multi-VLAN config cfg_path = Path("/etc/controld/ctrld.toml") cfg_path.parent.mkdir(parents=True, exist_ok=True) - toml = _build_toml(profiles) + toml = _build_toml(profiles, local_domain=local_domain, local_resolver=local_resolver) cfg_path.write_text(toml) ok(f"Config written to {cfg_path}") @@ -1127,67 +1215,160 @@ def _ctrld_install_local_setup(profiles: list): print(f""" {bold('ctrld is running on this machine at:')} {cyan(mgmt_ip)} - {bold('Next:')} In the switch manager DHCP tab, set DNS (option 6) - to {cyan(mgmt_ip)} for each VLAN pool. The switch will then hand - out this machine as the DNS server and ctrld will route each - VLAN to its Control D profile automatically. + {bold('Next — tell your router to use this machine for DNS:')} + In OPNsense: Services → DHCPv4 → [each VLAN interface] + Set {bold('DNS Server')} to {cyan(mgmt_ip)} and save. + + Or in the switch manager DHCP tab, set DNS (option 6) + to {cyan(mgmt_ip)} for each VLAN pool. + + Once done, every device that renews its DHCP lease will + start using ctrld automatically — no changes on the devices. """) -def _ctrld_opnsense_setup(profiles: list): +def _ctrld_opnsense_setup(profiles: list, + local_domain: str = "lan", + local_resolver: str = "127.0.0.1:5353"): opnsense_ip = ask("OPNsense IP address", "192.168.99.1") first_rid = profiles[0]["resolver_id"] install_cmd = f'sh -c \'sh -c "$(curl -sL https://api.controld.com/dl)" -s {first_rid} forced\'' ssh_cmd = f"ssh root@{opnsense_ip} '{install_cmd}'" - toml = _build_toml(profiles) + toml = _build_toml(profiles, local_domain=local_domain, local_resolver=local_resolver) cfg_path = "/usr/local/etc/controld/ctrld.toml" sep() print(f""" - {bold('Step 1 — Run this command (installs ctrld on OPNsense):')} + {bold('━━━ Before you run ctrld — move Unbound off port 53 ━━━')} + + OPNsense has a built-in DNS server called Unbound (currently on port 53). + ctrld also needs port 53. They cannot share it, so Unbound must move first. + + {bold('In OPNsense web interface:')} + 1. Go to {cyan('Services → Unbound DNS → General')} + 2. Change {bold('Listen Port')} from {cyan('53')} to {cyan('5353')} + 3. Change {bold('Network Interfaces')} to {cyan('Loopback')} only + {dim('(this means Unbound only answers from within OPNsense itself,')} + {dim(' not directly from your network devices — ctrld handles those)')} + 4. Click {cyan('Apply')} + + {dim('Why loopback only? After this change, ctrld is the one answering your')} + {dim('devices on port 53. Unbound only needs to answer ctrld, which runs on')} + {dim('the same machine. Loopback = same machine only.')} + + {bold('Do this now, then press Enter to continue.')} +""") + input(" Press Enter when Unbound has been moved to port 5353... ") + + sep() + print(f""" + {bold('━━━ Step 1 — Install ctrld on OPNsense (SSH command) ━━━')} + + Open a terminal and run this command: {cyan(ssh_cmd)} - {bold('Step 2 — Replace the config file on OPNsense:')} - {dim(f'scp your-toml-file root@{opnsense_ip}:{cfg_path}')} - {dim('then: ssh root@{opnsense_ip} "ctrld restart"')} + {dim('This connects to OPNsense over SSH and runs the official ctrld installer.')} + {dim('If you get a "permission denied" error, check that root SSH is enabled')} + {dim('in OPNsense: System → Settings → Administration → Secure Shell.')} - {bold('Step 3 — In the switch manager DHCP tab:')} - Set DNS (option 6) to {cyan(opnsense_ip)} for each VLAN pool. + {bold('Do this now, then press Enter to continue.')} """) + input(" Press Enter when ctrld is installed on OPNsense... ") + # Write the toml locally so we can scp it toml_file = HERE / "ctrld.toml" toml_file.write_text(toml) - ok(f"ctrld.toml written to {toml_file}") + ok(f"ctrld.toml written to: {toml_file}") - if ask_yn("Copy command to clipboard?", False): + scp_cmd = f"scp {toml_file} root@{opnsense_ip}:{cfg_path}" + restart_cmd = f"ssh root@{opnsense_ip} 'ctrld restart'" + + sep() + print(f""" + {bold('━━━ Step 2 — Copy the config to OPNsense ━━━')} + + Run these two commands in order: + + {cyan(scp_cmd)} + {cyan(restart_cmd)} + + {dim('scp copies the generated ctrld.toml to OPNsense.')} + {dim('ctrld restart applies the new per-VLAN configuration.')} + + {bold('Do this now, then press Enter to continue.')} +""") + input(" Press Enter when ctrld has been restarted on OPNsense... ") + + sep() + print(f""" + {bold('━━━ Step 3 — Tell your network to use ctrld for DNS ━━━')} + + In OPNsense: Services → DHCPv4 → [each VLAN interface] + Set {bold('DNS Server')} to {cyan(opnsense_ip)} and save. + (OPNsense's own IP — ctrld is now running there on port 53.) + + {dim('When a device renews its DHCP lease (or you reconnect it),')} + {dim('it will receive OPNsense as its DNS server and queries will')} + {dim('flow through ctrld automatically.')} + + {bold('━━━ How it all works now ━━━')} + + Device asks DNS question + ↓ + ctrld on OPNsense (port 53) + ├── *.{local_domain} or *.local → Unbound (port 5353, local names) + └── everything else (by VLAN subnet) → Control D cloud (filtered) + + {bold('Verify it is working:')} + {cyan(f'ssh root@{opnsense_ip} "ctrld status"')} +""") + + if ask_yn("Copy the SSH install command to clipboard?", False): try: import subprocess - subprocess.run(["xclip","-selection","clipboard"], + subprocess.run(["xclip", "-selection", "clipboard"], input=ssh_cmd.encode(), check=False) - ok("Copied") + ok("Copied to clipboard") except Exception: - info("xclip not available — copy manually from above") + info("xclip not available — copy the command manually from above") -def _ctrld_manual_setup(profiles: list): - toml = _build_toml(profiles) +def _ctrld_manual_setup(profiles: list, + local_domain: str = "lan", + local_resolver: str = ""): + toml = _build_toml(profiles, local_domain=local_domain, local_resolver=local_resolver) toml_file = HERE / "ctrld.toml" toml_file.write_text(toml) first_rid = profiles[0]["resolver_id"] sep() _install_cmd = "sh -c 'sh -c \"$(curl -sL https://api.controld.com/dl)\" -s " + first_rid + " forced'" + unbound_note = "" + if local_resolver: + unbound_note = f""" + {bold('IMPORTANT — before installing on OPNsense:')} + Move Unbound off port 53 first, or ctrld will fail to start. + In OPNsense: Services → Unbound DNS → General + Change Listen Port to {cyan('5353')}, Network Interfaces to {cyan('Loopback')}, Apply. +""" print(f""" {bold('ctrld.toml written to:')} {cyan(str(toml_file))} - {bold('Install ctrld on any Linux/Mac/OPNsense machine:')} + Open it with: {cyan(f'cat {toml_file}')} + Or nano it: {cyan(f'nano {toml_file}')} + {unbound_note} + {bold('To install ctrld on any Linux / Mac / OPNsense machine:')} {cyan(_install_cmd)} - {bold('Replace the default config with the generated ctrld.toml')} - {bold('then restart: ctrld restart')} + {bold('Copy the config file to the machine where ctrld is installed:')} + {dim('OPNsense path: /usr/local/etc/controld/ctrld.toml')} + {dim('Linux path: /etc/controld/ctrld.toml')} - {bold('Set switch DHCP option 6 to the IP where ctrld is running.')} - {bold('Configure this in the DNS Filtering tab once the site is running.')} + {bold('Then apply it:')} {cyan('ctrld restart')} + + {bold('Finally — set DNS for each VLAN:')} + In OPNsense: Services → DHCPv4 → [each VLAN] → DNS Server = IP of ctrld machine. + Once devices renew their DHCP lease, they will use ctrld automatically. Full docs: {dim('https://docs.controld.com/docs/ctrld')} """)