diff --git a/data/configd-include.json b/data/configd-include.json index 28267d575..d25fa3de7 100644 --- a/data/configd-include.json +++ b/data/configd-include.json @@ -1,75 +1,76 @@ [ "bcast_relay.py", "conntrack.py", "conntrack_sync.py", "dhcp_relay.py", "dhcpv6_relay.py", "dns_forwarding.py", "dynamic_dns.py", "firewall_options.py", "host_name.py", "https.py", "igmp_proxy.py", "intel_qat.py", "interfaces-bonding.py", "interfaces-bridge.py", "interfaces-dummy.py", "interfaces-ethernet.py", "interfaces-geneve.py", "interfaces-l2tpv3.py", "interfaces-loopback.py", "interfaces-macsec.py", "interfaces-openvpn.py", "interfaces-pppoe.py", "interfaces-pseudo-ethernet.py", "interfaces-tunnel.py", "interfaces-vxlan.py", "interfaces-wireguard.py", "interfaces-wireless.py", "interfaces-wirelessmodem.py", "ipsec-settings.py", "lldp.py", "nat.py", "nat66.py", "ntp.py", "policy.py", "policy-local-route.py", "protocols_bfd.py", "protocols_bgp.py", "protocols_igmp.py", "protocols_isis.py", "protocols_mpls.py", +"protocols_nhrp.py", "protocols_ospf.py", "protocols_ospfv3.py", "protocols_pim.py", "protocols_rip.py", "protocols_ripng.py", "protocols_static.py", "protocols_static_multicast.py", "salt-minion.py", "service_console-server.py", "service_ids_fastnetmon.py", "service_ipoe-server.py", "service_mdns-repeater.py", "service_pppoe-server.py", "service_router-advert.py", "ssh.py", "system-ip.py", "system-ipv6.py", "system-login-banner.py", "system-option.py", "system-syslog.py", "system-timezone.py", "system_console.py", "system_lcd.py", "task_scheduler.py", "tftp_server.py", "vpn_ipsec.py", "vpn_l2tp.py", "vpn_pptp.py", "vpn_rsa-keys.py", "vpn_sstp.py", "vrf.py", "vrrp.py", "vyos_cert.py" ] diff --git a/data/templates/nhrp/opennhrp.conf.tmpl b/data/templates/nhrp/opennhrp.conf.tmpl new file mode 100644 index 000000000..308459407 --- /dev/null +++ b/data/templates/nhrp/opennhrp.conf.tmpl @@ -0,0 +1,41 @@ +# Created by VyOS - manual changes will be overwritten + +{% if tunnel is defined %} +{% for name, tunnel_conf in tunnel.items() %} +{% set type = 'spoke' if 'map' in tunnel_conf or 'dynamic_map' in tunnel_conf else 'hub' %} +{% set profile_name = profile_map[name] if profile_map is defined and name in profile_map else '' %} +interface {{ name }} #{{ type }} {{ profile_name }} +{% if 'map' in tunnel_conf %} +{% for map, map_conf in tunnel_conf.map.items() %} +{% set cisco = ' cisco' if 'cisco' in map_conf else '' %} +{% set register = ' register' if 'register' in map_conf else '' %} + map {{ map }} {{ map_conf.nbma_address }}{{ register }}{{ cisco }} +{% endfor %} +{% endif %} +{% if 'dynamic_map' in tunnel_conf %} +{% for map, map_conf in tunnel_conf.dynamic_map.items() %} + dynamic-map {{ map }} {{ map_conf.nbma_domain_name }} +{% endfor %} +{% endif %} +{% if 'cisco_authentication' in tunnel_conf %} + cisco-authentication {{ tunnel_conf.cisco_authentication }} +{% endif %} +{% if 'holding_time' in tunnel_conf %} + holding-time {{ tunnel_conf.holding_time }} +{% endif %} +{% if 'multicast' in tunnel_conf %} + multicast {{ tunnel_conf.multicast }} +{% endif %} +{% for key in ['non_caching', 'redirect', 'shortcut', 'shortcut_destination'] %} +{% if key in tunnel_conf %} + {{ key | replace("_", "-") }} +{% endif %} +{% endfor %} +{% if 'shortcut_target' in tunnel_conf %} +{% for target, shortcut_conf in tunnel_conf.shortcut_target.items() %} + shortcut-target {{ target }} {{ shortcut_conf.holding_time if 'holding_time' in shortcut_conf else '' }} +{% endfor %} +{% endif %} + +{% endfor %} +{% endif %} diff --git a/debian/control b/debian/control index 105c4dfca..bbe8200ca 100644 --- a/debian/control +++ b/debian/control @@ -1,168 +1,169 @@ Source: vyos-1x Section: contrib/net Priority: extra Maintainer: VyOS Package Maintainers <maintainers@vyos.net> Build-Depends: debhelper (>= 9), fakeroot, gcc-multilib [amd64], clang [amd64], llvm [amd64], libelf-dev (>= 0.2) [amd64], libpcap-dev [amd64], build-essential, libvyosconfig0 (>= 0.0.7), libzmq3-dev, python3, python3-coverage, python3-lxml, python3-netifaces, python3-nose, python3-jinja2, python3-psutil, python3-setuptools, python3-sphinx, python3-xmltodict, quilt, whois Standards-Version: 3.9.6 Package: vyos-1x Architecture: amd64 arm64 Depends: accel-ppp, beep, bmon, bsdmainutils, conntrack, conntrackd, conserver-client, conserver-server, console-data, crda, cron, dbus, ddclient (>= 3.9.1), dropbear, easy-rsa, etherwake, fastnetmon, file, frr (>= 7.5), frr-pythontools, frr-rpki-rtrlib, frr-snmp, grc, hostapd (>= 0.6.8), hvinfo, igmpproxy, ipaddrcheck, iperf, iperf3, iputils-arping, isc-dhcp-client, isc-dhcp-relay, isc-dhcp-server, iw, keepalived (>=2.0.5), lcdproc, libatomic1, libndp-tools, libnetfilter-conntrack3, libnfnetlink0, libpam-radius-auth (>= 1.5.0), libstrongswan-standard-plugins (>=5.8), libstrongswan-extra-plugins (>=5.8), libcharon-extra-plugins (>=5.8), libvyosconfig0, lldpd, lm-sensors, lsscsi, mdns-repeater, minisign, mtr-tiny, netplug, nftables (>= 0.9.3), nginx-light, ntp, ntpdate, nvme-cli, ocserv, openssh-server, openssl, openvpn, openvpn-auth-ldap, openvpn-auth-radius, pciutils, pdns-recursor, pmacct (>= 1.6.0), podman, pppoe, procps, python3, python3-certbot-nginx, python3-crypto, ${python3:Depends}, python3-flask, python3-hurry.filesize, python3-isc-dhcp-leases, python3-jinja2, python3-jmespath, python3-netaddr, python3-netifaces, python3-paramiko, python3-psutil, python3-pystache, python3-pyudev, python3-six, python3-tabulate, python3-vici (>= 5.7.2), python3-voluptuous, python3-waitress, python3-xmltodict, python3-zmq, qrencode, radvd, salt-minion, smartmontools, snmp, snmpd, squid, squidclient, squidguard, ssl-cert, strongswan (>= 5.8), strongswan-swanctl (>= 5.8), systemd, tcpdump, tcptraceroute, telnet, tftpd-hpa, traceroute, tuned, udp-broadcast-relay, usb-modeswitch, usbutils, + vyos-opennhrp, vyos-http-api-tools, vyos-utils, wide-dhcpv6-client, wireguard-tools, wireless-regdb, wpasupplicant (>= 0.6.7), ndppd Description: VyOS configuration scripts and data VyOS configuration scripts, interface definitions, and everything Package: vyos-1x-vmware Architecture: amd64 Depends: vyos-1x, open-vm-tools Description: VyOS configuration scripts and data for VMware Adds configuration files required for VyOS running on VMware hosts. Package: vyos-1x-smoketest Architecture: all Depends: vyos-1x Description: VyOS build sanity checking toolkit diff --git a/debian/vyos-1x.install b/debian/vyos-1x.install index e5de7f074..0c6c226ee 100644 --- a/debian/vyos-1x.install +++ b/debian/vyos-1x.install @@ -1,27 +1,28 @@ etc/dhcp etc/ipsec.d etc/netplug +etc/opennhrp etc/ppp etc/rsyslog.d etc/systemd etc/sysctl.d etc/udev etc/vyos lib/ opt/ usr/sbin usr/bin/initial-setup usr/bin/vyos-config-file-query usr/bin/vyos-config-to-commands usr/bin/vyos-config-to-json usr/bin/vyos-hostsd-client usr/lib usr/libexec/vyos/completion usr/libexec/vyos/conf_mode usr/libexec/vyos/op_mode usr/libexec/vyos/services usr/libexec/vyos/system usr/libexec/vyos/validators usr/libexec/vyos/*.py usr/libexec/vyos/*.sh usr/share diff --git a/interface-definitions/protocols-nhrp.xml.in b/interface-definitions/protocols-nhrp.xml.in new file mode 100644 index 000000000..9dd9d3389 --- /dev/null +++ b/interface-definitions/protocols-nhrp.xml.in @@ -0,0 +1,134 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interfaceDefinition> + <node name="protocols"> + <children> + <node name="nhrp" owner="${vyos_conf_scripts_dir}/protocols_nhrp.py"> + <properties> + <help>NHRP parameters</help> + <priority>680</priority> + </properties> + <children> + <tagNode name="tunnel"> + <properties> + <help>Tunnel for NHRP [REQUIRED]</help> + <constraint> + <regex>^tun[0-9]+$</regex> + </constraint> + <valueHelp> + <format>tunN</format> + <description>NHRP tunnel name</description> + </valueHelp> + </properties> + <children> + <leafNode name="cisco-authentication"> + <properties> + <help>Pass phrase for cisco authentication</help> + <valueHelp> + <format>txt</format> + <description>Pass phrase for cisco authentication</description> + </valueHelp> + </properties> + </leafNode> + <tagNode name="dynamic-map"> + <properties> + <help>Set an HUB tunnel address</help> + <valueHelp> + <format>ipv4net</format> + <description>Set the IP address and prefix length</description> + </valueHelp> + </properties> + <children> + <leafNode name="nbma-domain-name"> + <properties> + <help>Set HUB fqdn (nbma-address - fqdn) [REQUIRED]</help> + <valueHelp> + <format><fqdn></format> + <description>Set the external HUB fqdn</description> + </valueHelp> + </properties> + </leafNode> + </children> + </tagNode> + <leafNode name="holding-time"> + <properties> + <help>Holding time in seconds</help> + </properties> + </leafNode> + <tagNode name="map"> + <properties> + <help>Set an HUB tunnel address</help> + </properties> + <children> + <leafNode name="cisco"> + <properties> + <help>If the statically mapped peer is running Cisco IOS, specify this</help> + <valueless/> + </properties> + </leafNode> + <leafNode name="nbma-address"> + <properties> + <help>Set HUB address (nbma-address - external hub address or fqdn) [REQUIRED]</help> + </properties> + </leafNode> + <leafNode name="register"> + <properties> + <help>Specifies that Registration Request should be sent to this peer on startup</help> + <valueless/> + </properties> + </leafNode> + </children> + </tagNode> + <leafNode name="multicast"> + <properties> + <help>Set multicast for NHRP</help> + <completionHelp> + <list>dynamic nhs</list> + </completionHelp> + <constraint> + <regex>^(dynamic|nhs)$</regex> + </constraint> + </properties> + </leafNode> + <leafNode name="non-caching"> + <properties> + <help>This can be used to reduce memory consumption on big NBMA subnets</help> + <valueless/> + </properties> + </leafNode> + <leafNode name="redirect"> + <properties> + <help>Enable sending of Cisco style NHRP Traffic Indication packets</help> + <valueless/> + </properties> + </leafNode> + <leafNode name="shortcut-destination"> + <properties> + <help>This instructs opennhrp to reply with authorative answers on NHRP Resolution Requests destined to addresses in this interface</help> + <valueless/> + </properties> + </leafNode> + <tagNode name="shortcut-target"> + <properties> + <help>Defines an off-NBMA network prefix for which the GRE interface will act as a gateway</help> + </properties> + <children> + <leafNode name="holding-time"> + <properties> + <help>Holding time in seconds</help> + </properties> + </leafNode> + </children> + </tagNode> + <leafNode name="shortcut"> + <properties> + <help>Enable creation of shortcut routes. A received NHRP Traffic Indication will trigger the resolution and establishment of a shortcut route</help> + <valueless/> + </properties> + </leafNode> + </children> + </tagNode> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/op-mode-definitions/nhrp.xml.in b/op-mode-definitions/nhrp.xml.in new file mode 100644 index 000000000..9e746cc35 --- /dev/null +++ b/op-mode-definitions/nhrp.xml.in @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interfaceDefinition> + <node name="reset"> + <children> + <node name="nhrp"> + <properties> + <help>Clear/Purge NHRP entries</help> + </properties> + <children> + <node name="flush"> + <properties> + <help>Clear all non-permanent entries</help> + </properties> + <children> + <tagNode name="tunnel"> + <properties> + <help>Clear all non-permanent entries</help> + </properties> + <command>sudo opennhrpctl flush dev $5 || echo OpenNHRP is not running.</command> + </tagNode> + </children> + <command>sudo opennhrpctl flush || echo OpenNHRP is not running.</command> + </node> + <node name="purge"> + <properties> + <help>Purge entries from NHRP cache</help> + </properties> + <children> + <tagNode name="tunnel"> + <properties> + <help>Purge all entries from NHRP cache</help> + </properties> + <command>sudo opennhrpctl purge dev $5 || echo OpenNHRP is not running.</command> + </tagNode> + </children> + <command>sudo opennhrpctl purge || echo OpenNHRP is not running.</command> + </node> + </children> + </node> + </children> + </node> + <node name="show"> + <children> + <node name="nhrp"> + <properties> + <help>Show NHRP info</help> + </properties> + <children> + <leafNode name="interface"> + <properties> + <help>Show NHRP interface connection information</help> + </properties> + <command>if [ -f /var/run/opennhrp.pid ]; then sudo opennhrpctl interface show; else echo OpenNHRP is not running.; fi</command> + </leafNode> + <leafNode name="tunnel"> + <properties> + <help>Show NHRP tunnel connection information</help> + </properties> + <command>if [ -f /var/run/opennhrp.pid ]; then sudo opennhrpctl show ; else echo OpenNHRP is not running.; fi</command> + </leafNode> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/smoketest/scripts/cli/test_protocols_nhrp.py b/smoketest/scripts/cli/test_protocols_nhrp.py new file mode 100755 index 000000000..8389e42e9 --- /dev/null +++ b/smoketest/scripts/cli/test_protocols_nhrp.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import unittest + +from base_vyostest_shim import VyOSUnitTestSHIM + +from vyos.util import call, process_named_running, read_file + +tunnel_path = ['interfaces', 'tunnel'] +nhrp_path = ['protocols', 'nhrp'] +vpn_path = ['vpn', 'ipsec'] + +class TestProtocolsNHRP(VyOSUnitTestSHIM.TestCase): + def tearDown(self): + self.cli_delete(nhrp_path) + self.cli_delete(tunnel_path) + self.cli_commit() + + def test_config(self): + self.cli_delete(nhrp_path) + self.cli_delete(tunnel_path) + + # Tunnel + self.cli_set(tunnel_path + ["tun100", "address", "172.16.253.134/29"]) + self.cli_set(tunnel_path + ["tun100", "encapsulation", "gre"]) + self.cli_set(tunnel_path + ["tun100", "source-address", "192.0.2.1"]) + self.cli_set(tunnel_path + ["tun100", "multicast", "enable"]) + self.cli_set(tunnel_path + ["tun100", "parameters", "ip", "key", "1"]) + + # NHRP + self.cli_set(nhrp_path + ["tunnel", "tun100", "cisco-authentication", "secret"]) + self.cli_set(nhrp_path + ["tunnel", "tun100", "holding-time", "300"]) + self.cli_set(nhrp_path + ["tunnel", "tun100", "multicast", "dynamic"]) + self.cli_set(nhrp_path + ["tunnel", "tun100", "redirect"]) + self.cli_set(nhrp_path + ["tunnel", "tun100", "shortcut"]) + + # IKE/ESP Groups + self.cli_set(vpn_path + ["esp-group", "ESP-HUB", "compression", "disable"]) + self.cli_set(vpn_path + ["esp-group", "ESP-HUB", "lifetime", "1800"]) + self.cli_set(vpn_path + ["esp-group", "ESP-HUB", "mode", "transport"]) + self.cli_set(vpn_path + ["esp-group", "ESP-HUB", "pfs", "dh-group2"]) + self.cli_set(vpn_path + ["esp-group", "ESP-HUB", "proposal", "1", "encryption", "aes256"]) + self.cli_set(vpn_path + ["esp-group", "ESP-HUB", "proposal", "1", "hash", "sha1"]) + self.cli_set(vpn_path + ["esp-group", "ESP-HUB", "proposal", "2", "encryption", "3des"]) + self.cli_set(vpn_path + ["esp-group", "ESP-HUB", "proposal", "2", "hash", "md5"]) + self.cli_set(vpn_path + ["ike-group", "IKE-HUB", "ikev2-reauth", "no"]) + self.cli_set(vpn_path + ["ike-group", "IKE-HUB", "key-exchange", "ikev1"]) + self.cli_set(vpn_path + ["ike-group", "IKE-HUB", "lifetime", "3600"]) + self.cli_set(vpn_path + ["ike-group", "IKE-HUB", "proposal", "1", "dh-group", "2"]) + self.cli_set(vpn_path + ["ike-group", "IKE-HUB", "proposal", "1", "encryption", "aes256"]) + self.cli_set(vpn_path + ["ike-group", "IKE-HUB", "proposal", "1", "hash", "sha1"]) + self.cli_set(vpn_path + ["ike-group", "IKE-HUB", "proposal", "2", "dh-group", "2"]) + self.cli_set(vpn_path + ["ike-group", "IKE-HUB", "proposal", "2", "encryption", "aes128"]) + self.cli_set(vpn_path + ["ike-group", "IKE-HUB", "proposal", "2", "hash", "sha1"]) + + # Profile - Not doing full DMVPN checks here, just want to verify the profile name in the output + self.cli_set(vpn_path + ["ipsec-interfaces", "interface", "eth0"]) + self.cli_set(vpn_path + ["profile", "NHRPVPN", "authentication", "mode", "pre-shared-secret"]) + self.cli_set(vpn_path + ["profile", "NHRPVPN", "authentication", "pre-shared-secret", "secret"]) + self.cli_set(vpn_path + ["profile", "NHRPVPN", "bind", "tunnel", "tun100"]) + self.cli_set(vpn_path + ["profile", "NHRPVPN", "esp-group", "ESP-HUB"]) + self.cli_set(vpn_path + ["profile", "NHRPVPN", "ike-group", "IKE-HUB"]) + + self.cli_commit() + + opennhrp_lines = [ + 'interface tun100 #hub NHRPVPN', + 'cisco-authentication secret', + 'holding-time 300', + 'shortcut', + 'multicast dynamic', + 'redirect' + ] + + tmp_opennhrp_conf = read_file('/run/opennhrp/opennhrp.conf') + + for line in opennhrp_lines: + self.assertIn(line, tmp_opennhrp_conf) + + self.assertTrue(process_named_running('opennhrp')) + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py new file mode 100755 index 000000000..12dacdba0 --- /dev/null +++ b/src/conf_mode/protocols_nhrp.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from vyos.config import Config +from vyos.configdict import node_changed +from vyos.template import render +from vyos.util import process_named_running +from vyos.util import run +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +opennhrp_conf = '/run/opennhrp/opennhrp.conf' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['protocols', 'nhrp'] + + nhrp = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + nhrp['del_tunnels'] = node_changed(conf, base + ['tunnel'], key_mangling=('-', '_')) + + if not conf.exists(base): + return nhrp + + nhrp['if_tunnel'] = conf.get_config_dict(['interfaces', 'tunnel'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + nhrp['profile_map'] = {} + profile = conf.get_config_dict(['vpn', 'ipsec', 'profile'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + for name, profile_conf in profile.items(): + if 'bind' in profile_conf and 'tunnel' in profile_conf['bind']: + interfaces = profile_conf['bind']['tunnel'] + if isinstance(interfaces, str): + interfaces = [interfaces] + for interface in interfaces: + nhrp['profile_map'][interface] = name + + return nhrp + +def verify(nhrp): + if 'tunnel' in nhrp: + for name, nhrp_conf in nhrp['tunnel'].items(): + if not nhrp['if_tunnel'] or name not in nhrp['if_tunnel']: + raise ConfigError(f'Tunnel interface "{name}" does not exist') + + tunnel_conf = nhrp['if_tunnel'][name] + + if 'encapsulation' not in tunnel_conf or tunnel_conf['encapsulation'] != 'gre': + raise ConfigError(f'Tunnel "{name}" is not an mGRE tunnel') + + if 'remote' in tunnel_conf: + raise ConfigError(f'Tunnel "{name}" cannot have a remote address defined') + + if 'map' in nhrp_conf: + for map_name, map_conf in nhrp_conf['map'].items(): + if 'nbma_address' not in map_conf: + raise ConfigError(f'nbma-address missing on map {map_name} on tunnel {name}') + + if 'dynamic_map' in nhrp_conf: + for map_name, map_conf in nhrp_conf['dynamic_map'].items(): + if 'nbma_domain_name' not in map_conf: + raise ConfigError(f'nbma-domain-name missing on dynamic-map {map_name} on tunnel {name}') + return None + +def generate(nhrp): + render(opennhrp_conf, 'nhrp/opennhrp.conf.tmpl', nhrp) + return None + +def apply(nhrp): + if 'tunnel' in nhrp: + for tunnel, tunnel_conf in nhrp['tunnel'].items(): + if 'source_address' in tunnel_conf: + chain = f'VYOS_NHRP_{tunnel}_OUT_HOOK' + source_address = tunnel_conf['source_address'] + + chain_exists = run(f'sudo iptables --check {chain} -j RETURN') == 0 + if not chain_exists: + run(f'sudo iptables --new {chain}') + run(f'sudo iptables --append {chain} -p gre -s {source_address} -d 224.0.0.0/4 -j DROP') + run(f'sudo iptables --append {chain} -j RETURN') + run(f'sudo iptables --insert OUTPUT 2 -j {chain}') + + for tunnel in nhrp['del_tunnels']: + chain = f'VYOS_NHRP_{tunnel}_OUT_HOOK' + chain_exists = run(f'sudo iptables --check {chain} -j RETURN') == 0 + if chain_exists: + run(f'sudo iptables --delete OUTPUT -j {chain}') + run(f'sudo iptables --flush {chain}') + run(f'sudo iptables --delete-chain {chain}') + + action = 'restart' if nhrp and 'tunnel' in nhrp else 'stop' + run(f'systemctl {action} opennhrp') + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index c57697a8f..eedb9098c 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -1,397 +1,399 @@ #!/usr/bin/env python3 # # Copyright (C) 2021 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import os from sys import exit from time import sleep from vyos.config import Config from vyos.configdict import leaf_node_changed from vyos.configverify import verify_interface_exists from vyos.ifconfig import Interface from vyos.template import render from vyos.util import call from vyos.util import dict_search from vyos.util import get_interface_address from vyos.util import process_named_running from vyos.util import run from vyos.util import cidr_fit from vyos import ConfigError from vyos import airbag airbag.enable() authby_translate = { 'pre-shared-secret': 'secret', 'rsa': 'rsasig', 'x509': 'rsasig' } default_pfs = 'dh-group2' pfs_translate = { 'dh-group1': 'modp768', 'dh-group2': 'modp1024', 'dh-group5': 'modp1536', 'dh-group14': 'modp2048', 'dh-group15': 'modp3072', 'dh-group16': 'modp4096', 'dh-group17': 'modp6144', 'dh-group18': 'modp8192', 'dh-group19': 'ecp256', 'dh-group20': 'ecp384', 'dh-group21': 'ecp512', 'dh-group22': 'modp1024s160', 'dh-group23': 'modp2048s224', 'dh-group24': 'modp2048s256', 'dh-group25': 'ecp192', 'dh-group26': 'ecp224', 'dh-group27': 'ecp224bp', 'dh-group28': 'ecp256bp', 'dh-group29': 'ecp384bp', 'dh-group30': 'ecp512bp', 'dh-group31': 'curve25519', 'dh-group32': 'curve448' } any_log_modes = [ 'dmn', 'mgr', 'ike', 'chd','job', 'cfg', 'knl', 'net', 'asn', 'enc', 'lib', 'esp', 'tls', 'tnc', 'imc', 'imv', 'pts' ] ike_ciphers = {} esp_ciphers = {} mark_base = 0x900000 CA_PATH = "/etc/ipsec.d/cacerts/" CRL_PATH = "/etc/ipsec.d/crls/" DHCP_BASE = "/var/lib/dhcp/dhclient" LOCAL_KEY_PATHS = ['/config/auth/', '/config/ipsec.d/rsa-keys/'] X509_PATH = '/config/auth/' def get_config(config=None): if config: conf = config else: conf = Config() base = ['vpn', 'ipsec'] if not conf.exists(base): return None # retrieve common dictionary keys ipsec = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) ipsec['interface_change'] = leaf_node_changed(conf, base + ['ipsec-interfaces', 'interface']) ipsec['l2tp_exists'] = conf.exists('vpn l2tp remote-access ipsec-settings ') ipsec['nhrp_exists'] = conf.exists('protocols nhrp tunnel') ipsec['rsa_keys'] = conf.get_config_dict(['vpn', 'rsa-keys'], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) default_ike_pfs = None if 'ike_group' in ipsec: for group, ike_conf in ipsec['ike_group'].items(): if 'proposal' in ike_conf: ciphers = [] for i in ike_conf['proposal']: proposal = ike_conf['proposal'][i] enc = proposal['encryption'] if 'encryption' in proposal else None hash = proposal['hash'] if 'hash' in proposal else None pfs = ('dh-group' + proposal['dh_group']) if 'dh_group' in proposal else default_pfs if not default_ike_pfs: default_ike_pfs = pfs if enc and hash: ciphers.append(f"{enc}-{hash}-{pfs_translate[pfs]}" if pfs else f"{enc}-{hash}") ike_ciphers[group] = ','.join(ciphers) + '!' if 'esp_group' in ipsec: for group, esp_conf in ipsec['esp_group'].items(): pfs = esp_conf['pfs'] if 'pfs' in esp_conf else 'enable' if pfs == 'disable': pfs = None if pfs == 'enable': pfs = default_ike_pfs if 'proposal' in esp_conf: ciphers = [] for i in esp_conf['proposal']: proposal = esp_conf['proposal'][i] enc = proposal['encryption'] if 'encryption' in proposal else None hash = proposal['hash'] if 'hash' in proposal else None if enc and hash: ciphers.append(f"{enc}-{hash}-{pfs_translate[pfs]}" if pfs else f"{enc}-{hash}") esp_ciphers[group] = ','.join(ciphers) + '!' return ipsec def get_rsa_local_key(ipsec): return dict_search('local_key.file', ipsec['rsa_keys']) def verify_rsa_local_key(ipsec): file = get_rsa_local_key(ipsec) if not file: return False for path in LOCAL_KEY_PATHS: full_path = os.path.join(path, file) if os.path.exists(full_path): return full_path return False def verify_rsa_key(ipsec, key_name): return dict_search(f'rsa_key_name.{key_name}.rsa_key', ipsec['rsa_keys']) def verify(ipsec): if not ipsec: return None if 'ipsec_interfaces' in ipsec and 'interface' in ipsec['ipsec_interfaces']: interfaces = ipsec['ipsec_interfaces']['interface'] if isinstance(interfaces, str): interfaces = [interfaces] for ifname in interfaces: verify_interface_exists(ifname) if 'profile' in ipsec: for profile, profile_conf in ipsec['profile'].items(): if 'esp_group' in profile_conf: if 'esp_group' not in ipsec or profile_conf['esp_group'] not in ipsec['esp_group']: raise ConfigError(f"Invalid esp-group on {profile} profile") else: raise ConfigError(f"Missing esp-group on {profile} profile") if 'ike_group' in profile_conf: if 'ike_group' not in ipsec or profile_conf['ike_group'] not in ipsec['ike_group']: raise ConfigError(f"Invalid ike-group on {profile} profile") else: raise ConfigError(f"Missing ike-group on {profile} profile") if 'authentication' not in profile_conf: raise ConfigError(f"Missing authentication on {profile} profile") if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']: for peer, peer_conf in ipsec['site_to_site']['peer'].items(): has_default_esp = False if 'default_esp_group' in peer_conf: has_default_esp = True if 'esp_group' not in ipsec or peer_conf['default_esp_group'] not in ipsec['esp_group']: raise ConfigError(f"Invalid esp-group on site-to-site peer {peer}") if 'ike_group' in peer_conf: if 'ike_group' not in ipsec or peer_conf['ike_group'] not in ipsec['ike_group']: raise ConfigError(f"Invalid ike-group on site-to-site peer {peer}") else: raise ConfigError(f"Missing ike-group on site-to-site peer {peer}") if 'authentication' not in peer_conf or 'mode' not in peer_conf['authentication']: raise ConfigError(f"Missing authentication on site-to-site peer {peer}") if peer_conf['authentication']['mode'] == 'x509': if 'x509' not in peer_conf['authentication']: raise ConfigError(f"Missing x509 settings on site-to-site peer {peer}") if 'key' not in peer_conf['authentication']['x509']: raise ConfigError(f"Missing x509 key on site-to-site peer {peer}") if 'ca_cert_file' not in peer_conf['authentication']['x509'] or 'cert_file' not in peer_conf['authentication']['x509']: raise ConfigError(f"Missing x509 settings on site-to-site peer {peer}") if 'file' not in peer_conf['authentication']['x509']['key']: raise ConfigError(f"Missing x509 key file on site-to-site peer {peer}") for key in ['ca_cert_file', 'cert_file', 'crl_file']: if key in peer_conf['authentication']['x509']: path = os.path.join(X509_PATH, peer_conf['authentication']['x509'][key]) if not os.path.exists(path): raise ConfigError(f"File not found for {key} on site-to-site peer {peer}") key_path = os.path.join(X509_PATH, peer_conf['authentication']['x509']['key']['file']) if not os.path.exists(key_path): raise ConfigError(f"Private key not found on site-to-site peer {peer}") if peer_conf['authentication']['mode'] == 'rsa': if not verify_rsa_local_key(ipsec): raise ConfigError(f"Invalid key on rsa-keys local-key") if 'rsa_key_name' not in peer_conf['authentication']: raise ConfigError(f"Missing rsa-key-name on site-to-site peer {peer}") if not verify_rsa_key(ipsec, peer_conf['authentication']['rsa_key_name']): raise ConfigError(f"Invalid rsa-key-name on site-to-site peer {peer}") if 'local_address' not in peer_conf and 'dhcp_interface' not in peer_conf: raise ConfigError(f"Missing local-address or dhcp-interface on site-to-site peer {peer}") if 'dhcp_interface' in peer_conf: dhcp_interface = peer_conf['dhcp_interface'] verify_interface_exists(dhcp_interface) if not os.path.exists(f'{DHCP_BASE}_{dhcp_interface}.conf'): raise ConfigError(f"Invalid dhcp-interface on site-to-site peer {peer}") address = Interface(dhcp_interface).get_addr() if not address: raise ConfigError(f"Failed to get address from dhcp-interface on site-to-site peer {peer}") if 'vti' in peer_conf: if 'local_address' in peer_conf and 'dhcp_interface' in peer_conf: raise ConfigError(f"A single local-address or dhcp-interface is required when using VTI on site-to-site peer {peer}") if 'bind' in peer_conf['vti']: vti_interface = peer_conf['vti']['bind'] if not os.path.exists(f'/sys/class/net/{vti_interface}'): raise ConfigError(f'VTI interface {vti_interface} for site-to-site peer {peer} does not exist!') if 'vti' not in peer_conf and 'tunnel' not in peer_conf: raise ConfigError(f"No VTI or tunnel specified on site-to-site peer {peer}") if 'tunnel' in peer_conf: for tunnel, tunnel_conf in peer_conf['tunnel'].items(): if 'esp_group' not in tunnel_conf and not has_default_esp: raise ConfigError(f"Missing esp-group on tunnel {tunnel} for site-to-site peer {peer}") esp_group_name = tunnel_conf['esp_group'] if 'esp_group' in tunnel_conf else peer_conf['default_esp_group'] if esp_group_name not in ipsec['esp_group']: raise ConfigError(f"Invalid esp-group on tunnel {tunnel} for site-to-site peer {peer}") esp_group = ipsec['esp_group'][esp_group_name] if 'mode' in esp_group and esp_group['mode'] == 'transport': if 'protocol' in tunnel_conf and ((peer in ['any', '0.0.0.0']) or ('local_address' not in peer_conf or peer_conf['local_address'] in ['any', '0.0.0.0'])): raise ConfigError(f"Fixed local-address or peer required when a protocol is defined with ESP transport mode on tunnel {tunnel} for site-to-site peer {peer}") if ('local' in tunnel_conf and 'prefix' in tunnel_conf['local']) or ('remote' in tunnel_conf and 'prefix' in tunnel_conf['remote']): raise ConfigError(f"Local/remote prefix cannot be used with ESP transport mode on tunnel {tunnel} for site-to-site peer {peer}") def generate(ipsec): data = {} if ipsec: data = ipsec data['authby'] = authby_translate data['ciphers'] = {'ike': ike_ciphers, 'esp': esp_ciphers} data['marks'] = {} data['rsa_local_key'] = verify_rsa_local_key(ipsec) data['x509_path'] = X509_PATH if 'site_to_site' in data and 'peer' in data['site_to_site']: for peer, peer_conf in ipsec['site_to_site']['peer'].items(): if peer_conf['authentication']['mode'] == 'x509': ca_cert_file = os.path.join(X509_PATH, peer_conf['authentication']['x509']['ca_cert_file']) call(f'cp -f {ca_cert_file} {CA_PATH}') if 'crl_file' in peer_conf['authentication']['x509']: crl_file = os.path.join(X509_PATH, peer_conf['authentication']['x509']['crl_file']) call(f'cp -f {crl_file} {CRL_PATH}') local_ip = '' if 'local_address' in peer_conf: local_ip = peer_conf['local_address'] elif 'dhcp_interface' in peer_conf: local_ip = Interface(peer_conf['dhcp_interface']).get_addr() data['site_to_site']['peer'][peer]['local_address'] = local_ip if 'vti' in peer_conf and 'bind' in peer_conf['vti']: vti_interface = peer_conf['vti']['bind'] data['marks'][vti_interface] = get_mark(vti_interface) else: for tunnel, tunnel_conf in peer_conf['tunnel'].items(): local_prefix = dict_search('local.prefix', tunnel_conf['local']['prefix']) remote_prefix = dict_search('remote.prefix', tunnel_conf['remote']['prefix']) if not local_prefix or not remote_prefix: continue passthrough = cidr_fit(local_prefix, remote_prefix) data['site_to_site']['peer'][peer]['tunnel'][tunnel]['passthrough'] = passthrough if 'logging' in ipsec and 'log_modes' in ipsec['logging']: modes = ipsec['logging']['log_modes'] level = ipsec['logging']['log_level'] if 'log_level' in ipsec['logging'] else '1' if isinstance(modes, str): modes = [modes] if 'any' in modes: modes = any_log_modes data['charondebug'] = f' {level}, '.join(modes) + ' ' + level render("/etc/ipsec.conf", "ipsec/ipsec.conf.tmpl", data) render("/etc/ipsec.secrets", "ipsec/ipsec.secrets.tmpl", data) render("/etc/strongswan.d/interfaces_use.conf", "ipsec/interfaces_use.conf.tmpl", data) render("/etc/swanctl/swanctl.conf", "ipsec/swanctl.conf.tmpl", data) def resync_l2tp(ipsec): if ipsec and not ipsec['l2tp_exists']: return tmp = run('/usr/libexec/vyos/conf_mode/ipsec-settings.py') if tmp > 0: print('ERROR: failed to reapply L2TP IPSec settings!') def resync_nhrp(ipsec): if ipsec and not ipsec['nhrp_exists']: return - run('/opt/vyatta/sbin/vyos-update-nhrp.pl --set_ipsec') + tmp = run('/usr/libexec/vyos/conf_mode/protocols_nhrp.py') + if tmp > 0: + print('ERROR: failed to reapply NHRP settings!') def apply(ipsec): if not ipsec: call('sudo /usr/sbin/ipsec stop') else: should_start = ('profile' in ipsec or dict_search('site_to_site.peer', ipsec)) if not process_named_running('charon') and should_start: args = f'--auto-update {ipsec["auto_update"]}' if 'auto_update' in ipsec else '' call(f'sudo /usr/sbin/ipsec start {args}') elif not should_start: call('sudo /usr/sbin/ipsec stop') elif ipsec['interface_change']: call('sudo /usr/sbin/ipsec restart') else: call('sudo /usr/sbin/ipsec rereadall') call('sudo /usr/sbin/ipsec reload') if should_start: sleep(2) # Give charon enough time to start call('sudo /usr/sbin/swanctl -q') resync_l2tp(ipsec) resync_nhrp(ipsec) def get_mark(vti_interface): vti_num = int(vti_interface.lstrip('vti')) return mark_base + vti_num if __name__ == '__main__': try: ipsec = get_config() verify(ipsec) generate(ipsec) apply(ipsec) except ConfigError as e: print(e) exit(1) diff --git a/src/etc/opennhrp/opennhrp-script.py b/src/etc/opennhrp/opennhrp-script.py new file mode 100755 index 000000000..74c45f2f6 --- /dev/null +++ b/src/etc/opennhrp/opennhrp-script.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from pprint import pprint +import os +import re +import sys +import vici + +from vyos.util import cmd +from vyos.util import process_named_running + +NHRP_CONFIG="/etc/opennhrp/opennhrp.conf" + +def parse_type_ipsec(interface): + with open(NHRP_CONFIG, 'r') as f: + lines = f.readlines() + match = rf'^interface {interface} #(hub|spoke)(?:\s([\w-]+))?$' + for line in lines: + m = re.match(match, line) + if m: + return m[1], m[2] + return None, None + +def vici_initiate(conn, child_sa, src_addr, dest_addr): + try: + session = vici.Session() + logs = session.initiate({ + 'ike': conn, + 'child': child_sa, + 'timeout': '-1', + 'my-host': src_addr, + 'other-host': dest_addr + }) + for log in logs: + message = log['msg'].decode('ascii') + print('INIT LOG:', message) + return True + except: + return None + +def vici_terminate(conn, child_sa, src_addr, dest_addr): + try: + session = vici.Session() + logs = session.terminate({ + 'ike': conn, + 'child': child_sa, + 'timeout': '-1', + 'my-host': src_addr, + 'other-host': dest_addr + }) + for log in logs: + message = log['msg'].decode('ascii') + print('TERM LOG:', message) + return True + except: + return None + +def iface_up(interface): + cmd(f'sudo ip route flush proto 42 dev {interface}') + cmd(f'sudo ip neigh flush dev {interface}') + +def peer_up(dmvpn_type, conn): + src_addr = os.getenv('NHRP_SRCADDR') + src_nbma = os.getenv('NHRP_SRCNBMA') + dest_addr = os.getenv('NHRP_DESTADDR') + dest_nbma = os.getenv('NHRP_DESTNBMA') + dest_mtu = os.getenv('NHRP_DESTMTU') + + if dest_mtu: + args = cmd(f'sudo ip route get {dest_nbma} from {src_nbma}') + cmd(f'sudo ip route add {args} proto 42 mtu {dest_mtu}') + + if conn and dmvpn_type == 'spoke' and process_named_running('charon'): + vici_terminate(conn, 'dmvpn', src_nbma, dest_nbma) + vici_initiate(conn, 'dmvpn', src_nbma, dest_nbma) + +def peer_down(dmvpn_type, conn): + src_nbma = os.getenv('NHRP_SRCNBMA') + dest_nbma = os.getenv('NHRP_DESTNBMA') + + if conn and dmvpn_type == 'spoke' and process_named_running('charon'): + vici_terminate(conn, 'dmvpn', src_nbma, dest_nbma) + + cmd(f'sudo ip route del {dest_nbma} src {src_nbma} proto 42') + +def route_up(interface): + dest_addr = os.getenv('NHRP_DESTADDR') + dest_prefix = os.getenv('NHRP_DESTPREFIX') + next_hop = os.getenv('NHRP_NEXTHOP') + + cmd(f'sudo ip route replace {dest_addr}/{dest_prefix} proto 42 via {next_hop} dev {interface}') + cmd('sudo ip route flush cache') + +def route_down(interface): + dest_addr = os.getenv('NHRP_DESTADDR') + dest_prefix = os.getenv('NHRP_DESTPREFIX') + + cmd(f'sudo ip route del {dest_addr}/{dest_prefix} proto 42') + cmd('sudo ip route flush cache') + +if __name__ == '__main__': + action = sys.argv[1] + interface = os.getenv('NHRP_INTERFACE') + dmvpn_type, profile_name = parse_type_ipsec(interface) + + dmvpn_conn = None + + if profile_name: + dmvpn_conn = f'dmvpn-{profile_name}-{interface}' + + if action == 'interface-up': + iface_up(interface) + elif action == 'peer-register': + pass + elif action == 'peer-up': + peer_up(dmvpn_type, dmvpn_conn) + elif action == 'peer-down': + peer_down(dmvpn_type, dmvpn_conn) + elif action == 'route-up': + route_up(interface) + elif action == 'route-down': + route_down(interface) diff --git a/src/systemd/opennhrp.service b/src/systemd/opennhrp.service new file mode 100644 index 000000000..70235f89d --- /dev/null +++ b/src/systemd/opennhrp.service @@ -0,0 +1,13 @@ +[Unit] +Description=OpenNHRP +After=vyos-router.service +ConditionPathExists=/run/opennhrp/opennhrp.conf +StartLimitIntervalSec=0 + +[Service] +Type=forking +ExecStart=/usr/sbin/opennhrp -d -v -a /run/opennhrp.socket -c /run/opennhrp/opennhrp.conf -s /etc/opennhrp/opennhrp-script.py -p /run/opennhrp.pid +ExecReload=/usr/bin/kill -HUP $MAINPID +PIDFile=/run/opennhrp.pid +Restart=on-failure +RestartSec=20