Fix ctrld setup: ask subnet/domain/resolver, newbie-friendly OPNsense instructions

- 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
This commit is contained in:
Claude
2026-03-23 14:33:35 +00:00
parent ed2380edc9
commit b1b2b91905
+243 -62
View File
@@ -903,34 +903,47 @@ def setup_ctrld(vlans_config: list):
step(21, "DNS Filtering — Control D (optional)") step(21, "DNS Filtering — Control D (optional)")
sep() sep()
print(f""" print(f"""
{bold('Control D')} filters DNS per VLAN — ads, malware, adult content, {bold('What is DNS filtering?')}
social media, and more. Each VLAN gets its own filtering profile. 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: {bold('Control D')} is a DNS filtering service. Each of your VLANs gets
- Listens on port 53 for normal DNS from your devices its own filtering profile — so your Staff VLAN can browse freely while
- Routes queries by source VLAN subnet to the right profile your Guest VLAN blocks everything except basic browsing.
- Forwards upstream via encrypted DoH3 to Control D
- Runs as a system service, starts on boot
{bold('You need a Control D account:')} {dim('https://controld.com')} {cyan('ctrld')} is a small program that runs on your router or this computer.
Create a Device (type: Router) per VLAN to get a Resolver ID. 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)')} {cyan('A — On this management computer')} {dim('(fully automated)')}
ctrld runs here alongside the switch manager. ctrld runs on the same computer as the switch manager.
Pros: self-contained, no other machine needed. Good if you do not have OPNsense or just want to get started.
Cons: if this machine is down, DNS filtering stops Downside: if this computer sleeps or shuts down, filtering stops
(devices still get IPs, just no filtering). (your devices can still get on the internet, just unfiltered).
{cyan('B — On OPNsense')} {dim('(semi-automated — one SSH command)')} {cyan('B — On OPNsense')} {dim('(recommended — one SSH command + two OPNsense clicks)')}
ctrld runs on your router. ctrld runs on your router, which is always on.
Pros: DNS filtering survives management computer outage. Filtering survives this computer being off or rebooted.
Cons: requires SSH access to OPNsense. 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)')} {cyan('C — Generate config only')} {dim('(copy-paste the file yourself)')}
Site generates ctrld.toml and install commands. Produces a ready-to-use ctrld.toml you can nano/copy anywhere.
Install wherever you want, whenever you want. Good if you want to review the file before anything is changed.
""") """)
sep() sep()
@@ -938,32 +951,105 @@ def setup_ctrld(vlans_config: list):
info("Skipping — you can configure this in the DNS Filtering tab later") info("Skipping — you can configure this in the DNS Filtering tab later")
return 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 warn("Invalid choice — skipping"); return
# Collect Resolver IDs per VLAN # ── Local domain name ────────────────────────────────────────────────────
sep() sep()
print(f" {bold('Enter your Control D Resolver ID for each VLAN.')}") print(f"""
print(f" {dim('Find it at: controld.com → Endpoints → your device → Resolver ID')}") {bold('Local domain name')}
print(f" {dim('Press Enter to skip a VLAN (it will use the first configured profile as fallback).')}") OPNsense gives every device on your network a name ending in your local
print() 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 = [] profiles = []
for vlan in (vlans_config or [{"id":10,"name":"Staff"},{"id":20,"name":"Servers"}, for vlan in (vlans_config or [{"id": 10, "name": "Staff"},
{"id":30,"name":"IoT"},{"id":40,"name":"Guest"}, {"id": 20, "name": "Servers"},
{"id": 30, "name": "IoT"},
{"id": 40, "name": "Guest"},
{"id": 50, "name": "Cameras"}]): {"id": 50, "name": "Cameras"}]):
rid = ask(f" VLAN {vlan['id']} ({vlan['name']}) Resolver ID", "").strip() 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: if rid:
profiles.append({ profiles.append({
"vlan_id": vlan["id"], "vlan_id": vid,
"name": vlan["name"], "name": name,
"subnet": f"192.168.{vlan['id']}.0/24", "subnet": subnet,
"resolver_id": rid, "resolver_id": rid,
}) })
ok(f"VLAN {vid} ({name}) — {subnet} → ControlD profile {rid}")
else: else:
info(f"Skipping VLAN {vlan['id']} — will use fallback") info(f"VLAN {vid} ({name}) — skipped (no filtering)")
if not profiles: if not profiles:
warn("No Resolver IDs entered — skipping DNS setup") warn("No Resolver IDs entered — skipping DNS setup")
@@ -972,11 +1058,11 @@ def setup_ctrld(vlans_config: list):
sep() sep()
if choice == "A": if choice == "A":
_ctrld_install_local_setup(profiles) _ctrld_install_local_setup(profiles, local_domain, local_resolver)
elif choice == "B": elif choice == "B":
_ctrld_opnsense_setup(profiles) _ctrld_opnsense_setup(profiles, local_domain, local_resolver)
else: else:
_ctrld_manual_setup(profiles) _ctrld_manual_setup(profiles, local_domain, local_resolver)
def _build_toml(profiles: list, local_domain: str = "lan", 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) 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...") info("Installing ctrld on this machine...")
first_rid = profiles[0]["resolver_id"] first_rid = profiles[0]["resolver_id"]
@@ -1107,7 +1195,7 @@ def _ctrld_install_local_setup(profiles: list):
# Write multi-VLAN config # Write multi-VLAN config
cfg_path = Path("/etc/controld/ctrld.toml") cfg_path = Path("/etc/controld/ctrld.toml")
cfg_path.parent.mkdir(parents=True, exist_ok=True) 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) cfg_path.write_text(toml)
ok(f"Config written to {cfg_path}") ok(f"Config written to {cfg_path}")
@@ -1127,67 +1215,160 @@ def _ctrld_install_local_setup(profiles: list):
print(f""" print(f"""
{bold('ctrld is running on this machine at:')} {cyan(mgmt_ip)} {bold('ctrld is running on this machine at:')} {cyan(mgmt_ip)}
{bold('Next:')} In the switch manager DHCP tab, set DNS (option 6) {bold('Next — tell your router to use this machine for DNS:')}
to {cyan(mgmt_ip)} for each VLAN pool. The switch will then hand In OPNsense: Services → DHCPv4 → [each VLAN interface]
out this machine as the DNS server and ctrld will route each Set {bold('DNS Server')} to {cyan(mgmt_ip)} and save.
VLAN to its Control D profile automatically.
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") opnsense_ip = ask("OPNsense IP address", "192.168.99.1")
first_rid = profiles[0]["resolver_id"] first_rid = profiles[0]["resolver_id"]
install_cmd = f'sh -c \'sh -c "$(curl -sL https://api.controld.com/dl)" -s {first_rid} forced\'' 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}'" 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" cfg_path = "/usr/local/etc/controld/ctrld.toml"
sep() sep()
print(f""" 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)} {cyan(ssh_cmd)}
{bold('Step 2 — Replace the config file on OPNsense:')} {dim('This connects to OPNsense over SSH and runs the official ctrld installer.')}
{dim(f'scp your-toml-file root@{opnsense_ip}:{cfg_path}')} {dim('If you get a "permission denied" error, check that root SSH is enabled')}
{dim('then: ssh root@{opnsense_ip} "ctrld restart"')} {dim('in OPNsense: System → Settings → Administration → Secure Shell.')}
{bold('Step 3 — In the switch manager DHCP tab:')} {bold('Do this now, then press Enter to continue.')}
Set DNS (option 6) to {cyan(opnsense_ip)} for each VLAN pool.
""") """)
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 = HERE / "ctrld.toml"
toml_file.write_text(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: try:
import subprocess import subprocess
subprocess.run(["xclip", "-selection", "clipboard"], subprocess.run(["xclip", "-selection", "clipboard"],
input=ssh_cmd.encode(), check=False) input=ssh_cmd.encode(), check=False)
ok("Copied") ok("Copied to clipboard")
except Exception: 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): def _ctrld_manual_setup(profiles: list,
toml = _build_toml(profiles) 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 = HERE / "ctrld.toml"
toml_file.write_text(toml) toml_file.write_text(toml)
first_rid = profiles[0]["resolver_id"] first_rid = profiles[0]["resolver_id"]
sep() sep()
_install_cmd = "sh -c 'sh -c \"$(curl -sL https://api.controld.com/dl)\" -s " + first_rid + " forced'" _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""" print(f"""
{bold('ctrld.toml written to:')} {cyan(str(toml_file))} {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)} {cyan(_install_cmd)}
{bold('Replace the default config with the generated ctrld.toml')} {bold('Copy the config file to the machine where ctrld is installed:')}
{bold('then restart: ctrld restart')} {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('Then apply it:')} {cyan('ctrld restart')}
{bold('Configure this in the DNS Filtering tab once the site is running.')}
{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')} Full docs: {dim('https://docs.controld.com/docs/ctrld')}
""") """)