From 89b9577f7a72e9739fb5947251d8f02fe19310ec Mon Sep 17 00:00:00 2001 From: Guy Van Sanden Date: Tue, 26 May 2026 18:32:31 +0200 Subject: [PATCH] Many fix iteration including support for aliases --- pfsense2opnsense.py | 1110 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 1044 insertions(+), 66 deletions(-) mode change 100644 => 100755 pfsense2opnsense.py diff --git a/pfsense2opnsense.py b/pfsense2opnsense.py old mode 100644 new mode 100755 index 5e65753..5db0cab --- a/pfsense2opnsense.py +++ b/pfsense2opnsense.py @@ -131,11 +131,20 @@ def convert_system(pf_root, op_root): 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) + # DNS servers: pfSense uses .. tags, while OPNsense uses + # repeated elements. Also check for direct tags. + pf_dns_added = 0 + for dns_tag in ['dns1', 'dns2', 'dns3', 'dns4']: + val = safe_text(pf_sys, dns_tag) + if val: + child = ET.SubElement(op_sys, 'dnsserver') + child.text = val + pf_dns_added += 1 for dns in pf_sys.findall('dnsserver'): if dns.text: child = ET.SubElement(op_sys, 'dnsserver') child.text = dns.text + pf_dns_added += 1 # DNS gateway settings for gw in ['dns1gw', 'dns2gw', 'dns3gw', 'dns4gw']: @@ -216,6 +225,38 @@ def convert_system(pf_root, op_root): set_text(op_sys, 'nextgid', safe_text(pf_sys, 'nextgid')) +def _build_virtual_ifaces(pf_root): + """Build a set of virtual pseudo-interface names that should not be + locked in OPNsense (VLANs, WireGuard tunnels, etc.)""" + vif_set = set() + # VLANs: vlanif like vlan0.10, vlan1.20 + for vlan in pf_root.findall('.//vlans/vlan'): + vif = vlan.findtext('vlanif', '') + if vif: + vif_set.add(vif) + # WireGuard tunnels: tun_wgX + pf_wg = _find_wireguard(pf_root) + if pf_wg is not None: + tunnels = pf_wg.find('tunnels') + if tunnels is not None: + for item in tunnels.findall('item'): + name = item.findtext('name', '') + if name: + vif_set.add(name) + # OpenVPN tun/tap devices + pf_ovpn = pf_root.find('openvpn') + if pf_ovpn is not None: + for srv in pf_ovpn.findall('openvpn-server'): + dev = srv.findtext('dev', '') + if dev: + vif_set.add(dev) + for cli in pf_ovpn.findall('openvpn-client'): + dev = cli.findtext('dev', '') + if dev: + vif_set.add(dev) + return vif_set + + def convert_interfaces(pf_root, op_root): """Convert interface configuration.""" pf_ifaces = pf_root.find('interfaces') @@ -224,6 +265,11 @@ def convert_interfaces(pf_root, op_root): op_ifaces = ET.SubElement(op_root, 'interfaces') + # Collect VLAN device names from pfSense so we can skip + # locking them — locks interfere with OPNsense VLAN assignment + # for pseudo-interfaces that don't exist at boot time. + vlan_ifaces = _build_virtual_ifaces(pf_root) + for iface_tag in ['wan', 'lan', 'opt1', 'opt2', 'opt3', 'opt4', 'opt5', 'opt6', 'opt7', 'opt8', 'opt9', 'opt10', 'opt11', 'opt12', 'opt13', 'opt14', 'opt15']: @@ -249,26 +295,91 @@ def convert_interfaces(pf_root, op_root): # Apply interface name mapping to (e.g. tun_wg0 -> wg0) iface_elem = op_iface.find('if') + raw_iface_val = iface_elem.text if iface_elem is not None else '' if iface_elem is not None and iface_elem.text: iface_elem.text = _map_iface_in_text(iface_elem.text) + # Ensure 1 for interfaces that have a device + # assigned but no explicit element. OPNsense requires + # explicit enable to bring up the interface and apply its IP. + # This is critical for VLAN/OPT interfaces: pfSense often omits + # for implicitly-enabled interfaces, but OPNsense will + # not configure the IP without it. + if raw_iface_val and op_iface.find('enable') is None: + ET.SubElement(op_iface, 'enable').text = '1' + # 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' + # Skip locking for virtual pseudo-interfaces (VLANs, WireGuard + # tunnels, OpenVPN tunnels) — the lock prevents OPNsense from + # properly binding them since they don't exist as hardware + # devices at boot time. + # Use the original (pre-mapping) name to match pfSense VLAN vlanif. + if raw_iface_val not in vlan_ifaces: + lock_elem = op_iface.find('lock') + if lock_elem is None: + ET.SubElement(op_iface, 'lock').text = '1' + + +def _vlan_uuid(pif, tag): + """Generate a deterministic UUID for a VLAN entry.""" + return _gen_uuid(f'vlan-{pif}-{tag}') def convert_vlans(pf_root, op_root): - """Convert VLAN configuration.""" + """Convert VLAN configuration. + + OPNsense requires all VLAN interface names to start with 'vlan0.' + (e.g. vlan0.10, vlan0.20). pfSense uses an incrementing instance + number (vlan0.10, vlan1.20, vlan2.30). We normalize to vlan0.X + and add IFACE_MAP entries so all interface references are updated. + + OPNsense also uses for the parent interface (pfSense uses ), + requires a element, and expects a uuid attribute on each + entry for proper GUI editing (see OPNsense issue #6086). + """ 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) + op_vlan = copy_element_into(pf_vlan, op_vlans) + + pif = pf_vlan.findtext('pif', '') + tag = pf_vlan.findtext('tag', '') + + # Add uuid attribute — OPNsense model needs it for the API to + # look up individual VLAN items. Without it, the edit form in + # the GUI loads empty (issue #6086). + op_vlan.set('uuid', _vlan_uuid(pif, tag)) + + # Rename to — OPNsense model expects for parent + # interface, while pfSense uses . + pif_elem = op_vlan.find('pif') + if_elem = op_vlan.find('if') + if pif_elem is not None and if_elem is None: + # Promote to + if_elem = ET.SubElement(op_vlan, 'if') + if_elem.text = pif_elem.text + if_elem.tail = pif_elem.tail + op_vlan.remove(pif_elem) + + # Add default PCP if missing — OPNsense model requires it + if op_vlan.find('pcp') is None: + ET.SubElement(op_vlan, 'pcp').text = '0' + + vlanif_elem = op_vlan.find('vlanif') + tag_elem = op_vlan.find('tag') + if vlanif_elem is not None and vlanif_elem.text and tag_elem is not None and tag_elem.text: + old_name = vlanif_elem.text + # Normalize to vlan0.{tag} — OPNsense won't accept vlan1.X, vlan2.X, etc. + new_name = f'vlan0.{tag_elem.text}' + if old_name != new_name: + vlanif_elem.text = new_name + # Add mapping so all interface references are updated too + IFACE_MAP[old_name] = new_name def convert_dhcpd(pf_root, op_root): @@ -410,9 +521,10 @@ def convert_kea_dhcp(pf_root, op_root): 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. + # OPNsense Kea model uses InterfaceField which stores logical + # interface names (lan, opt2), not device names (igc1, vlan0.20). + # The model's getConfigPhysicalInterfaces() internally resolves + # these to the actual device names for the Kea config file. if tag not in dhcp_ifaces: dhcp_ifaces.append(tag) @@ -531,16 +643,66 @@ def convert_kea_dhcp(pf_root, op_root): def convert_aliases(pf_root, op_root): - """Convert firewall aliases.""" + """Convert firewall aliases to OPNsense format. + + OPNsense stores aliases in the MVC model at: + //OPNsense/Firewall/Alias -> + + Key field mappings (pfSense -> OPNsense): +
-> + -> + uuid attribute -> added (required by OPNsense model) + -> 1 (added if missing) + """ pf_aliases = pf_root.find('aliases') if pf_aliases is None: return - # Check if there's an section or individual 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) + # Root-level (legacy OPNsense parser compatibility) + op_aliases_root = ET.SubElement(op_root, 'aliases') + + # MVC format at + op_opnsense = op_root.find('OPNsense') + if op_opnsense is None: + op_opnsense = ET.SubElement(op_root, 'OPNsense') + op_firewall = op_opnsense.find('Firewall') + if op_firewall is None: + op_firewall = ET.SubElement(op_opnsense, 'Firewall') + op_firewall_alias = ET.SubElement(op_firewall, 'Alias') + op_aliases_mvc = ET.SubElement(op_firewall_alias, 'aliases') + + for pf_alias in pf_aliases.findall('alias'): + name = pf_alias.findtext('name', '') + auuid = _gen_uuid(f'alias-{name}') + + def _build_alias(parent): + op_alias = ET.SubElement(parent, 'alias') + op_alias.set('uuid', auuid) + + name_elem = pf_alias.find('name') + if name_elem is not None and name_elem.text: + ET.SubElement(op_alias, 'name').text = name_elem.text + + type_elem = pf_alias.find('type') + if type_elem is not None and type_elem.text: + ET.SubElement(op_alias, 'type').text = type_elem.text + + address = pf_alias.find('address') + if address is not None and address.text: + ET.SubElement(op_alias, 'content').text = address.text + + descr = pf_alias.find('descr') + if descr is not None and descr.text: + ET.SubElement(op_alias, 'description').text = descr.text + + detail = pf_alias.find('detail') + if detail is not None and detail.text: + ET.SubElement(op_alias, 'detail').text = detail.text + + ET.SubElement(op_alias, 'enabled').text = '1' + + _build_alias(op_aliases_root) + _build_alias(op_aliases_mvc) def convert_nat(pf_root, op_root): @@ -579,7 +741,14 @@ def convert_nat(pf_root, op_root): 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) + # OPNsense does NOT support pfSense's "hybrid" mode — it only + # supports "automatic", "manual", and "disabled". Map "hybrid" + # to "automatic" so that auto-generated rules are still created. + mode_el = pf_outbound.find('mode') + if mode_el is not None and mode_el.text == 'hybrid': + set_text(op_outbound, 'mode', 'automatic') + else: + copy_element_into(mode_el, op_outbound) for pf_orule in pf_outbound.findall('rule'): op_orule = ET.SubElement(op_outbound, 'rule') @@ -614,6 +783,28 @@ def convert_nat(pf_root, op_root): copy_element_into(pf_sep, op_nat) +def _build_iface_networks(pf_root): + """Build a map: interface tag (lan, opt2, etc.) -> subnet CIDR.""" + nets = {} + pf_ifaces = pf_root.find('interfaces') + if pf_ifaces is None: + return nets + for tag in ['wan', 'lan', 'opt1', 'opt2', 'opt3', 'opt4', 'opt5', + 'opt6', 'opt7', 'opt8', 'opt9', 'opt10']: + iface = pf_ifaces.find(tag) + if iface is None: + continue + ip = iface.findtext('ipaddr', '') + subnet = iface.findtext('subnet', '') + if ip and subnet and ip != 'pppoe' and ip != 'dhcp': + try: + intf = ipaddress.IPv4Interface(f'{ip}/{subnet}') + nets[tag] = str(intf.network) + except Exception: + pass + return nets + + def convert_filter(pf_root, op_root): """Convert firewall rules.""" pf_filter = pf_root.find('filter') @@ -622,6 +813,9 @@ def convert_filter(pf_root, op_root): op_filter = ET.SubElement(op_root, 'filter') + # Build interface-to-subnet map to resolve dynamic network references. + iface_networks = _build_iface_networks(pf_root) + for pf_rule in pf_filter.findall('rule'): op_rule = ET.SubElement(op_filter, 'rule') @@ -640,6 +834,23 @@ def convert_filter(pf_root, op_root): if iface_elem is not None and iface_elem.text: iface_elem.text = _map_iface_in_text(iface_elem.text) + # Resolve dynamic interface_name references + # in source and destination to explicit
cidr
+ # values. OPNsense can sometimes fail to resolve these dynamic + # references during config import, especially for VLAN interfaces + # whose IPs may not yet be assigned. + for scope_tag in ['source', 'destination']: + scope = op_rule.find(scope_tag) + if scope is None: + continue + net_elem = scope.find('network') + if net_elem is not None and net_elem.text: + ifname = net_elem.text + if ifname in iface_networks: + addr_elem = ET.SubElement(scope, 'address') + addr_elem.text = iface_networks[ifname] + scope.remove(net_elem) + for meta in ['updated', 'created']: pf_meta = pf_rule.find(meta) if pf_meta is not None: @@ -661,49 +872,554 @@ def convert_filter(pf_root, op_root): def convert_certificates(pf_root, op_root): """Convert Certificate Authorities and Certificates. - pfSense structure: - ...... - ...... - ...... + pfSense can store CAs/certs in different formats: + (a) Flat: xxx...... + (single CA, or multiple CAs as repeated root-level ) + (b) Nested: xxx... - OPNsense uses the same structure. + OPNsense expects: + + + ... + ... + + + where refid is an ATTRIBUTE, not a child element. """ for section_tag in ['ca', 'cert', 'crl']: - pf_section = pf_root.find(section_tag) - if pf_section is None: + # Find all matching elements at root level (handles both single + # and multiple CA/cert entries). + pf_entries = pf_root.findall(section_tag) + if not pf_entries: 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) + + for pf_entry in pf_entries: + # Check if already in OPNsense format: has children + # with refid attributes. + nested = pf_entry.find(section_tag) + if nested is not None: + # Already nested format — just copy children + for child in pf_entry: + copy_element_into(child, op_section) + continue + + # Flat format: is a child element. + # Need to promote it to an attribute on a wrapping element. + refid_elem = pf_entry.find('refid') + if refid_elem is not None and refid_elem.text: + refid = refid_elem.text + op_entry = ET.SubElement(op_section, section_tag) + op_entry.set('refid', refid) + for pf_child in pf_entry: + if pf_child.tag == 'refid': + continue + copy_element_into(pf_child, op_entry) + else: + # No refid found — copy children as-is + for pf_child in pf_entry: + copy_element_into(pf_child, op_section) + + +def _map_openvpn_mode_role(mode): + """Map pfSense OpenVPN mode to OPNsense role.""" + if mode in ('server_tls', 'p2p_tls', 'server_user', 'server_user_tls', + 'p2p_shared_key', 'server_tls_validate'): + return 'server' + elif mode in ('client_tls', 'client_user', 'client_user_tls', + 'p2p_tls_client', 'client_tls_validate'): + return 'client' + return 'server' + + +def _map_openvpn_protocol(proto): + """Map pfSense protocol to OPNsense proto.""" + m = { + 'UDP': 'udp', 'UDP4': 'udp4', 'UDP6': 'udp6', + 'TCP': 'tcp', 'TCP4': 'tcp4', 'TCP6': 'tcp6', + } + return m.get(proto, 'udp') + + +def _map_openvpn_dev_mode(dev): + """Map pfSense dev_mode to OPNsense dev_type.""" + return dev if dev in ('tun', 'tap', 'ovpn') else 'tun' + + +def _map_openvpn_verbosity(level): + """Map pfSense verbosity level string to OPNsense verb.""" + try: + v = int(level) + return str(max(0, min(11, v))) + except (ValueError, TypeError): + return '3' + + +def _pf_openvpn_ip_list(pf_srv, tag, count=4): + """Collect a list of IP addresses from pfSense openvpn N tags.""" + ips = [] + for i in range(1, count + 1): + val = safe_text(pf_srv, f'{tag}{i}') + if val: + ips.append(val) + return ips + + +def _pf_openvpn_network_list(pf_srv, tag): + """Collect a CIDR list from pfSense openvpn remote/local network tags.""" + networks = [] + for row in pf_srv.findall(tag): + val = safe_text(row, 'network') + if val: + networks.append(val) + return networks + + +def _openvpn_mode_to_authmode(mode, pf_srv): + """Infer OPNsense authmode from pfSense OpenVPN mode and config.""" + if mode in ('server_user', 'server_user_tls', 'client_user', 'client_user_tls'): + return 'Local Database' + pf_authmode = pf_srv.find('authmode') + if pf_authmode is not None and pf_authmode.text: + return pf_authmode.text + return None + + +def _openvpn_flags_from_pf(pf_srv): + """Collect various_flags boolean options from pfSense tags.""" + flags = [] + flag_map = { + 'passtos': 'passtos', + 'client_to_client': 'client-to-client', + 'duplicate_cn': 'duplicate-cn', + 'dynamic_ip': 'float', + } + for pf_tag, ovpn_flag in flag_map.items(): + val = safe_text(pf_srv, pf_tag) + if val and val in ('yes', '1'): + flags.append(ovpn_flag) + return flags def convert_openvpn(pf_root, op_root): - """Convert OpenVPN configuration.""" + """Convert OpenVPN configuration to OPNsense MVC format. + + OPNsense 24.1+ uses MVC model at for OpenVPN. + The legacy root-level format is still supported via the + os-openvpn-legacy plugin. + + pfSense stores: + + + 1 + server_tls + UDP + tun + wan + 1194 + ... + + ... + ... + + """ pf_ovpn = pf_root.find('openvpn') if pf_ovpn is None: return + # Legacy format (kept for os-openvpn-legacy plugin compatibility) op_ovpn = ET.SubElement(op_root, 'openvpn') - # OpenVPN servers + # OPNsense MVC format + op_opnsense = op_root.find('OPNsense') + if op_opnsense is None: + op_opnsense = ET.SubElement(op_root, 'OPNsense') + op_mvc = ET.SubElement(op_opnsense, 'OpenVPN') + + # --- Static Keys (collect first so instances can reference them) --- + # pfSense stores TLS key inline in each server/client. OPNsense MVC + # requires them as separate StaticKey objects with a UUID reference. + # We collect unique key+mode combos and assign deterministic UUIDs. + op_static_keys = ET.SubElement(op_mvc, 'StaticKeys') + tls_key_map = {} # (key_content, mode) -> uuid + + # --- Instances (servers + clients) --- + op_instances = ET.SubElement(op_mvc, 'Instances') + + # Process 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 + mode = safe_text(pf_srv, 'mode', 'server_tls') + role = _map_openvpn_mode_role(mode) + + # Build unique seed for deterministic instance UUID + vpnid = safe_text(pf_srv, 'vpnid', '1') + descr = safe_text(pf_srv, 'description', '') + certref = safe_text(pf_srv, 'certref', '') + iuuid = _gen_uuid(f'ovpn-instance-{vpnid}-{certref}-{descr}') + op_instance = ET.SubElement(op_instances, 'Instance') + op_instance.set('uuid', iuuid) + + # Basic fields + set_text(op_instance, 'vpnid', vpnid) + set_text(op_instance, 'enabled', '1') + set_text(op_instance, 'role', role) + set_text(op_instance, 'dev_type', + _map_openvpn_dev_mode(safe_text(pf_srv, 'dev_mode', 'tun'))) + set_text(op_instance, 'proto', + _map_openvpn_protocol(safe_text(pf_srv, 'protocol', 'UDP'))) + set_text(op_instance, 'port', safe_text(pf_srv, 'local_port')) + set_text(op_instance, 'description', descr) + set_text(op_instance, 'verb', + _map_openvpn_verbosity(safe_text(pf_srv, 'verbosity_level', '3'))) + ET.SubElement(op_instance, 'compression') + + # Topology + topology = safe_text(pf_srv, 'topology') + if topology: + set_text(op_instance, 'topology', topology) + + # Server tunnel network (CIDR) + tun_net = safe_text(pf_srv, 'tunnel_network') + if tun_net: + set_text(op_instance, 'server', tun_net) + tun_net6 = safe_text(pf_srv, 'tunnel_networkv6') + if tun_net6: + set_text(op_instance, 'server_ipv6', tun_net6) + + # Bridge config + bridge_gw = safe_text(pf_srv, 'server_bridge_gateway') + if bridge_gw: + set_text(op_instance, 'bridge_gateway', bridge_gw) + bridge_start = safe_text(pf_srv, 'server_bridge_dhcp_start') + bridge_end = safe_text(pf_srv, 'server_bridge_dhcp_end') + if bridge_start and bridge_end: + set_text(op_instance, 'bridge_pool', f'{bridge_start}-{bridge_end}') + + # Certificate references + if certref: + set_text(op_instance, 'cert', certref) + caref = safe_text(pf_srv, 'caref') + if caref: + set_text(op_instance, 'ca', caref) + + # TLS key -> StaticKeys + tls_text = safe_text(pf_srv, 'tls') + if tls_text: + tls_type = safe_text(pf_srv, 'tls_type', 'crypt') + key_tuple = (tls_text, tls_type) + if key_tuple not in tls_key_map: + sk_uuid = _gen_uuid(f'ovpn-tls-{tls_text[:32]}') + op_sk = ET.SubElement(op_static_keys, 'StaticKey') + op_sk.set('uuid', sk_uuid) + set_text(op_sk, 'mode', tls_type) + set_text(op_sk, 'key', tls_text) + set_text(op_sk, 'description', + f'Converted from {descr or f"VPN {vpnid}"}') + tls_key_map[key_tuple] = sk_uuid + set_text(op_instance, 'tls_key', tls_key_map[key_tuple]) + + # Routes + routes = _pf_openvpn_network_list(pf_srv, 'remote_network') + if routes: + set_text(op_instance, 'route', ','.join(routes)) + + # Push routes + push_routes = _pf_openvpn_network_list(pf_srv, 'local_network') + if push_routes: + set_text(op_instance, 'push_route', ','.join(push_routes)) + + # DNS + dns_servers = _pf_openvpn_ip_list(pf_srv, 'dns_server', 4) + if dns_servers: + set_text(op_instance, 'dns_servers', ','.join(dns_servers)) + dns_domain = safe_text(pf_srv, 'dns_domain') + if dns_domain: + set_text(op_instance, 'dns_domain', dns_domain) + + # NTP + ntp_servers = _pf_openvpn_ip_list(pf_srv, 'ntp_server', 2) + if ntp_servers: + set_text(op_instance, 'ntp_servers', ','.join(ntp_servers)) + + # Various flags + flags = _openvpn_flags_from_pf(pf_srv) + if flags: + set_text(op_instance, 'various_flags', ','.join(flags)) + + # Max clients + max_clients = safe_text(pf_srv, 'maxclients') + if max_clients: + set_text(op_instance, 'maxclients', max_clients) + + # Certificate depth + cert_depth = safe_text(pf_srv, 'cert_depth') + if cert_depth: + set_text(op_instance, 'cert_depth', cert_depth) + + # Remote cert TLS + remote_cert_tls = safe_text(pf_srv, 'remote_cert_tls') + if remote_cert_tls and remote_cert_tls in ('yes', '1'): + set_text(op_instance, 'remote_cert_tls', '1') + + # OCSP + use_ocsp = safe_text(pf_srv, 'use_ocsp') + if use_ocsp and use_ocsp in ('yes', '1'): + set_text(op_instance, 'use_ocsp', '1') + + # Keepalive + ka_interval = safe_text(pf_srv, 'keepalive_interval') + if ka_interval: + set_text(op_instance, 'keepalive_interval', ka_interval) + ka_timeout = safe_text(pf_srv, 'keepalive_timeout') + if ka_timeout: + set_text(op_instance, 'keepalive_timeout', ka_timeout) + + # Renegotiation + reneg = safe_text(pf_srv, 'renegotiate_seconds') + if not reneg: + reneg = safe_text(pf_srv, 'reneg-sec') + if reneg: + set_text(op_instance, 'reneg-sec', reneg) + + # Data ciphers + ciphers = safe_text(pf_srv, 'data_ciphers') + if ciphers: + set_text(op_instance, 'data-ciphers', ciphers) + fallback = safe_text(pf_srv, 'data_ciphers_fallback') + if fallback: + set_text(op_instance, 'data-ciphers-fallback', fallback) + + # Auth + auth = safe_text(pf_srv, 'auth') + if auth: + set_text(op_instance, 'auth', auth) + + # HTTP proxy + proxy_addr = safe_text(pf_srv, 'proxy_addr') + proxy_port = safe_text(pf_srv, 'proxy_port') + if proxy_addr and proxy_port: + set_text(op_instance, 'http-proxy', f'{proxy_addr}:{proxy_port}') + + # Auth mode / username-as-common-name + local_auth = safe_text(pf_srv, 'local_auth') + if local_auth and local_auth in ('yes', '1'): + set_text(op_instance, 'username_as_common_name', '1') + + # Various push flags + push_flags = [] + block_outside = safe_text(pf_srv, 'block_outside_dns') + if block_outside and block_outside in ('yes', '1'): + push_flags.append('block-outside-dns') + + # Register DNS + register_dns = safe_text(pf_srv, 'register_dns') + if register_dns and register_dns in ('yes', '1'): + push_flags.append('register-dns') + + if push_flags: + set_text(op_instance, 'various_push_flags', ','.join(push_flags)) + + # Send/Receive buffers + sndbuf = safe_text(pf_srv, 'sndbuf') + if sndbuf: + set_text(op_instance, 'tun_mtu', sndbuf) + # Note: OPNsense only has tun_mtu for buffer sizes + + # Compression + comp = safe_text(pf_srv, 'compression') + if comp: + set_text(op_instance, 'compress_migrate', '1' if comp in ('yes', '1') else '0') + + # MSS fix + mssfix = safe_text(pf_srv, 'mssfix') + if mssfix: + set_text(op_instance, 'mssfix', '1' if mssfix in ('yes', '1') else '0') + + # Route metric + route_metric = safe_text(pf_srv, 'route_metric') + if route_metric: + set_text(op_instance, 'route_metric', route_metric) + + # Redirect gateway + redir_gw = safe_text(pf_srv, 'gw_redir') + if redir_gw: + set_text(op_instance, 'redirect_gateway', redir_gw) + + # Process 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'): + mode = safe_text(pf_cli, 'mode', 'client_tls') + role = _map_openvpn_mode_role(mode) + + vpnid = safe_text(pf_cli, 'vpnid', '1') + descr = safe_text(pf_cli, 'description', '') + certref = safe_text(pf_cli, 'certref', '') + iuuid = _gen_uuid(f'ovpn-instance-{vpnid}-{certref}-{descr}') + op_instance = ET.SubElement(op_instances, 'Instance') + op_instance.set('uuid', iuuid) + + set_text(op_instance, 'vpnid', vpnid) + set_text(op_instance, 'enabled', '1') + set_text(op_instance, 'role', role) + set_text(op_instance, 'dev_type', + _map_openvpn_dev_mode(safe_text(pf_cli, 'dev_mode', 'tun'))) + set_text(op_instance, 'proto', + _map_openvpn_protocol(safe_text(pf_cli, 'protocol', 'UDP'))) + set_text(op_instance, 'port', safe_text(pf_cli, 'local_port')) + set_text(op_instance, 'description', descr) + set_text(op_instance, 'verb', + _map_openvpn_verbosity(safe_text(pf_cli, 'verbosity_level', '3'))) + ET.SubElement(op_instance, 'compression') + + # Server address/port for client mode + server_addr = safe_text(pf_cli, 'server_addr') + if server_addr: + server_port = safe_text(pf_cli, 'server_port', '1194') + op_remote = ET.SubElement(op_instance, 'remote') + set_text(op_remote, 'host', server_addr) + set_text(op_remote, 'port', server_port) + + # Resolve interface name to IP for 'local' + iface_name = safe_text(pf_cli, 'interface') + if iface_name: + set_text(op_instance, 'local', iface_name) + + # Certificate references + if certref: + set_text(op_instance, 'cert', certref) + caref = safe_text(pf_cli, 'caref') + if caref: + set_text(op_instance, 'ca', caref) + + # TLS key + tls_text = safe_text(pf_cli, 'tls') + if tls_text: + tls_type = safe_text(pf_cli, 'tls_type', 'crypt') + key_tuple = (tls_text, tls_type) + if key_tuple not in tls_key_map: + sk_uuid = _gen_uuid(f'ovpn-tls-{tls_text[:32]}') + op_sk = ET.SubElement(op_static_keys, 'StaticKey') + op_sk.set('uuid', sk_uuid) + set_text(op_sk, 'mode', tls_type) + set_text(op_sk, 'key', tls_text) + set_text(op_sk, 'description', + f'Converted from {descr or f"VPN {vpnid}"}') + tls_key_map[key_tuple] = sk_uuid + set_text(op_instance, 'tls_key', tls_key_map[key_tuple]) + + # Client-specific: tunnel network for client + tun_net = safe_text(pf_cli, 'tunnel_network') + if tun_net: + # Client uses the tunnel network as its local tunnel address + set_text(op_instance, 'server', tun_net) + + # DNS + dns_servers = _pf_openvpn_ip_list(pf_cli, 'dns_server', 4) + if dns_servers: + set_text(op_instance, 'dns_servers', ','.join(dns_servers)) + dns_domain = safe_text(pf_cli, 'dns_domain') + if dns_domain: + set_text(op_instance, 'dns_domain', dns_domain) + + # Keepalive + ka_interval = safe_text(pf_cli, 'keepalive_interval') + if ka_interval: + set_text(op_instance, 'keepalive_interval', ka_interval) + ka_timeout = safe_text(pf_cli, 'keepalive_timeout') + if ka_timeout: + set_text(op_instance, 'keepalive_timeout', ka_timeout) + + # Auth + auth = safe_text(pf_cli, 'auth') + if auth: + set_text(op_instance, 'auth', auth) + + # Data ciphers + ciphers = safe_text(pf_cli, 'data_ciphers') + if ciphers: + set_text(op_instance, 'data-ciphers', ciphers) + fallback = safe_text(pf_cli, 'data_ciphers_fallback') + if fallback: + set_text(op_instance, 'data-ciphers-fallback', fallback) + + # Topology + topology = safe_text(pf_cli, 'topology') + if topology: + set_text(op_instance, 'topology', topology) + + # Proxy + proxy_addr = safe_text(pf_cli, 'proxy_addr') + proxy_port = safe_text(pf_cli, 'proxy_port') + if proxy_addr and proxy_port: + set_text(op_instance, 'http-proxy', f'{proxy_addr}:{proxy_port}') + + # --- Client-Specific Overrides --- + op_overwrites = ET.SubElement(op_mvc, 'Overwrites') + for pf_cso in pf_ovpn.findall('openvpn-csc'): op_cso = ET.SubElement(op_ovpn, 'openvpn-cso') for child in pf_cso: copy_element_into(child, op_cso) + common_name = safe_text(pf_cso, 'common_name', '') + server_list = safe_text(pf_cso, 'server_list', '') + ouuid = _gen_uuid(f'ovpn-overwrite-{common_name}-{server_list}') + op_overwrite = ET.SubElement(op_overwrites, 'Overwrite') + op_overwrite.set('uuid', ouuid) + + set_text(op_overwrite, 'enabled', '1') + set_text(op_overwrite, 'common_name', common_name) + + if server_list: + set_text(op_overwrite, 'servers', server_list) + + # Tunnel network + tun_net = safe_text(pf_cso, 'tunnel_network') + if tun_net: + set_text(op_overwrite, 'tunnel_network', tun_net) + tun_net6 = safe_text(pf_cso, 'tunnel_networkv6') + if tun_net6: + set_text(op_overwrite, 'tunnel_networkv6', tun_net6) + + # Local/remote networks + local_networks = [] + for row in pf_cso.findall('local_network'): + val = safe_text(row, 'network') + if val: + local_networks.append(val) + if local_networks: + set_text(op_overwrite, 'local_networks', ','.join(local_networks)) + + remote_networks = [] + for row in pf_cso.findall('remote_network'): + val = safe_text(row, 'network') + if val: + remote_networks.append(val) + if remote_networks: + set_text(op_overwrite, 'remote_networks', ','.join(remote_networks)) + + # Redirect gateway + redir_gw = safe_text(pf_cso, 'gw_redir') + if redir_gw: + set_text(op_overwrite, 'redirect_gateway', redir_gw) + + # DNS + dns_servers = _pf_openvpn_ip_list(pf_cso, 'dns_server', 4) + if dns_servers: + set_text(op_overwrite, 'dns_servers', ','.join(dns_servers)) + dns_domain = safe_text(pf_cso, 'dns_domain') + if dns_domain: + set_text(op_overwrite, 'dns_domain', dns_domain) + + set_text(op_overwrite, 'description', safe_text(pf_cso, 'description')) + def convert_ipsec(pf_root, op_root): """Convert IPsec configuration.""" @@ -787,19 +1503,22 @@ def convert_wireguard(pf_root, op_root): OPNsense stores under (nested, mount: OPNsense/wireguard): 1 - - 1 - wg0 - 0 - ... - ... - 51820 - 10.0.9.254/24 - uuid1,uuid2 - 1420 - 0 - - + + + 1 + wg0 + 0 + ... + ... + 51820 + 10.0.9.254/24 + uuid1,uuid2 + 1420 + 0 + 0 + + + 1 @@ -839,28 +1558,34 @@ def convert_wireguard(pf_root, op_root): peer_data.append((pf_item, puuid)) # Servers (tunnels) section + # Each server entry uses its UUID as the XML element name (same pattern + # as clients), placed directly under . 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')): + raw_name = safe_text(pf_item, 'name', '') + mapped_name = _map_iface_in_text(raw_name) if raw_name else '' + pubkey = safe_text(pf_item, 'publickey', '') + suuid = _gen_uuid(f'wg-server-{pubkey or mapped_name or instance_idx}') + op_server = ET.SubElement(op_servers, 'server') + op_server.set('uuid', suuid) 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, 'name', _sanitize_name(mapped_name)) set_text(op_server, 'instance', str(instance_idx)) - set_text(op_server, 'pubkey', safe_text(pf_item, 'publickey')) + set_text(op_server, 'pubkey', pubkey) 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') + set_text(op_server, 'debug', '0') ET.SubElement(op_server, 'gateway') # Tunnel addresses as comma-separated CIDR @@ -941,17 +1666,22 @@ def convert_static_routes(pf_root, op_root): def convert_gateways(pf_root, op_root): - """Convert gateway configuration.""" + """Convert gateway configuration. + + OPNsense uses separate defaultgw4/defaultgw6 elements, while + pfSense uses a single defaultgw element. Convert accordingly. + """ pf_gws = pf_root.find('gateways') if pf_gws is None: return op_gws = ET.SubElement(op_root, 'gateways') - # Default gateway + # Default gateway — OPNsense uses defaultgw4/defaultgw6 pf_dgw = pf_gws.find('defaultgw') - if pf_dgw is not None: - copy_element_into(pf_dgw, op_gws) + if pf_dgw is not None and pf_dgw.text: + set_text(op_gws, 'defaultgw4', pf_dgw.text) + ET.SubElement(op_gws, 'defaultgw6') # Gateway entries for pf_gw in pf_gws.findall('gateway_item'): @@ -962,21 +1692,269 @@ def convert_gateways(pf_root, op_root): 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) +def _convert_dns_host_override(pf_host, op_hosts, host_uuids): + """Convert one pfSense dnsmasq/unbound host override to OPNsense format. - # unbound + Returns (uuid, alias_list) where alias_list is a list of (hostname, domain) tuples. + """ + hostname = safe_text(pf_host, 'host', '') + domain = safe_text(pf_host, 'domain', '') + ip = safe_text(pf_host, 'ip', '') + descr = safe_text(pf_host, 'descr', '') + + if not hostname and not domain: + return None, None + + seed = f'unbound-host-{hostname}-{domain}-{ip}' + huuid = _gen_uuid(seed) + + op_host = ET.SubElement(op_hosts, 'host') + op_host.set('uuid', huuid) + ET.SubElement(op_host, 'enabled').text = '1' + + if hostname: + ET.SubElement(op_host, 'hostname').text = hostname + if domain: + ET.SubElement(op_host, 'domain').text = domain + if ip: + # Determine RR type from IP + if ':' in ip: + ET.SubElement(op_host, 'rr').text = 'AAAA' + else: + ET.SubElement(op_host, 'rr').text = 'A' + ET.SubElement(op_host, 'server').text = ip + ET.SubElement(op_host, 'addptr').text = '0' + if descr: + ET.SubElement(op_host, 'description').text = descr + + # Extract inline aliases from pfSense format + aliases = [] + pf_aliases = pf_host.find('aliases') + if pf_aliases is not None: + for pf_item in pf_aliases.findall('item'): + a_host = safe_text(pf_item, 'host', '') + a_domain = safe_text(pf_item, 'domain', '') + if a_host or a_domain: + aliases.append((a_host, a_domain)) + + return huuid, aliases + + +def convert_dns(pf_root, op_root): + """Convert pfSense dnsmasq config to OPNsense unbound. + + pfSense stores DNS config in and sections. + OPNsense uses (legacy root-level) or (MVC). + We convert dnsmasq config to unbound format since OPNsense only runs unbound. + """ + pf_dnsmasq = pf_root.find('dnsmasq') pf_unbound = pf_root.find('unbound') + if pf_dnsmasq is None and pf_unbound is None: + return + + # Root-level section (works in both legacy and modern OPNsense) + op_unbound = ET.SubElement(op_root, 'unbound') + + # Also produce MVC format under + op_opnsense = op_root.find('OPNsense') + if op_opnsense is None: + op_opnsense = ET.SubElement(op_root, 'OPNsense') + op_mvc = ET.SubElement(op_opnsense, 'unboundplus') + op_mvc_general = ET.SubElement(op_mvc, 'general') + + # Which section to read from: prefer dnsmasq if present (pfSense uses + # dnsmasq as primary DNS forwarder by default), else fall back to unbound. + pf_src = pf_dnsmasq if pf_dnsmasq is not None else pf_unbound + + # --- General settings --- + + def _pf_bool(src, tag): + """Check pfSense boolean: True if tag exists (self-closing or with truthy text).""" + el = src.find(tag) + if el is None: + return False + return el.text is None or el.text in ('1', 'yes') + + # Enable: pfSense or 1 -> 1 + if _pf_bool(pf_src, 'enable'): + set_text(op_unbound, 'enabled', '1') + set_text(op_mvc_general, 'enabled', '1') + else: + set_text(op_unbound, 'enabled', '0') + set_text(op_mvc_general, 'enabled', '0') + + # Port + port = safe_text(pf_src, 'port', '53') + set_text(op_unbound, 'port', port) + set_text(op_mvc_general, 'port', port) + + # Interface: pfSense lan -> lan + iface = safe_text(pf_src, 'interface', '') + if iface: + set_text(op_unbound, 'active_interface', iface) + set_text(op_mvc_general, 'active_interface', iface) + + # Register DHCP leases + if _pf_bool(pf_src, 'regdhcp'): + set_text(op_unbound, 'regdhcp', '1') + set_text(op_mvc_general, 'regdhcp', '1') + + # Register DHCP static mappings + if _pf_bool(pf_src, 'regdhcpstatic'): + set_text(op_unbound, 'regdhcpstatic', '1') + set_text(op_mvc_general, 'regdhcpstatic', '1') + + # Domain + domain = safe_text(pf_src, 'domain', '') + if domain: + set_text(op_unbound, 'domain', domain) + set_text(op_mvc_general, 'regdhcpdomain', domain) + + # DNSSEC: default to on in OPNsense + set_text(op_unbound, 'dnssec', '1') + set_text(op_mvc_general, 'dnssec', '1') + + # Local zone type: domainneeded -> deny (approximate mapping) + if _pf_bool(pf_src, 'domainneeded'): + set_text(op_unbound, 'local_zone_type', 'deny') + set_text(op_mvc_general, 'local_zone_type', 'deny') + else: + set_text(op_mvc_general, 'local_zone_type', 'transparent') + + # Hide version/identity (OPNsense security defaults) + set_text(op_unbound, 'hideidentity', '1') + set_text(op_unbound, 'hideversion', '1') + + # Custom options + custom = safe_text(pf_src, 'custom_options', '') + if custom: + ET.SubElement(op_unbound, 'custom_options').text = custom + + # --- Host overrides --- + # pfSense dnsmasq stores hosts in + # Also check if dnsmasq is absent. + op_hosts = ET.SubElement(op_unbound, 'hosts') + op_mvc_hosts = ET.SubElement(op_mvc, 'hosts') + op_mvc_aliases = ET.SubElement(op_mvc, 'aliases') + + all_aliases = [] # list of (parent_uuid, hostname, domain) + + for pf_host_src in [pf_dnsmasq, pf_unbound]: + if pf_host_src is None: + continue + pf_hosts = pf_host_src.find('hosts') + if pf_hosts is None: + continue + for pf_host in pf_hosts.findall('host'): + huuid, aliases = _convert_dns_host_override(pf_host, op_hosts, {}) + if huuid is None: + continue + # Add to MVC as well + mvc_host = ET.SubElement(op_mvc_hosts, 'host') + mvc_host.set('uuid', huuid) + for child in op_hosts.findall(f'host[@uuid="{huuid}"]')[0]: + copy_element_into(child, mvc_host) + + for a_hostname, a_domain in (aliases or []): + all_aliases.append((huuid, a_hostname, a_domain)) + + # Create MVC host alias entries referencing parent host UUIDs + for parent_uuid, a_hostname, a_domain in all_aliases: + auuid = _gen_uuid(f'unbound-hostalias-{parent_uuid}-{a_hostname}-{a_domain}') + op_alias = ET.SubElement(op_mvc_aliases, 'alias') + op_alias.set('uuid', auuid) + ET.SubElement(op_alias, 'enabled').text = '1' + if parent_uuid: + ET.SubElement(op_alias, 'host').text = parent_uuid + if a_hostname: + ET.SubElement(op_alias, 'hostname').text = a_hostname + if a_domain: + ET.SubElement(op_alias, 'domain').text = a_domain + + # --- Forwarding (upstream DNS) --- + # In OPNsense, Unbound forwarding forwards all queries to upstream + # DNS servers. pfSense dnsmasq always forwards to upstream servers. + # Enable forwarding and add system DNS servers as dot entries. + op_mvc_forwarding = ET.SubElement(op_mvc, 'forwarding') + ET.SubElement(op_mvc_forwarding, 'enabled').text = '1' + + # Collect upstream DNS servers from system section + upstream_dns = [] + pf_sys = pf_root.find('system') + if pf_sys is not None: + for dns_tag in ['dns1', 'dns2', 'dns3', 'dns4']: + val = safe_text(pf_sys, dns_tag) + if val: + upstream_dns.append(val) + for dns in pf_sys.findall('dnsserver'): + if dns.text: + upstream_dns.append(dns.text) + + # MVC dots section for forwarding entries + op_mvc_dots = ET.SubElement(op_mvc, 'dots') + + # Add system DNS servers as upstream forwarders (domain = ".") + for idx, dns_ip in enumerate(upstream_dns): + duuid = _gen_uuid(f'unbound-upstream-{dns_ip}') + op_dot = ET.SubElement(op_mvc_dots, 'dot') + op_dot.set('uuid', duuid) + ET.SubElement(op_dot, 'enabled').text = '1' + ET.SubElement(op_dot, 'type').text = 'forward' + ET.SubElement(op_dot, 'domain').text = '.' + ET.SubElement(op_dot, 'server').text = dns_ip + ET.SubElement(op_dot, 'description').text = f'Upstream DNS {idx + 1}' + + # --- Domain overrides --- + # Root-level (legacy format) + op_domain_overrides = ET.SubElement(op_unbound, 'domainoverrides') + + for pf_do_src in [pf_dnsmasq, pf_unbound]: + if pf_do_src is None: + continue + pf_dos = pf_do_src.find('domainoverrides') + if pf_dos is None: + continue + for pf_entry in pf_dos.findall('entry'): + do_domain = safe_text(pf_entry, 'domain', '') + do_ip = safe_text(pf_entry, 'ip', '') + do_descr = safe_text(pf_entry, 'descr', '') + if not do_domain: + continue + douuid = _gen_uuid(f'unbound-domainoverride-{do_domain}-{do_ip}') + op_do = ET.SubElement(op_domain_overrides, 'domainoverride') + op_do.set('uuid', douuid) + ET.SubElement(op_do, 'enabled').text = '1' + ET.SubElement(op_do, 'domain').text = do_domain + if do_ip: + ET.SubElement(op_do, 'server').text = do_ip + if do_descr: + ET.SubElement(op_do, 'description').text = do_descr + + # Also add as MVC dot entry for the domain override + douuid2 = _gen_uuid(f'unbound-domain-dot-{do_domain}-{do_ip}') + op_dot = ET.SubElement(op_mvc_dots, 'dot') + op_dot.set('uuid', douuid2) + ET.SubElement(op_dot, 'enabled').text = '1' + ET.SubElement(op_dot, 'type').text = 'forward' + ET.SubElement(op_dot, 'domain').text = do_domain + if do_ip: + ET.SubElement(op_dot, 'server').text = do_ip + if do_descr: + ET.SubElement(op_dot, 'description').text = do_descr + + # --- Copy pfSense unbound config as-is for additional settings --- + # If pfSense had an section, merge any remaining fields + # that weren't covered by the dnsmasq conversion above. if pf_unbound is not None: - op_unbound = ET.SubElement(op_root, 'unbound') - for child in pf_unbound: - copy_element_into(child, op_unbound) + # Copy tags not already set + for pf_child in pf_unbound: + tag = pf_child.tag + if tag in ('enable', 'port', 'interface', 'regdhcp', 'regdhcpstatic', + 'domain', 'hosts', 'domainoverrides', 'custom_options', + 'dnssec', 'active_interface'): + continue + if op_unbound.find(tag) is None: + copy_element_into(pf_child, op_unbound) def convert_snmp(pf_root, op_root): @@ -1274,7 +2252,7 @@ def _post_process_iface_map(op_root): if not IFACE_MAP: return # Tags that can contain interface device names or references - iface_tags = {'if', 'interface', 'tun', 'name', 'bridgeif', 'member', 'tunnel'} + iface_tags = {'if', 'interface', 'interfaces', 'tun', 'name', 'bridgeif', 'member', 'tunnel', 'pif'} for elem in op_root.iter(): if elem.tag in iface_tags and elem.text: elem.text = _map_iface_in_text(elem.text)