#! @PYTHON3@ # Copyright (c) 2017 Nicira, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at: # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import argparse import copy import ipaddress import os import re import subprocess import sys from string import Template import ovs.daemon import ovs.db.idl import ovs.dirs import ovs.unixctl import ovs.unixctl.server import ovs.util import ovs.vlog FILE_HEADER = "# Generated by ovs-monitor-ipsec...do not modify by hand!\n\n" transp_tmpl = {"gre": Template("""\ conn $ifname-$version $auth_section leftprotoport=gre rightprotoport=gre """), "gre64": Template("""\ conn $ifname-$version $auth_section leftprotoport=gre rightprotoport=gre """), "geneve": Template("""\ conn $ifname-in-$version $auth_section leftprotoport=udp/6081 rightprotoport=udp conn $ifname-out-$version $auth_section leftprotoport=udp rightprotoport=udp/6081 """), "stt": Template("""\ conn $ifname-in-$version $auth_section leftprotoport=tcp/7471 rightprotoport=tcp conn $ifname-out-$version $auth_section leftprotoport=tcp rightprotoport=tcp/7471 """), "vxlan": Template("""\ conn $ifname-in-$version $auth_section leftprotoport=udp/4789 rightprotoport=udp conn $ifname-out-$version $auth_section leftprotoport=udp rightprotoport=udp/4789 """)} vlog = ovs.vlog.Vlog("ovs-monitor-ipsec") exiting = False monitor = None xfrm = None class XFRM(object): """This class is a simple wrapper around ip-xfrm (8) command line utility. We are using this class only for informational purposes so that ovs-monitor-ipsec could verify that IKE keying daemon has installed IPsec policies and security associations into kernel as expected.""" def __init__(self, ip_root_prefix): self.IP = ip_root_prefix + "/sbin/ip" def get_policies(self): """This function returns IPsec policies (from kernel) in a dictionary where is destination IPv4 address and is SELECTOR of the IPsec policy.""" policies = {} proc = subprocess.Popen([self.IP, 'xfrm', 'policy'], stdout=subprocess.PIPE) while True: line = proc.stdout.readline().strip().decode() if line == '': break a = line.split(" ") if len(a) >= 4 and a[0] == "src" and a[2] == "dst": dst = (a[3].split("/"))[0] if dst not in policies: policies[dst] = [] policies[dst].append(line) src = (a[3].split("/"))[0] if src not in policies: policies[src] = [] policies[src].append(line) return policies def get_securities(self): """This function returns IPsec security associations (from kernel) in a dictionary where is destination IPv4 address and is SELECTOR.""" securities = {} proc = subprocess.Popen([self.IP, 'xfrm', 'state'], stdout=subprocess.PIPE) while True: line = proc.stdout.readline().strip().decode() if line == '': break a = line.split(" ") if len(a) >= 4 and a[0] == "sel" \ and a[1] == "src" and a[3] == "dst": remote_ip = a[4].rstrip().split("/")[0] local_ip = a[2].rstrip().split("/")[0] if remote_ip not in securities: securities[remote_ip] = [] securities[remote_ip].append(line) if local_ip not in securities: securities[local_ip] = [] securities[local_ip].append(line) return securities class StrongSwanHelper(object): """This class does StrongSwan specific configurations.""" STRONGSWAN_CONF = """%s charon { plugins { kernel-netlink { set_proto_port_transport_sa = yes xfrm_ack_expires = 10 } gcm { load = yes } } load_modular = yes } """ % (FILE_HEADER) CONF_HEADER = """%s config setup uniqueids=yes conn %%default keyingtries=%%forever type=transport keyexchange=ikev2 auto=route ike=aes256gcm16-sha256-modp2048 esp=aes256gcm16-modp2048 """ % (FILE_HEADER) CA_SECTION = """ca ca_auth cacert=%s """ SHUNT_POLICY = """conn prevent_unencrypted_gre type=drop leftprotoport=gre mark={0} conn prevent_unencrypted_geneve type=drop leftprotoport=udp/6081 mark={0} conn prevent_unencrypted_stt type=drop leftprotoport=tcp/7471 mark={0} conn prevent_unencrypted_vxlan type=drop leftprotoport=udp/4789 mark={0} """ auth_tmpl = {"psk": Template("""\ left=%any right=$remote_ip authby=psk"""), "pki_remote": Template("""\ left=%any right=$remote_ip leftid=$local_name rightid=$remote_name leftcert=$certificate rightcert=$remote_cert"""), "pki_ca": Template("""\ left=%any right=$remote_ip leftid=$local_name rightid=$remote_name leftcert=$certificate""")} def __init__(self, root_prefix): self.CHARON_CONF = root_prefix + "/etc/strongswan.d/ovs.conf" self.IPSEC = root_prefix + "/usr/sbin/ipsec" self.IPSEC_CONF = root_prefix + "/etc/ipsec.conf" self.IPSEC_SECRETS = root_prefix + "/etc/ipsec.secrets" self.conf_file = None self.secrets_file = None def restart_ike_daemon(self): """This function restarts StrongSwan.""" f = open(self.CHARON_CONF, "w") f.write(self.STRONGSWAN_CONF) f.close() f = open(self.IPSEC_CONF, "w") f.write(self.CONF_HEADER) f.close() f = open(self.IPSEC_SECRETS, "w") f.write(FILE_HEADER) f.close() vlog.info("Restarting StrongSwan") subprocess.call([self.IPSEC, "restart"]) def get_active_conns(self): """This function parses output from 'ipsec status' command. It returns dictionary where is interface name (as in OVSDB) and is another dictionary. This another dictionary uses strongSwan connection name as and more detailed sample line from the parsed outpus as . """ conns = {} proc = subprocess.Popen([self.IPSEC, 'status'], stdout=subprocess.PIPE) while True: line = proc.stdout.readline().strip().decode() if line == '': break tunnel_name = line.split(":") if len(tunnel_name) < 2: continue m = re.match(r"(.*)(-in-\d+|-out-\d+|-\d+).*", tunnel_name[0]) if not m: continue ifname = m.group(1) if ifname not in conns: conns[ifname] = {} (conns[ifname])[tunnel_name[0]] = line return conns def config_init(self): self.conf_file = open(self.IPSEC_CONF, "w") self.secrets_file = open(self.IPSEC_SECRETS, "w") self.conf_file.write(self.CONF_HEADER) self.secrets_file.write(FILE_HEADER) def config_global(self, monitor): """Configure the global state of IPsec tunnels.""" needs_refresh = False if monitor.conf_in_use != monitor.conf: monitor.conf_in_use = copy.deepcopy(monitor.conf) needs_refresh = True # Configure the shunt policy if monitor.conf_in_use["skb_mark"]: skb_mark = monitor.conf_in_use["skb_mark"] self.conf_file.write(self.SHUNT_POLICY.format(skb_mark)) # Configure the CA cert if monitor.conf_in_use["pki"]["ca_cert"]: cacert = monitor.conf_in_use["pki"]["ca_cert"] self.conf_file.write(self.CA_SECTION % cacert) return needs_refresh def config_tunnel(self, tunnel): if tunnel.conf["psk"]: self.secrets_file.write('%%any %s : PSK "%s"\n' % (tunnel.conf["remote_ip"], tunnel.conf["psk"])) auth_section = self.auth_tmpl["psk"].substitute(tunnel.conf) else: self.secrets_file.write("%%any %s : RSA %s\n" % (tunnel.conf["remote_ip"], tunnel.conf["private_key"])) if tunnel.conf["remote_cert"]: tmpl = self.auth_tmpl["pki_remote"] auth_section = tmpl.substitute(tunnel.conf) else: tmpl = self.auth_tmpl["pki_ca"] auth_section = tmpl.substitute(tunnel.conf) if "custom_options" in tunnel.conf: for key, value in tunnel.conf["custom_options"].items(): auth_section += "\n " + key + "=" + value vals = tunnel.conf.copy() vals["auth_section"] = auth_section vals["version"] = tunnel.version conf_text = transp_tmpl[tunnel.conf["tunnel_type"]].substitute(vals) self.conf_file.write(conf_text) def config_fini(self): self.secrets_file.close() self.conf_file.close() self.secrets_file = None self.conf_file = None def refresh(self, monitor): """This functions refreshes strongSwan configuration. Behind the scenes this function calls: 1. once "ipsec update" command that tells strongSwan to load all new tunnels from "ipsec.conf"; and 2. once "ipsec rereadsecrets" command that tells strongswan to load secrets from "ipsec.conf" file 3. for every removed tunnel "ipsec stroke down-nb " command that removes old tunnels. Once strongSwan vici bindings will be distributed with major Linux distributions this function could be simplified.""" vlog.info("Refreshing StrongSwan configuration") proc = subprocess.Popen([self.IPSEC, "update"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) outs, errs = proc.communicate() if proc.returncode != 0: vlog.err("StrongSwan failed to update configuration:\n" "%s \n %s" % (str(outs), str(errs))) subprocess.call([self.IPSEC, "rereadsecrets"]) # "ipsec update" command does not remove those tunnels that were # updated or that disappeared from the ipsec.conf file. So, we have # to manually remove them by calling "ipsec stroke down-nb " # command. We use number to tell apart tunnels that # were just updated. # "ipsec down-nb" command is designed to be non-blocking (opposed # to "ipsec down" command). This means that we should not be concerned # about possibility of ovs-monitor-ipsec to block for each tunnel # while strongSwan sends IKE messages over Internet. conns_dict = self.get_active_conns() for ifname, conns in conns_dict.items(): tunnel = monitor.tunnels.get(ifname) for conn in conns: # IPsec "connection" names that we choose in strongswan # must start with Interface name if not conn.startswith(ifname): vlog.err("%s does not start with %s" % (conn, ifname)) continue # version number should be the first integer after # interface name in IPsec "connection" try: ver = int(re.findall(r'\d+', conn[len(ifname):])[0]) except IndexError: vlog.err("%s does not contain version number") continue except ValueError: vlog.err("%s does not contain version number") continue if not tunnel or tunnel.version != ver: vlog.info("%s is outdated %u" % (conn, ver)) subprocess.call([self.IPSEC, "stroke", "down-nb", conn]) class LibreSwanHelper(object): """This class does LibreSwan specific configurations.""" CONF_HEADER = """%s config setup uniqueids=yes conn %%default keyingtries=%%forever type=transport auto=route ike=aes_gcm256-sha2_256 esp=aes_gcm256 ikev2=insist """ % (FILE_HEADER) SHUNT_POLICY = """conn prevent_unencrypted_gre type=drop left=%defaultroute leftprotoport=gre mark={0} conn prevent_unencrypted_geneve type=drop left=%defaultroute leftprotoport=udp/6081 mark={0} conn prevent_unencrypted_stt type=drop left=%defaultroute leftprotoport=tcp/7471 mark={0} conn prevent_unencrypted_vxlan type=drop left=%defaultroute leftprotoport=udp/4789 mark={0} """ IPV6_CONN = """\ hostaddrfamily=ipv6 clientaddrfamily=ipv6 """ auth_tmpl = {"psk": Template("""\ left=$local_ip right=$remote_ip authby=secret"""), "pki_remote": Template("""\ left=$local_ip right=$remote_ip leftid=@$local_name rightid=@$remote_name leftcert="ovs_certkey_$local_name" rightcert="ovs_cert_$remote_name" leftrsasigkey=%cert"""), "pki_ca": Template("""\ left=$local_ip right=$remote_ip leftid=@$local_name rightid=@$remote_name leftcert="ovs_certkey_$local_name" leftrsasigkey=%cert rightca=%same""")} CERT_PREFIX = "ovs_cert_" CERTKEY_PREFIX = "ovs_certkey_" def __init__(self, libreswan_root_prefix, args): ipsec_conf = args.ipsec_conf if args.ipsec_conf else "/etc/ipsec.conf" ipsec_d = args.ipsec_d if args.ipsec_d else "/etc/ipsec.d" ipsec_secrets = (args.ipsec_secrets if args.ipsec_secrets else "/etc/ipsec.secrets") ipsec_ctl = (args.ipsec_ctl if args.ipsec_ctl else "/run/pluto/pluto.ctl") self.IPSEC = libreswan_root_prefix + "/usr/sbin/ipsec" self.IPSEC_CONF = libreswan_root_prefix + ipsec_conf self.IPSEC_SECRETS = libreswan_root_prefix + ipsec_secrets self.IPSEC_D = "sql:" + libreswan_root_prefix + ipsec_d self.IPSEC_CTL = libreswan_root_prefix + ipsec_ctl self.conf_file = None self.secrets_file = None vlog.dbg("Using: " + self.IPSEC) vlog.dbg("Configuration file: " + self.IPSEC_CONF) vlog.dbg("Secrets file: " + self.IPSEC_SECRETS) vlog.dbg("ipsec.d: " + self.IPSEC_D) vlog.dbg("Pluto socket: " + self.IPSEC_CTL) def restart_ike_daemon(self): """This function restarts LibreSwan.""" # Remove the stale information from the NSS database self._nss_clear_database() f = open(self.IPSEC_CONF, "w") f.write(self.CONF_HEADER) f.close() f = open(self.IPSEC_SECRETS, "w") f.write(FILE_HEADER) f.close() vlog.info("Restarting LibreSwan") subprocess.call([self.IPSEC, "restart"]) def config_init(self): self.conf_file = open(self.IPSEC_CONF, "w") self.secrets_file = open(self.IPSEC_SECRETS, "w") self.conf_file.write(self.CONF_HEADER) self.secrets_file.write(FILE_HEADER) def config_global(self, monitor): """Configure the global state of IPsec tunnels.""" needs_refresh = False if monitor.conf_in_use["pki"] != monitor.conf["pki"]: # Clear old state if monitor.conf_in_use["pki"]["certificate"]: local_name = monitor.conf_in_use["pki"]["local_name"] self._nss_delete_cert_and_key(self.CERTKEY_PREFIX + local_name) if monitor.conf_in_use["pki"]["ca_cert"]: self._nss_delete_cert(self.CERT_PREFIX + "cacert") # Load new state if monitor.conf["pki"]["certificate"]: cert = monitor.conf["pki"]["certificate"] key = monitor.conf["pki"]["private_key"] name = monitor.conf["pki"]["local_name"] name = self.CERTKEY_PREFIX + name self._nss_import_cert_and_key(cert, key, name) if monitor.conf["pki"]["ca_cert"]: self._nss_import_cert(monitor.conf["pki"]["ca_cert"], self.CERT_PREFIX + "cacert", 'CT,,') monitor.conf_in_use["pki"] = copy.deepcopy(monitor.conf["pki"]) needs_refresh = True # Configure the shunt policy if monitor.conf["skb_mark"]: skb_mark = monitor.conf["skb_mark"] self.conf_file.write(self.SHUNT_POLICY.format(skb_mark)) # Will update conf_in_use later in the 'refresh' method if monitor.conf_in_use["skb_mark"] != monitor.conf["skb_mark"]: needs_refresh = True return needs_refresh def config_tunnel(self, tunnel): if tunnel.conf["psk"]: self.secrets_file.write('%%any %s : PSK "%s"\n' % (tunnel.conf["remote_ip"], tunnel.conf["psk"])) auth_section = self.auth_tmpl["psk"].substitute(tunnel.conf) elif tunnel.conf["remote_cert"]: auth_section = self.auth_tmpl["pki_remote"].substitute(tunnel.conf) self._nss_import_cert(tunnel.conf["remote_cert"], self.CERT_PREFIX + tunnel.conf["remote_name"], 'P,P,P') else: auth_section = self.auth_tmpl["pki_ca"].substitute(tunnel.conf) if tunnel.conf["address_family"] == "IPv6": auth_section = self.IPV6_CONN + auth_section if "custom_options" in tunnel.conf: for key, value in tunnel.conf["custom_options"].items(): auth_section += "\n " + key + "=" + value vals = tunnel.conf.copy() vals["auth_section"] = auth_section vals["version"] = tunnel.version conf_text = transp_tmpl[tunnel.conf["tunnel_type"]].substitute(vals) self.conf_file.write(conf_text) def config_fini(self): self.secrets_file.close() self.conf_file.close() self.secrets_file = None self.conf_file = None def clear_tunnel_state(self, tunnel): if tunnel.conf["remote_cert"]: name = self.CERT_PREFIX + tunnel.conf["remote_name"] self._nss_delete_cert(name) def refresh(self, monitor): vlog.info("Refreshing LibreSwan configuration") subprocess.call([self.IPSEC, "auto", "--ctlsocket", self.IPSEC_CTL, "--config", self.IPSEC_CONF, "--rereadsecrets"]) tunnels = set(monitor.tunnels.keys()) # Delete old connections conns_dict = self.get_active_conns() for ifname, conns in conns_dict.items(): tunnel = monitor.tunnels.get(ifname) for conn in conns: # IPsec "connection" names must start with Interface name if not conn.startswith(ifname): vlog.err("%s does not start with %s" % (conn, ifname)) continue # version number should be the first integer after # interface name in IPsec "connection" try: ver = int(re.findall(r'\d+', conn[len(ifname):])[0]) except ValueError: vlog.err("%s does not contain version number") continue except IndexError: vlog.err("%s does not contain version number") continue if not tunnel or tunnel.version != ver: vlog.info("%s is outdated %u" % (conn, ver)) subprocess.call([self.IPSEC, "auto", "--ctlsocket", self.IPSEC_CTL, "--config", self.IPSEC_CONF, "--delete", conn]) elif ifname in tunnels: tunnels.remove(ifname) # Activate new connections for name in tunnels: ver = monitor.tunnels[name].version if monitor.tunnels[name].conf["tunnel_type"] == "gre": conn = "%s-%s" % (name, ver) self._start_ipsec_connection(conn) else: conn_in = "%s-in-%s" % (name, ver) conn_out = "%s-out-%s" % (name, ver) self._start_ipsec_connection(conn_in) self._start_ipsec_connection(conn_out) # Update shunt policy if changed if monitor.conf_in_use["skb_mark"] != monitor.conf["skb_mark"]: if monitor.conf["skb_mark"]: subprocess.call([self.IPSEC, "auto", "--config", self.IPSEC_CONF, "--ctlsocket", self.IPSEC_CTL, "--add", "--asynchronous", "prevent_unencrypted_gre"]) subprocess.call([self.IPSEC, "auto", "--config", self.IPSEC_CONF, "--ctlsocket", self.IPSEC_CTL, "--add", "--asynchronous", "prevent_unencrypted_geneve"]) subprocess.call([self.IPSEC, "auto", "--config", self.IPSEC_CONF, "--ctlsocket", self.IPSEC_CTL, "--add", "--asynchronous", "prevent_unencrypted_stt"]) subprocess.call([self.IPSEC, "auto", "--config", self.IPSEC_CONF, "--ctlsocket", self.IPSEC_CTL, "--add", "--asynchronous", "prevent_unencrypted_vxlan"]) else: subprocess.call([self.IPSEC, "auto", "--config", self.IPSEC_CONF, "--ctlsocket", self.IPSEC_CTL, "--delete", "--asynchronous", "prevent_unencrypted_gre"]) subprocess.call([self.IPSEC, "auto", "--config", self.IPSEC_CONF, "--ctlsocket", self.IPSEC_CTL, "--delete", "--asynchronous", "prevent_unencrypted_geneve"]) subprocess.call([self.IPSEC, "auto", "--config", self.IPSEC_CONF, "--ctlsocket", self.IPSEC_CTL, "--delete", "--asynchronous", "prevent_unencrypted_stt"]) subprocess.call([self.IPSEC, "auto", "--config", self.IPSEC_CONF, "--ctlsocket", self.IPSEC_CTL, "--delete", "--asynchronous", "prevent_unencrypted_vxlan"]) monitor.conf_in_use["skb_mark"] = monitor.conf["skb_mark"] def get_active_conns(self): """This function parses output from 'ipsec status' command. It returns dictionary where is interface name (as in OVSDB) and is another dictionary. This another dictionary uses LibreSwan connection name as and more detailed sample line from the parsed outpus as . """ conns = {} proc = subprocess.Popen([self.IPSEC, 'status', '--ctlsocket', self.IPSEC_CTL], stdout=subprocess.PIPE) while True: line = proc.stdout.readline().strip().decode() if line == '': break m = re.search(r"#\d+: \"(.*)\".*", line) if not m: continue conn = m.group(1) m = re.match(r"(.*)(-in-\d+|-out-\d+)", conn) if not m: # GRE connections have format - m = re.match(r"(.*)(-\d+)", conn) if not m: continue ifname = m.group(1) if ifname not in conns: conns[ifname] = {} (conns[ifname])[conn] = line return conns def _start_ipsec_connection(self, conn): # In a corner case, LibreSwan daemon restarts for some reason and # the "ipsec auto --start" command is lost. Just retry to make sure # the command is received by LibreSwan. while True: proc = subprocess.Popen([self.IPSEC, "auto", "--config", self.IPSEC_CONF, "--ctlsocket", self.IPSEC_CTL, "--start", "--asynchronous", conn], stdout=subprocess.PIPE, stderr=subprocess.PIPE) perr = str(proc.stderr.read()) pout = str(proc.stdout.read()) if not re.match(r".*Connection refused.*", perr) and \ not re.match(r".*need --listen.*", pout): break if re.match(r".*[F|f]ailed to initiate connection.*", pout): vlog.err('Failed to initiate connection through' ' Interface %s.\n' % (conn.split('-')[0])) vlog.err(pout) def _nss_clear_database(self): """Remove all OVS IPsec related state from the NSS database""" try: proc = subprocess.Popen(['certutil', '-L', '-d', self.IPSEC_D], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) lines = proc.stdout.readlines() for line in lines: s = line.strip().split() if len(s) < 1: continue name = s[0] if name.startswith(self.CERT_PREFIX): self._nss_delete_cert(name) elif name.startswith(self.CERTKEY_PREFIX): self._nss_delete_cert_and_key(name) except Exception as e: vlog.err("Failed to clear NSS database.\n" + str(e)) def _nss_import_cert(self, cert, name, cert_type): """Cert_type is 'CT,,' for the CA certificate and 'P,P,P' for the normal certificate.""" try: proc = subprocess.Popen(['certutil', '-A', '-a', '-i', cert, '-d', self.IPSEC_D, '-n', name, '-t', cert_type], stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc.wait() if proc.returncode: raise Exception(proc.stderr.read()) except Exception as e: vlog.err("Failed to import certificate into NSS.\n" + str(e)) def _nss_delete_cert(self, name): try: proc = subprocess.Popen(['certutil', '-D', '-d', self.IPSEC_D, '-n', name], stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc.wait() if proc.returncode: raise Exception(proc.stderr.read()) except Exception as e: vlog.err("Failed to delete certificate from NSS.\n" + str(e)) def _nss_import_cert_and_key(self, cert, key, name): try: # Avoid deleting other files path = os.path.abspath('/tmp/%s.p12' % name) if not path.startswith('/tmp/'): raise Exception("Illegal certificate name!") # Create p12 file from pem files proc = subprocess.Popen(['openssl', 'pkcs12', '-export', '-in', cert, '-inkey', key, '-out', path, '-name', name, '-passout', 'pass:'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc.wait() if proc.returncode: raise Exception(proc.stderr.read()) # Load p12 file to the database proc = subprocess.Popen(['pk12util', '-i', path, '-d', self.IPSEC_D, '-W', ''], stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc.wait() if proc.returncode: raise Exception(proc.stderr.read()) except Exception as e: vlog.err("Import cert and key failed.\n" + str(e)) os.remove(path) def _nss_delete_cert_and_key(self, name): try: # Delete certificate and private key proc = subprocess.Popen(['certutil', '-F', '-d', self.IPSEC_D, '-n', name], stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc.wait() if proc.returncode: raise Exception(proc.stderr.read()) except Exception as e: vlog.err("Delete cert and key failed.\n" + str(e)) class IPsecTunnel(object): """This is the base class for IPsec tunnel.""" unixctl_config_tmpl = Template("""\ Tunnel Type: $tunnel_type Local IP: $local_ip Remote IP: $remote_ip Address Family: $address_family SKB mark: $skb_mark Local cert: $certificate Local name: $local_name Local key: $private_key Remote cert: $remote_cert Remote name: $remote_name CA cert: $ca_cert PSK: $psk Custom Options: $custom_options """) unixctl_status_tmpl = Template("""\ Ofport: $ofport CFM state: $cfm_state """) def __init__(self, name, row): self.name = name # 'name' will not change because it is key in OVSDB self.version = 0 # 'version' is increased on configuration changes self.last_refreshed_version = -1 self.state = "INIT" self.conf = {} self.status = {} self.update_conf(row) def update_conf(self, row): """This function updates IPsec tunnel configuration by using 'row' from OVSDB interface table. If configuration was actually changed in OVSDB then this function returns True. Otherwise, it returns False.""" ret = False options = row.options remote_cert = options.get("remote_cert") remote_name = options.get("remote_name") if remote_cert: remote_name = monitor._get_cn_from_cert(remote_cert) new_conf = { "ifname": self.name, "tunnel_type": row.type, "local_ip": options.get("local_ip", "%defaultroute"), "remote_ip": options.get("remote_ip"), "address_family": self._get_conn_address_family( options.get("remote_ip"), options.get("local_ip")), "skb_mark": monitor.conf["skb_mark"], "certificate": monitor.conf["pki"]["certificate"], "private_key": monitor.conf["pki"]["private_key"], "ca_cert": monitor.conf["pki"]["ca_cert"], "remote_cert": remote_cert, "remote_name": remote_name, "local_name": monitor.conf["pki"]["local_name"], "psk": options.get("psk"), "custom_options": {}} # add custom ipsec options to the connection for key, value in options.items(): if key.startswith("ipsec_"): new_conf["custom_options"][key[len("ipsec_"):]] = value if self.conf != new_conf: # Configuration was updated in OVSDB. Validate it and figure # out what to do next with this IPsec tunnel. Also, increment # version number of this IPsec tunnel so that we could tell # apart old and new tunnels in "ipsec status" output. self.version += 1 ret = True self.conf = new_conf if self._is_valid_tunnel_conf(): self.state = "CONFIGURED" else: vlog.warn("%s contains invalid configuration%s" % (self.name, self.invalid_reason)) self.state = "INVALID" new_status = { "cfm_state": "Up" if row.cfm_fault == [False] else "Down" if row.cfm_fault == [True] else "Disabled", "ofport": "Not assigned" if (row.ofport in [[], [-1]]) else row.ofport[0]} if self.status != new_status: # Tunnel has become unhealthy or ofport changed. Simply log this. vlog.dbg("%s changed status from %s to %s" % (self.name, str(self.status), str(new_status))) self.status = new_status return ret def mark_for_removal(self): """This function marks tunnel for removal.""" self.version += 1 self.state = "REMOVED" def show(self, policies, securities, conns): state = self.state if self.state == "INVALID": state += self.invalid_reason header = "Interface name: %s v%u (%s)\n" % (self.name, self.version, state) conf = self.unixctl_config_tmpl.substitute(self.conf) status = self.unixctl_status_tmpl.substitute(self.status) spds = "Kernel policies installed:\n" remote_ip = self.conf["remote_ip"] if remote_ip in policies: for line in policies[remote_ip]: spds += " " + line + "\n" sas = "Kernel security associations installed:\n" if remote_ip in securities: for line in securities[remote_ip]: sas += " " + line + "\n" cons = "IPsec connections that are active:\n" if self.name in conns: for tname in conns[self.name]: cons += " " + conns[self.name][tname] + "\n" return header + conf + status + spds + sas + cons + "\n" def _get_conn_address_family(self, remote_ip, local_ip): remote = address_family(remote_ip) local = address_family(local_ip) if local is None: return remote elif local != remote: return None else: return remote def _is_valid_tunnel_conf(self): """This function verifies if IPsec tunnel has valid configuration set in 'conf'. If it is valid, then it returns True. Otherwise, it returns False and sets the reason why configuration was considered as invalid. This function could be improved in future to also verify validness of certificates themselves so that ovs-monitor-ipsec would not pass malformed configuration to IKE daemon.""" self.invalid_reason = None if not self.conf["remote_ip"]: self.invalid_reason = ": 'remote_ip' is not set" return False if self.conf["psk"]: if self.conf["certificate"] or self.conf["private_key"] \ or self.conf["ca_cert"] or self.conf["remote_cert"] \ or self.conf["remote_name"]: self.invalid_reason = ": 'certificate', 'private_key', "\ "'ca_cert', 'remote_cert', and "\ "'remote_name' must be unset with PSK" return False # If configuring authentication with CA-signed certificate or # self-signed certificate, the 'remote_name' should be specified at # this point. When using CA-signed certificate, the 'remote_name' is # read from interface's options field. When using self-signed # certificate, the 'remote_name' is extracted from the 'remote_cert' # file. elif self.conf["remote_name"]: if not self.conf["certificate"]: self.invalid_reason = ": must set 'certificate' as local"\ " certificate when using CA-signed"\ " certificate or self-signed"\ " certificate to authenticate peers" return False elif not self.conf["private_key"]: self.invalid_reason = ": must set 'private_key' as local"\ " private key when using CA-signed"\ " certificate or self-signed"\ " certificate to authenticate peers" return False if not self.conf["remote_cert"] and not self.conf["ca_cert"]: self.invalid_reason = ": must set 'remote_cert' when using"\ " self-signed certificate"\ " authentication or 'ca_cert' when"\ " using CA-signed certificate"\ " authentication" return False else: self.invalid_reason = ": must choose a authentication method" return False return True class IPsecMonitor(object): """This class monitors and configures IPsec tunnels""" def __init__(self, root_prefix, ike_daemon, restart, args): self.IPSEC = root_prefix + "/usr/sbin/ipsec" self.tunnels = {} # Global configuration shared by all tunnels self.conf = { "pki": { "private_key": None, "certificate": None, "ca_cert": None, "local_name": None }, "skb_mark": None } self.conf_in_use = copy.deepcopy(self.conf) # Choose to either use StrongSwan or LibreSwan as IKE daemon if ike_daemon == "strongswan": self.ike_helper = StrongSwanHelper(root_prefix) elif ike_daemon == "libreswan": self.ike_helper = LibreSwanHelper(root_prefix, args) else: vlog.err("The IKE daemon should be strongswan or libreswan.") sys.exit(1) # Check whether ipsec command is available if not os.path.isfile(self.IPSEC) or \ not os.access(self.IPSEC, os.X_OK): vlog.err("IKE daemon is not installed in the system.") if restart: vlog.info("Restarting IKE daemon") self.ike_helper.restart_ike_daemon() def is_tunneling_type_supported(self, tunnel_type): """Returns True if we know how to configure IPsec for these types of tunnels. Otherwise, returns False.""" return tunnel_type in ["gre", "geneve", "vxlan", "stt"] def is_ipsec_required(self, options_column): """Return True if tunnel needs to be encrypted. Otherwise, returns False.""" return "psk" in options_column or \ "remote_name" in options_column or \ "remote_cert" in options_column def add_tunnel(self, name, row): """Adds a new tunnel that monitor will provision with 'name'.""" vlog.info("Tunnel %s appeared in OVSDB" % (name)) self.tunnels[name] = IPsecTunnel(name, row) def update_tunnel(self, name, row): """Updates configuration of already existing tunnel with 'name'.""" tunnel = self.tunnels[name] if tunnel.update_conf(row): vlog.info("Tunnel's '%s' configuration changed in OVSDB to %u" % (tunnel.name, tunnel.version)) def del_tunnel(self, name): """Deletes tunnel by 'name'.""" vlog.info("Tunnel %s disappeared from OVSDB" % (name)) self.tunnels[name].mark_for_removal() def update_conf(self, pki, skb_mark): """Update the global configuration for IPsec tunnels""" self.conf["pki"]["certificate"] = pki[0] self.conf["pki"]["private_key"] = pki[1] self.conf["pki"]["ca_cert"] = pki[2] self.conf["pki"]["local_name"] = pki[3] # Update skb_mark used in IPsec policies. self.conf["skb_mark"] = skb_mark def read_ovsdb_open_vswitch_table(self, data): """This functions reads IPsec relevant configuration from Open_vSwitch table.""" pki = [None, None, None, None] skb_mark = None is_valid = False for row in data["Open_vSwitch"].rows.values(): pki[0] = row.other_config.get("certificate") pki[1] = row.other_config.get("private_key") pki[2] = row.other_config.get("ca_cert") skb_mark = row.other_config.get("ipsec_skb_mark") # Test whether it's a valid configration if pki[0] and pki[1]: pki[3] = self._get_cn_from_cert(pki[0]) if pki[3]: is_valid = True elif not pki[0] and not pki[1] and not pki[2]: is_valid = True if not is_valid: vlog.warn("The cert and key configuration is not valid. " "The valid configuations are 1): certificate, private_key " "and ca_cert are not set; or 2): certificate and " "private_key are all set.") else: self.update_conf(pki, skb_mark) def read_ovsdb_interface_table(self, data): """This function reads the IPsec relevant configuration from Interface table.""" ifaces = set() for row in data["Interface"].rows.values(): if not self.is_tunneling_type_supported(row.type): continue if not self.is_ipsec_required(row.options): continue if row.name in self.tunnels: self.update_tunnel(row.name, row) else: self.add_tunnel(row.name, row) ifaces.add(row.name) # Mark for removal those tunnels that just disappeared from OVSDB for tunnel in self.tunnels.keys(): if tunnel not in ifaces: self.del_tunnel(tunnel) def read_ovsdb(self, data): """This function reads all configuration from OVSDB that ovs-monitor-ipsec is interested in.""" self.read_ovsdb_open_vswitch_table(data) self.read_ovsdb_interface_table(data) def show(self, unix_conn, policies, securities): """This function prints all tunnel state in 'unix_conn'. It uses 'policies' and securities' received from Linux Kernel to show if tunnels were actually configured by the IKE deamon.""" if not self.tunnels: unix_conn.reply("No tunnels configured with IPsec") return s = "" conns = self.ike_helper.get_active_conns() for name, tunnel in self.tunnels.items(): s += tunnel.show(policies, securities, conns) unix_conn.reply(s) def run(self): """This function runs state machine that represents whole IPsec configuration (i.e. merged together from individual tunnel state machines). It creates configuration files and tells IKE daemon to update configuration.""" needs_refresh = False removed_tunnels = [] self.ike_helper.config_init() if self.ike_helper.config_global(self): needs_refresh = True for name, tunnel in self.tunnels.items(): if tunnel.last_refreshed_version != tunnel.version: tunnel.last_refreshed_version = tunnel.version needs_refresh = True if tunnel.state == "REMOVED" or tunnel.state == "INVALID": removed_tunnels.append(name) elif tunnel.state == "CONFIGURED": self.ike_helper.config_tunnel(self.tunnels[name]) self.ike_helper.config_fini() for name in removed_tunnels: # LibreSwan needs to clear state from database if hasattr(self.ike_helper, "clear_tunnel_state"): self.ike_helper.clear_tunnel_state(self.tunnels[name]) del self.tunnels[name] if needs_refresh: self.ike_helper.refresh(self) def _get_cn_from_cert(self, cert): try: proc = subprocess.Popen(['openssl', 'x509', '-noout', '-subject', '-nameopt', 'RFC2253', '-in', cert], stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc.wait() if proc.returncode: raise Exception(proc.stderr.read()) m = re.search(r"CN=(.+?),", proc.stdout.readline().decode()) if not m: raise Exception("No CN in the certificate subject.") except Exception as e: vlog.warn(str(e)) return None return m.group(1) def address_family(address): try: ip = ipaddress.ip_address(address) ipstr = str(type(ip)) # ipaddress has inconsistencies with what exceptions are raised: # https://mail.openvswitch.org/pipermail/ovs-dev/2021-April/381696.html except (ValueError, ipaddress.AddressValueError): return None if ipstr.find('v6') != -1: return "IPv6" return "IPv4" def unixctl_xfrm_policies(conn, unused_argv, unused_aux): global xfrm policies = xfrm.get_policies() conn.reply(str(policies)) def unixctl_xfrm_state(conn, unused_argv, unused_aux): global xfrm securities = xfrm.get_securities() conn.reply(str(securities)) def unixctl_ipsec_status(conn, unused_argv, unused_aux): global monitor conns = monitor.ike_helper.get_active_conns() conn.reply(str(conns)) def unixctl_show(conn, unused_argv, unused_aux): global monitor global xfrm policies = xfrm.get_policies() securities = xfrm.get_securities() monitor.show(conn, policies, securities) def unixctl_refresh(conn, unused_argv, unused_aux): global monitor monitor.ike_helper.refresh(monitor) conn.reply(None) def unixctl_exit(conn, argv, unused_aux): global monitor global exiting ret = None exiting = True cleanup = True for arg in argv: if arg == "--no-cleanup": cleanup = False else: cleanup = False exiting = False ret = str("unrecognized parameter: %s" % arg) if cleanup: # Make sure persistent global states are cleared monitor.update_conf([None, None, None, None], None) # Make sure persistent tunnel states are cleared for tunnel in monitor.tunnels.keys(): monitor.del_tunnel(tunnel) monitor.run() conn.reply(ret) def main(): parser = argparse.ArgumentParser() parser.add_argument("database", metavar="DATABASE", help="A socket on which ovsdb-server is listening.") parser.add_argument("--root-prefix", metavar="DIR", help="Use DIR as alternate root directory" " (for testing).") parser.add_argument("--ike-daemon", metavar="IKE-DAEMON", help="The IKE daemon used for IPsec tunnels" " (either libreswan or strongswan).") parser.add_argument("--no-restart-ike-daemon", action='store_true', help="Don't restart the IKE daemon on startup.") parser.add_argument("--ipsec-conf", metavar="IPSEC-CONF", help="Use DIR/IPSEC-CONF as location for " " ipsec.conf (libreswan only).") parser.add_argument("--ipsec-d", metavar="IPSEC-D", help="Use DIR/IPSEC-D as location for " " ipsec.d (libreswan only).") parser.add_argument("--ipsec-secrets", metavar="IPSEC-SECRETS", help="Use DIR/IPSEC-SECRETS as location for " " ipsec.secrets (libreswan only).") parser.add_argument("--ipsec-ctl", metavar="IPSEC-CTL", help="Use DIR/IPSEC-CTL as location for " " pluto ctl socket (libreswan only).") ovs.vlog.add_args(parser) ovs.daemon.add_args(parser) args = parser.parse_args() ovs.vlog.handle_args(args) ovs.daemon.handle_args(args) global monitor global xfrm root_prefix = args.root_prefix if args.root_prefix else "" xfrm = XFRM(root_prefix) monitor = IPsecMonitor(root_prefix, args.ike_daemon, not args.no_restart_ike_daemon, args) remote = args.database schema_helper = ovs.db.idl.SchemaHelper() schema_helper.register_columns("Interface", ["name", "type", "options", "cfm_fault", "ofport"]) schema_helper.register_columns("Open_vSwitch", ["other_config"]) idl = ovs.db.idl.Idl(remote, schema_helper) ovs.daemon.daemonize() ovs.unixctl.command_register("list-commands", "", 0, 0, ovs.unixctl._unixctl_help, None) ovs.unixctl.command_register("xfrm/policies", "", 0, 0, unixctl_xfrm_policies, None) ovs.unixctl.command_register("xfrm/state", "", 0, 0, unixctl_xfrm_state, None) ovs.unixctl.command_register("ipsec/status", "", 0, 0, unixctl_ipsec_status, None) ovs.unixctl.command_register("tunnels/show", "", 0, 0, unixctl_show, None) ovs.unixctl.command_register("refresh", "", 0, 0, unixctl_refresh, None) ovs.unixctl.command_register("exit", "[--no-cleanup]", 0, 1, unixctl_exit, None) error, unixctl_server = ovs.unixctl.server.UnixctlServer.create(None) if error: ovs.util.ovs_fatal(error, "could not create unixctl server", vlog) # Sequence number when OVSDB was processed last time seqno = idl.change_seqno while True: unixctl_server.run() if exiting: break idl.run() if seqno != idl.change_seqno: monitor.read_ovsdb(idl.tables) seqno = idl.change_seqno monitor.run() poller = ovs.poller.Poller() unixctl_server.wait(poller) idl.wait(poller) poller.block() unixctl_server.close() idl.close() if __name__ == '__main__': try: main() except SystemExit: # Let system.exit() calls complete normally raise except: vlog.exception("traceback") sys.exit(ovs.daemon.RESTART_EXIT_CODE)