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
+246 -65
View File
@@ -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')}
""")