Wire WireGuard peer DNS to ControlD profiles via ctrld

When creating a WireGuard peer on OPNsense:
- Client config DNS now points to OPNsense's IP (not tunnel gateway)
  so DNS flows: client → OPNsense → Unbound → ctrld → ControlD
- New dns_profile field: select which ControlD profile applies to
  VPN clients (default: "house" for VLAN 99)
- Generates ctrld.toml instructions for WireGuard tunnel subnet
  routing — tells user what to add so ctrld routes VPN DNS queries
  to the correct ControlD profile
- QR modal now shows ControlD setup instructions alongside the
  WireGuard config

This solves the Android Private DNS conflict: WireGuard's DNS setting
overrides Android's Private DNS, pointing to OPNsense which runs
Unbound → ctrld. No Private DNS toggle needed on the phone.

Multi-VLAN access for VPN peers works because the peer is on the
WireGuard interface (not on any VLAN). OPNsense routes between the
tunnel and VLANs per firewall rules. VLAN isolation preserved.

https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
Claude
2026-03-28 12:16:00 +00:00
parent e486de26b0
commit 31f2008153
2 changed files with 72 additions and 7 deletions
+32 -4
View File
@@ -1913,7 +1913,7 @@ function DeviceAccessTab({ session, onNeedAuth, backendOk }) {
// WIREGUARD TAB
//
function QRModal({ config, name, onClose }) {
function QRModal({ config, name, ctrldNote, onClose }) {
// Render QR using a simple API since we can't use native qrencode in browser
const [qrUrl, setQrUrl] = useState('');
useEffect(() => {
@@ -1936,6 +1936,14 @@ function QRModal({ config, name, onClose }) {
maxHeight:120,overflowY:"auto",marginBottom:12}}>
{config}
</div>
{ctrldNote && (
<div style={{background:"var(--b1)",borderRadius:4,padding:8,fontSize:10,
color:"var(--dm)",textAlign:"left",whiteSpace:"pre-wrap",
marginBottom:12,lineHeight:1.6}}>
<div style={{fontWeight:700,color:"var(--ac)",marginBottom:4}}>ControlD DNS Setup</div>
{ctrldNote}
</div>
)}
<div style={{display:"flex",gap:8,justifyContent:"center"}}>
<button className="btn bg" onClick={()=>{
const a=document.createElement('a');
@@ -2200,6 +2208,7 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
});
const [opnPeerName, setOpnPeerName] = useState('');
const [opnVlans, setOpnVlans] = useState([]); // checked VLAN IDs
const [opnDnsProfile, setOpnDnsProfile] = useState('house'); // ControlD profile for VPN clients
const [opnAdding, setOpnAdding] = useState(false);
const [opnQr, setOpnQr] = useState(null); // { config, name }
@@ -2294,9 +2303,10 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
const r = await API("/opnsense/wireguard/add-peer", {
method:"POST",
body:{ token: session.token, name: opnPeerName.trim(),
allowed_vlans: opnVlans, vlan_subnets }
allowed_vlans: opnVlans, vlan_subnets,
dns_profile: opnDnsProfile }
});
setOpnQr({ config: r.config, name: r.name });
setOpnQr({ config: r.config, name: r.name, ctrld_note: r.ctrld_note });
setOpnPeerName(''); setOpnVlans([]);
await loadOpnWg();
} catch(e) { setOpnError(e.message); }
@@ -2363,6 +2373,7 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
opnSetup={opnSetup} setOpnSetup={setOpnSetup}
opnPeerName={opnPeerName} setOpnPeerName={setOpnPeerName}
opnVlans={opnVlans} opnAdding={opnAdding}
opnDnsProfile={opnDnsProfile} setOpnDnsProfile={setOpnDnsProfile}
session={session} onNeedAuth={onNeedAuth} vlans={vlans}
onToggleVlan={opnToggleVlan} onSetupServer={opnSetupServer}
onDeleteServer={opnDeleteServer} onAddPeer={opnAddPeer}
@@ -2511,6 +2522,8 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
setOpnPeerName={setOpnPeerName}
opnVlans={opnVlans}
opnAdding={opnAdding}
opnDnsProfile={opnDnsProfile}
setOpnDnsProfile={setOpnDnsProfile}
session={session}
onNeedAuth={onNeedAuth}
vlans={vlans}
@@ -2524,7 +2537,8 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
/>
)}
{opnQr && <QRModal config={opnQr.config} name={opnQr.name} onClose={()=>setOpnQr(null)}/>}
{opnQr && <QRModal config={opnQr.config} name={opnQr.name}
ctrldNote={opnQr.ctrld_note} onClose={()=>setOpnQr(null)}/>}
</div>
);
}
@@ -2533,6 +2547,7 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
function OPNsenseWGSection({
opnWg, opnLoading, opnError, opnSetup, setOpnSetup,
opnPeerName, setOpnPeerName, opnVlans, opnAdding,
opnDnsProfile, setOpnDnsProfile,
session, onNeedAuth, vlans,
onToggleVlan, onSetupServer, onDeleteServer,
onAddPeer, onRevokePeer, onShowConf, onRefresh,
@@ -2779,6 +2794,19 @@ function OPNsenseWGSection({
</div>
</div>
<div style={{marginTop:10}}>
<div className="field" style={{margin:0,maxWidth:260}}>
<label>ControlD DNS Profile (applied via ctrld)</label>
<input value={opnDnsProfile}
onChange={e=>setOpnDnsProfile(e.target.value)}
placeholder="house"/>
</div>
<div style={{fontSize:11,color:"var(--dm)",marginTop:4}}>
VPN clients use OPNsense DNS Unbound ctrld ControlD.
This profile applies to the WireGuard tunnel subnet.
</div>
</div>
<div style={{marginTop:12,display:"flex",gap:8,alignItems:"center"}}>
{!session && (
<button className="btn bg" style={{fontSize:11,padding:"4px 10px"}}
+40 -3
View File
@@ -3034,6 +3034,8 @@ class OPNWGAddPeer(BaseModel):
name: str
allowed_vlans: list # list of VLAN IDs: [10, 20, 30]
vlan_subnets: dict # {10: "192.168.10.0/24", 20: "192.168.20.0/24", ...}
dns_profile: Optional[str] = "" # ControlD profile name to apply (matches a ctrld upstream)
dns_server: Optional[str] = "" # Override DNS server IP in client config (default: OPNsense VLAN 99 IP)
@app.get("/api/opnsense/wireguard/status")
@@ -3302,17 +3304,31 @@ def opnsense_wg_add_peer(body: OPNWGAddPeer):
except Exception:
pass
# ── Determine DNS server for client config ─────────────────────
# Use OPNsense's VLAN 99 gateway IP so DNS goes through:
# Unbound (:53) → ctrld (127.0.0.1:5354) → ControlD
# This applies the correct ControlD profile based on source IP.
if body.dns_server:
dns_ip = body.dns_server
else:
# Use OPNsense's host IP (typically its VLAN 99 gateway)
dns_ip = opn_cfg.get("host", "")
if not dns_ip:
# Fallback to tunnel gateway
dns_ip = wg.get("server_tunnel_ip", "").split("/")[0]
# ── Build client .conf ────────────────────────────────────────────
server_pubkey = wg.get("server_pubkey", "")
endpoint_host = wg.get("public_endpoint", "") or "<YOUR-OPNSENSE-PUBLIC-IP>"
endpoint_port = wg.get("listen_port", 51820)
tunnel_gw = wg.get("server_tunnel_ip", "").split("/")[0]
client_conf = (
f"[Interface]\n"
f"PrivateKey = {c_priv}\n"
f"Address = {peer_ip}\n"
f"DNS = {tunnel_gw}\n\n"
f"DNS = {dns_ip}\n"
f"# DNS goes to OPNsense → Unbound → ctrld → ControlD\n"
f"# ControlD profile applied by source IP (WireGuard tunnel subnet)\n\n"
f"[Peer]\n"
f"PublicKey = {server_pubkey or '<SERVER_PUBKEY>'}\n"
f"Endpoint = {endpoint_host}:{endpoint_port}\n"
@@ -3320,6 +3336,22 @@ def opnsense_wg_add_peer(body: OPNWGAddPeer):
f"PersistentKeepalive = 25\n"
)
# ── Add ctrld network rule for WireGuard tunnel subnet ───────────
# So ctrld can route DNS queries from VPN clients to the right
# ControlD profile (e.g. "house" profile for VLAN 99 users)
ctrld_note = ""
if body.dns_profile:
tunnel_subnet = wg.get("tunnel_subnet", "10.99.2.0/24")
ctrld_note = (
f"Add this to your ctrld.toml (proxy mode) or configure via DNS tab:\n"
f" [network.wg]\n"
f" name = 'WireGuard VPN'\n"
f" cidrs = ['{tunnel_subnet}']\n\n"
f" Then map network.wg to the '{body.dns_profile}' upstream in "
f"listener.0.policy.networks.\n"
f" This applies the '{body.dns_profile}' ControlD profile to all VPN clients."
)
# ── Persist peer metadata locally ────────────────────────────────
peer_meta = {
"uuid": peer_uuid,
@@ -3329,6 +3361,8 @@ def opnsense_wg_add_peer(body: OPNWGAddPeer):
"tunnel_ip": peer_ip,
"allowed_vlans": body.allowed_vlans,
"allowed_ips": allowed_ips,
"dns_server": dns_ip,
"dns_profile": body.dns_profile,
"config": client_conf,
}
peers = [p for p in wg.get("peers", []) if p.get("name") != body.name]
@@ -3336,14 +3370,17 @@ def opnsense_wg_add_peer(body: OPNWGAddPeer):
wg["peers"] = peers
_save_opnsense_wg(wg)
log.info(f"OPNsense WG peer added: {body.name}{peer_ip} VLANs={body.allowed_vlans}")
log.info(f"OPNsense WG peer added: {body.name}{peer_ip} VLANs={body.allowed_vlans} DNS={dns_ip}")
return {
"success": True,
"uuid": peer_uuid,
"name": body.name,
"tunnel_ip": peer_ip,
"allowed_vlans": body.allowed_vlans,
"dns_server": dns_ip,
"dns_profile": body.dns_profile,
"config": client_conf,
"ctrld_note": ctrld_note,
}