1365 lines
49 KiB
Python
1365 lines
49 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
pfSense to OPNsense configuration converter.
|
||
|
|
|
||
|
|
Reads a pfSense config.xml and converts it to an OPNsense-compatible config.xml.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
python pfsense2opnsense.py config-pfsense.xml > config-opnsense.xml
|
||
|
|
|
||
|
|
Sections converted:
|
||
|
|
- System (hostname, domain, DNS, timezone, webgui, SSH, users)
|
||
|
|
- Interfaces (WAN, LAN, OPTx) and VLANs
|
||
|
|
- DHCP server config and static mappings
|
||
|
|
- Firewall rules (filter)
|
||
|
|
- NAT rules (port forwarding + outbound NAT)
|
||
|
|
- Aliases
|
||
|
|
- Certificates and CAs
|
||
|
|
- OpenVPN servers/clients
|
||
|
|
- WireGuard (core since 24.1, no plugin needed on 26.x)
|
||
|
|
- IPsec
|
||
|
|
- Static routes
|
||
|
|
- DNS (dnsmasq/unbound) with domain overrides and host overrides
|
||
|
|
- SNMP
|
||
|
|
- Schedules
|
||
|
|
- Interface groups
|
||
|
|
- Virtual IPs
|
||
|
|
- CRLs
|
||
|
|
- System tunables (sysctl)
|
||
|
|
"""
|
||
|
|
|
||
|
|
import xml.etree.ElementTree as ET
|
||
|
|
import sys
|
||
|
|
import uuid
|
||
|
|
import ipaddress
|
||
|
|
import re
|
||
|
|
|
||
|
|
# Interface name mapping (e.g. tun_wg0 -> wg0 for OPNsense)
|
||
|
|
# Can be overridden via --interface-map CLI arg
|
||
|
|
IFACE_MAP = {}
|
||
|
|
|
||
|
|
# OPNsense name validation: alphanumeric, dot, underscore, dash, 1-64 chars
|
||
|
|
OPN_NAME_RE = re.compile(r'^[0-9a-zA-Z._\-]{1,64}$')
|
||
|
|
|
||
|
|
def _sanitize_name(name):
|
||
|
|
"""Replace characters not allowed in OPNsense names with underscore."""
|
||
|
|
return re.sub(r'[^0-9a-zA-Z._\-]', '_', name)
|
||
|
|
|
||
|
|
|
||
|
|
def indent(elem, level=0):
|
||
|
|
"""Pretty-print XML with proper indentation."""
|
||
|
|
i = "\n" + level * "\t"
|
||
|
|
if len(elem):
|
||
|
|
if not elem.text or not elem.text.strip():
|
||
|
|
elem.text = i + "\t"
|
||
|
|
if not elem.tail or not elem.tail.strip():
|
||
|
|
elem.tail = i
|
||
|
|
for child in elem:
|
||
|
|
indent(child, level + 1)
|
||
|
|
if not child.tail or not child.tail.strip():
|
||
|
|
child.tail = i
|
||
|
|
else:
|
||
|
|
if level and (not elem.tail or not elem.tail.strip()):
|
||
|
|
elem.tail = i
|
||
|
|
|
||
|
|
|
||
|
|
def safe_text(elem, path, default=""):
|
||
|
|
"""Safely get text from a sub-element."""
|
||
|
|
if elem is None:
|
||
|
|
return default
|
||
|
|
found = elem.find(path)
|
||
|
|
if found is not None and found.text:
|
||
|
|
return found.text
|
||
|
|
return default
|
||
|
|
|
||
|
|
|
||
|
|
def set_text(parent, tag, value, cdata=False):
|
||
|
|
"""Set text in a sub-element, creating it if needed."""
|
||
|
|
if not value and value != 0:
|
||
|
|
return
|
||
|
|
child = parent.find(tag)
|
||
|
|
if child is None:
|
||
|
|
child = ET.SubElement(parent, tag)
|
||
|
|
if cdata:
|
||
|
|
child.text = value
|
||
|
|
else:
|
||
|
|
child.text = str(value)
|
||
|
|
|
||
|
|
|
||
|
|
def copy_element_into(src_elem, dst_parent, tag_map=None, skip_tags=None):
|
||
|
|
"""
|
||
|
|
Copy an element (with all children) from src into dst_parent with optional tag renaming.
|
||
|
|
Returns the new element.
|
||
|
|
"""
|
||
|
|
if tag_map is None:
|
||
|
|
tag_map = {}
|
||
|
|
if skip_tags is None:
|
||
|
|
skip_tags = set()
|
||
|
|
|
||
|
|
tag = tag_map.get(src_elem.tag, src_elem.tag)
|
||
|
|
if tag in skip_tags:
|
||
|
|
return None
|
||
|
|
|
||
|
|
new = ET.SubElement(dst_parent, tag)
|
||
|
|
if src_elem.text and src_elem.text.strip():
|
||
|
|
new.text = src_elem.text
|
||
|
|
new.tail = src_elem.tail
|
||
|
|
|
||
|
|
for attr_name, attr_val in src_elem.attrib.items():
|
||
|
|
new.set(attr_name, attr_val)
|
||
|
|
|
||
|
|
for child in src_elem:
|
||
|
|
copy_element_into(child, new, tag_map, skip_tags)
|
||
|
|
return new
|
||
|
|
|
||
|
|
|
||
|
|
def convert_system(pf_root, op_root):
|
||
|
|
"""Convert system section."""
|
||
|
|
pf_sys = pf_root.find('system')
|
||
|
|
if pf_sys is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
op_sys = ET.SubElement(op_root, 'system')
|
||
|
|
|
||
|
|
# Hostname and domain
|
||
|
|
set_text(op_sys, 'hostname', safe_text(pf_sys, 'hostname'))
|
||
|
|
set_text(op_sys, 'domain', safe_text(pf_sys, 'domain'))
|
||
|
|
set_text(op_sys, 'timezone', safe_text(pf_sys, 'timezone'))
|
||
|
|
set_text(op_sys, 'language', safe_text(pf_sys, 'language'))
|
||
|
|
|
||
|
|
# NTP
|
||
|
|
set_text(op_sys, 'timeservers', safe_text(pf_sys, 'timeservers'))
|
||
|
|
set_text(op_sys, 'time-update-interval', safe_text(pf_sys, 'time-update-interval'))
|
||
|
|
|
||
|
|
# DNS servers (use append_element for multi-value tags)
|
||
|
|
for dns in pf_sys.findall('dnsserver'):
|
||
|
|
if dns.text:
|
||
|
|
child = ET.SubElement(op_sys, 'dnsserver')
|
||
|
|
child.text = dns.text
|
||
|
|
|
||
|
|
# DNS gateway settings
|
||
|
|
for gw in ['dns1gw', 'dns2gw', 'dns3gw', 'dns4gw']:
|
||
|
|
val = safe_text(pf_sys, gw)
|
||
|
|
if val:
|
||
|
|
set_text(op_sys, gw, val)
|
||
|
|
|
||
|
|
# WebGUI — skip pfSense-specific elements that break OPNsense (e.g. pfSense.css)
|
||
|
|
pf_gui = pf_sys.find('webgui')
|
||
|
|
if pf_gui is not None:
|
||
|
|
op_gui = ET.SubElement(op_sys, 'webgui')
|
||
|
|
pf_skip = {'loginautocomplete', 'loginshowhost', 'webguicss', 'logincss',
|
||
|
|
'dashboardenabled', 'msscheck', 'ipv6', 'sha256'}
|
||
|
|
for child in pf_gui:
|
||
|
|
if child.tag not in pf_skip:
|
||
|
|
copy_element_into(child, op_gui)
|
||
|
|
|
||
|
|
# SSH
|
||
|
|
pf_ssh = pf_sys.find('ssh')
|
||
|
|
if pf_ssh is not None:
|
||
|
|
op_ssh = ET.SubElement(op_sys, 'ssh')
|
||
|
|
pf_ssh_enable = pf_ssh.find('enable')
|
||
|
|
if pf_ssh_enable is not None:
|
||
|
|
copy_element_into(pf_ssh_enable, op_ssh)
|
||
|
|
|
||
|
|
# Optimization
|
||
|
|
set_text(op_sys, 'optimization', safe_text(pf_sys, 'optimization'))
|
||
|
|
|
||
|
|
# Maximum table entries
|
||
|
|
set_text(op_sys, 'maximumtableentries', safe_text(pf_sys, 'maximumtableentries'))
|
||
|
|
set_text(op_sys, 'maximumstates', safe_text(pf_sys, 'maximumstates'))
|
||
|
|
set_text(op_sys, 'maximumfrags', safe_text(pf_sys, 'maximumfrags'))
|
||
|
|
set_text(op_sys, 'maximumtables', safe_text(pf_sys, 'maximumtables'))
|
||
|
|
|
||
|
|
# Disable offloading
|
||
|
|
for offload in ['disablesegmentationoffloading', 'disablelargereceiveoffloading']:
|
||
|
|
val = safe_text(pf_sys, offload)
|
||
|
|
if val:
|
||
|
|
set_text(op_sys, offload, val)
|
||
|
|
|
||
|
|
# Proxy
|
||
|
|
set_text(op_sys, 'proxyuser', safe_text(pf_sys, 'proxyuser'))
|
||
|
|
set_text(op_sys, 'proxypass', safe_text(pf_sys, 'proxypass'))
|
||
|
|
|
||
|
|
# Early shellcmd
|
||
|
|
for cmd in pf_sys.findall('earlyshellcmd'):
|
||
|
|
set_text(op_sys, 'earlyshellcmd', cmd.text)
|
||
|
|
|
||
|
|
# Bogons
|
||
|
|
pf_bogons = pf_sys.find('bogons')
|
||
|
|
if pf_bogons is not None:
|
||
|
|
op_bogons = ET.SubElement(op_sys, 'bogons')
|
||
|
|
pf_interval = pf_bogons.find('interval')
|
||
|
|
if pf_interval is not None:
|
||
|
|
copy_element_into(pf_interval, op_bogons)
|
||
|
|
|
||
|
|
# Enable serial
|
||
|
|
set_text(op_sys, 'enableserial', safe_text(pf_sys, 'enableserial'))
|
||
|
|
|
||
|
|
# Disable NAT reflection
|
||
|
|
set_text(op_sys, 'disablenatreflection', safe_text(pf_sys, 'disablenatreflection'))
|
||
|
|
|
||
|
|
# Cryptographic hardware
|
||
|
|
set_text(op_sys, 'crypto_hardware', safe_text(pf_sys, 'crypto_hardware'))
|
||
|
|
|
||
|
|
# Already run config upgrade
|
||
|
|
set_text(op_sys, 'already_run_config_upgrade', safe_text(pf_sys, 'already_run_config_upgrade'))
|
||
|
|
|
||
|
|
# Users and groups
|
||
|
|
for group in pf_sys.findall('group'):
|
||
|
|
copy_element_into(group, op_sys)
|
||
|
|
|
||
|
|
for user in pf_sys.findall('user'):
|
||
|
|
copy_element_into(user, op_sys)
|
||
|
|
|
||
|
|
# nextuid/nextgid
|
||
|
|
set_text(op_sys, 'nextuid', safe_text(pf_sys, 'nextuid'))
|
||
|
|
set_text(op_sys, 'nextgid', safe_text(pf_sys, 'nextgid'))
|
||
|
|
|
||
|
|
|
||
|
|
def convert_interfaces(pf_root, op_root):
|
||
|
|
"""Convert interface configuration."""
|
||
|
|
pf_ifaces = pf_root.find('interfaces')
|
||
|
|
if pf_ifaces is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
op_ifaces = ET.SubElement(op_root, 'interfaces')
|
||
|
|
|
||
|
|
for iface_tag in ['wan', 'lan', 'opt1', 'opt2', 'opt3', 'opt4', 'opt5',
|
||
|
|
'opt6', 'opt7', 'opt8', 'opt9', 'opt10', 'opt11',
|
||
|
|
'opt12', 'opt13', 'opt14', 'opt15']:
|
||
|
|
pf_iface = pf_ifaces.find(iface_tag)
|
||
|
|
if pf_iface is None:
|
||
|
|
continue
|
||
|
|
|
||
|
|
op_iface = ET.SubElement(op_ifaces, iface_tag)
|
||
|
|
|
||
|
|
# Copy interface configuration elements
|
||
|
|
for tag in ['enable', 'if', 'descr', 'spoofmac', 'ipaddr', 'subnet',
|
||
|
|
'blockpriv', 'blockbogons', 'gateway', 'mtu', 'media',
|
||
|
|
'mediaopt', 'adv']:
|
||
|
|
elem = pf_iface.find(tag)
|
||
|
|
if elem is not None:
|
||
|
|
copy_element_into(elem, op_iface)
|
||
|
|
|
||
|
|
# OPNsense expects <enable>1</enable> for enabled interfaces,
|
||
|
|
# not the pfSense-style <enable /> (empty self-closing tag)
|
||
|
|
enable_elem = op_iface.find('enable')
|
||
|
|
if enable_elem is not None and not enable_elem.text:
|
||
|
|
enable_elem.text = '1'
|
||
|
|
|
||
|
|
# Apply interface name mapping to <if> (e.g. tun_wg0 -> wg0)
|
||
|
|
iface_elem = op_iface.find('if')
|
||
|
|
if iface_elem is not None and iface_elem.text:
|
||
|
|
iface_elem.text = _map_iface_in_text(iface_elem.text)
|
||
|
|
|
||
|
|
# Lock interfaces to prevent OPNsense from auto-reassigning
|
||
|
|
# them during boot (mismatch detection). Without this, OPNsense
|
||
|
|
# may overwrite WAN/LAN interface assignments on every boot.
|
||
|
|
lock_elem = op_iface.find('lock')
|
||
|
|
if lock_elem is None:
|
||
|
|
ET.SubElement(op_iface, 'lock').text = '1'
|
||
|
|
|
||
|
|
|
||
|
|
def convert_vlans(pf_root, op_root):
|
||
|
|
"""Convert VLAN configuration."""
|
||
|
|
pf_vlans = pf_root.find('vlans')
|
||
|
|
if pf_vlans is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
op_vlans = ET.SubElement(op_root, 'vlans')
|
||
|
|
for pf_vlan in pf_vlans.findall('vlan'):
|
||
|
|
copy_element_into(pf_vlan, op_vlans)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_dhcpd(pf_root, op_root):
|
||
|
|
"""Convert DHCP server configuration."""
|
||
|
|
pf_dhcpd = pf_root.find('dhcpd')
|
||
|
|
if pf_dhcpd is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
op_dhcpd = ET.SubElement(op_root, 'dhcpd')
|
||
|
|
|
||
|
|
for tag in ['lan', 'wan', 'opt1', 'opt2', 'opt3', 'opt4', 'opt5',
|
||
|
|
'opt6', 'opt7', 'opt8', 'opt9', 'opt10', 'opt11',
|
||
|
|
'opt12', 'opt13', 'opt14', 'opt15']:
|
||
|
|
pf_scope = pf_dhcpd.find(tag)
|
||
|
|
if pf_scope is None:
|
||
|
|
continue
|
||
|
|
|
||
|
|
op_scope = ET.SubElement(op_dhcpd, tag)
|
||
|
|
|
||
|
|
# Copy simple config elements
|
||
|
|
for simple in ['enable', 'range', 'defaultleasetime', 'maxleasetime',
|
||
|
|
'netmask', 'gateway', 'domain', 'domainsearchlist',
|
||
|
|
'dnsserver', 'ntpserver', 'tfpt', 'ldap', 'filename',
|
||
|
|
'rootpath', 'nextserver', 'failover_peerip',
|
||
|
|
'mac_allow', 'mac_deny', 'ddnsdomain', 'ddnsdomainprimary',
|
||
|
|
'ddnsdomainkeyname', 'ddnsdomainkey', 'ddnsdomainkeyalgorithm',
|
||
|
|
'ddnsclientupdates', 'ddnsdomainsecondary',
|
||
|
|
'filename32', 'filename64', 'filename32arm', 'filename64arm',
|
||
|
|
'uefihttpboot', 'numberoptions']:
|
||
|
|
for elem in pf_scope.findall(simple):
|
||
|
|
copy_element_into(elem, op_scope)
|
||
|
|
|
||
|
|
# Static mappings with enhanced field mapping
|
||
|
|
for pf_map in pf_scope.findall('staticmap'):
|
||
|
|
op_map = ET.SubElement(op_scope, 'staticmap')
|
||
|
|
for tag_name in ['mac', 'cid', 'ipaddr', 'hostname', 'descr',
|
||
|
|
'filename', 'rootpath', 'defaultleasetime',
|
||
|
|
'maxleasetime', 'gateway', 'domain',
|
||
|
|
'domainsearchlist', 'ddnsdomain',
|
||
|
|
'ddnsdomainprimary', 'ddnsdomainsecondary',
|
||
|
|
'ddnsdomainkeyname', 'ddnsdomainkeyalgorithm',
|
||
|
|
'ddnsdomainkey', 'tftp', 'ldap', 'nextserver',
|
||
|
|
'filename32', 'filename64', 'filename32arm',
|
||
|
|
'filename64arm', 'uefihttpboot', 'numberoptions',
|
||
|
|
'earlydnsregpolicy']:
|
||
|
|
elem = pf_map.find(tag_name)
|
||
|
|
if elem is not None and elem.text:
|
||
|
|
copy_element_into(elem, op_map)
|
||
|
|
|
||
|
|
# Pool (used in some OPNsense versions)
|
||
|
|
pf_pool = pf_scope.find('pool')
|
||
|
|
if pf_pool is not None:
|
||
|
|
copy_element_into(pf_pool, op_scope)
|
||
|
|
|
||
|
|
# Normalize <enable /> to <enable>1</enable>
|
||
|
|
enable_elem = op_scope.find('enable')
|
||
|
|
if enable_elem is not None and not enable_elem.text:
|
||
|
|
enable_elem.text = '1'
|
||
|
|
|
||
|
|
# DHCP backend (preserve pfSense value: kea or isc)
|
||
|
|
set_text(op_dhcpd, 'backend', safe_text(pf_root, 'dhcpbackend', 'kea'))
|
||
|
|
|
||
|
|
|
||
|
|
def convert_kea_dhcp(pf_root, op_root):
|
||
|
|
"""Convert Kea DHCP configuration.
|
||
|
|
|
||
|
|
pfSense stores DHCP config in ISC-format <dhcpd> section even when
|
||
|
|
using Kea backend. OPNsense expects Kea config under <OPNsense><Kea>
|
||
|
|
with subnets and reservations in OPNsense MVC model format.
|
||
|
|
"""
|
||
|
|
pf_dhcpd = pf_root.find('dhcpd')
|
||
|
|
if pf_dhcpd is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
# Build interface info map: scope tag -> {if, ip, subnet, descr}
|
||
|
|
scope_to_iface = {}
|
||
|
|
pf_ifaces = pf_root.find('interfaces')
|
||
|
|
if pf_ifaces is not None:
|
||
|
|
for tag in ['wan', 'lan', 'opt1', 'opt2', 'opt3', 'opt4', 'opt5',
|
||
|
|
'opt6', 'opt7', 'opt8', 'opt9', 'opt10', 'opt11',
|
||
|
|
'opt12', 'opt13', 'opt14', 'opt15']:
|
||
|
|
iface = pf_ifaces.find(tag)
|
||
|
|
if iface is None:
|
||
|
|
continue
|
||
|
|
if_el = iface.find('if')
|
||
|
|
ip_el = iface.find('ipaddr')
|
||
|
|
sn_el = iface.find('subnet')
|
||
|
|
descr_el = iface.find('descr')
|
||
|
|
scope_to_iface[tag] = {
|
||
|
|
'if': if_el.text if if_el is not None and if_el.text else '',
|
||
|
|
'ip': ip_el.text if ip_el is not None and ip_el.text else '',
|
||
|
|
'subnet': sn_el.text if sn_el is not None and sn_el.text else '',
|
||
|
|
'descr': descr_el.text if descr_el is not None and descr_el.text else tag.upper(),
|
||
|
|
}
|
||
|
|
|
||
|
|
# Find or create <OPNsense> for OPNsense MVC mount elements
|
||
|
|
op_opnsense = op_root.find('OPNsense')
|
||
|
|
if op_opnsense is None:
|
||
|
|
op_opnsense = ET.SubElement(op_root, 'OPNsense')
|
||
|
|
|
||
|
|
# Create <Kea><dhcp4> structure
|
||
|
|
op_kea = ET.SubElement(op_opnsense, 'Kea')
|
||
|
|
op_dhcp4 = ET.SubElement(op_kea, 'dhcp4')
|
||
|
|
|
||
|
|
# General settings
|
||
|
|
op_general = ET.SubElement(op_dhcp4, 'general')
|
||
|
|
set_text(op_general, 'enabled', '1')
|
||
|
|
set_text(op_general, 'fwrules', '1')
|
||
|
|
set_text(op_general, 'valid_lifetime', '7200')
|
||
|
|
set_text(op_general, 'dhcp_socket_type', 'raw')
|
||
|
|
|
||
|
|
# Collect unique interfaces for the interfaces field
|
||
|
|
dhcp_ifaces = []
|
||
|
|
|
||
|
|
# Subnets container
|
||
|
|
op_subnets = ET.SubElement(op_dhcp4, 'subnets')
|
||
|
|
|
||
|
|
# Build subnets from enabled DHCP scopes + scopes with static maps
|
||
|
|
subnet_uuids = {} # scope tag -> subnet UUID
|
||
|
|
for tag in ['lan', 'wan', 'opt1', 'opt2', 'opt3', 'opt4', 'opt5',
|
||
|
|
'opt6', 'opt7', 'opt8', 'opt9', 'opt10', 'opt11',
|
||
|
|
'opt12', 'opt13', 'opt14', 'opt15']:
|
||
|
|
pf_scope = pf_dhcpd.find(tag)
|
||
|
|
if pf_scope is None:
|
||
|
|
continue
|
||
|
|
|
||
|
|
enable_el = pf_scope.find('enable')
|
||
|
|
has_staticmaps = pf_scope.find('staticmap') is not None
|
||
|
|
pf_range = pf_scope.find('range')
|
||
|
|
has_range = pf_range is not None and (pf_range.find('from') is not None or pf_range.text is not None)
|
||
|
|
|
||
|
|
# Only create subnet if scope is enabled or has static maps/ranges
|
||
|
|
if enable_el is None and not has_staticmaps and not has_range:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Get interface info
|
||
|
|
iface_info = scope_to_iface.get(tag, {})
|
||
|
|
ifname = iface_info.get('if', '')
|
||
|
|
ipaddr = iface_info.get('ip', '')
|
||
|
|
subnet_mask = iface_info.get('subnet', '')
|
||
|
|
|
||
|
|
# Kea's interfaces field expects OPNsense interface description
|
||
|
|
# names (lan, opt2, opt3, ...) not device names (igc1, igc1.10).
|
||
|
|
# OPNsense maps these to device names internally.
|
||
|
|
if tag not in dhcp_ifaces:
|
||
|
|
dhcp_ifaces.append(tag)
|
||
|
|
|
||
|
|
# Compute subnet CIDR
|
||
|
|
subnet_cidr = ''
|
||
|
|
if ipaddr and subnet_mask:
|
||
|
|
try:
|
||
|
|
ip_intf = ipaddress.IPv4Interface('%s/%s' % (ipaddr, subnet_mask))
|
||
|
|
subnet_cidr = str(ip_intf.network)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# Skip WAN-type interfaces that aren't real subnets
|
||
|
|
if ipaddr == 'pppoe' or not subnet_cidr:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Generate subnet UUID
|
||
|
|
suuid = _gen_uuid('dhcp-subnet-' + tag)
|
||
|
|
subnet_uuids[tag] = suuid
|
||
|
|
|
||
|
|
# Create subnet4
|
||
|
|
op_subnet = ET.SubElement(op_subnets, 'subnet4')
|
||
|
|
op_subnet.set('uuid', suuid)
|
||
|
|
set_text(op_subnet, 'subnet', subnet_cidr)
|
||
|
|
set_text(op_subnet, 'option_data_autocollect', '0')
|
||
|
|
set_text(op_subnet, 'description', iface_info.get('descr', tag.upper()))
|
||
|
|
|
||
|
|
# Option data
|
||
|
|
op_od = ET.SubElement(op_subnet, 'option_data')
|
||
|
|
# Gateway -> routers. When no explicit gateway in dhcpd scope,
|
||
|
|
# use the interface IP (it acts as the subnet's default gateway).
|
||
|
|
gw = pf_scope.find('gateway')
|
||
|
|
if gw is not None and gw.text:
|
||
|
|
set_text(op_od, 'routers', gw.text)
|
||
|
|
elif ipaddr:
|
||
|
|
set_text(op_od, 'routers', ipaddr)
|
||
|
|
# DNS servers
|
||
|
|
dns_servers = [d.text for d in pf_scope.findall('dnsserver') if d.text]
|
||
|
|
if dns_servers:
|
||
|
|
set_text(op_od, 'domain_name_servers', ','.join(dns_servers))
|
||
|
|
# Domain
|
||
|
|
domain = pf_scope.find('domain')
|
||
|
|
if domain is not None and domain.text:
|
||
|
|
set_text(op_od, 'domain_name', domain.text)
|
||
|
|
# Domain search
|
||
|
|
dsl = pf_scope.find('domainsearchlist')
|
||
|
|
if dsl is not None and dsl.text:
|
||
|
|
set_text(op_od, 'domain_search', dsl.text)
|
||
|
|
# NTP
|
||
|
|
ntp = pf_scope.find('ntpserver')
|
||
|
|
if ntp is not None and ntp.text:
|
||
|
|
set_text(op_od, 'ntp_servers', ntp.text)
|
||
|
|
# Next server (PXE)
|
||
|
|
ns = pf_scope.find('nextserver')
|
||
|
|
if ns is not None and ns.text:
|
||
|
|
set_text(op_subnet, 'next_server', ns.text)
|
||
|
|
|
||
|
|
# Pools: convert pfSense <range><from>X</from><to>Y</to></range> to "X-Y"
|
||
|
|
if pf_range is not None:
|
||
|
|
from_elem = pf_range.find('from')
|
||
|
|
to_elem = pf_range.find('to')
|
||
|
|
from_ip = from_elem.text if from_elem is not None else None
|
||
|
|
to_ip = to_elem.text if to_elem is not None else None
|
||
|
|
if from_ip and to_ip:
|
||
|
|
set_text(op_subnet, 'pools', '%s-%s' % (from_ip, to_ip))
|
||
|
|
|
||
|
|
# Set interfaces list on general
|
||
|
|
if dhcp_ifaces:
|
||
|
|
set_text(op_general, 'interfaces', ','.join(dhcp_ifaces))
|
||
|
|
|
||
|
|
# Reservations container
|
||
|
|
op_reservations = ET.SubElement(op_dhcp4, 'reservations')
|
||
|
|
|
||
|
|
# Build reservations from static maps across all scopes
|
||
|
|
seen_ips_per_subnet = {} # suuid -> set of IPs already reserved
|
||
|
|
for tag, suuid in subnet_uuids.items():
|
||
|
|
pf_scope = pf_dhcpd.find(tag)
|
||
|
|
if pf_scope is None:
|
||
|
|
continue
|
||
|
|
|
||
|
|
if suuid not in seen_ips_per_subnet:
|
||
|
|
seen_ips_per_subnet[suuid] = set()
|
||
|
|
|
||
|
|
for pf_sm in pf_scope.findall('staticmap'):
|
||
|
|
mac = pf_sm.findtext('mac', '')
|
||
|
|
ipaddr = pf_sm.findtext('ipaddr', '')
|
||
|
|
hostname = pf_sm.findtext('hostname', '')
|
||
|
|
descr = pf_sm.findtext('descr', '')
|
||
|
|
|
||
|
|
if not mac and not ipaddr:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Deduplicate: skip same IP already reserved in this subnet
|
||
|
|
if ipaddr:
|
||
|
|
if ipaddr in seen_ips_per_subnet[suuid]:
|
||
|
|
continue
|
||
|
|
seen_ips_per_subnet[suuid].add(ipaddr)
|
||
|
|
|
||
|
|
# Deterministic UUID from MAC or IP
|
||
|
|
seed = mac or ipaddr
|
||
|
|
ruuid = _gen_uuid('dhcp-reservation-' + seed)
|
||
|
|
|
||
|
|
op_res = ET.SubElement(op_reservations, 'reservation')
|
||
|
|
op_res.set('uuid', ruuid)
|
||
|
|
set_text(op_res, 'subnet', suuid)
|
||
|
|
if ipaddr:
|
||
|
|
set_text(op_res, 'ip_address', ipaddr)
|
||
|
|
if mac:
|
||
|
|
set_text(op_res, 'hw_address', mac)
|
||
|
|
if hostname:
|
||
|
|
set_text(op_res, 'hostname', hostname)
|
||
|
|
if descr:
|
||
|
|
set_text(op_res, 'description', descr)
|
||
|
|
# Empty option_data for reservation
|
||
|
|
ET.SubElement(op_res, 'option_data')
|
||
|
|
|
||
|
|
|
||
|
|
def convert_aliases(pf_root, op_root):
|
||
|
|
"""Convert firewall aliases."""
|
||
|
|
pf_aliases = pf_root.find('aliases')
|
||
|
|
if pf_aliases is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
# Check if there's an <alias> section or individual <aliases><alias> entries
|
||
|
|
if pf_aliases.find('alias') is not None:
|
||
|
|
op_aliases = ET.SubElement(op_root, 'aliases')
|
||
|
|
for pf_alias in pf_aliases.findall('alias'):
|
||
|
|
copy_element_into(pf_alias, op_aliases)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_nat(pf_root, op_root):
|
||
|
|
"""Convert NAT rules (port forwarding + outbound NAT)."""
|
||
|
|
pf_nat = pf_root.find('nat')
|
||
|
|
if pf_nat is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
op_nat = ET.SubElement(op_root, 'nat')
|
||
|
|
|
||
|
|
# Port forwarding rules
|
||
|
|
for pf_rule in pf_nat.findall('rule'):
|
||
|
|
op_rule = ET.SubElement(op_nat, 'rule')
|
||
|
|
|
||
|
|
for child_tag in ['source', 'destination', 'ipprotocol', 'protocol',
|
||
|
|
'target', 'local-port', 'interface', 'descr',
|
||
|
|
'associated-rule-id', 'disabled', 'log', 'natreflection',
|
||
|
|
'nordr', 'poolopts', 'source_hash_key',
|
||
|
|
'allow', 'max', 'max-src-nodes', 'max-src-conn',
|
||
|
|
'max-src-states', 'statetimeout', 'statetype']:
|
||
|
|
for elem in pf_rule.findall(child_tag):
|
||
|
|
copy_element_into(elem, op_rule)
|
||
|
|
|
||
|
|
# Apply interface name mapping
|
||
|
|
iface_elem = op_rule.find('interface')
|
||
|
|
if iface_elem is not None and iface_elem.text:
|
||
|
|
iface_elem.text = _map_iface_in_text(iface_elem.text)
|
||
|
|
|
||
|
|
# updated/created timestamps if present
|
||
|
|
for meta in ['updated', 'created']:
|
||
|
|
pf_meta = pf_rule.find(meta)
|
||
|
|
if pf_meta is not None:
|
||
|
|
copy_element_into(pf_meta, op_rule)
|
||
|
|
|
||
|
|
# Outbound NAT
|
||
|
|
pf_outbound = pf_nat.find('outbound')
|
||
|
|
if pf_outbound is not None:
|
||
|
|
op_outbound = ET.SubElement(op_nat, 'outbound')
|
||
|
|
copy_element_into(pf_outbound.find('mode'), op_outbound)
|
||
|
|
|
||
|
|
for pf_orule in pf_outbound.findall('rule'):
|
||
|
|
op_orule = ET.SubElement(op_outbound, 'rule')
|
||
|
|
for child_tag in ['source', 'sourceport', 'destination', 'descr',
|
||
|
|
'target', 'interface', 'poolopts',
|
||
|
|
'source_hash_key', 'staticnatport', 'ipprotocol',
|
||
|
|
'protocol', 'disabled', 'target_subnet',
|
||
|
|
'nonat', 'log', 'mirror']:
|
||
|
|
for elem in pf_orule.findall(child_tag):
|
||
|
|
copy_element_into(elem, op_orule)
|
||
|
|
|
||
|
|
# Apply interface name mapping
|
||
|
|
iface_elem = op_orule.find('interface')
|
||
|
|
if iface_elem is not None and iface_elem.text:
|
||
|
|
iface_elem.text = _map_iface_in_text(iface_elem.text)
|
||
|
|
|
||
|
|
for meta in ['created', 'updated']:
|
||
|
|
pf_meta = pf_orule.find(meta)
|
||
|
|
if pf_meta is not None:
|
||
|
|
copy_element_into(pf_meta, op_orule)
|
||
|
|
|
||
|
|
# 1:1 NAT
|
||
|
|
pf_onetoone = pf_nat.find('onetoone')
|
||
|
|
if pf_onetoone is not None:
|
||
|
|
op_onetoone = ET.SubElement(op_nat, 'onetoone')
|
||
|
|
for pf_rule in pf_onetoone.findall('rule'):
|
||
|
|
copy_element_into(pf_rule, op_onetoone)
|
||
|
|
|
||
|
|
# NAT separators
|
||
|
|
pf_sep = pf_nat.find('separator')
|
||
|
|
if pf_sep is not None:
|
||
|
|
copy_element_into(pf_sep, op_nat)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_filter(pf_root, op_root):
|
||
|
|
"""Convert firewall rules."""
|
||
|
|
pf_filter = pf_root.find('filter')
|
||
|
|
if pf_filter is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
op_filter = ET.SubElement(op_root, 'filter')
|
||
|
|
|
||
|
|
for pf_rule in pf_filter.findall('rule'):
|
||
|
|
op_rule = ET.SubElement(op_filter, 'rule')
|
||
|
|
|
||
|
|
for child_tag in ['id', 'tracker', 'type', 'interface', 'ipprotocol',
|
||
|
|
'protocol', 'icmptype', 'source', 'destination',
|
||
|
|
'descr', 'tag', 'tagged', 'max', 'max-src-nodes',
|
||
|
|
'max-src-conn', 'max-src-states', 'statetimeout',
|
||
|
|
'statetype', 'os', 'direction', 'log',
|
||
|
|
'disabled', 'gateway', 'bridgeto', 'srcmac',
|
||
|
|
'dstmac', 'allowopts', 'pflow']:
|
||
|
|
for elem in pf_rule.findall(child_tag):
|
||
|
|
copy_element_into(elem, op_rule)
|
||
|
|
|
||
|
|
# Apply interface name mapping to the <interface> tag
|
||
|
|
iface_elem = op_rule.find('interface')
|
||
|
|
if iface_elem is not None and iface_elem.text:
|
||
|
|
iface_elem.text = _map_iface_in_text(iface_elem.text)
|
||
|
|
|
||
|
|
for meta in ['updated', 'created']:
|
||
|
|
pf_meta = pf_rule.find(meta)
|
||
|
|
if pf_meta is not None:
|
||
|
|
copy_element_into(pf_meta, op_rule)
|
||
|
|
|
||
|
|
# Filter separators (if present)
|
||
|
|
pf_sep = pf_filter.find('separator')
|
||
|
|
if pf_sep is not None:
|
||
|
|
copy_element_into(pf_sep, op_filter)
|
||
|
|
|
||
|
|
# Default deny rules configuration
|
||
|
|
for tag in ['defaultallow', 'disablestatenormalization',
|
||
|
|
'defaultdenyimg']:
|
||
|
|
val = safe_text(pf_filter, tag)
|
||
|
|
if val:
|
||
|
|
set_text(op_filter, tag, val)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_certificates(pf_root, op_root):
|
||
|
|
"""Convert Certificate Authorities and Certificates.
|
||
|
|
|
||
|
|
pfSense structure:
|
||
|
|
<ca><ca refid="...">...</ca><ca refid="...">...</ca></ca>
|
||
|
|
<cert><cert refid="...">...</cert><cert refid="...">...</cert></cert>
|
||
|
|
<crl><cert refid="...">...</cert>...</crl>
|
||
|
|
|
||
|
|
OPNsense uses the same structure.
|
||
|
|
"""
|
||
|
|
for section_tag in ['ca', 'cert', 'crl']:
|
||
|
|
pf_section = pf_root.find(section_tag)
|
||
|
|
if pf_section is None:
|
||
|
|
continue
|
||
|
|
op_section = ET.SubElement(op_root, section_tag)
|
||
|
|
# Each child of the section element is a single entry
|
||
|
|
for pf_entry in pf_section:
|
||
|
|
copy_element_into(pf_entry, op_section)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_openvpn(pf_root, op_root):
|
||
|
|
"""Convert OpenVPN configuration."""
|
||
|
|
pf_ovpn = pf_root.find('openvpn')
|
||
|
|
if pf_ovpn is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
op_ovpn = ET.SubElement(op_root, 'openvpn')
|
||
|
|
|
||
|
|
# OpenVPN servers
|
||
|
|
for pf_srv in pf_ovpn.findall('openvpn-server'):
|
||
|
|
op_srv = ET.SubElement(op_ovpn, 'openvpn-server')
|
||
|
|
for child in pf_srv:
|
||
|
|
copy_element_into(child, op_srv)
|
||
|
|
|
||
|
|
# OpenVPN clients
|
||
|
|
for pf_cli in pf_ovpn.findall('openvpn-client'):
|
||
|
|
op_cli = ET.SubElement(op_ovpn, 'openvpn-client')
|
||
|
|
for child in pf_cli:
|
||
|
|
copy_element_into(child, op_cli)
|
||
|
|
|
||
|
|
# OpenVPN CSO (client-specific overrides)
|
||
|
|
for pf_cso in pf_ovpn.findall('openvpn-cso'):
|
||
|
|
op_cso = ET.SubElement(op_ovpn, 'openvpn-cso')
|
||
|
|
for child in pf_cso:
|
||
|
|
copy_element_into(child, op_cso)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_ipsec(pf_root, op_root):
|
||
|
|
"""Convert IPsec configuration."""
|
||
|
|
pf_ipsec = pf_root.find('ipsec')
|
||
|
|
if pf_ipsec is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
op_ipsec = ET.SubElement(op_root, 'ipsec')
|
||
|
|
|
||
|
|
# IPsec phase 1
|
||
|
|
for pf_p1 in pf_ipsec.findall('phase1'):
|
||
|
|
copy_element_into(pf_p1, op_ipsec)
|
||
|
|
|
||
|
|
# IPsec phase 2
|
||
|
|
for pf_p2 in pf_ipsec.findall('phase2'):
|
||
|
|
copy_element_into(pf_p2, op_ipsec)
|
||
|
|
|
||
|
|
# IPsec mobile clients
|
||
|
|
for pf_m in pf_ipsec.findall('mobile'):
|
||
|
|
copy_element_into(pf_m, op_ipsec)
|
||
|
|
|
||
|
|
|
||
|
|
def _map_iface_in_text(text):
|
||
|
|
"""Map interface names inside a text value (replacing tun_wgX with wgX etc)."""
|
||
|
|
result = text
|
||
|
|
for old, new in IFACE_MAP.items():
|
||
|
|
result = result.replace(old, new)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def _find_wireguard(pf_root):
|
||
|
|
"""Find the WireGuard config in pfSense XML.
|
||
|
|
|
||
|
|
pfSense stores WireGuard config either:
|
||
|
|
- At top level: <wireguard>
|
||
|
|
- Inside installedpackages: <installedpackages><wireguard>
|
||
|
|
"""
|
||
|
|
pf_wg = pf_root.find('wireguard')
|
||
|
|
if pf_wg is not None and len(pf_wg):
|
||
|
|
return pf_wg
|
||
|
|
pf_wg = pf_root.find('installedpackages/wireguard')
|
||
|
|
if pf_wg is not None:
|
||
|
|
return pf_wg
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _gen_uuid(seed_str):
|
||
|
|
"""Generate a UUID that is valid as an XML element name (starts with letter)."""
|
||
|
|
while True:
|
||
|
|
u = str(uuid.uuid5(uuid.NAMESPACE_DNS, seed_str))
|
||
|
|
if u[0].isalpha():
|
||
|
|
return u
|
||
|
|
# Salt and retry if UUID starts with a digit (invalid XML element name)
|
||
|
|
seed_str = f'{seed_str}_retry'
|
||
|
|
|
||
|
|
|
||
|
|
def convert_wireguard(pf_root, op_root):
|
||
|
|
"""Convert WireGuard configuration.
|
||
|
|
|
||
|
|
pfSense stores (in <installedpackages><wireguard>):
|
||
|
|
<tunnels><item>
|
||
|
|
<name>tun_wg0</name>
|
||
|
|
<enabled>yes</enabled>
|
||
|
|
<publickey>...</publickey>
|
||
|
|
<privatekey>...</privatekey>
|
||
|
|
<listenport>51820</listenport>
|
||
|
|
<mtu>1420</mtu>
|
||
|
|
<descr>...</descr>
|
||
|
|
<addresses><row><address>10.0.9.254</address><mask>24</mask></row></addresses>
|
||
|
|
</item></tunnels>
|
||
|
|
<peers><item>
|
||
|
|
<enabled>yes</enabled>
|
||
|
|
<tun>tun_wg0</tun>
|
||
|
|
<publickey>...</publickey>
|
||
|
|
<presharedkey>...</presharedkey>
|
||
|
|
<endpoint>host</endpoint><port>51820</port>
|
||
|
|
<persistentkeepalive>60</persistentkeepalive>
|
||
|
|
<descr>...</descr>
|
||
|
|
<allowedips><row><address>10.0.9.1</address><mask>32</mask></row></allowedips>
|
||
|
|
</item></peers>
|
||
|
|
|
||
|
|
OPNsense stores under <OPNsense><wireguard> (nested, mount: OPNsense/wireguard):
|
||
|
|
<general><enabled>1</enabled></general>
|
||
|
|
<server><servers><server>
|
||
|
|
<enabled>1</enabled>
|
||
|
|
<name>wg0</name>
|
||
|
|
<instance>0</instance>
|
||
|
|
<pubkey>...</pubkey>
|
||
|
|
<privkey>...</privkey>
|
||
|
|
<port>51820</port>
|
||
|
|
<tunneladdress>10.0.9.254/24</tunneladdress>
|
||
|
|
<peers>uuid1,uuid2</peers>
|
||
|
|
<mtu>1420</mtu>
|
||
|
|
<disableroutes>0</disableroutes>
|
||
|
|
<gateway></gateway>
|
||
|
|
</server></servers></server>
|
||
|
|
<client><clients>
|
||
|
|
<uuid1> <!-- element name is the UUID -->
|
||
|
|
<enabled>1</enabled>
|
||
|
|
<name>...</name>
|
||
|
|
<pubkey>...</pubkey>
|
||
|
|
<psk></psk>
|
||
|
|
<tunneladdress>10.0.9.1/32</tunneladdress>
|
||
|
|
<serveraddress>...</serveraddress>
|
||
|
|
<serverport>51820</serverport>
|
||
|
|
<keepalive>60</keepalive>
|
||
|
|
</uuid1>
|
||
|
|
</clients></client>
|
||
|
|
"""
|
||
|
|
pf_wg = _find_wireguard(pf_root)
|
||
|
|
if pf_wg is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
# OPNsense MVC mount: OPNsense/wireguard → <OPNsense><wireguard> (nested, not a single tag!)
|
||
|
|
op_opnsense = op_root.find('OPNsense')
|
||
|
|
if op_opnsense is None:
|
||
|
|
op_opnsense = ET.SubElement(op_root, 'OPNsense')
|
||
|
|
op_wg = ET.SubElement(op_opnsense, 'wireguard')
|
||
|
|
|
||
|
|
# General section
|
||
|
|
op_general = ET.SubElement(op_wg, 'general')
|
||
|
|
set_text(op_general, 'enabled', '1')
|
||
|
|
|
||
|
|
# First pass: collect all peers and assign UUIDs
|
||
|
|
peer_data = [] # list of (elem, uuid)
|
||
|
|
pf_peers = pf_wg.find('peers')
|
||
|
|
if pf_peers is not None:
|
||
|
|
for pf_item in pf_peers.findall('item'):
|
||
|
|
pubkey = safe_text(pf_item, 'publickey', '')
|
||
|
|
descr = safe_text(pf_item, 'descr', '')
|
||
|
|
seed = pubkey or descr or str(len(peer_data))
|
||
|
|
puuid = _gen_uuid(f'wg-peer-{seed}')
|
||
|
|
peer_data.append((pf_item, puuid))
|
||
|
|
|
||
|
|
# Servers (tunnels) section
|
||
|
|
op_server_root = ET.SubElement(op_wg, 'server')
|
||
|
|
op_servers = ET.SubElement(op_server_root, 'servers')
|
||
|
|
pf_tunnels = pf_wg.find('tunnels')
|
||
|
|
if pf_tunnels is not None:
|
||
|
|
for instance_idx, pf_item in enumerate(pf_tunnels.findall('item')):
|
||
|
|
op_server = ET.SubElement(op_servers, 'server')
|
||
|
|
|
||
|
|
enabled = safe_text(pf_item, 'enabled', 'yes')
|
||
|
|
set_text(op_server, 'enabled', '1' if enabled == 'yes' else '0')
|
||
|
|
|
||
|
|
raw_name = safe_text(pf_item, 'name', '')
|
||
|
|
raw_name = _map_iface_in_text(raw_name) if raw_name else ''
|
||
|
|
set_text(op_server, 'name', _sanitize_name(raw_name))
|
||
|
|
|
||
|
|
set_text(op_server, 'instance', str(instance_idx))
|
||
|
|
set_text(op_server, 'pubkey', safe_text(pf_item, 'publickey'))
|
||
|
|
set_text(op_server, 'privkey', safe_text(pf_item, 'privatekey'))
|
||
|
|
set_text(op_server, 'port', safe_text(pf_item, 'listenport'))
|
||
|
|
set_text(op_server, 'mtu', safe_text(pf_item, 'mtu'))
|
||
|
|
|
||
|
|
# Defaults
|
||
|
|
set_text(op_server, 'disableroutes', '0')
|
||
|
|
ET.SubElement(op_server, 'gateway')
|
||
|
|
|
||
|
|
# Tunnel addresses as comma-separated CIDR
|
||
|
|
addresses = []
|
||
|
|
pf_addresses = pf_item.find('addresses')
|
||
|
|
if pf_addresses is not None:
|
||
|
|
for pf_row in pf_addresses.findall('row'):
|
||
|
|
addr = safe_text(pf_row, 'address')
|
||
|
|
mask = safe_text(pf_row, 'mask')
|
||
|
|
if addr and mask:
|
||
|
|
addresses.append(f'{addr}/{mask}')
|
||
|
|
if addresses:
|
||
|
|
set_text(op_server, 'tunneladdress', ','.join(addresses))
|
||
|
|
|
||
|
|
# Peers reference (comma-separated UUIDs)
|
||
|
|
tunnel_name = safe_text(pf_item, 'name', '')
|
||
|
|
mapped_tunnel_name = _map_iface_in_text(tunnel_name) if tunnel_name else ''
|
||
|
|
peer_uuids_for_server = []
|
||
|
|
for pf_peer_item, puuid in peer_data:
|
||
|
|
peer_tun = safe_text(pf_peer_item, 'tun', '')
|
||
|
|
if peer_tun == tunnel_name or _map_iface_in_text(peer_tun) == mapped_tunnel_name:
|
||
|
|
peer_uuids_for_server.append(puuid)
|
||
|
|
if peer_uuids_for_server:
|
||
|
|
set_text(op_server, 'peers', ','.join(peer_uuids_for_server))
|
||
|
|
|
||
|
|
# Clients (peers) section
|
||
|
|
# In OPNsense, each client entry uses its UUID as the XML element name,
|
||
|
|
# placed directly under <clients> (no <client> wrapper).
|
||
|
|
op_client_root = ET.SubElement(op_wg, 'client')
|
||
|
|
op_clients = ET.SubElement(op_client_root, 'clients')
|
||
|
|
for pf_item, puuid in peer_data:
|
||
|
|
|
||
|
|
# The UUID is the element name for the client entry itself
|
||
|
|
op_client_uuid = ET.SubElement(op_clients, puuid)
|
||
|
|
|
||
|
|
enabled = safe_text(pf_item, 'enabled', 'yes')
|
||
|
|
set_text(op_client_uuid, 'enabled', '1' if enabled == 'yes' else '0')
|
||
|
|
|
||
|
|
# Sanitize name to match OPNsense validation (no slashes/spaces)
|
||
|
|
raw_name = safe_text(pf_item, 'descr', '')
|
||
|
|
sane_name = _sanitize_name(raw_name) if raw_name else f'peer_{puuid[:8]}'
|
||
|
|
set_text(op_client_uuid, 'name', sane_name)
|
||
|
|
set_text(op_client_uuid, 'pubkey', safe_text(pf_item, 'publickey'))
|
||
|
|
set_text(op_client_uuid, 'psk', safe_text(pf_item, 'presharedkey'))
|
||
|
|
|
||
|
|
# Allowed IPs (comma-separated CIDRs)
|
||
|
|
allowed_ips = []
|
||
|
|
pf_allowed = pf_item.find('allowedips')
|
||
|
|
if pf_allowed is not None:
|
||
|
|
for pf_row in pf_allowed.findall('row'):
|
||
|
|
addr = safe_text(pf_row, 'address')
|
||
|
|
mask = safe_text(pf_row, 'mask')
|
||
|
|
if addr and mask:
|
||
|
|
allowed_ips.append(f'{addr}/{mask}')
|
||
|
|
if allowed_ips:
|
||
|
|
set_text(op_client_uuid, 'tunneladdress', ','.join(allowed_ips))
|
||
|
|
|
||
|
|
# Endpoint
|
||
|
|
endpoint = safe_text(pf_item, 'endpoint', '')
|
||
|
|
port = safe_text(pf_item, 'port', '')
|
||
|
|
if endpoint:
|
||
|
|
set_text(op_client_uuid, 'serveraddress', endpoint)
|
||
|
|
if port:
|
||
|
|
set_text(op_client_uuid, 'serverport', port)
|
||
|
|
|
||
|
|
set_text(op_client_uuid, 'keepalive', safe_text(pf_item, 'persistentkeepalive'))
|
||
|
|
|
||
|
|
|
||
|
|
def convert_static_routes(pf_root, op_root):
|
||
|
|
"""Convert static routes."""
|
||
|
|
pf_routes = pf_root.find('staticroutes')
|
||
|
|
if pf_routes is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
op_routes = ET.SubElement(op_root, 'staticroutes')
|
||
|
|
for pf_route in pf_routes:
|
||
|
|
copy_element_into(pf_route, op_routes)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_gateways(pf_root, op_root):
|
||
|
|
"""Convert gateway configuration."""
|
||
|
|
pf_gws = pf_root.find('gateways')
|
||
|
|
if pf_gws is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
op_gws = ET.SubElement(op_root, 'gateways')
|
||
|
|
|
||
|
|
# Default gateway
|
||
|
|
pf_dgw = pf_gws.find('defaultgw')
|
||
|
|
if pf_dgw is not None:
|
||
|
|
copy_element_into(pf_dgw, op_gws)
|
||
|
|
|
||
|
|
# Gateway entries
|
||
|
|
for pf_gw in pf_gws.findall('gateway_item'):
|
||
|
|
copy_element_into(pf_gw, op_gws)
|
||
|
|
|
||
|
|
# Gateway groups
|
||
|
|
for pf_gg in pf_gws.findall('gateway_group'):
|
||
|
|
copy_element_into(pf_gg, op_gws)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_dns(pf_root, op_root):
|
||
|
|
"""Convert DNS resolver/forwarder configuration."""
|
||
|
|
# dnsmasq
|
||
|
|
pf_dnsmasq = pf_root.find('dnsmasq')
|
||
|
|
if pf_dnsmasq is not None:
|
||
|
|
op_dnsmasq = ET.SubElement(op_root, 'dnsmasq')
|
||
|
|
for child in pf_dnsmasq:
|
||
|
|
copy_element_into(child, op_dnsmasq)
|
||
|
|
|
||
|
|
# unbound
|
||
|
|
pf_unbound = pf_root.find('unbound')
|
||
|
|
if pf_unbound is not None:
|
||
|
|
op_unbound = ET.SubElement(op_root, 'unbound')
|
||
|
|
for child in pf_unbound:
|
||
|
|
copy_element_into(child, op_unbound)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_snmp(pf_root, op_root):
|
||
|
|
"""Convert SNMP configuration."""
|
||
|
|
pf_snmp = pf_root.find('snmpd')
|
||
|
|
if pf_snmp is not None:
|
||
|
|
op_snmp = ET.SubElement(op_root, 'snmpd')
|
||
|
|
for child in pf_snmp:
|
||
|
|
copy_element_into(child, op_snmp)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_schedules(pf_root, op_root):
|
||
|
|
"""Convert firewall schedules."""
|
||
|
|
pf_scheds = pf_root.find('schedules')
|
||
|
|
if pf_scheds is not None:
|
||
|
|
op_scheds = ET.SubElement(op_root, 'schedules')
|
||
|
|
for pf_sched in pf_scheds.findall('schedule'):
|
||
|
|
copy_element_into(pf_sched, op_scheds)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_virtual_ips(pf_root, op_root):
|
||
|
|
"""Convert virtual IPs."""
|
||
|
|
pf_vips = pf_root.find('virtualip')
|
||
|
|
if pf_vips is not None:
|
||
|
|
op_vips = ET.SubElement(op_root, 'virtualip')
|
||
|
|
for pf_vip in pf_vips.findall('vip'):
|
||
|
|
copy_element_into(pf_vip, op_vips)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_ifgroups(pf_root, op_root):
|
||
|
|
"""Convert interface groups."""
|
||
|
|
pf_groups = pf_root.find('ifgroups')
|
||
|
|
if pf_groups is not None:
|
||
|
|
op_groups = ET.SubElement(op_root, 'ifgroups')
|
||
|
|
for pf_group in pf_groups.findall('ifgroupentry'):
|
||
|
|
copy_element_into(pf_group, op_groups)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_sysctl(pf_root, op_root):
|
||
|
|
"""Convert sysctl tunables."""
|
||
|
|
pf_sysctl = pf_root.find('sysctl')
|
||
|
|
if pf_sysctl is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
op_sysctl = ET.SubElement(op_root, 'sysctl')
|
||
|
|
for pf_item in pf_sysctl.findall('item'):
|
||
|
|
copy_element_into(pf_item, op_sysctl)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_syslog(pf_root, op_root):
|
||
|
|
"""Convert syslog configuration."""
|
||
|
|
pf_syslog = pf_root.find('syslog')
|
||
|
|
if pf_syslog is not None:
|
||
|
|
op_syslog = ET.SubElement(op_root, 'syslog')
|
||
|
|
for child in pf_syslog:
|
||
|
|
copy_element_into(child, op_syslog)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_rrd(pf_root, op_root):
|
||
|
|
"""Convert RRD graphing configuration."""
|
||
|
|
pf_rrd = pf_root.find('rrd')
|
||
|
|
if pf_rrd is not None:
|
||
|
|
op_rrd = ET.SubElement(op_root, 'rrd')
|
||
|
|
for child in pf_rrd:
|
||
|
|
copy_element_into(child, op_rrd)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_dyndns(pf_root, op_root):
|
||
|
|
"""Convert Dynamic DNS configuration."""
|
||
|
|
pf_ddns = pf_root.find('dyndnses')
|
||
|
|
if pf_ddns is not None:
|
||
|
|
op_ddns = ET.SubElement(op_root, 'dyndnses')
|
||
|
|
for pf_entry in pf_ddns.findall('dyndns'):
|
||
|
|
copy_element_into(pf_entry, op_ddns)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_captive_portal(pf_root, op_root):
|
||
|
|
"""Convert Captive Portal configuration."""
|
||
|
|
pf_cp = pf_root.find('captiveportal')
|
||
|
|
if pf_cp is not None:
|
||
|
|
op_cp = ET.SubElement(op_root, 'captiveportal')
|
||
|
|
for pf_zone in pf_cp.findall('zone'):
|
||
|
|
copy_element_into(pf_zone, op_cp)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_load_balancer(pf_root, op_root):
|
||
|
|
"""Convert HAProxy/relayd load balancer config."""
|
||
|
|
for section in ['lbpool', 'lbvirtual', 'lbserver', 'haproxy']:
|
||
|
|
pf_section = pf_root.find(section)
|
||
|
|
if pf_section is not None:
|
||
|
|
copy_element_into(pf_section, op_root)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_cron(pf_root, op_root):
|
||
|
|
"""Convert cron jobs."""
|
||
|
|
pf_cron = pf_root.find('cron')
|
||
|
|
if pf_cron is not None:
|
||
|
|
op_cron = ET.SubElement(op_root, 'cron')
|
||
|
|
for pf_item in pf_cron.findall('item'):
|
||
|
|
copy_element_into(pf_item, op_cron)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_dhcrelay(pf_root, op_root):
|
||
|
|
"""Convert DHCP relay config."""
|
||
|
|
for section in ['dhcrelay', 'dhcrelay6']:
|
||
|
|
pf_section = pf_root.find(section)
|
||
|
|
if pf_section is not None:
|
||
|
|
copy_element_into(pf_section, op_root)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_ppps(pf_root, op_root):
|
||
|
|
"""Convert PPPoE/PPTP/L2TP interface configs."""
|
||
|
|
pf_ppps = pf_root.find('ppps')
|
||
|
|
if pf_ppps is not None:
|
||
|
|
op_ppps = ET.SubElement(op_root, 'ppps')
|
||
|
|
for pf_ppp in pf_ppps.findall('ppp'):
|
||
|
|
copy_element_into(pf_ppp, op_ppps)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_igmpproxy(pf_root, op_root):
|
||
|
|
"""Convert IGMP proxy config."""
|
||
|
|
pf_igmp = pf_root.find('igmpproxy')
|
||
|
|
if pf_igmp is not None:
|
||
|
|
op_igmp = ET.SubElement(op_root, 'igmpproxy')
|
||
|
|
for child in pf_igmp:
|
||
|
|
copy_element_into(child, op_igmp)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_ntpd(pf_root, op_root):
|
||
|
|
"""Convert NTPd configuration."""
|
||
|
|
pf_ntpd = pf_root.find('ntpd')
|
||
|
|
if pf_ntpd is not None:
|
||
|
|
op_ntpd = ET.SubElement(op_root, 'ntpd')
|
||
|
|
for child in pf_ntpd:
|
||
|
|
copy_element_into(child, op_ntpd)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_sshdata(pf_root, op_root):
|
||
|
|
"""Convert SSH host keys."""
|
||
|
|
pf_sshdata = pf_root.find('sshdata')
|
||
|
|
if pf_sshdata is not None:
|
||
|
|
op_sshdata = ET.SubElement(op_root, 'sshdata')
|
||
|
|
for pf_keyfile in pf_sshdata.findall('sshkeyfile'):
|
||
|
|
copy_element_into(pf_keyfile, op_sshdata)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_bridge(pf_root, op_root):
|
||
|
|
"""Convert bridge configuration."""
|
||
|
|
pf_bridge = pf_root.find('bridge')
|
||
|
|
if pf_bridge is not None:
|
||
|
|
op_bridge = ET.SubElement(op_root, 'bridge')
|
||
|
|
for pf_bridge_entry in pf_bridge.findall('bridge'):
|
||
|
|
copy_element_into(pf_bridge_entry, op_bridge)
|
||
|
|
|
||
|
|
|
||
|
|
def convert(pf_config_path, output_path=None, interface_map=None):
|
||
|
|
"""Main conversion function."""
|
||
|
|
global IFACE_MAP
|
||
|
|
if interface_map:
|
||
|
|
IFACE_MAP.update(interface_map)
|
||
|
|
|
||
|
|
tree = ET.parse(pf_config_path)
|
||
|
|
pf_root = tree.getroot()
|
||
|
|
|
||
|
|
op_root = ET.Element('opnsense')
|
||
|
|
|
||
|
|
# Version
|
||
|
|
ET.SubElement(op_root, 'version').text = '26.1'
|
||
|
|
|
||
|
|
# Convert sections
|
||
|
|
convert_system(pf_root, op_root)
|
||
|
|
convert_interfaces(pf_root, op_root)
|
||
|
|
convert_vlans(pf_root, op_root)
|
||
|
|
convert_dhcpd(pf_root, op_root)
|
||
|
|
convert_kea_dhcp(pf_root, op_root)
|
||
|
|
convert_aliases(pf_root, op_root)
|
||
|
|
convert_nat(pf_root, op_root)
|
||
|
|
convert_filter(pf_root, op_root)
|
||
|
|
convert_certificates(pf_root, op_root)
|
||
|
|
convert_openvpn(pf_root, op_root)
|
||
|
|
convert_ipsec(pf_root, op_root)
|
||
|
|
convert_wireguard(pf_root, op_root)
|
||
|
|
convert_static_routes(pf_root, op_root)
|
||
|
|
convert_gateways(pf_root, op_root)
|
||
|
|
convert_dns(pf_root, op_root)
|
||
|
|
convert_snmp(pf_root, op_root)
|
||
|
|
convert_schedules(pf_root, op_root)
|
||
|
|
convert_virtual_ips(pf_root, op_root)
|
||
|
|
convert_ifgroups(pf_root, op_root)
|
||
|
|
convert_sysctl(pf_root, op_root)
|
||
|
|
convert_syslog(pf_root, op_root)
|
||
|
|
convert_rrd(pf_root, op_root)
|
||
|
|
convert_dyndns(pf_root, op_root)
|
||
|
|
convert_captive_portal(pf_root, op_root)
|
||
|
|
convert_load_balancer(pf_root, op_root)
|
||
|
|
convert_cron(pf_root, op_root)
|
||
|
|
convert_dhcrelay(pf_root, op_root)
|
||
|
|
convert_ppps(pf_root, op_root)
|
||
|
|
convert_igmpproxy(pf_root, op_root)
|
||
|
|
convert_ntpd(pf_root, op_root)
|
||
|
|
convert_sshdata(pf_root, op_root)
|
||
|
|
convert_bridge(pf_root, op_root)
|
||
|
|
|
||
|
|
# Copy unknown/unhandled top-level sections as-is for maximum compatibility
|
||
|
|
# List all sections that our converter functions create
|
||
|
|
handled_sections = {
|
||
|
|
'pfsense', 'system', 'interfaces', 'vlans', 'dhcpd', 'dnsmasq',
|
||
|
|
'snmpd', 'diag', 'bridge', 'syslog', 'nat', 'filter',
|
||
|
|
'staticroutes', 'gateways', 'aliases', 'ca', 'cert', 'certca',
|
||
|
|
'crl', 'openvpn', 'ipsec', 'wireguard', 'unbound', 'ifgroups',
|
||
|
|
'virtualip', 'dhcpbackend', 'qinqs', 'schedules', 'sshdata',
|
||
|
|
'rrd', 'dyndnses', 'captiveportal', 'cron', 'dhcrelay',
|
||
|
|
'dhcrelay6', 'ppps', 'igmpproxy', 'ntpd', 'OPNsense',
|
||
|
|
}
|
||
|
|
|
||
|
|
for pf_child in pf_root:
|
||
|
|
tag = pf_child.tag
|
||
|
|
# Skip the pfSense root element name
|
||
|
|
if tag == 'pfsense':
|
||
|
|
continue
|
||
|
|
# Skip version - we set our own OPNsense version
|
||
|
|
if tag == 'version':
|
||
|
|
continue
|
||
|
|
# Skip sections we already handled
|
||
|
|
if tag in handled_sections:
|
||
|
|
continue
|
||
|
|
# Also skip if the element already exists in output
|
||
|
|
if op_root.find(tag) is not None:
|
||
|
|
continue
|
||
|
|
# Handle installedpackages specially: copy but remove wireguard child since we handled it
|
||
|
|
if tag == 'installedpackages':
|
||
|
|
op_copy = copy_element_into(pf_child, op_root)
|
||
|
|
if op_copy is not None:
|
||
|
|
wg_copy = op_copy.find('wireguard')
|
||
|
|
if wg_copy is not None:
|
||
|
|
op_copy.remove(wg_copy)
|
||
|
|
continue
|
||
|
|
# Copy unhandled sections for maximum compatibility
|
||
|
|
copy_element_into(pf_child, op_root)
|
||
|
|
|
||
|
|
# Apply interface name mapping to any remaining references in the output tree
|
||
|
|
_post_process_iface_map(op_root)
|
||
|
|
|
||
|
|
# Set default root password
|
||
|
|
_set_root_password(op_root)
|
||
|
|
|
||
|
|
# Add OPNsense-specific defaults
|
||
|
|
_add_opnsense_defaults(op_root)
|
||
|
|
|
||
|
|
# Pretty-print
|
||
|
|
indent(op_root)
|
||
|
|
|
||
|
|
# Add XML declaration
|
||
|
|
result = '<?xml version="1.0"?>\n' + ET.tostring(op_root, encoding='unicode')
|
||
|
|
|
||
|
|
if output_path:
|
||
|
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||
|
|
f.write(result)
|
||
|
|
print(f"Converted config written to: {output_path}", file=sys.stderr)
|
||
|
|
else:
|
||
|
|
print(result)
|
||
|
|
|
||
|
|
|
||
|
|
def _set_root_password(op_root):
|
||
|
|
"""Set the admin/root password to 'opnsense' as a bcrypt hash."""
|
||
|
|
op_sys = op_root.find('system')
|
||
|
|
if op_sys is None:
|
||
|
|
return
|
||
|
|
# OPNsense expects the admin user to be named 'root' (not 'admin' as in pfSense)
|
||
|
|
for user in op_sys.findall('user'):
|
||
|
|
name = user.find('name')
|
||
|
|
uid = user.find('uid')
|
||
|
|
is_admin = (name is not None and name.text == 'admin') or (uid is not None and uid.text == '0')
|
||
|
|
if is_admin:
|
||
|
|
# Rename admin -> root for OPNsense compatibility
|
||
|
|
if name is not None and name.text == 'admin':
|
||
|
|
name.text = 'root'
|
||
|
|
# Replace password with bcrypt hash of 'opnsense'
|
||
|
|
pw = user.find('password')
|
||
|
|
if pw is not None:
|
||
|
|
pw.text = '$2b$10$ebdSKP5HpJ1eOmgnTrfmy.pKBjDIrrzSTIRPNZd8EGmm/EU6hIwAy'
|
||
|
|
# Also set bcrypt-hash field that OPNsense may use
|
||
|
|
set_text(user, 'bcrypt-hash', '$2b$10$ebdSKP5HpJ1eOmgnTrfmy.pKBjDIrrzSTIRPNZd8EGmm/EU6hIwAy')
|
||
|
|
# Remove md5-hash as OPNsense uses bcrypt
|
||
|
|
md5 = user.find('md5-hash')
|
||
|
|
if md5 is not None:
|
||
|
|
user.remove(md5)
|
||
|
|
|
||
|
|
|
||
|
|
def _post_process_iface_map(op_root):
|
||
|
|
"""Post-process the output tree to apply interface name mapping
|
||
|
|
to any tags that may reference interface names (e.g. tun_wg0 -> wg0).
|
||
|
|
This catches any references in sections that were copied passthrough.
|
||
|
|
"""
|
||
|
|
if not IFACE_MAP:
|
||
|
|
return
|
||
|
|
# Tags that can contain interface device names or references
|
||
|
|
iface_tags = {'if', 'interface', 'tun', 'name', 'bridgeif', 'member', 'tunnel'}
|
||
|
|
for elem in op_root.iter():
|
||
|
|
if elem.tag in iface_tags and elem.text:
|
||
|
|
elem.text = _map_iface_in_text(elem.text)
|
||
|
|
# Also check CDATA-like description fields that might reference interface names
|
||
|
|
if elem.tag == 'description' and elem.text and any(
|
||
|
|
old in elem.text for old in IFACE_MAP
|
||
|
|
):
|
||
|
|
elem.text = _map_iface_in_text(elem.text)
|
||
|
|
|
||
|
|
|
||
|
|
def _add_opnsense_defaults(op_root):
|
||
|
|
"""Add OPNsense-specific default elements that may be needed."""
|
||
|
|
# OPNsense uses <theme>opnsense</theme> (text content, no child elements).
|
||
|
|
# pfSense may use <theme>pfsense</theme>.
|
||
|
|
# ControllerBase.php casts to string, so child elements would yield "" → broken paths.
|
||
|
|
op_theme = op_root.find('theme')
|
||
|
|
if op_theme is not None:
|
||
|
|
op_root.remove(op_theme)
|
||
|
|
theme = ET.SubElement(op_root, 'theme')
|
||
|
|
theme.text = 'opnsense'
|
||
|
|
|
||
|
|
# Already set in convert()
|
||
|
|
|
||
|
|
# Set lastchange if missing
|
||
|
|
if op_root.find('lastchange') is None:
|
||
|
|
ET.SubElement(op_root, 'lastchange')
|
||
|
|
|
||
|
|
# Add some OPNsense-specific system defaults
|
||
|
|
op_sys = op_root.find('system')
|
||
|
|
if op_sys is not None:
|
||
|
|
# Ensure webgui defaults
|
||
|
|
if op_sys.find('webgui') is None:
|
||
|
|
wg = ET.SubElement(op_sys, 'webgui')
|
||
|
|
set_text(wg, 'protocol', 'https')
|
||
|
|
# Add OPNsense-specific hn_* defaults
|
||
|
|
for hn_opt in ['hn_altq_enable', 'hn_mac_filter']:
|
||
|
|
val = safe_text(op_sys, hn_opt)
|
||
|
|
if not val:
|
||
|
|
ET.SubElement(op_sys, hn_opt)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
if len(sys.argv) < 2:
|
||
|
|
print(f"Usage: {sys.argv[0]} <pfsense-config.xml> [output.xml] [--interface-map old=new ...]", file=sys.stderr)
|
||
|
|
print(f"", file=sys.stderr)
|
||
|
|
print(f"Examples:", file=sys.stderr)
|
||
|
|
print(f" {sys.argv[0]} config-pfsense.xml config-opnsense.xml", file=sys.stderr)
|
||
|
|
print(f" {sys.argv[0]} config-pfsense.xml --interface-map tun_wg0=wg0 tun_wg1=wg1", file=sys.stderr)
|
||
|
|
print(f"", file=sys.stderr)
|
||
|
|
print(f"If output.xml is omitted, the converted config is printed to stdout.", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
input_path = sys.argv[1]
|
||
|
|
output_path = None
|
||
|
|
interface_map = {}
|
||
|
|
|
||
|
|
# Default mapping: tun_wgX -> wgX
|
||
|
|
interface_map['tun_wg0'] = 'wg0'
|
||
|
|
interface_map['tun_wg1'] = 'wg1'
|
||
|
|
interface_map['tun_wg2'] = 'wg2'
|
||
|
|
interface_map['tun_wg3'] = 'wg3'
|
||
|
|
interface_map['tun_wg4'] = 'wg4'
|
||
|
|
interface_map['tun_wg5'] = 'wg5'
|
||
|
|
|
||
|
|
i = 2
|
||
|
|
while i < len(sys.argv):
|
||
|
|
arg = sys.argv[i]
|
||
|
|
if arg == '--interface-map' or arg == '-m':
|
||
|
|
i += 1
|
||
|
|
if i >= len(sys.argv):
|
||
|
|
print("Error: --interface-map requires a mapping", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
mapping = sys.argv[i]
|
||
|
|
if '=' in mapping:
|
||
|
|
old, new = mapping.split('=', 1)
|
||
|
|
interface_map[old] = new
|
||
|
|
else:
|
||
|
|
print(f"Error: invalid mapping '{mapping}'. Use old=new format.", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
elif output_path is None and not arg.startswith('-'):
|
||
|
|
output_path = arg
|
||
|
|
else:
|
||
|
|
print(f"Error: unknown argument '{arg}'", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
i += 1
|
||
|
|
|
||
|
|
convert(input_path, output_path, interface_map=interface_map)
|