495 lines
17 KiB
Python
Executable File
495 lines
17 KiB
Python
Executable File
#!/usr/bin/python
|
|
# Copyright (c) 2009, 2010, 2011, 2012 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.
|
|
|
|
|
|
# A daemon to monitor attempts to create GRE-over-IPsec tunnels.
|
|
# Uses racoon and setkey to support the configuration. Assumes that
|
|
# OVS has complete control over IPsec configuration for the box.
|
|
|
|
# xxx To-do:
|
|
# - Doesn't actually check that Interface is connected to bridge
|
|
# - If a certificate is badly formed, Racoon will refuse to start. We
|
|
# should do a better job of verifying certificates are valid before
|
|
# adding an interface to racoon.conf.
|
|
|
|
|
|
import argparse
|
|
import glob
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
import ovs.dirs
|
|
from ovs.db import error
|
|
from ovs.db import types
|
|
import ovs.util
|
|
import ovs.daemon
|
|
import ovs.db.idl
|
|
import ovs.unixctl
|
|
import ovs.unixctl.server
|
|
import ovs.vlog
|
|
|
|
vlog = ovs.vlog.Vlog("ovs-monitor-ipsec")
|
|
root_prefix = '' # Prefix for absolute file names, for testing.
|
|
SETKEY = "/usr/sbin/setkey"
|
|
exiting = False
|
|
|
|
|
|
def unixctl_exit(conn, unused_argv, unused_aux):
|
|
global exiting
|
|
exiting = True
|
|
conn.reply(None)
|
|
|
|
|
|
# Class to configure the racoon daemon, which handles IKE negotiation
|
|
class Racoon:
|
|
# Default locations for files
|
|
conf_file = "/etc/racoon/racoon.conf"
|
|
cert_dir = "/etc/racoon/certs"
|
|
psk_file = "/etc/racoon/psk.txt"
|
|
|
|
# Racoon configuration header we use for IKE
|
|
conf_header = """# Configuration file generated by Open vSwitch
|
|
#
|
|
# Do not modify by hand!
|
|
|
|
path pre_shared_key "%s";
|
|
path certificate "%s";
|
|
|
|
"""
|
|
|
|
# Racoon configuration footer we use for IKE
|
|
conf_footer = """sainfo anonymous {
|
|
pfs_group 2;
|
|
lifetime time 1 hour;
|
|
encryption_algorithm aes;
|
|
authentication_algorithm hmac_sha1, hmac_md5;
|
|
compression_algorithm deflate;
|
|
}
|
|
|
|
"""
|
|
|
|
# Certificate entry template.
|
|
cert_entry = """remote %s {
|
|
exchange_mode main;
|
|
nat_traversal on;
|
|
ike_frag on;
|
|
certificate_type x509 "%s" "%s";
|
|
my_identifier asn1dn;
|
|
peers_identifier asn1dn;
|
|
peers_certfile x509 "%s";
|
|
verify_identifier on;
|
|
proposal {
|
|
encryption_algorithm aes;
|
|
hash_algorithm sha1;
|
|
authentication_method rsasig;
|
|
dh_group 2;
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
# Pre-shared key template.
|
|
psk_entry = """remote %s {
|
|
exchange_mode main;
|
|
nat_traversal on;
|
|
proposal {
|
|
encryption_algorithm aes;
|
|
hash_algorithm sha1;
|
|
authentication_method pre_shared_key;
|
|
dh_group 2;
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.psk_hosts = {}
|
|
self.cert_hosts = {}
|
|
|
|
if not os.path.isdir(root_prefix + self.cert_dir):
|
|
os.mkdir(self.cert_dir)
|
|
|
|
# Clean out stale peer certs from previous runs
|
|
for ovs_cert in glob.glob("%s%s/ovs-*.pem"
|
|
% (root_prefix, self.cert_dir)):
|
|
try:
|
|
os.remove(ovs_cert)
|
|
except OSError:
|
|
vlog.warn("couldn't remove %s" % ovs_cert)
|
|
|
|
# Replace racoon's conf file with our template
|
|
self.commit()
|
|
|
|
def reload(self):
|
|
exitcode = subprocess.call([root_prefix + "/etc/init.d/racoon",
|
|
"reload"])
|
|
if exitcode != 0:
|
|
# Racoon is finicky about its configuration file and will
|
|
# refuse to start if it sees something it doesn't like
|
|
# (e.g., a certificate file doesn't exist). Try restarting
|
|
# the process before giving up.
|
|
vlog.warn("attempting to restart racoon")
|
|
exitcode = subprocess.call([root_prefix + "/etc/init.d/racoon",
|
|
"restart"])
|
|
if exitcode != 0:
|
|
vlog.warn("couldn't reload racoon")
|
|
|
|
def commit(self):
|
|
# Rewrite the Racoon configuration file
|
|
conf_file = open(root_prefix + self.conf_file, 'w')
|
|
conf_file.write(Racoon.conf_header % (self.psk_file, self.cert_dir))
|
|
|
|
for host, vals in self.cert_hosts.iteritems():
|
|
conf_file.write(Racoon.cert_entry % (host, vals["certificate"],
|
|
vals["private_key"], vals["peer_cert_file"]))
|
|
|
|
for host in self.psk_hosts:
|
|
conf_file.write(Racoon.psk_entry % host)
|
|
|
|
conf_file.write(Racoon.conf_footer)
|
|
conf_file.close()
|
|
|
|
# Rewrite the pre-shared keys file; it must only be readable by root.
|
|
orig_umask = os.umask(0077)
|
|
psk_file = open(root_prefix + Racoon.psk_file, 'w')
|
|
os.umask(orig_umask)
|
|
|
|
psk_file.write("# Generated by Open vSwitch...do not modify by hand!")
|
|
psk_file.write("\n\n")
|
|
for host, vals in self.psk_hosts.iteritems():
|
|
psk_file.write("%s %s\n" % (host, vals["psk"]))
|
|
psk_file.close()
|
|
|
|
self.reload()
|
|
|
|
def _add_psk(self, host, psk):
|
|
if host in self.cert_hosts:
|
|
raise error.Error("host %s already defined for cert" % host)
|
|
|
|
self.psk_hosts[host] = psk
|
|
self.commit()
|
|
|
|
def _verify_certs(self, vals):
|
|
# Racoon will refuse to start if the certificate files don't
|
|
# exist, so verify that they're there.
|
|
if not os.path.isfile(root_prefix + vals["certificate"]):
|
|
raise error.Error("'certificate' file does not exist: %s"
|
|
% vals["certificate"])
|
|
elif not os.path.isfile(root_prefix + vals["private_key"]):
|
|
raise error.Error("'private_key' file does not exist: %s"
|
|
% vals["private_key"])
|
|
|
|
# Racoon won't start if a given certificate or private key isn't
|
|
# valid. This is a weak test, but will detect the most flagrant
|
|
# errors.
|
|
if vals["peer_cert"].find("-----BEGIN CERTIFICATE-----") == -1:
|
|
raise error.Error("'peer_cert' is not in valid PEM format")
|
|
|
|
cert = open(root_prefix + vals["certificate"]).read()
|
|
if cert.find("-----BEGIN CERTIFICATE-----") == -1:
|
|
raise error.Error("'certificate' is not in valid PEM format")
|
|
|
|
cert = open(root_prefix + vals["private_key"]).read()
|
|
if cert.find("-----BEGIN RSA PRIVATE KEY-----") == -1:
|
|
raise error.Error("'private_key' is not in valid PEM format")
|
|
|
|
def _add_cert(self, host, vals):
|
|
if host in self.psk_hosts:
|
|
raise error.Error("host %s already defined for psk" % host)
|
|
|
|
if vals["certificate"] == None:
|
|
raise error.Error("'certificate' not defined for %s" % host)
|
|
elif vals["private_key"] == None:
|
|
# Assume the private key is stored in the same PEM file as
|
|
# the certificate. We make a copy of "vals" so that we don't
|
|
# modify the original "vals", which would cause the script
|
|
# to constantly think that the configuration has changed
|
|
# in the database.
|
|
vals = vals.copy()
|
|
vals["private_key"] = vals["certificate"]
|
|
|
|
self._verify_certs(vals)
|
|
|
|
# The peer's certificate comes to us in PEM format as a string.
|
|
# Write that string to a file for Racoon to use.
|
|
f = open(root_prefix + vals["peer_cert_file"], "w")
|
|
f.write(vals["peer_cert"])
|
|
f.close()
|
|
|
|
self.cert_hosts[host] = vals
|
|
self.commit()
|
|
|
|
def _del_cert(self, host):
|
|
peer_cert_file = self.cert_hosts[host]["peer_cert_file"]
|
|
del self.cert_hosts[host]
|
|
self.commit()
|
|
try:
|
|
os.remove(root_prefix + peer_cert_file)
|
|
except OSError:
|
|
pass
|
|
|
|
def add_entry(self, host, vals):
|
|
if vals["peer_cert"]:
|
|
self._add_cert(host, vals)
|
|
elif vals["psk"]:
|
|
self._add_psk(host, vals)
|
|
|
|
def del_entry(self, host):
|
|
if host in self.cert_hosts:
|
|
self._del_cert(host)
|
|
elif host in self.psk_hosts:
|
|
del self.psk_hosts[host]
|
|
self.commit()
|
|
|
|
|
|
# Class to configure IPsec on a system using racoon for IKE and setkey
|
|
# for maintaining the Security Association Database (SAD) and Security
|
|
# Policy Database (SPD). Only policies for GRE are supported.
|
|
class IPsec:
|
|
def __init__(self):
|
|
self.sad_flush()
|
|
self.spd_flush()
|
|
self.racoon = Racoon()
|
|
self.entries = []
|
|
|
|
def call_setkey(self, cmds):
|
|
try:
|
|
p = subprocess.Popen([root_prefix + SETKEY, "-c"],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE)
|
|
except:
|
|
vlog.err("could not call %s%s" % (root_prefix, SETKEY))
|
|
sys.exit(1)
|
|
|
|
# xxx It is safer to pass the string into the communicate()
|
|
# xxx method, but it didn't work for slightly longer commands.
|
|
# xxx An alternative may need to be found.
|
|
p.stdin.write(cmds)
|
|
return p.communicate()[0]
|
|
|
|
def get_spi(self, local_ip, remote_ip, proto="esp"):
|
|
# Run the setkey dump command to retrieve the SAD. Then, parse
|
|
# the output looking for SPI buried in the output. Note that
|
|
# multiple SAD entries can exist for the same "flow", since an
|
|
# older entry could be in a "dying" state.
|
|
spi_list = []
|
|
host_line = "%s %s" % (local_ip, remote_ip)
|
|
results = self.call_setkey("dump ;\n").split("\n")
|
|
for i in range(len(results)):
|
|
if results[i].strip() == host_line:
|
|
# The SPI is in the line following the host pair
|
|
spi_line = results[i + 1]
|
|
if (spi_line[1:4] == proto):
|
|
spi = spi_line.split()[2]
|
|
spi_list.append(spi.split('(')[1].rstrip(')'))
|
|
return spi_list
|
|
|
|
def sad_flush(self):
|
|
self.call_setkey("flush;\n")
|
|
|
|
def sad_del(self, local_ip, remote_ip):
|
|
# To delete all SAD entries, we should be able to use setkey's
|
|
# "deleteall" command. Unfortunately, it's fundamentally broken
|
|
# on Linux and not documented as such.
|
|
cmds = ""
|
|
|
|
# Delete local_ip->remote_ip SAD entries
|
|
spi_list = self.get_spi(local_ip, remote_ip)
|
|
for spi in spi_list:
|
|
cmds += "delete %s %s esp %s;\n" % (local_ip, remote_ip, spi)
|
|
|
|
# Delete remote_ip->local_ip SAD entries
|
|
spi_list = self.get_spi(remote_ip, local_ip)
|
|
for spi in spi_list:
|
|
cmds += "delete %s %s esp %s;\n" % (remote_ip, local_ip, spi)
|
|
|
|
if cmds:
|
|
self.call_setkey(cmds)
|
|
|
|
def spd_flush(self):
|
|
self.call_setkey("spdflush;\n")
|
|
|
|
def spd_add(self, local_ip, remote_ip):
|
|
cmds = ("spdadd %s %s gre -P out ipsec esp/transport//require;\n" %
|
|
(local_ip, remote_ip))
|
|
cmds += ("spdadd %s %s gre -P in ipsec esp/transport//require;\n" %
|
|
(remote_ip, local_ip))
|
|
self.call_setkey(cmds)
|
|
|
|
def spd_del(self, local_ip, remote_ip):
|
|
cmds = "spddelete %s %s gre -P out;\n" % (local_ip, remote_ip)
|
|
cmds += "spddelete %s %s gre -P in;\n" % (remote_ip, local_ip)
|
|
self.call_setkey(cmds)
|
|
|
|
def add_entry(self, local_ip, remote_ip, vals):
|
|
if remote_ip in self.entries:
|
|
raise error.Error("host %s already configured for ipsec"
|
|
% remote_ip)
|
|
|
|
self.racoon.add_entry(remote_ip, vals)
|
|
self.spd_add(local_ip, remote_ip)
|
|
|
|
self.entries.append(remote_ip)
|
|
|
|
def del_entry(self, local_ip, remote_ip):
|
|
if remote_ip in self.entries:
|
|
self.racoon.del_entry(remote_ip)
|
|
self.spd_del(local_ip, remote_ip)
|
|
self.sad_del(local_ip, remote_ip)
|
|
|
|
self.entries.remove(remote_ip)
|
|
|
|
|
|
def update_ipsec(ipsec, interfaces, new_interfaces):
|
|
for name, vals in interfaces.iteritems():
|
|
if name not in new_interfaces:
|
|
ipsec.del_entry(vals["local_ip"], vals["remote_ip"])
|
|
|
|
for name, vals in new_interfaces.iteritems():
|
|
orig_vals = interfaces.get(name)
|
|
if orig_vals:
|
|
# Configuration for this host already exists. Check if it's
|
|
# changed. We use set difference, since we want to ignore
|
|
# any local additions to "orig_vals" that we've made
|
|
# (e.g. the "peer_cert_file" key).
|
|
if set(vals.items()) - set(orig_vals.items()):
|
|
ipsec.del_entry(vals["local_ip"], vals["remote_ip"])
|
|
else:
|
|
continue
|
|
|
|
try:
|
|
ipsec.add_entry(vals["local_ip"], vals["remote_ip"], vals)
|
|
except error.Error, msg:
|
|
vlog.warn("skipping ipsec config for %s: %s" % (name, msg))
|
|
|
|
|
|
def get_ssl_cert(data):
|
|
for ovs_rec in data["Open_vSwitch"].rows.itervalues():
|
|
if ovs_rec.ssl:
|
|
ssl = ovs_rec.ssl[0]
|
|
if ssl.certificate and ssl.private_key:
|
|
return (ssl.certificate, ssl.private_key)
|
|
|
|
return None
|
|
|
|
|
|
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).")
|
|
|
|
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 root_prefix
|
|
if args.root_prefix:
|
|
root_prefix = args.root_prefix
|
|
|
|
remote = args.database
|
|
schema_helper = ovs.db.idl.SchemaHelper()
|
|
schema_helper.register_columns("Interface", ["name", "type", "options"])
|
|
schema_helper.register_columns("Open_vSwitch", ["ssl"])
|
|
schema_helper.register_columns("SSL", ["certificate", "private_key"])
|
|
idl = ovs.db.idl.Idl(remote, schema_helper)
|
|
|
|
ovs.daemon.daemonize()
|
|
|
|
ovs.unixctl.command_register("exit", "", 0, 0, 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)
|
|
|
|
ipsec = IPsec()
|
|
|
|
interfaces = {}
|
|
seqno = idl.change_seqno # Sequence number when we last processed the db
|
|
while True:
|
|
unixctl_server.run()
|
|
if exiting:
|
|
break
|
|
|
|
idl.run()
|
|
if seqno == idl.change_seqno:
|
|
poller = ovs.poller.Poller()
|
|
unixctl_server.wait(poller)
|
|
idl.wait(poller)
|
|
poller.block()
|
|
continue
|
|
seqno = idl.change_seqno
|
|
|
|
ssl_cert = get_ssl_cert(idl.tables)
|
|
|
|
new_interfaces = {}
|
|
for rec in idl.tables["Interface"].rows.itervalues():
|
|
if rec.type == "ipsec_gre" or rec.type == "ipsec_gre64":
|
|
name = rec.name
|
|
options = rec.options
|
|
peer_cert_name = "ovs-%s.pem" % (options.get("remote_ip"))
|
|
entry = {
|
|
"remote_ip": options.get("remote_ip"),
|
|
"local_ip": options.get("local_ip", "0.0.0.0/0"),
|
|
"certificate": options.get("certificate"),
|
|
"private_key": options.get("private_key"),
|
|
"use_ssl_cert": options.get("use_ssl_cert"),
|
|
"peer_cert": options.get("peer_cert"),
|
|
"peer_cert_file": Racoon.cert_dir + "/" + peer_cert_name,
|
|
"psk": options.get("psk")}
|
|
|
|
if entry["peer_cert"] and entry["psk"]:
|
|
vlog.warn("both 'peer_cert' and 'psk' defined for %s"
|
|
% name)
|
|
continue
|
|
elif not entry["peer_cert"] and not entry["psk"]:
|
|
vlog.warn("no 'peer_cert' or 'psk' defined for %s" % name)
|
|
continue
|
|
|
|
# The "use_ssl_cert" option is deprecated and will
|
|
# likely go away in the near future.
|
|
if entry["use_ssl_cert"] == "true":
|
|
if not ssl_cert:
|
|
vlog.warn("no valid SSL entry for %s" % name)
|
|
continue
|
|
|
|
entry["certificate"] = ssl_cert[0]
|
|
entry["private_key"] = ssl_cert[1]
|
|
|
|
new_interfaces[name] = entry
|
|
|
|
if interfaces != new_interfaces:
|
|
update_ipsec(ipsec, interfaces, new_interfaces)
|
|
interfaces = new_interfaces
|
|
|
|
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)
|