Add VoIP/SIP ACL template for Asterisk PBX, port range support

ACL backend:
- AclRule gains optional port_end field; build_acl generates
  "range X Y" when both port and port_end are set (needed for RTP)

New ACL template — "SIP Phone VLAN — Asterisk / FreePBX access":
- Permits SIP signaling UDP/TCP 5060 to PBX IP
- Permits SIP/TLS TCP 5061 to PBX IP
- Permits RTP audio UDP range 10000-20000 to PBX IP (uses new range syntax)
- Blocks management VLAN 99
- Permits internet and all other traffic
- Requires entering the Asterisk server IP (restricts SIP/RTP to that
  exact host, not the whole VLAN subnet)

Template description explains:
- Why OPNsense firewall rules are also needed (inter-VLAN routing)
- Exactly which OPNsense rules to add (including return RTP)
- Remote access options: WebRTC via Caddy reverse proxy (recommended)
  and SIP/TLS with fail2ban for traditional SIP clients

Template modal:
- New pbxIp param field shown for VoIP template
- Description box scrollable for longer template descriptions
- Preview renders "range X Y" for port range rules

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-23 16:15:38 +00:00
parent 7fe42416d8
commit 2ebdb22e0a
3 changed files with 77 additions and 8 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+68 -5
View File
@@ -1099,6 +1099,51 @@ const ACL_TEMPLATES = [
],
}),
},
{
id: "voip",
label: "SIP Phone VLAN — Asterisk / FreePBX access",
description: [
"Permits SIP phones to reach an Asterisk PBX on another VLAN.",
"Allows SIP signaling (UDP/TCP 5060), SIP/TLS (TCP 5061), and RTP audio (UDP 1000020000) to the PBX IP.",
"Blocks access to the management VLAN (99). All other traffic (internet, etc.) is permitted.",
"",
"NOTE — inter-VLAN routing is enforced by OPNsense, not the switch.",
"Also add these rules in OPNsense Firewall → Rules → VLAN interfaces:",
" • Pass: src=phone VLAN dst=PBX IP proto=UDP/TCP port=5060,5061",
" • Pass: src=phone VLAN dst=PBX IP proto=UDP port=10000-20000",
" • Pass: src=PBX IP dst=phone VLAN proto=UDP port=10000-20000 (return RTP)",
" • Block: src=phone VLAN dst=any other LAN VLAN (optional — defence in depth)",
"",
"For remote Asterisk access without VPN:",
" WebRTC (recommended) — add a Caddy reverse proxy for pbx.yourdomain.com → PBX:8089 (WSS)",
" SIP/TLS — NAT port 5061 to PBX, install fail2ban, use a TLS-capable SIP client",
].join("\n"),
params: ["subnet", "pbxIp"],
build: ({ subnet, vid, pbxIp }) => ({
name: `VOIP-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ action:"permit", proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"5060", portEnd:"",
_comment: "SIP signaling to PBX (UDP)" },
{ action:"permit", proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"5060", portEnd:"",
_comment: "SIP signaling to PBX (TCP)" },
{ action:"permit", proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"5061", portEnd:"",
_comment: "SIP/TLS to PBX (TCP)" },
{ action:"permit", proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"10000", portEnd:"20000",
_comment: "RTP audio to PBX (UDP 10000-20000)" },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"192.168.99.0", dstMask:"0.0.0.255", dstAny:false, port:"",
_comment: "Block management VLAN 99" },
{ action:"permit", proto:"ip", src:"", srcMask:"", srcAny:true,
dst:"", dstMask:"", dstAny:true, port:"",
_comment: "Permit internet and all other traffic" },
],
}),
},
];
function AclTemplateModal({ vlans, onApply, onClose }) {
@@ -1106,13 +1151,14 @@ function AclTemplateModal({ vlans, onApply, onClose }) {
const [vid, setVid] = useState(vlans[0]?.id || 1);
const [ctrldIp, setCtrldIp] = useState("");
const [nvrIp, setNvrIp] = useState("");
const [pbxIp, setPbxIp] = useState("");
const tmpl = ACL_TEMPLATES.find(t => t.id === tpl);
const vlan = vlans.find(v => v.id === vid);
const subnet = `192.168.${vid}.0`;
const apply = () => {
const acl = tmpl.build({ subnet, vid, ctrldIp, nvrIp });
const acl = tmpl.build({ subnet, vid, ctrldIp, nvrIp, pbxIp });
// Strip _comment keys — they are just for display here
acl.rules = acl.rules.map(({ _comment, ...r }) => r);
acl.applyVlan = vid;
@@ -1145,7 +1191,7 @@ function AclTemplateModal({ vlans, onApply, onClose }) {
{tmpl && (
<div style={{fontSize:11,color:"var(--dm)",lineHeight:1.7,marginBottom:12,
padding:"8px 12px",background:"var(--bg)",borderRadius:4,
border:"1px solid var(--b1)"}}>
border:"1px solid var(--b1)",whiteSpace:"pre-wrap",maxHeight:140,overflowY:"auto"}}>
{tmpl.description}
</div>
)}
@@ -1180,14 +1226,27 @@ function AclTemplateModal({ vlans, onApply, onClose }) {
</div>
)}
{/* Preview */}
{tmpl?.params.includes("pbxIp") && (
<div className="field" style={{margin:"0 0 10px"}}>
<label>Asterisk / PBX IP address</label>
<input value={pbxIp} onChange={e=>setPbxIp(e.target.value)}
placeholder="e.g. 192.168.20.10"
style={{fontFamily:"var(--mono)",maxWidth:200}}/>
<div style={{fontSize:10,color:"var(--dm)",marginTop:3}}>
IP of your Asterisk server. SIP and RTP will only be permitted to this exact IP.
</div>
</div>
)}
{/* Preview */}
{tmpl && (
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#7090a0",
lineHeight:1.7,marginBottom:14,maxHeight:160,overflowY:"auto",
lineHeight:1.7,marginBottom:14,maxHeight:180,overflowY:"auto",
}}>
{tmpl.build({subnet,vid,ctrldIp,nvrIp}).rules.map((r,i)=>(
{tmpl.build({subnet,vid,ctrldIp,nvrIp,pbxIp}).rules.map((r,i)=>(
<div key={i}>
<span style={{color:"#566"}}>{` ${i+1} `}</span>
<span style={{color:r.action==="permit"?"#0e7":"#f55"}}>{r.action}</span>
@@ -1199,7 +1258,11 @@ function AclTemplateModal({ vlans, onApply, onClose }) {
<span style={{color:"#a0c0d0"}}>
{r.dstAny?"any":`${r.dst||"?"} ${r.dstMask||""}`}
</span>
{r.port?<span style={{color:"#fa0"}}>{` eq ${r.port}`}</span>:null}
{r.portEnd
? <span style={{color:"#fa0"}}>{` range ${r.port} ${r.portEnd}`}</span>
: r.port
? <span style={{color:"#fa0"}}>{` eq ${r.port}`}</span>
: null}
{r._comment && <span style={{color:"#445"}}>{` # ${r._comment}`}</span>}
</div>
))}
+7 -1
View File
@@ -550,6 +550,7 @@ class AclRule(BaseModel):
dst_mask: Optional[str] = "0.0.0.255"
dst_any: Optional[bool] = True
port: Optional[str] = ""
port_end: Optional[str] = "" # when set, generates "range port port_end"
@field_validator("action")
@classmethod
def ca(cls, v): return _san(v, _RE_ACTION, "action")
@@ -640,7 +641,12 @@ def build_acl(acl: AclCreate) -> list[str]:
for i, r in enumerate(acl.rules):
src = "any" if r.src_any else f"{r.src} {r.src_mask}"
dst = "any" if r.dst_any else f"{r.dst} {r.dst_mask}"
port_str = f" eq {r.port}" if r.port else ""
if r.port and r.port_end:
port_str = f" range {r.port} {r.port_end}"
elif r.port:
port_str = f" eq {r.port}"
else:
port_str = ""
cmds.append(f" {i+1} {r.action} {r.proto} {src} {dst}{port_str}")
vid = san_vid(acl.apply_vlan, "apply_vlan")
cmds += [f"interface vlan {vid}",