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:
+32
-4
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user