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:
+246
-65
@@ -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')}
|
||||
""")
|
||||
|
||||
Reference in New Issue
Block a user