diff options
author | Simon <965089+sarthurdev@users.noreply.github.com> | 2021-05-28 16:35:12 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-28 16:35:12 +0200 |
commit | eabc5f1c2e5bfe548cb3d62f2f85f8d61be29b92 (patch) | |
tree | 2efe4cf2ad2e0cb9b9f355dffa22707b26f785ef | |
parent | b0e1c8a9c9ef470297bf3c9f5059ad7c720c46ff (diff) | |
download | vyos-1x-eabc5f1c2e5bfe548cb3d62f2f85f8d61be29b92.tar.gz vyos-1x-eabc5f1c2e5bfe548cb3d62f2f85f8d61be29b92.zip |
ipsec: T2816: IPSec python rework, includes DMVPN and VTI support
24 files changed, 1788 insertions, 50 deletions
@@ -39,11 +39,7 @@ interface_definitions: $(config_xml_obj) # XXX: delete top level node.def's that now live in other packages rm -f $(TMPL_DIR)/firewall/node.def - rm -f $(TMPL_DIR)/vpn/node.def - rm -f $(TMPL_DIR)/vpn/ipsec/node.def rm -rf $(TMPL_DIR)/nfirewall - rm -rf $(TMPL_DIR)/vpn/nipsec - # XXX: test if there are empty node.def files - this is not allowed as these # could mask help strings or mandatory priority statements find $(TMPL_DIR) -name node.def -type f -empty -exec false {} + || sh -c 'echo "There are empty node.def files! Check your interface definitions." && exit 1' @@ -62,12 +58,10 @@ op_mode_definitions: $(op_xml_obj) rm -f $(OP_TMPL_DIR)/delete/node.def rm -f $(OP_TMPL_DIR)/generate/node.def rm -f $(OP_TMPL_DIR)/monitor/node.def - rm -f $(OP_TMPL_DIR)/reset/vpn/node.def rm -f $(OP_TMPL_DIR)/set/node.def rm -f $(OP_TMPL_DIR)/show/interfaces/node.def rm -f $(OP_TMPL_DIR)/show/node.def rm -f $(OP_TMPL_DIR)/show/system/node.def - rm -f $(OP_TMPL_DIR)/show/vpn/node.def # XXX: ping must be able to recursivly call itself as the # options are provided from the script itself diff --git a/data/configd-include.json b/data/configd-include.json index c3d59592a..28267d575 100644 --- a/data/configd-include.json +++ b/data/configd-include.json @@ -64,8 +64,10 @@ "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", diff --git a/data/templates/ipsec/ike-esp.tmpl b/data/templates/ipsec/ike-esp.tmpl new file mode 100644 index 000000000..deeb8c80d --- /dev/null +++ b/data/templates/ipsec/ike-esp.tmpl @@ -0,0 +1,32 @@ +{% macro conn(ike, ike_ciphers, esp, esp_ciphers) -%} +{% if ike %} +{% if "key_exchange" in ike %} + keyexchange = {{ ike.key_exchange }} +{% endif %} + ike = {{ ike_ciphers }} +{% if "lifetime" in ike %} + ikelifetime = {{ ike.lifetime }}s +{% endif %} + reauth = {{ ike.ikev2_reauth if "ikev2_reauth" in ike else "no" }} + closeaction = {{ ike.close_action if "close_action" in ike else "none" }} +{% if "dead_peer_detection" in ike %} + dpdaction = {{ ike.dead_peer_detection.action }} + dpdtimeout = {{ ike.dead_peer_detection.timeout }} + dpddelay = {{ ike.dead_peer_detection.interval }} +{% endif %} +{% if "key_exchange" in ike and ike.key_exchange == "ikev1" and "mode" in ike and ike.mode == "aggressive" %} + aggressive = yes +{% endif %} +{% if "key_exchange" in ike and ike.key_exchange == "ikev2" %} + mobike = {{ "yes" if "mobike" not in ike or ike.mobike == "enable" else "no" }} +{% endif %} +{% endif %} +{% if esp %} + esp = {{ esp_ciphers }} +{% if "lifetime" in esp %} + keylife = {{ esp.lifetime }}s +{% endif %} + compress = {{ 'yes' if "compression" in esp and esp.compression == 'enable' else 'no' }} + type = {{ esp.mode if "mode" in esp else "tunnel" }} +{% endif %} +{%- endmacro %} diff --git a/data/templates/ipsec/ipsec.conf.tmpl b/data/templates/ipsec/ipsec.conf.tmpl index d0b60765b..75f3de39c 100644 --- a/data/templates/ipsec/ipsec.conf.tmpl +++ b/data/templates/ipsec/ipsec.conf.tmpl @@ -1,3 +1,121 @@ +# Created by VyOS - manual changes will be overwritten + +{% import 'ipsec/ike-esp.tmpl' as ike_esp %} + +config setup + charondebug = "{{ charondebug }}" + uniqueids = {{ "no" if disable_uniqreqids is defined else "yes" }} + +{% if site_to_site is defined and "peer" in site_to_site -%} +{% for peer, peer_conf in site_to_site.peer.items() %} +{% set peer_index = loop.index %} +{% set peer_ike = ike_group[peer_conf.ike_group] %} +{% set peer_esp = esp_group[peer_conf.default_esp_group] if 'default_esp_group' in peer_conf else None %} +conn peer-{{ peer }} +{% if peer_conf.authentication.mode in authby %} + authby = {{ authby[peer_conf.authentication.mode] }} +{% endif %} +{% if peer_conf.authentication.mode == 'x509' %} +{% set cert_file = peer_conf.authentication.x509.cert_file %} + leftcert = {{ cert_file if cert_file.startswith(x509_path) else (x509_path + cert_file) }} + leftsendcert = always + rightca = %same +{% elif peer_conf.authentication.mode == 'rsa' %} + leftsigkey = localhost.pub + rightsigkey = {{ peer_conf.authentication.rsa_key_name }}.pub +{% endif %} + left = {{ peer_conf.local_address if peer_conf.local_address != 'any' else '%defaultroute' }} # dhcp:{{ peer_conf.dhcp_interface if 'dhcp_interface' in peer_conf else 'no' }} +{% if "id" in peer_conf.authentication and "use_x509_id" not in peer_conf.authentication %} + leftid = "{{ peer_conf.authentication.id }}" +{% endif %} + right = {{ peer if peer not in ['any', '0.0.0.0'] and peer[0:1] != '@' else '%any' }} +{% if "remote_id" in peer_conf.authentication %} + rightid = "{{ peer_conf.authentication.remote_id }}" +{% elif peer[0:1] == '@' %} + rightid = "{{ peer }}" +{% endif %} + keylife = 3600s + rekeymargin = 540s +{{ ike_esp.conn(peer_ike, ciphers.ike[peer_conf.ike_group], peer_esp, ciphers.esp[peer_conf.default_esp_group] if peer_esp else None) }} +{% if "vti" in peer_conf and "bind" in peer_conf.vti %} +{% set vti_esp = esp_group[peer_conf.vti.esp_group] if "esp_group" in peer_conf.vti else None %} +conn peer-{{ peer }}-vti + also = peer-{{ peer }} + leftsubnet = 0.0.0.0/0 + leftupdown = "/etc/ipsec.d/vti-up-down {{ peer_conf.vti.bind }}" + rightsubnet = 0.0.0.0/0 + mark = {{ marks[peer_conf.vti.bind] }} +{{ ike_esp.conn(None, None, vti_esp, ciphers.esp[peer_conf.vti.esp_group] if vti_esp else None) }} +{% if peer[0:1] == '@' %} + rekey = no + auto = add + keyingtries = %forever +{% else %} +{% if 'connection_type' not in peer_conf or peer_conf.connection_type == 'initiate' %} + auto = start + keyingtries = %forever +{% endif %} +{% if peer_conf.connection_type == 'respond' %} + auto = route + keyingtries = 1 +{% endif %} +{% endif %} +{% elif "tunnel" in peer_conf %} +{% for tunnel_id, tunnel_conf in peer_conf.tunnel.items() %} +{% set tunnel_esp_name = tunnel_conf.esp_group if "esp_group" in tunnel_conf else peer_conf.default_esp_group %} +{% set tunnel_esp = esp_group[tunnel_esp_name] %} +{% set proto = tunnel_conf.protocol if "protocol" in tunnel_conf else '%any' %} +conn peer-{{ peer }}-tunnel-{{tunnel_id}} + also = peer-{{ peer }} +{% if 'mode' not in tunnel_esp or tunnel_esp.mode == 'tunnel' %} +{% if 'local' in tunnel_conf and 'prefix' in tunnel_conf.local %} + leftsubnet = {{ tunnel_conf.local.prefix if tunnel_conf.local.prefix != 'any' else '0.0.0.0/0' }}[{{ proto }}/{{ tunnel_conf.local.port if "port" in tunnel_conf.local else '%any' }}] +{% endif %} +{% if 'remote' in tunnel_conf and 'prefix' in tunnel_conf.remote %} + rightsubnet = {{ tunnel_conf.remote.prefix if tunnel_conf.remote.prefix != 'any' else '0.0.0.0/0' }}[{{ proto }}/{{ tunnel_conf.remote.port if "port" in tunnel_conf.remote else '%any' }}] +{% endif %} +{% elif tunnel_esp.mode == 'transport' %} + leftsubnet = {{ peer_conf.local_address }}[{{ proto }}/{{ tunnel_conf.local.port if "local" in tunnel_conf and "port" in tunnel_conf.local else '%any' }}] + rightsubnet = {{ peer }}[{{ proto }}/{{ tunnel_conf.local.port if "local" in tunnel_conf and "port" in tunnel_conf.local else '%any' }}] +{% endif %} +{% if 'esp_group' in tunnel_conf %} +{{ ike_esp.conn(None, None, tunnel_esp, ciphers.esp[tunnel_esp_name]) }} +{% endif %} +{% if peer[0:1] == '@' %} + rekey = no + auto = add + keyingtries = %forever +{% else %} +{% if 'connection_type' not in peer_conf or peer_conf.connection_type == 'initiate' %} + auto = start + keyingtries = %forever +{% endif %} +{% if peer_conf.connection_type == 'respond' %} + auto = route + keyingtries = 1 +{% endif %} +{% endif %} +{% if 'passthrough' in tunnel_conf and tunnel_conf.passthrough %} +conn peer-{{ peer }}-tunnel-{{ tunnel_id }}-passthough + left = {{ peer_conf.local_address if peer_conf.local_address != 'any' else '%defaultroute' }} + right = {{ peer if peer not in ['any', '0.0.0.0'] and peer[0:1] != '@' else '%any' }} + leftsubnet = {{ tunnel_conf.local.prefix }} + rightsubnet = {{ tunnel_conf.local.prefix }} + type = passthrough + authby = never + auto = route +{% endif %} +{% endfor %} +{% endif %} +{% endfor %} +{%- endif %} + +{% if include_ipsec_conf is defined %} +include {{ include_ipsec_conf }} +{% endif %} + +{% if delim_ipsec_l2tp_begin is defined -%} {{delim_ipsec_l2tp_begin}} include {{ipsec_ra_conn_file}} {{delim_ipsec_l2tp_end}} +{%- endif %} diff --git a/data/templates/ipsec/ipsec.secrets.tmpl b/data/templates/ipsec/ipsec.secrets.tmpl index 55c010a3b..a1432de57 100644 --- a/data/templates/ipsec/ipsec.secrets.tmpl +++ b/data/templates/ipsec/ipsec.secrets.tmpl @@ -1,7 +1,34 @@ +# Created by VyOS - manual changes will be overwritten + +{% if site_to_site is defined and "peer" in site_to_site %} +{% set ns = namespace(local_key_set=False) %} +{% for peer, peer_conf in site_to_site.peer.items() %} +{% if peer_conf.authentication.mode == 'pre-shared-secret' %} +{{ (peer_conf.local_address if "local_address" in peer_conf else "%any") ~ + (" " ~ peer) ~ + ((" " ~ peer_conf.authentication.id) if "id" in peer_conf.authentication else "") ~ + ((" " ~ peer_conf.authentication.remote_id) if "remote_id" in peer_conf.authentication else "") +}} : PSK "{{ peer_conf.authentication.pre_shared_secret }}" # dhcp:{{ peer_conf.dhcp_interface if 'dhcp_interface' in peer_conf else 'no' }} +{% elif peer_conf.authentication.mode == 'x509' %} +{% set key_file = peer_conf.authentication.x509.key.file %} +: RSA {{ key_file if key_file.startswith(x509_path) else (x509_path + key_file) }}{% if "password" in peer_conf.authentication.x509.key and peer_conf.authentication.x509.key.password %} "{{ peer_conf.authentication.x509.key.password}}"{% endif %} +{% elif peer_conf.authentication.mode == 'rsa' and not ns.local_key_set %} +{% set ns.local_key_set = True %} +: RSA {{ rsa_local_key }} +{% endif %} +{% endfor %} +{% endif %} + +{% if include_ipsec_secrets is defined %} +include {{ include_ipsec_secrets }} +{% endif %} + +{% if delim_ipsec_l2tp_begin is defined %} {{delim_ipsec_l2tp_begin}} -{% if ipsec_l2tp_auth_mode == 'pre-shared-secret' %} +{% if ipsec_l2tp_auth_mode == 'pre-shared-secret' %} {{outside_addr}} %any : PSK "{{ipsec_l2tp_secret}}" -{% elif ipsec_l2tp_auth_mode == 'x509' %} +{% elif ipsec_l2tp_auth_mode == 'x509' %} : RSA {{server_key_file_copied}} -{% endif%} +{% endif %} {{delim_ipsec_l2tp_end}} +{% endif %} diff --git a/data/templates/ipsec/swanctl.conf.tmpl b/data/templates/ipsec/swanctl.conf.tmpl new file mode 100644 index 000000000..0ce703f20 --- /dev/null +++ b/data/templates/ipsec/swanctl.conf.tmpl @@ -0,0 +1,54 @@ +# Created by VyOS - manual changes will be overwritten + +{% if profile is defined %} +connections { +{% for name, profile_conf in profile.items() if "bind" in profile_conf and "tunnel" in profile_conf.bind %} +{% set dmvpn_ike = ike_group[profile_conf.ike_group] %} +{% set dmvpn_esp = esp_group[profile_conf.esp_group] %} +{% for interface in profile_conf.bind.tunnel %} + dmvpn-{{ name }}-{{ interface }} { + proposals = {{ ciphers.ike[profile_conf.ike_group][:-1] }} + version = {{ dmvpn_ike.key_exchange[4:] if "key_exchange" in dmvpn_ike else "0" }} + rekey_time = {{ dmvpn_ike.lifetime if 'lifetime' in dmvpn_ike else '28800' }}s + keyingtries = 0 +{% if profile_conf.authentication.mode == 'pre-shared-secret' %} + local { + auth = psk + } + remote { + auth = psk + } +{% endif %} + children { + dmvpn { + esp_proposals = {{ ciphers.esp[profile_conf.esp_group][:-1] }} + rekey_time = {{ dmvpn_esp.lifetime if 'lifetime' in dmvpn_esp else '3600' }}s + rand_time = 540s + local_ts = dynamic[gre] + remote_ts = dynamic[gre] + mode = {{ dmvpn_esp.mode if "mode" in dmvpn_esp else "transport" }} +{% if 'dead_peer_detection' in dmvpn_ike and 'action' in dmvpn_ike.dead_peer_detection %} + dpd_action = {{ dmvpn_ike.dead_peer_detection.action }} +{% endif %} +{% if 'compression' in dmvpn_esp and dmvpn_esp['compression'] == 'enable' %} + ipcomp = yes +{% endif %} + } + } + } +{% endfor %} +{% endfor %} +} + +secrets { +{% for name, profile_conf in profile.items() if "bind" in profile_conf and "tunnel" in profile_conf.bind %} +{% if profile_conf.authentication.mode == 'pre-shared-secret' %} +{% for interface in profile_conf.bind.tunnel %} + ike-dmvpn-{{ interface }} { + secret = {{ profile_conf.authentication.pre_shared_secret }} + } +{% endfor %} +{% endif %} +{% endfor %} +} +{% endif %} diff --git a/debian/control b/debian/control index e4ecac616..3ec5ccb7d 100644 --- a/debian/control +++ b/debian/control @@ -71,6 +71,9 @@ Depends: libnetfilter-conntrack3, libnfnetlink0, libpam-radius-auth (>= 1.5.0), + libstrongswan-standard-plugins (>=5.6), + libstrongswan-extra-plugins (>=5.6), + libcharon-extra-plugins (>=5.6), libvyosconfig0, lldpd, lm-sensors, @@ -98,6 +101,7 @@ Depends: procps, python3, python3-certbot-nginx, + python3-crypto, ${python3:Depends}, python3-flask, python3-hurry.filesize, @@ -127,6 +131,8 @@ Depends: squidclient, squidguard, ssl-cert, + strongswan (>= 5.6), + strongswan-swanctl (>= 5.6), systemd, tcpdump, tcptraceroute, diff --git a/debian/vyos-1x.install b/debian/vyos-1x.install index 51a1bb38a..e5de7f074 100644 --- a/debian/vyos-1x.install +++ b/debian/vyos-1x.install @@ -1,4 +1,5 @@ etc/dhcp +etc/ipsec.d etc/netplug etc/ppp etc/rsyslog.d diff --git a/debian/vyos-1x.postinst b/debian/vyos-1x.postinst index 8acc87cc8..136d025d4 100644 --- a/debian/vyos-1x.postinst +++ b/debian/vyos-1x.postinst @@ -66,3 +66,16 @@ fi # ensure hte proxy user has a proper shell chsh -s /bin/sh proxy + +# vyatta-cfg-vpn migration +for init in openswan ipsec setkey; do + update-rc.d -f ${init} remove >/dev/null +done + +# remove keys +rm -f /etc/ipsec.secrets +touch /etc/ipsec.secrets +chown root:root /etc/ipsec.secrets +chmod 600 /etc/ipsec.secrets +rm -f /etc/ipsec.d/private/localhost.localdomainKey.pem +rm -f /etc/ipsec.d/certs/localhost.localdomainCert.pem
\ No newline at end of file diff --git a/interface-definitions/include/ip-protocol.xml.i b/interface-definitions/include/ip-protocol.xml.i new file mode 100644 index 000000000..ce9345024 --- /dev/null +++ b/interface-definitions/include/ip-protocol.xml.i @@ -0,0 +1,17 @@ +<!-- include start from ip-protocol.xml.i --> +<leafNode name="protocol"> + <properties> + <help>Protocol</help> + <valueHelp> + <format>txt</format> + <description>Protocol name</description> + </valueHelp> + <completionHelp> + <script>${vyos_completion_dir}/list_protocols.sh</script> + </completionHelp> + <constraint> + <validator name="ip-protocol"/> + </constraint> + </properties> +</leafNode> +<!-- include end from ip-protocol.xml.i --> diff --git a/interface-definitions/ipsec-settings.xml.in b/interface-definitions/ipsec-settings.xml.in index bc54baa27..0bcba9a84 100644 --- a/interface-definitions/ipsec-settings.xml.in +++ b/interface-definitions/ipsec-settings.xml.in @@ -7,6 +7,7 @@ <node name="options" owner="${vyos_conf_scripts_dir}/ipsec-settings.py"> <properties> <help>Global IPsec settings</help> + <priority>902</priority> </properties> <children> <leafNode name="disable-route-autoinstall"> diff --git a/interface-definitions/vpn_ipsec.xml.in b/interface-definitions/vpn_ipsec.xml.in index 426d7e71c..5bf0ef9ba 100644 --- a/interface-definitions/vpn_ipsec.xml.in +++ b/interface-definitions/vpn_ipsec.xml.in @@ -1,10 +1,15 @@ <?xml version="1.0"?> <interfaceDefinition> <node name="vpn"> + <properties> + <help>Virtual Private Network (VPN)</help> + <priority>900</priority> + </properties> <children> - <node name="nipsec" owner="${vyos_conf_scripts_dir}/vpn_ipsec.py"> + <node name="ipsec" owner="${vyos_conf_scripts_dir}/vpn_ipsec.py"> <properties> <help>VPN IP security (IPsec) parameters</help> + <priority>901</priority> </properties> <children> <leafNode name="auto-update"> @@ -296,7 +301,7 @@ </completionHelp> <valueHelp> <format>yes</format> - <description>Enable remote host re-autentication during an IKE rekey. Currently broken due to a strong swan bug</description> + <description>Enable remote host re-authentication during an IKE rekey. Currently broken due to a strongswan bug</description> </valueHelp> <valueHelp> <format>no</format> @@ -386,6 +391,7 @@ </properties> <children> <leafNode name="dh-group"> + <defaultValue>2</defaultValue> <properties> <help>dh-grouphelp</help> <completionHelp> @@ -621,7 +627,7 @@ </node> <node name="nat-networks"> <properties> - <help>Network Address Translation (NAT) networks</help> + <help>Network Address Translation (NAT) networks (Obsolete)</help> </properties> <children> <tagNode name="allowed-network"> @@ -655,7 +661,7 @@ </node> <leafNode name="nat-traversal"> <properties> - <help>Network Address Translation (NAT) traversal</help> + <help>Network Address Translation (NAT) traversal (Obsolete)</help> <completionHelp> <list>disable enable</list> </completionHelp> @@ -695,19 +701,18 @@ <help>Authentication [REQUIRED]</help> </properties> <children> - <node name="mode"> + <leafNode name="mode"> <properties> <help>Authentication mode</help> + <completionHelp> + <list>pre-shared-secret</list> + </completionHelp> + <valueHelp> + <format>pre-shared-secret</format> + <description>Use pre shared secret key</description> + </valueHelp> </properties> - <children> - <leafNode name="pre-shared-secret"> - <properties> - <help>Use pre-shared secret key</help> - <valueless/> - </properties> - </leafNode> - </children> - </node> + </leafNode> <leafNode name="pre-shared-secret"> <properties> <help>Pre-shared secret key</help> @@ -724,17 +729,21 @@ <help>DMVPN crypto configuration</help> </properties> <children> - <leafNode name="bind_child"> + <leafNode name="tunnel"> <properties> - <help>bind_child_help</help> - <valueless/> + <help>Tunnel interface associated with this configuration profile</help> + <valueHelp> + <format>txt</format> + <description>Tunnel interface associated with this configuration profile</description> + </valueHelp> + <multi/> </properties> </leafNode> </children> </node> <leafNode name="esp-group"> <properties> - <help>Esp group name [REQUIRED]</help> + <help>ESP group name [REQUIRED]</help> <completionHelp> <path>vpn ipsec esp-group</path> </completionHelp> @@ -742,7 +751,7 @@ </leafNode> <leafNode name="ike-group"> <properties> - <help>Ike group name [REQUIRED]</help> + <help>IKE group name [REQUIRED]</help> <completionHelp> <path>vpn ipsec ike-group</path> </completionHelp> @@ -909,6 +918,9 @@ <leafNode name="default-esp-group"> <properties> <help>Defult ESP group name</help> + <completionHelp> + <path>vpn ipsec esp-group</path> + </completionHelp> </properties> </leafNode> <leafNode name="description"> @@ -920,7 +932,9 @@ <leafNode name="dhcp-interface"> <properties> <help>DHCP interface to listen on</help> - <valueless/> + <completionHelp> + <script>${vyos_completion_dir}/list_interfaces.py</script> + </completionHelp> </properties> </leafNode> <leafNode name="force-encapsulation"> @@ -1091,12 +1105,7 @@ </leafNode> </children> </node> - <leafNode name="protocol"> - <properties> - <help>Protocol to encrypt</help> - <valueless/> - </properties> - </leafNode> + #include <include/ip-protocol.xml.i> <node name="remote"> <properties> <help>Remote parameters for interesting traffic</help> diff --git a/interface-definitions/vpn_rsa-keys.xml.in b/interface-definitions/vpn_rsa-keys.xml.in new file mode 100644 index 000000000..f65ae4b5a --- /dev/null +++ b/interface-definitions/vpn_rsa-keys.xml.in @@ -0,0 +1,46 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="vpn"> + <children> + <node name="rsa-keys" owner="${vyos_conf_scripts_dir}/vpn_rsa-keys.py"> + <properties> + <help>RSA keys</help> + </properties> + <children> + <node name="local-key"> + <properties> + <help>Local RSA key</help> + </properties> + <children> + <leafNode name="file"> + <properties> + <help>Local RSA key file location</help> + <valueHelp> + <format>txt</format> + <description>File in /config/auth or /config/ipsec.d/rsa-keys</description> + </valueHelp> + </properties> + </leafNode> + </children> + </node> + <tagNode name="rsa-key-name"> + <properties> + <help>Name of remote RSA key</help> + </properties> + <children> + <leafNode name="rsa-key"> + <properties> + <help>Remote RSA key</help> + <valueHelp> + <format>txt</format> + <description>Remote RSA key</description> + </valueHelp> + </properties> + </leafNode> + </children> + </tagNode> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/op-mode-definitions/vpn-ipsec.xml.in b/op-mode-definitions/vpn-ipsec.xml.in new file mode 100644 index 000000000..b836b193f --- /dev/null +++ b/op-mode-definitions/vpn-ipsec.xml.in @@ -0,0 +1,251 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interfaceDefinition> + <node name="generate"> + <children> + <node name="vpn"> + <properties> + <help>VPN key generation utility</help> + </properties> + <children> + <node name="rsa-key"> + <properties> + <help>Generate local RSA key (default: bits=2192)</help> + </properties> + <children> + <tagNode name="bits"> + <properties> + <help>Generate local RSA key with specified number of bits</help> + <completionHelp> + <list><16-4096></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="rsa-key" --bits="$5"</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="rsa-key" --bits="2192"</command> + </node> + <node name="x509"> + <properties> + <help>x509 key-pair generation tool</help> + </properties> + <children> + <tagNode name="key-pair"> + <properties> + <help>Generate x509 key-pair</help> + <completionHelp> + <list><common-name></list> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="x509" --name="$5"</command> + </tagNode> + </children> + </node> + </children> + </node> + </children> + </node> + <node name="reset"> + <children> + <node name="vpn"> + <properties> + <help>Reset Virtual Private Network (VPN) information</help> + </properties> + <children> + <tagNode name="ipsec-peer"> + <properties> + <help>Reset all tunnels for given peer</help> + <completionHelp> + <path>vpn ipsec site-to-site peer</path> + </completionHelp> + </properties> + <children> + <tagNode name="tunnel"> + <properties> + <help>Reset a specific tunnel for given peer</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="reset-peer" --name="$4" --tunnel="$6"</command> + </tagNode> + <node name="vti"> + <properties> + <help>Reset the VTI tunnel for given peer</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="reset-peer" --name="$4" --tunnel="vti"</command> + </node> + </children> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="reset-peer" --name="$4" --tunnel="all"</command> + </tagNode> + <tagNode name="ipsec-profile"> + <properties> + <help>Reset all tunnels for given DMVPN profile</help> + <completionHelp> + <path>vpn ipsec profile</path> + </completionHelp> + </properties> + <children> + <tagNode name="tunnel"> + <properties> + <help>Reset a specific tunnel for given DMVPN profile</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="reset-profile" --name="$4" --tunnel="$6"</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="reset-profile" --name="$4" --tunnel="all"</command> + </tagNode> + </children> + </node> + </children> + </node> + <node name="restart"> + <children> + <node name="vpn"> + <properties> + <help>Restart IPSec VPN</help> + </properties> + <command>if pgrep charon >/dev/null ; then sudo /usr/sbin/ipsec restart ; else echo "IPSec process not running" ; fi</command> + </node> + </children> + </node> + <node name="show"> + <children> + <node name="vpn"> + <properties> + <help>Show Virtual Private Network (VPN) information</help> + </properties> + <children> + <node name="debug"> + <properties> + <help>Show VPN debugging information</help> + </properties> + <children> + <tagNode name="peer"> + <properties> + <help>Show debugging information for a peer</help> + </properties> + <children> + <tagNode name="tunnel"> + <properties> + <help>Show debugging information for a peer's tunnel</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="vpn-debug" --name="$5" --tunnel="$7"</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="vpn-debug" --name="$5" --tunnel="all"</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="vpn-debug" --name="all"</command> + </node> + <node name="ike"> + <properties> + <help>Show Internet Key Exchange (IKE) information</help> + </properties> + <children> + <node name="rsa-keys"> + <properties> + <help>Show VPN RSA keys</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="rsa-key-show"</command> + </node> + <node name="sa"> + <properties> + <help>Show all currently active IKE Security Associations (SA)</help> + </properties> + <children> + <node name="nat-traversal"> + <properties> + <help>Show all currently active IKE Security Associations (SA) that are using NAT Traversal</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vpn_ike_sa.py --nat="yes"</command> + </node> + <tagNode name="peer"> + <properties> + <help>Show all currently active IKE Security Associations (SA) for a peer</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/vpn_ike_sa.py --peer="$6"</command> + </tagNode> + </children> + <command>sudo ${vyos_op_scripts_dir}/vpn_ike_sa.py</command> + </node> + <node name="secrets"> + <properties> + <help>Show all the pre-shared key secrets</help> + </properties> + <command>sudo cat /etc/ipsec.secrets | sed 's/#.*//'</command> + </node> + <node name="status"> + <properties> + <help>Show summary of IKE process information</help> + </properties> + <command>if pgrep charon >/dev/null ; then echo "Running: $(pgrep charon)" ; else echo "Process is not running" ; fi</command> + </node> + </children> + </node> + <node name="ipsec"> + <properties> + <help>Show Internet Protocol Security (IPSec) information</help> + </properties> + <children> + <node name="policy"> + <properties> + <help>Show the in-kernel crypto policies</help> + </properties> + <command>sudo ip xfrm policy list</command> + </node> + <node name="sa"> + <properties> + <help>Show all active IPSec Security Associations (SA)</help> + </properties> + <children> + <!-- + <node name="detail"> + <properties> + <help>Show Detail on all active IPSec Security Associations (SA)</help> + </properties> + <command></command> + </node> + <tagNode name="stats"> + <properties> + <help>Show statistics for all currently active IPSec Security Associations (SA)</help> + <valueHelp> + <format>txt</format> + <description>Show Statistics for SAs associated with a specific peer</description> + </valueHelp> + </properties> + <children> + <tagNode name="tunnel"> + <properties> + <help>Show Statistics for SAs associated with a specific peer</help> + </properties> + <command></command> + </tagNode> + </children> + <command></command> + </tagNode> + --> + <node name="verbose"> + <properties> + <help>Show Verbose Detail on all active IPSec Security Associations (SA)</help> + </properties> + <command>if pgrep charon >/dev/null ; then sudo /usr/sbin/ipsec statusall ; else echo "IPSec process not running" ; fi</command> + </node> + </children> + <command>if pgrep charon >/dev/null ; then sudo /usr/libexec/vyos/op_mode/show_ipsec_sa.py ; else echo "IPSec process not running" ; fi</command> + </node> + <node name="state"> + <properties> + <help>Show the in-kernel crypto state</help> + </properties> + <command>sudo ip xfrm state list</command> + </node> + <node name="status"> + <properties> + <help>Show status of IPSec process</help> + </properties> + <command>if pgrep charon >/dev/null ; then echo -e "IPSec Process Running: $(pgrep charon)\n$(sudo /usr/sbin/ipsec status)" ; else echo "IPSec process not running" ; fi</command> + </node> + </children> + </node> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/python/vyos/util.py b/python/vyos/util.py index b77c62cd5..16fcbf10b 100644 --- a/python/vyos/util.py +++ b/python/vyos/util.py @@ -664,6 +664,16 @@ def get_interface_config(interface): tmp = loads(cmd(f'ip -d -j link show {interface}'))[0] return tmp +def get_interface_address(interface): + """ Returns the used encapsulation protocol for given interface. + If interface does not exist, None is returned. + """ + if not os.path.exists(f'/sys/class/net/{interface}'): + return None + from json import loads + tmp = loads(cmd(f'ip -d -j addr show {interface}'))[0] + return tmp + def get_all_vrfs(): """ Return a dictionary of all system wide known VRF instances """ from json import loads @@ -676,3 +686,35 @@ def get_all_vrfs(): name = entry.pop('name') data[name] = entry return data + +def cidr_fit(cidr_a, cidr_b, both_directions = False): + """ + Does CIDR A fit inside of CIDR B? + + Credit: https://gist.github.com/magnetikonline/686fde8ee0bce4d4930ce8738908a009 + """ + def split_cidr(cidr): + part_list = cidr.split("/") + if len(part_list) == 1: + # if just an IP address, assume /32 + part_list.append("32") + + # return address and prefix size + return part_list[0].strip(), int(part_list[1]) + def address_to_bits(address): + # convert each octet of IP address to binary + bit_list = [bin(int(part)) for part in address.split(".")] + + # join binary parts together + # note: part[2:] to slice off the leading "0b" from bin() results + return "".join([part[2:].zfill(8) for part in bit_list]) + def binary_network_prefix(cidr): + # return CIDR as bits, to the length of the prefix size only (drop the rest) + address, prefix_size = split_cidr(cidr) + return address_to_bits(address)[:prefix_size] + + prefix_a = binary_network_prefix(cidr_a) + prefix_b = binary_network_prefix(cidr_b) + if both_directions: + return prefix_a.startswith(prefix_b) or prefix_b.startswith(prefix_a) + return prefix_a.startswith(prefix_b) diff --git a/smoketest/scripts/cli/test_vpn_ipsec.py b/smoketest/scripts/cli/test_vpn_ipsec.py new file mode 100644 index 000000000..4a3340ffb --- /dev/null +++ b/smoketest/scripts/cli/test_vpn_ipsec.py @@ -0,0 +1,149 @@ +#!/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'] +base_path = ['vpn', 'ipsec'] + +class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): + def tearDown(self): + self.cli_delete(base_path) + self.cli_delete(nhrp_path) + self.cli_delete(tunnel_path) + self.cli_commit() + + def test_site_to_site(self): + self.cli_delete(base_path) + + # IKE/ESP Groups + self.cli_set(base_path + ["esp-group", "MyESPGroup", "proposal", "1", "encryption", "aes128"]) + self.cli_set(base_path + ["esp-group", "MyESPGroup", "proposal", "1", "hash", "sha1"]) + self.cli_set(base_path + ["ike-group", "MyIKEGroup", "proposal", "1", "dh-group", "2"]) + self.cli_set(base_path + ["ike-group", "MyIKEGroup", "proposal", "1", "encryption", "aes128"]) + self.cli_set(base_path + ["ike-group", "MyIKEGroup", "proposal", "1", "hash", "sha1"]) + + # Site to site + self.cli_set(base_path + ["ipsec-interfaces", "interface", "eth0"]) + self.cli_set(base_path + ["site-to-site", "peer", "203.0.113.45", "authentication", "mode", "pre-shared-secret"]) + self.cli_set(base_path + ["site-to-site", "peer", "203.0.113.45", "authentication", "pre-shared-secret", "MYSECRETKEY"]) + self.cli_set(base_path + ["site-to-site", "peer", "203.0.113.45", "ike-group", "MyIKEGroup"]) + self.cli_set(base_path + ["site-to-site", "peer", "203.0.113.45", "default-esp-group", "MyESPGroup"]) + self.cli_set(base_path + ["site-to-site", "peer", "203.0.113.45", "local-address", "192.0.2.10"]) + self.cli_set(base_path + ["site-to-site", "peer", "203.0.113.45", "tunnel", "1", "protocol", "gre"]) + + self.cli_commit() + + ipsec_conf_lines = [ + 'authby = secret', + 'ike = aes128-sha1-modp1024!', + 'esp = aes128-sha1-modp1024!', + 'left = 192.0.2.10', + 'right = 203.0.113.45', + 'type = tunnel' + ] + + ipsec_secrets_lines = [ + '192.0.2.10 203.0.113.45 : PSK "MYSECRETKEY" # dhcp:no' + ] + + tmp_ipsec_conf = read_file('/etc/ipsec.conf') + + for line in ipsec_conf_lines: + self.assertIn(line, tmp_ipsec_conf) + + call('sudo chmod 644 /etc/ipsec.secrets') # Needs to be readable + tmp_ipsec_secrets = read_file('/etc/ipsec.secrets') + + for line in ipsec_secrets_lines: + self.assertIn(line, tmp_ipsec_secrets) + + # Check for running process + self.assertTrue(process_named_running('charon')) + + def test_dmvpn(self): + self.cli_delete(base_path) + 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(base_path + ["esp-group", "ESP-HUB", "compression", "disable"]) + self.cli_set(base_path + ["esp-group", "ESP-HUB", "lifetime", "1800"]) + self.cli_set(base_path + ["esp-group", "ESP-HUB", "mode", "transport"]) + self.cli_set(base_path + ["esp-group", "ESP-HUB", "pfs", "dh-group2"]) + self.cli_set(base_path + ["esp-group", "ESP-HUB", "proposal", "1", "encryption", "aes256"]) + self.cli_set(base_path + ["esp-group", "ESP-HUB", "proposal", "1", "hash", "sha1"]) + self.cli_set(base_path + ["esp-group", "ESP-HUB", "proposal", "2", "encryption", "3des"]) + self.cli_set(base_path + ["esp-group", "ESP-HUB", "proposal", "2", "hash", "md5"]) + self.cli_set(base_path + ["ike-group", "IKE-HUB", "ikev2-reauth", "no"]) + self.cli_set(base_path + ["ike-group", "IKE-HUB", "key-exchange", "ikev1"]) + self.cli_set(base_path + ["ike-group", "IKE-HUB", "lifetime", "3600"]) + self.cli_set(base_path + ["ike-group", "IKE-HUB", "proposal", "1", "dh-group", "2"]) + self.cli_set(base_path + ["ike-group", "IKE-HUB", "proposal", "1", "encryption", "aes256"]) + self.cli_set(base_path + ["ike-group", "IKE-HUB", "proposal", "1", "hash", "sha1"]) + self.cli_set(base_path + ["ike-group", "IKE-HUB", "proposal", "2", "dh-group", "2"]) + self.cli_set(base_path + ["ike-group", "IKE-HUB", "proposal", "2", "encryption", "aes128"]) + self.cli_set(base_path + ["ike-group", "IKE-HUB", "proposal", "2", "hash", "sha1"]) + + # Profile + self.cli_set(base_path + ["ipsec-interfaces", "interface", "eth0"]) + self.cli_set(base_path + ["profile", "NHRPVPN", "authentication", "mode", "pre-shared-secret"]) + self.cli_set(base_path + ["profile", "NHRPVPN", "authentication", "pre-shared-secret", "secret"]) + self.cli_set(base_path + ["profile", "NHRPVPN", "bind", "tunnel", "tun100"]) + self.cli_set(base_path + ["profile", "NHRPVPN", "esp-group", "ESP-HUB"]) + self.cli_set(base_path + ["profile", "NHRPVPN", "ike-group", "IKE-HUB"]) + + self.cli_commit() + + swanctl_lines = [ + 'proposals = aes256-sha1-modp1024,aes128-sha1-modp1024', + 'version = 1', + 'rekey_time = 3600s', + 'esp_proposals = aes256-sha1-modp1024,3des-md5-modp1024', + 'local_ts = dynamic[gre]', + 'remote_ts = dynamic[gre]', + 'mode = transport', + 'secret = secret' + ] + + tmp_swanctl_conf = read_file('/etc/swanctl/swanctl.conf') + + for line in swanctl_lines: + self.assertIn(line, tmp_swanctl_conf) + + self.assertTrue(process_named_running('charon')) + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/src/completion/list_protocols.sh b/src/completion/list_protocols.sh new file mode 100755 index 000000000..e9d50a70f --- /dev/null +++ b/src/completion/list_protocols.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +grep -v '^#' /etc/protocols | awk 'BEGIN {ORS=""} {if ($3) {print TRS $1; TRS=" "}}' diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index 969266c30..a1c36ea3b 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# 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 @@ -16,52 +16,446 @@ import os +from copy import deepcopy +from subprocess import DEVNULL from sys import exit +from time import sleep from vyos.config import Config +from vyos.configdiff import ConfigDiff from vyos.template import render -from vyos.util import call -from vyos.util import dict_search +from vyos.util import call, get_interface_address, process_named_running, run, cidr_fit from vyos import ConfigError from vyos import airbag -from pprint import pprint 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' +} + +ike_ciphers = {} +esp_ciphers = {} + +marks = {} +mark_base = 0x900000 +mark_index = 1 + +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/' + +conf = None + +def resync_l2tp(conf): + if not conf.exists('vpn l2tp remote-access ipsec-settings '): + 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(conf): + if not conf.exists('protocols nhrp tunnel'): + return + + run('/opt/vyatta/sbin/vyos-update-nhrp.pl --set_ipsec') + def get_config(config=None): + global conf if config: conf = config else: conf = Config() - base = ['vpn', 'nipsec'] + 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) + ipsec = conf.get_config_dict(base, 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 verify(ipsec): if not ipsec: return None + 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'] or '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 settings on site-to-site peer {peer}") + + for key in ['ca_cert_file', 'cert_file', 'crl_file']: + if key in peer_conf['authentication']['x509']: + path = peer_conf['authentication']['x509'][key] + if not os.path.exists(path if path.startswith(X509_PATH) else (X509_PATH + path)): + raise ConfigError(f"File not found for {key} on site-to-site peer {peer}") + + key_path = peer_conf['authentication']['x509']['key']['file'] + if not os.path.exists(key_path if key_path.startswith(X509_PATH) else (X509_PATH + 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(): + 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(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'] + if not os.path.exists(f'{DHCP_BASE}_{dhcp_interface}.conf'): + raise ConfigError(f"Invalid 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 get_vti_interface(vti_interface): + raise ConfigError(f'Invalid VTI interface on site-to-site peer {peer}') + + if 'vti' not in peer_conf and 'tunnel' not in peer_conf: + raise ConfigError(f"No vti or tunnels 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 get_rsa_local_key(): + global conf + base = ['vpn', 'rsa-keys'] + if not conf.exists(base + ['local-key', 'file']): + return False + + return conf.return_value(base + ['local-key', 'file']) + +def verify_rsa_local_key(): + file = get_rsa_local_key() + + if not file: + return False + + for path in LOCAL_KEY_PATHS: + if os.path.exists(path + file): + return path + file + + return False + +def verify_rsa_key(key_name): + global conf + base = ['vpn', 'rsa-keys'] + if not conf.exists(base): + return False + return conf.exists(base + ['rsa-key-name', key_name, 'rsa-key']) + def generate(ipsec): - if not ipsec: - return None + data = {} - return ipsec + if ipsec: + data = deepcopy(ipsec) + + 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 = peer_conf['authentication']['x509']['ca_cert_file'] + crl_file = peer_conf['authentication']['x509']['crl_file'] if 'crl_file' in peer_conf['authentication']['x509'] else None + + if not ca_cert_file.startswith(X509_PATH): + ca_cert_file = (X509_PATH + ca_cert_file) + + if crl_file and not crl_file.startswith(X509_PATH): + crl_file = (X509_PATH + crl_file) + + call(f'cp -f {ca_cert_file} {CA_PATH}/') + if 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 = get_dhcp_address(peer_conf['dhcp_interface']) + + 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'] + get_mark(vti_interface) + else: + for tunnel, tunnel_conf in peer_conf['tunnel'].items(): + if ('local' not in tunnel_conf or 'prefix' not in tunnel_conf['local']) or ('remote' not in tunnel_conf or 'prefix' not in tunnel_conf['remote']): + continue + local_prefix = tunnel_conf['local']['prefix'] + remote_prefix = tunnel_conf['remote']['prefix'] + passthrough = cidr_fit(local_prefix, remote_prefix) + data['site_to_site']['peer'][peer]['tunnel'][tunnel]['passthrough'] = passthrough + + data['authby'] = authby_translate + data['ciphers'] = {'ike': ike_ciphers, 'esp': esp_ciphers} + data['marks'] = marks + data['rsa_local_key'] = verify_rsa_local_key() + data['x509_path'] = X509_PATH + + 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 = ['dmn', 'mgr', 'ike', 'chd', 'job', 'cfg', 'knl', 'net', 'asn', 'enc', 'lib', 'esp', 'tls', 'tnc', 'imc', 'imv', 'pts'] + 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/swanctl/swanctl.conf", "ipsec/swanctl.conf.tmpl", data) def apply(ipsec): if not ipsec: - return None + if conf.exists('vpn l2tp '): + call('sudo /usr/sbin/ipsec rereadall') + call('sudo /usr/sbin/ipsec reload') + call('sudo /usr/sbin/swanctl -q') + else: + call('sudo /usr/sbin/ipsec stop') + cleanup_vti_interfaces() + resync_l2tp(conf) + resync_nhrp(conf) + return + + diff = ConfigDiff(conf, key_mangling=('-', '_')) + diff.set_level(['vpn', 'ipsec']) + + old_if, new_if = diff.get_value_diff(['ipsec-interfaces', 'interface']) + interface_change = (old_if != new_if) + + should_start = ('profile' in ipsec or ('site_to_site' in ipsec and 'peer' in ipsec['site_to_site'])) + + if should_start: + apply_vti_interfaces(ipsec) + else: + cleanup_vti_interfaces() + + if not process_named_running('charon'): + args = '' + if 'auto_update' in ipsec: + args = f'--auto-update {ipsec["auto_update"]}' + + if should_start: + call(f'sudo /usr/sbin/ipsec start {args}') + else: + if not should_start: + call('sudo /usr/sbin/ipsec stop') + elif 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') - pprint(ipsec) + resync_l2tp(conf) + resync_nhrp(conf) + +def apply_vti_interfaces(ipsec): + # While vyatta-vti-config.pl is still active, this interface will get deleted by cleanupVtiNotConfigured() + if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']: + for peer, peer_conf in ipsec['site_to_site']['peer'].items(): + if 'vti' in peer_conf and 'bind' in peer_conf['vti']: + vti_interface = peer_conf['vti']['bind'] + vti_conf = get_vti_interface(vti_interface) + if not vti_conf: + continue + vti_mtu = vti_conf['mtu'] if 'mtu' in vti_conf else 1500 + mark = get_mark(vti_interface) + + local_ip = '' + if 'local_address' in peer_conf: + local_ip = peer_conf['local_address'] + elif 'dhcp_interface' in peer_conf: + local_ip = get_dhcp_address(peer_conf['dhcp_interface']) + + call(f'sudo /usr/sbin/ip link delete {vti_interface} type vti', stderr=DEVNULL) + call(f'sudo /usr/sbin/ip link add {vti_interface} type vti local {local_ip} remote {peer} okey {mark} ikey {mark}') + call(f'sudo /usr/sbin/ip link set {vti_interface} mtu {vti_mtu}') + if 'address' in vti_conf: + address = vti_conf['address'] + if isinstance(address, list): + for addr in address: + call(f'sudo /usr/sbin/ip addr add {addr} dev {vti_interface}') + else: + call(f'sudo /usr/sbin/ip addr add {address} dev {vti_interface}') + + if 'description' in vti_conf: + description = vti_conf['description'] + call(f'sudo echo "{description}" > /sys/class/net/{vti_interface}/ifalias') + +def get_vti_interface(vti_interface): + global conf + section = conf.get_config_dict(['interfaces', 'vti'], get_first_key=True) + for interface, interface_conf in section.items(): + if interface == vti_interface: + return interface_conf + return None + +def cleanup_vti_interfaces(): + global conf + section = conf.get_config_dict(['interfaces', 'vti'], get_first_key=True) + for interface, interface_conf in section.items(): + call(f'sudo /usr/sbin/ip link delete {interface} type vti', stderr=DEVNULL) + +def get_mark(vti_interface): + global mark_base, mark_index + if vti_interface not in marks: + marks[vti_interface] = mark_base + mark_index + mark_index += 1 + return marks[vti_interface] + +def get_dhcp_address(interface): + addr = get_interface_address(interface) + if not addr: + return None + if len(addr['addr_info']) == 0: + return None + return addr['addr_info'][0]['local'] if __name__ == '__main__': try: - c = get_config() - verify(c) - generate(c) - apply(c) + ipsec = get_config() + verify(ipsec) + generate(ipsec) + apply(ipsec) except ConfigError as e: print(e) exit(1) diff --git a/src/conf_mode/vpn_rsa-keys.py b/src/conf_mode/vpn_rsa-keys.py new file mode 100755 index 000000000..a0e2e2690 --- /dev/null +++ b/src/conf_mode/vpn_rsa-keys.py @@ -0,0 +1,110 @@ +#!/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 base64 +import os +import struct + +from sys import exit + +from vyos.config import Config +from vyos.util import call +from vyos import ConfigError +from vyos import airbag +from Crypto.PublicKey.RSA import construct + +airbag.enable() + +LOCAL_KEY_PATHS = ['/config/auth/', '/config/ipsec.d/rsa-keys/'] +LOCAL_OUTPUT = '/etc/ipsec.d/certs/localhost.pub' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['vpn', 'rsa-keys'] + if not conf.exists(base): + return None + + return conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + +def verify(conf): + if not conf: + return + + if 'local_key' in conf and 'file' in conf['local_key']: + local_key = conf['local_key']['file'] + if not local_key: + raise ConfigError(f'Invalid local-key') + + if not get_local_key(local_key): + raise ConfigError(f'File not found for local-key: {local_key}') + +def get_local_key(local_key): + for path in LOCAL_KEY_PATHS: + if os.path.exists(path + local_key): + return path + local_key + return False + +def generate(conf): + if not conf: + return + + if 'local_key' in conf and 'file' in conf['local_key']: + local_key = conf['local_key']['file'] + local_key_path = get_local_key(local_key) + call(f'sudo /usr/bin/openssl rsa -in {local_key_path} -pubout -out {LOCAL_OUTPUT}') + + if 'rsa_key_name' in conf: + for key_name, key_conf in conf['rsa_key_name'].items(): + if 'rsa_key' not in key_conf: + continue + + remote_key = key_conf['rsa_key'] + + if remote_key[:2] == "0s": # Vyatta format + remote_key = migrate_from_vyatta_key(remote_key) + else: + remote_key = bytes('-----BEGIN PUBLIC KEY-----\n' + remote_key + '\n-----END PUBLIC KEY-----\n', 'utf-8') + + with open(f'/etc/ipsec.d/certs/{key_name}.pub', 'wb') as f: + f.write(remote_key) + +def migrate_from_vyatta_key(data): + data = base64.b64decode(data[2:]) + length = struct.unpack('B', data[:1])[0] + e = int.from_bytes(data[1:1+length], 'big') + n = int.from_bytes(data[1+length:], 'big') + pubkey = construct((n, e)) + return pubkey.exportKey(format='PEM') + +def apply(conf): + if not conf: + return + + call('sudo /usr/sbin/ipsec rereadall') + call('sudo /usr/sbin/ipsec reload') + +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/etc/dhcp/dhclient-exit-hooks.d/ipsec-dhclient-hook b/src/etc/dhcp/dhclient-exit-hooks.d/ipsec-dhclient-hook new file mode 100644 index 000000000..36edf04f3 --- /dev/null +++ b/src/etc/dhcp/dhclient-exit-hooks.d/ipsec-dhclient-hook @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +import os +import sys + +from vyos.util import call + +IPSEC_CONF="/etc/ipsec.conf" +IPSEC_SECRETS="/etc/ipsec.secrets" + +def getlines(file): + with open(file, 'r') as f: + return f.readlines() + +def writelines(file, lines): + with open(file, 'w') as f: + f.writelines(lines) + +if __name__ == '__main__': + interface = os.getenv('interface') + new_ip = os.getenv('new_ip_address') + old_ip = os.getenv('old_ip_address') + reason = os.getenv('reason') + + if (old_ip == new_ip and reason != 'BOUND') or reason in ['REBOOT', 'EXPIRE']: + sys.exit(0) + + conf_lines = getlines(IPSEC_CONF) + secrets_lines = getlines(IPSEC_SECRETS) + found = False + to_match = f'# dhcp:{interface}' + + for i, line in enumerate(conf_lines): + if line.find(to_match) > 0: + conf_lines[i] = line.replace(old_ip, new_ip) + found = True + + for i, line in enumerate(secrets_lines): + if line.find(to_match) > 0: + secrets_lines[i] = line.replace(old_ip, new_ip) + + if found: + writelines(IPSEC_CONF, conf_lines) + writelines(IPSEC_SECRETS, secrets_lines) + call('sudo /usr/sbin/ipsec rereadall') + call('sudo /usr/sbin/ipsec reload') diff --git a/src/etc/ipsec.d/key-pair.template b/src/etc/ipsec.d/key-pair.template new file mode 100644 index 000000000..56be97516 --- /dev/null +++ b/src/etc/ipsec.d/key-pair.template @@ -0,0 +1,67 @@ +[ req ] + default_bits = 2048 + default_keyfile = privkey.pem + distinguished_name = req_distinguished_name + string_mask = utf8only + attributes = req_attributes + dirstring_type = nobmp +# SHA-1 is deprecated, so use SHA-2 instead. + default_md = sha256 +# Extension to add when the -x509 option is used. + x509_extensions = v3_ca + +[ req_distinguished_name ] + countryName = Country Name (2 letter code) + countryName_min = 2 + countryName_max = 2 + ST = State Name + localityName = Locality Name (eg, city) + organizationName = Organization Name (eg, company) + organizationalUnitName = Organizational Unit Name (eg, department) + commonName = Common Name (eg, Device hostname) + commonName_max = 64 + emailAddress = Email Address + emailAddress_max = 40 +[ req_attributes ] + challengePassword = A challenge password (optional) + challengePassword_min = 4 + challengePassword_max = 20 +[ v3_ca ] + subjectKeyIdentifier=hash + authorityKeyIdentifier=keyid:always,issuer:always + basicConstraints = critical, CA:true + keyUsage = critical, digitalSignature, cRLSign, keyCertSign +[ v3_intermediate_ca ] +# Extensions for a typical intermediate CA (`man x509v3_config`). + subjectKeyIdentifier = hash + authorityKeyIdentifier = keyid:always,issuer + basicConstraints = critical, CA:true, pathlen:0 + keyUsage = critical, digitalSignature, cRLSign, keyCertSign +[ usr_cert ] +# Extensions for client certificates (`man x509v3_config`). + basicConstraints = CA:FALSE + nsCertType = client, email + nsComment = "OpenSSL Generated Client Certificate" + subjectKeyIdentifier = hash + authorityKeyIdentifier = keyid,issuer + keyUsage = critical, nonRepudiation, digitalSignature, keyEncipherment + extendedKeyUsage = clientAuth, emailProtection +[ server_cert ] +# Extensions for server certificates (`man x509v3_config`). + basicConstraints = CA:FALSE + nsCertType = server + nsComment = "OpenSSL Generated Server Certificate" + subjectKeyIdentifier = hash + authorityKeyIdentifier = keyid,issuer:always + keyUsage = critical, digitalSignature, keyEncipherment + extendedKeyUsage = serverAuth +[ crl_ext ] +# Extension for CRLs (`man x509v3_config`). + authorityKeyIdentifier=keyid:always +[ ocsp ] +# Extension for OCSP signing certificates (`man ocsp`). + basicConstraints = CA:FALSE + subjectKeyIdentifier = hash + authorityKeyIdentifier = keyid,issuer + keyUsage = critical, digitalSignature + extendedKeyUsage = critical, OCSPSigning diff --git a/src/etc/ipsec.d/vti-up-down b/src/etc/ipsec.d/vti-up-down new file mode 100644 index 000000000..416966056 --- /dev/null +++ b/src/etc/ipsec.d/vti-up-down @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +## Script called up strongswan to bring the vti interface up/down based on the state of the IPSec tunnel. +## Called as vti_up_down vti_intf_name + +import os +import sys + +from vyos.config import Config +from vyos.util import call, get_interface_config, get_interface_address + +def get_config(config, base): + if not config.exists(base): + return None + + return conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + +def get_dhcp_address(interface): + addr = get_interface_address(interface) + if not addr: + return None + if len(addr['addr_info']) == 0: + return None + return addr['addr_info'][0]['local'] + +if __name__ == '__main__': + verb = os.getenv('PLUTO_VERB') + connection = os.getenv('PLUTO_CONNECTION') + parent_conn = connection[:-3] + interface = sys.argv[1] + + print(f'vti-up-down: start: {verb} {connection} {interface}') + + if verb in ['up-client', 'up-host']: + call('sudo /usr/sbin/ip route delete default table 220') + + vti_base = ['interfaces', 'vti', interface] + ipsec_base = ['vpn', 'ipsec', 'site-to-site'] + + conf = Config() + vti_conf = get_config(conf, vti_base) + ipsec_conf = get_config(conf, ipsec_base) + + if not vti_conf or 'disable' in vti_conf or not ipsec_conf or 'peer' not in ipsec_conf: + print('vti-up-down: exit: vti not found, disabled or no peers found') + sys.exit(0) + + peer_conf = None + + for peer, peer_tmp_conf in ipsec_conf['peer'].items(): + if 'vti' in peer_tmp_conf and 'bind' in peer_tmp_conf['vti']: + bind = peer_tmp_conf['vti']['bind'] + if isinstance(bind, str): + bind = [bind] + if interface in bind: + peer_conf = peer_tmp_conf + break + + if not peer_conf: + print(f'vti-up-down: exit: No peer found for {interface}') + sys.exit(0) + + vti_link = get_interface_config(interface) + vti_link_up = vti_link['operstate'] == 'UP' if vti_link else False + + child_sa_installed = False + try: + child_sa_installed = (call(f'sudo /usr/sbin/swanctl -l -r -i {connection} {parent_conn} | grep -s -q state=INSTALLED', timeout = 5) == 0) + except: + print('vti-up-down: child-sa check failed') + + if verb in ['up-client', 'up-host']: + if not vti_link_up: + if 'dhcp_interface' in peer_conf: + local_ip = get_dhcp_address(peer_conf['dhcp_interface']) + call(f'sudo /usr/sbin/ip tunnel change {interface} local {local_ip}') + if child_sa_installed: + call(f'sudo /usr/sbin/ip link set {interface} up') + elif verb in ['down-client', 'down-host']: + if vti_link_up and not child_sa_installed: + call(f'sudo /usr/sbin/ip link set {interface} down') + + print('vti-up-down: finish') diff --git a/src/op_mode/vpn_ike_sa.py b/src/op_mode/vpn_ike_sa.py new file mode 100755 index 000000000..28da9f8dc --- /dev/null +++ b/src/op_mode/vpn_ike_sa.py @@ -0,0 +1,68 @@ +#!/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 argparse +import re +import vici + +ike_sa_peer_prefix = """\ +Peer ID / IP Local ID / IP +------------ -------------""" + +ike_sa_tunnel_prefix = """ + + State IKEVer Encrypt Hash D-H Group NAT-T A-Time L-Time + ----- ------ ------- ---- --------- ----- ------ ------""" + +def s(byte_string): + return str(byte_string, 'utf-8') + +def ike_sa(peer, nat): + session = vici.Session() + sas = session.list_sas() + peers = [] + for conn in sas: + for name, sa in conn.items(): + if peer and not name.startswith('peer-' + peer): + continue + if name.startswith('peer-') and name in peers: + continue + if nat and 'nat-local' not in sa: + continue + peers.append(name) + remote_str = f'{s(sa["remote-host"])} {s(sa["remote-id"])}' if s(sa['remote-id']) != '%any' else s(sa["remote-host"]) + local_str = f'{s(sa["local-host"])} {s(sa["local-id"])}' if s(sa['local-id']) != '%any' else s(sa["local-host"]) + print(ike_sa_peer_prefix) + print('%-39s %-39s' % (remote_str, local_str)) + state = 'up' if 'state' in sa and s(sa['state']) == 'ESTABLISHED' else 'down' + version = 'IKEv' + s(sa['version']) + encryption = f'{s(sa["encr-alg"])}_{s(sa["encr-keysize"])}' if 'encr-alg' in sa else 'n/a' + integrity = s(sa['integ-alg']) if 'integ-alg' in sa else 'n/a' + dh_group = s(sa['dh-group']) if 'dh-group' in sa else 'n/a' + natt = 'yes' if 'nat-local' in sa and s(sa['nat-local']) == 'yes' else 'no' + atime = s(sa['established']) if 'established' in sa else '0' + ltime = s(sa['rekey-time']) if 'rekey_time' in sa else '0' + print(ike_sa_tunnel_prefix) + print(' %-6s %-6s %-12s %-13s %-14s %-6s %-7s %-7s\n' % (state, version, encryption, integrity, dh_group, natt, atime, ltime)) + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--peer', help='Peer name', required=False) + parser.add_argument('--nat', help='NAT Traversal', required=False) + + args = parser.parse_args() + + ike_sa(args.peer, args.nat)
\ No newline at end of file diff --git a/src/op_mode/vpn_ipsec.py b/src/op_mode/vpn_ipsec.py new file mode 100755 index 000000000..434186abb --- /dev/null +++ b/src/op_mode/vpn_ipsec.py @@ -0,0 +1,206 @@ +#!/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 base64 +import os +import re +import struct +import sys +import argparse +from subprocess import TimeoutExpired + +from vyos.util import ask_yes_no, call, cmd, process_named_running +from Crypto.PublicKey.RSA import importKey + +RSA_LOCAL_KEY_PATH = '/config/ipsec.d/rsa-keys/localhost.key' +RSA_LOCAL_PUB_PATH = '/etc/ipsec.d/certs/localhost.pub' +RSA_KEY_PATHS = ['/config/auth', '/config/ipsec.d/rsa-keys'] + +X509_CONFIG_PATH = '/etc/ipsec.d/key-pair.template' +X509_PATH = '/config/auth/' + +IPSEC_CONF = '/etc/ipsec.conf' +SWANCTL_CONF = '/etc/swanctl.conf' + +def migrate_to_vyatta_key(path): + with open(path, 'r') as f: + key = importKey(f.read()) + e = key.e.to_bytes((key.e.bit_length() + 7) // 8, 'big') + n = key.n.to_bytes((key.n.bit_length() + 7) // 8, 'big') + return '0s' + str(base64.b64encode(struct.pack('B', len(e)) + e + n), 'ascii') + return None + +def find_rsa_keys(): + keys = [] + for path in RSA_KEY_PATHS: + if not os.path.exists(path): + continue + for filename in os.listdir(path): + full_path = os.path.join(path, filename) + if os.path.isfile(full_path) and full_path.endswith(".key"): + keys.append(full_path) + return keys + +def show_rsa_keys(): + for key_path in find_rsa_keys(): + print('Private key: ' + os.path.basename(key_path)) + print('Public key: ' + migrate_to_vyatta_key(key_path) + '\n') + +def generate_rsa_key(bits = 2192): + if (bits < 16 or bits > 4096) or bits % 16 != 0: + print('Invalid bit length') + return + + if os.path.exists(RSA_LOCAL_KEY_PATH): + if not ask_yes_no("A local RSA key file already exists and will be overwritten. Continue?"): + return + + print(f'Generating rsa-key to {RSA_LOCAL_KEY_PATH}') + + directory = os.path.dirname(RSA_LOCAL_KEY_PATH) + call(f'sudo mkdir -p {directory}') + result = call(f'sudo /usr/bin/openssl genrsa -out {RSA_LOCAL_KEY_PATH} {bits}') + + if result != 0: + print(f'Could not generate RSA key: {result}') + return + + call(f'sudo /usr/bin/openssl rsa -inform PEM -in {RSA_LOCAL_KEY_PATH} -pubout -out {RSA_LOCAL_PUB_PATH}') + + print('Your new local RSA key has been generated') + print('The public portion of the key is:\n') + print(migrate_to_vyatta_key(RSA_LOCAL_KEY_PATH)) + +def generate_x509_pair(name): + if os.path.exists(X509_PATH + name): + if not ask_yes_no("A certificate request with this name already exists and will be overwritten. Continue?"): + return + + result = os.system(f'openssl req -new -nodes -keyout {X509_PATH}{name}.key -out {X509_PATH}{name}.csr -config {X509_CONFIG_PATH}') + + if result != 0: + print(f'Could not generate x509 key-pair: {result}') + return + + print('Private key and certificate request has been generated') + print(f'CSR: {X509_PATH}{name}.csr') + print(f'Private key: {X509_PATH}{name}.key') + +def get_peer_connections(peer, tunnel, return_all = False): + search = rf'^conn (peer-{peer}-(tunnel-[\d]+|vti))$' + matches = [] + with open(IPSEC_CONF, 'r') as f: + for line in f.readlines(): + result = re.match(search, line) + if result: + suffix = f'tunnel-{tunnel}' if tunnel.isnumeric() else tunnel + if return_all or (result[2] == suffix): + matches.append(result[1]) + return matches + +def reset_peer(peer, tunnel): + if not peer: + print('Invalid peer, aborting') + return + + conns = get_peer_connections(peer, tunnel, return_all = (not tunnel or tunnel == 'all')) + + if not conns: + print('Tunnel(s) not found, aborting') + return + + result = True + for conn in conns: + try: + call(f'sudo /usr/sbin/ipsec down {conn}', timeout = 10) + call(f'sudo /usr/sbin/ipsec up {conn}', timeout = 10) + except TimeoutExpired as e: + print(f'Timed out while resetting {conn}') + result = False + + + print('Peer reset result: ' + ('success' if result else 'failed')) + +def get_profile_connection(profile, tunnel = None): + search = rf'(dmvpn-{profile}-[\w]+)' if tunnel == 'all' else rf'(dmvpn-{profile}-{tunnel})' + with open(SWANCTL_CONF, 'r') as f: + for line in f.readlines(): + result = re.search(search, line) + if result: + return result[1] + return None + +def reset_profile(profile, tunnel): + if not profile: + print('Invalid profile, aborting') + return + + if not tunnel: + print('Invalid tunnel, aborting') + return + + conn = get_profile_connection(profile) + + if not conn: + print('Profile not found, aborting') + return + + call(f'sudo /usr/sbin/ipsec down {conn}') + result = call(f'sudo /usr/sbin/ipsec up {conn}') + + print('Profile reset result: ' + ('success' if result == 0 else 'failed')) + +def debug_peer(peer, tunnel): + if not peer or peer == "all": + call('sudo /usr/sbin/ipsec statusall') + return + + if not tunnel or tunnel == 'all': + tunnel = '' + + conn = get_peer_connection(peer, tunnel) + + if not conn: + print('Peer not found, aborting') + return + + call(f'sudo /usr/sbin/ipsec statusall | grep {conn}') + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--action', help='Control action', required=True) + parser.add_argument('--bits', help='Bits for rsa-key', required=False) + parser.add_argument('--name', help='Name for x509 key-pair, peer for reset', required=False) + parser.add_argument('--tunnel', help='Specific tunnel of peer', required=False) + + args = parser.parse_args() + + if args.action == 'rsa-key': + bits = int(args.bits) if args.bits else 2192 + generate_rsa_key(bits) + elif args.action == 'rsa-key-show': + show_rsa_keys() + elif args.action == 'x509': + if not args.name: + print('Invalid name for key-pair, aborting.') + sys.exit(0) + generate_x509_pair(args.name) + elif args.action == 'reset-peer': + reset_peer(args.name, args.tunnel) + elif args.action == "reset-profile": + reset_profile(args.name, args.tunnel) + elif args.action == "vpn-debug": + debug_peer(args.name, args.tunnel) |