diff --git a/datafiles/schema.sql b/datafiles/schema.sql index c8cca318b137f6fe18e61717e911ffafb9701073..9ce1f579d38dbf728dca8a10b1b3203d2a998d9c 100644 --- a/datafiles/schema.sql +++ b/datafiles/schema.sql @@ -101,3 +101,15 @@ CREATE TABLE generic_backend_connections ( allocated boolean NOT NULL -- indicated if the resources are actually allocated ); + +-- Force this to only have a single row +-- generate new id with: +-- there needs to be a conflict check to see if the backend has a row (and insert corrosonding start value) +-- INSERT INTO backend_connection_id (connection_id) VALUES (190000) ON CONFLICT DO NOTHING; +-- Generate new id with: +-- UPDATE backend_connection_id SET connection_id = connection_id + 1 RETURNING connection_id; +CREATE TABLE backend_connection_id ( + id integer PRIMARY KEY NOT NULL DEFAULT(1) CHECK (id = 1), + connection_id serial NOT NULL +); + diff --git a/opennsa/backends/junipervpls.py b/opennsa/backends/junipervpls.py new file mode 100644 index 0000000000000000000000000000000000000000..44ad96783afebe7906257ba414c9a5297012da97 --- /dev/null +++ b/opennsa/backends/junipervpls.py @@ -0,0 +1,430 @@ +""" +OpenNSA Juniper/JunOS VPLS backend. + +Intended to match Canaries usage. + +Requires a JunOS device with VPLS support (duh) + +Author: Henrik Thostrup Jensen <htj@nordu.net> +Copyright: NORDUnet (2017) + +""" + +# Configuration conventions / environment, and snippets + +# We assign each service a unique Circuit-ID and this CID, or parts of it, +# are used for provisioning +# +# Circuit-ID follows this naming/syntax <Unique_ID> - <Organizational_ID> +# - CANARIE_site_short<1..9> - CANARIE_site_short<1..9> - Service_Name +# for example, 13903CS01-NORDUNet-AMST1-NYCN1 +# +# This circuit ID is used in "description" of sub-interface/logical unit +# number for example, description "13903CS01-NORDUNet-AMST1-NYCN1 +# [with some extra information in square brackets .... L2VPN circuit +# Amsterdam to New York for XYZ]"; + +# This circuit ID is also used as the name for the routing instance +# for example, +# routing-instances { +# 13903CS01-NORDUNet-AMST1-NYCN1 { +# ..... +# +# route-distinguisher and vrf-target for the routing instance are also +# composed using the information that derives from <Unique_ID> +# The two characters (in this example CS) are removed and seven digest +# used for the second, after ":" part. The first part is our AS number. +# +# for unique_id "13903CS01", for example +# route-distinguisher 6509:1390301; +# vrf-target target:6509:1390301; +# +# the sites for the routing instance are directly derived from Circuit-ID, +# site AMST1 { +# site-identifier 1; +# ..... +# site NYCN1 { +# site-identifier 2; +# .... +# +# logical unit number normally matches a vlan ID (or a first VLAN number in a list) +# +# interfaces { +# et-x/y/z { +# description "ANA-300 link to Amsterdam"; +# flexible-vlan-tagging; +# encapsulation flexible-ethernet-services; +# ..... +# unit 1000 { +# description "13903CS01-NORDUNet-AMST1-NYCN1 [L2VPN circuit Amsterdam to New York for NORDUNet]"; +# encapsulation vlan-vpls; +# vlan-id-list 1000; +# family vpls; +# } +# +# et-x/y/w { +# description "CANARIE/ANA-300 link to New York City"; +# flexible-vlan-tagging; +# encapsulation flexible-ethernet-services; +# ..... +# unit 1000 { +# description "13903CS01-NORDUNet-AMST1-NYCN1 [L2VPN circuit Amsterdam to New York for NORDUNet]"; +# encapsulation vlan-vpls; +# vlan-id-list 1000; +# family vpls; +# } +# } +# +# +# routing-instances { +# 13903CS01-NORDUNet-AMST1-NYCN1 { +# instance-type vpls; +# interface et-x/y/z.1000; +# interface et-x/y/w.1000; +# route-distinguisher 6509:1390301; +# vrf-target target:6509:1390301; +# protocols { +# vpls { +# site-range 2; +# no-tunnel-services; +# site AMST1 { +# site-identifier 1; +# interface et-x/y/z.1000; +# } +# site NYCN1 { +# site-identifier 2; +# interface et-x/y/w.1000; +# } +# } +# } +# } +# + + +#import random + +from twisted.python import log +from twisted.internet import defer + +from opennsa import constants as cnt, config, database +from opennsa.backends.common import genericbackend, ssh + + +LOG_SYSTEM = 'JuniperVPLS' + + +# JunOS commands, static +CONFIGURE = 'configure' +COMMIT = 'commit' + +# JunOS commands, parameterized + +# Interface unit configuration +#SET_UNIT = 'set interfaces %(interface) unit %(unit)' +#SET_UNIT_DESCRIPTION = 'set interfaces %(interface) unit %(unit) description %(description)' +#SET_UNIT_ENCAPSULATION = 'set interfaces %(interface) unit %(unit) encapsulation vlan-vpls' +#SET_UNIT_VLAN = 'set interfaces %(interface) unit %(unit) vlan-id-list %(vlan)' +#SET_UNIT_FAMILY = 'set interfaces %(interface) unit %(unit) family vpls' + +SET_UNIT = 'set interfaces %(interface) unit %(unit) description %(description) encapsulation vlan-vpls vlan-id %(vlan) family vpls' + +# Routing instance configuration +#SET_RI = 'set routing-instance %(instance)' +SET_RI_INSTANCE_TYPE = 'set routing-instances %(instance) instance-type vpls' +SET_RI_INTERFACE = 'set routing-instances %(instance) interface %(interface)' +SET_RI_ROUTE_DISTINGUISHER = 'set routing-instances %(instance) route-distinguisher %(route-distinguisher)' +SET_RI_VRF_TARGET = 'set routing-instances %(instance) vrf-target %(vrf-target)' +SET_RI_PROTOCOLS = 'set routing-instances %(instance) protocols vpls site-range 2 no-tunnel-services' +SET_RI_VPLS_SITE = 'set routing-instances %(instance) protocols vpls site %(site) site-identifier %(site-id) interface %(interface)' +#SET_RI_VPLS_SITE2 = 'set routing-instances %(instance) protocols vpls site %(site) site-identifier 2 interface %(interface)' + +# Delete statements +DELETE_UNIT = 'delete interfaces %(interface) unit $(unit)' +DELETE_ROUTING_INSTANCE = 'delete routing-instance %(instance)' + + + +def createSetupCommands(source_port, dest_port, vlan, instance_id, description, route_distinguiser, vrf_target): + + commands = [ + SET_UNIT % {'interface': source_port, 'unit': vlan, 'description': description, 'vlan': vlan}, + SET_UNIT % {'interface': dest_port, 'unit': vlan, 'description': description, 'vlan': vlan}, + + SET_RI_INSTANCE_TYPE % {'instance': instance_id }, + SET_RI_INTERFACE % {'instance': instance_id, 'interface': source_port + '.' + vlan }, + SET_RI_INTERFACE % {'instance': instance_id, 'interface': dest_port + '.' + vlan }, + SET_RI_ROUTE_DISTINGUISHER % {'instance': instance_id, 'route-distinguisher': route_distinguiser }, + SET_RI_VRF_TARGET % {'instance': instance_id, 'vrf-target': vrf_target }, + SET_RI_PROTOCOLS % {'instance': instance_id }, + SET_RI_VPLS_SITE % {'instance': instance_id, 'site': 'SITE1', 'site-id': 1 }, + SET_RI_VPLS_SITE % {'instance': instance_id, 'site': 'SITE2', 'site-id': 2 } + ] + + return commands + + +def createDeleteCommands(source_port, dest_port, vlan, instance_id): + + commands = [ + DELETE_UNIT % {'interface': source_port, 'unit': vlan }, + DELETE_UNIT % {'interface': dest_port, 'unit': vlan }, + DELETE_ROUTING_INSTANCE % {'instance' : instance_id } + ] + + return commands + + +# --- + + +class SSHChannel(ssh.SSHChannel): + + name = 'session' + + def __init__(self, conn): + ssh.SSHChannel.__init__(self, conn=conn) + + self.line = '' + + self.wait_defer = None + self.wait_line = None + + + @defer.inlineCallbacks + def sendCommands(self, commands): + LT = '\r' # line termination + + try: + yield self.conn.sendRequest(self, 'shell', '', wantReply=1) + d = self.waitForLine('>') + self.write(CONFIGURE + LT) + yield d + + log.msg('Entered configure mode', debug=True, system=LOG_SYSTEM) + + for cmd in commands: + log.msg('CMD> %s' % cmd, system=LOG_SYSTEM) + d = self.waitForLine('[edit]') + self.write(cmd + LT) + yield d + + # commit commands, check for 'commit complete' as success + # not quite sure how to handle failure here + + d = self.waitForLine('commit complete') + self.write(COMMIT + LT) + yield d + + except Exception, e: + log.msg('Error sending commands: %s' % str(e)) + raise e + + log.msg('Commands successfully committed', debug=True, system=LOG_SYSTEM) + self.sendEOF() + self.closeIt() + + + def waitForLine(self, line): + self.wait_line = line + self.wait_defer = defer.Deferred() + return self.wait_defer + + + def matchLine(self, line): + if self.wait_line and self.wait_defer: + if self.wait_line in line.strip(): + d = self.wait_defer + self.wait_line = None + self.wait_defer = None + d.callback(self) + else: + pass + + + def dataReceived(self, data): + if len(data) == 0: + pass + else: + self.line += data + if '\n' in data: + lines = [ line.strip() for line in self.line.split('\n') if line.strip() ] + self.line = '' + for l in lines: + self.matchLine(l) + + + +class JuniperVPLSCommandSender: + + + def __init__(self, host, port, ssh_host_fingerprint, user, ssh_public_key_path, ssh_private_key_path): + + self.ssh_connection_creator = \ + ssh.SSHConnectionCreator(host, port, [ ssh_host_fingerprint ], user, ssh_public_key_path, ssh_private_key_path) + + self.ssh_connection = None # cached connection + + + def _getSSHChannel(self): + + def setSSHConnectionCache(ssh_connection): + log.msg('SSH Connection created and cached', system=LOG_SYSTEM) + self.ssh_connection = ssh_connection + return ssh_connection + + def gotSSHConnection(ssh_connection): + channel = SSHChannel(conn = ssh_connection) + ssh_connection.openChannel(channel) + return channel.channel_open + + if self.ssh_connection and not self.ssh_connection.transport.factory.stopped: + log.msg('Reusing SSH connection', debug=True, system=LOG_SYSTEM) + return gotSSHConnection(self.ssh_connection) + else: + # since creating a new connection should be uncommon, we log it + # this makes it possible to see if something fucks up and creates connections continuously + log.msg('Creating new SSH connection', system=LOG_SYSTEM) + d = self.ssh_connection_creator.getSSHConnection() + d.addCallback(setSSHConnectionCache) + d.addCallback(gotSSHConnection) + return d + + + def _sendCommands(self, commands): + + def gotChannel(channel): + d = channel.sendCommands(commands) + return d + + d = self._getSSHChannel() + d.addCallback(gotChannel) + return d + + + #def setupLink(self, source_port, source_vlan, dest_port, dest_vlan): + def setupLink(self, source_port, dest_port, vlan, instance_id, as_number): + + # createSetupCommands(source_port, dest_port, vlan, instance_id, description, route_distinguiser, vrf_target) + + description = instance_id + '[ X-connect created by OpenNSA ]' + unique_id = instance_id[:5] + instance_id[7:9] + route_distinguisher = as_number + ':' + unique_id + vrf_target = 'target:' + as_number + ':' + unique_id + + commands = createSetupCommands(source_port, dest_port, vlan, instance_id, description, route_distinguisher, vrf_target) + + return self._sendCommands(commands) + + + def teardownLink(self, source_port, dest_port, vlan, instance_id): + + # createDeleteCommands(source_port, dest_port, vlan, instance_id) + + commands = createDeleteCommands(source_port, dest_port, vlan, instance_id) + + return self._sendCommands(commands) + + +# -------- + + +class JunosUnitTarget(object): + + def __init__(self, port, vlan): + self.port = port + self.vlan = vlan + + def __str__(self): + return '<JunosUnitTarget %s.%i>' % (self.port, self.vlan) + + + +class JuniperVPLSConnectionManager: + + + def __init__(self, port_map, host, port, host_fingerprint, user, ssh_public_key, ssh_private_key, as_number): + + self.port_map = port_map + self.command_sender = JuniperVPLSCommandSender(host, port, host_fingerprint, user, ssh_public_key, ssh_private_key) + self.as_number = as_number + + + def getResource(self, port, label): + assert label is not None and label.type_ == cnt.ETHERNET_VLAN, 'Label must be vlan' + device_port = self.port_map[port] + if device_port is None: + raise ValueError('Invalid port specified: %s' % device_port) + return port + '.' + label.labelValue() + + + def getTarget(self, port, label): + assert label is not None and label.type_ == cnt.ETHERNET_VLAN, 'Label must be vlan' + device_port = self.port_map[port] + if device_port is None: + raise ValueError('Invalid port specified: %s' % device_port) + + vlan = int(label.labelValue()) + assert 1 <= vlan <= 4095, 'Invalid label value for vlan: %s' % label.labelValues() + + return JunosUnitTarget(self.port_map[port], vlan) + + + def createConnectionId(self, source_target, dest_target): + # This needs to be fixed! + unique_id = database.getBackendConnectionId() + if unique_id is None: + raise ValueError("Could not generate an connection id from the database, most likely serviceid_start isn't set") + + # not quite done here... + connection_id = unique_id[:5] + 'CS' + unique_id[5:] + '-ANA' + print 'generated id', connection_id + return connection_id + + + def canSwapLabel(self, label_type): + # Not right now at least, maybe in the future + return False + + + def setupLink(self, connection_id, source_target, dest_target, bandwidth): + + def linkUp(_): + log.msg('Link %s -> %s setup done' % (source_target, dest_target), system=LOG_SYSTEM) + + assert source_target.vlan == dest_target.vlan, 'Source and destination vlan must match' + + d = self.command_sender.setupLink(source_target.port, dest_target.port, dest_target.vlan, connection_id, self.as_number) + d.addCallback(linkUp) + return d + + + def teardownLink(self, connection_id, source_target, dest_target, bandwidth): + + def linkDown(_): + log.msg('Link %s -> %s teardown done' % (source_target, dest_target), system=LOG_SYSTEM) + + assert source_target.vlan == dest_target.vlan, 'Source and destination vlan must match' + + d = self.command_sender.teardownLink(source_target.port, source_target.vlan, dest_target.port, dest_target.vlan) + d.addCallback(linkDown) + return d + + + +def JuniperVPLSBackend(network_name, nrm_ports, parent_requester, cfg): + + name = 'JuniperVPLS %s' % network_name + nrm_map = dict( [ (p.name, p) for p in nrm_ports ] ) # for the generic backend + port_map = dict( [ (p.name, p.interface) for p in nrm_ports ] ) # for the nrm backend + + # extract config items + host = cfg[config.JUNIPER_HOST] + port = cfg.get(config.JUNIPER_PORT, 22) + host_fingerprint = cfg[config.JUNIPER_HOST_FINGERPRINT] + user = cfg[config.JUNIPER_USER] + ssh_public_key = cfg[config.JUNIPER_SSH_PUBLIC_KEY] + ssh_private_key = cfg[config.JUNIPER_SSH_PRIVATE_KEY] + as_number = cfg[config.AS_NUMBER] + + cm = JuniperVPLSConnectionManager(port_map, host, port, host_fingerprint, user, ssh_public_key, ssh_private_key, as_number) + return genericbackend.GenericBackend(network_name, nrm_map, cm, parent_requester, name) diff --git a/opennsa/config.py b/opennsa/config.py index 85e4b615063a1ddd1a5a95f8a0c162c87a83752b..b558ee616ee2bd1fd3835b6eb61e5915337f3665 100644 --- a/opennsa/config.py +++ b/opennsa/config.py @@ -27,6 +27,7 @@ DEFAULT_CERTIFICATE_DIR = '/etc/ssl/certs' # This will work on most mordern linu BLOCK_SERVICE = 'service' BLOCK_DUD = 'dud' BLOCK_JUNIPER_EX = 'juniperex' +BLOCK_JUNIPER_VPLS = 'junipervpls' BLOCK_JUNOS = 'junos' BLOCK_FORCE10 = 'force10' BLOCK_BROCADE = 'brocade' @@ -46,6 +47,7 @@ NRM_MAP_FILE = 'nrmmap' PEERS = 'peers' POLICY = 'policy' PLUGIN = 'plugin' +SERVICE_ID_START = 'serviceid_start' # database DATABASE = 'database' # mandatory @@ -59,7 +61,7 @@ CERTIFICATE_DIR = 'certdir' # mandatory (but dir can be empty) VERIFY_CERT = 'verify' ALLOWED_HOSTS = 'allowedhosts' # comma seperated list -# generic ssh stuff, don't use directly +# generic stuff _SSH_HOST = 'host' _SSH_PORT = 'port' _SSH_HOST_FINGERPRINT = 'fingerprint' @@ -68,6 +70,8 @@ _SSH_PASSWORD = 'password' _SSH_PUBLIC_KEY = 'publickey' _SSH_PRIVATE_KEY = 'privatekey' +AS_NUMBER = 'asnumber' + # juniper block - same for ex/qxf backend and mx backend JUNIPER_HOST = _SSH_HOST JUNIPER_PORT = _SSH_PORT @@ -236,6 +240,11 @@ def readVerifyConfig(cfg): except ConfigParser.NoOptionError: vc[DATABASE_PASSWORD] = None + try: + vc[SERVICE_ID_START] = cfg.get(BLOCK_SERVICE, SERVICE_ID_START) + except ConfigParser.NoOptionError: + vc[SERVICE_ID_START] = None + # we always extract certdir and verify as we need that for performing https requests try: certdir = cfg.get(BLOCK_SERVICE, CERTIFICATE_DIR) @@ -291,7 +300,7 @@ def readVerifyConfig(cfg): if name in backends: raise ConfigurationError('Can only have one backend named "%s"' % name) - if backend_type in (BLOCK_DUD, BLOCK_JUNIPER_EX, BLOCK_JUNOS, BLOCK_FORCE10, BLOCK_BROCADE, + if backend_type in (BLOCK_DUD, BLOCK_JUNIPER_EX, BLOCK_JUNIPER_VPLS, BLOCK_JUNOS, BLOCK_FORCE10, BLOCK_BROCADE, BLOCK_DELL, BLOCK_NCSVPN, BLOCK_PICA8OVS, BLOCK_OESS, 'asyncfail'): backend_conf = dict( cfg.items(section) ) backend_conf['_backend_type'] = backend_type diff --git a/opennsa/database.py b/opennsa/database.py index f637d117721a43f2d62eb05e75cbb30e7b81c6cf..284a097d3469e7584930fc4b01b148826f8e4cf6 100644 --- a/opennsa/database.py +++ b/opennsa/database.py @@ -59,7 +59,7 @@ def castDatetime(value, cur): # setup -def setupDatabase(database, user, password=None): +def setupDatabase(database, user, password=None, connection_id_start=None): # hack on, use psycopg2 connection to register postgres label -> nsa label adaptation import psycopg2 @@ -74,6 +74,9 @@ def setupDatabase(database, user, password=None): DT = psycopg2.extensions.new_type((timestamptz_oid,), "timestamptz", castDatetime) psycopg2.extensions.register_type(DT) + if connection_id_start: + cur.execute("INSERT INTO backend_connection_id (connection_id) VALUES (%s) ON CONFLICT DO NOTHING;", connection_id_start) + conn.close() Registry.DBPOOL = adbapi.ConnectionPool('psycopg2', user=user, password=password, database=database) @@ -81,6 +84,8 @@ def setupDatabase(database, user, password=None): + + # ORM Objects class ServiceConnection(DBObject): @@ -91,5 +96,36 @@ class SubConnection(DBObject): BELONGSTO = ['ServiceConnection'] +class STPAuthz(DBObject): + TABLENAME = 'stp_authz' + + +# Not really needed +class BackendConnectionID(DBObject): + TABLENAME = 'backend_connection_id' + +#@defer.inlineCallbacks +def getBackendConnectionId(): + +# rows = yield BackendConnectionID.find() +# if len(rows) == 0: +# defer.returnValue(0) +# else: +# connection_id = rows[0].connection_id +# rows[0].connection_id += 1 +# rows[0].save() +# defer.returnValue(connection_id) + + def gotResult(rows): + print 'rows', rows + if len(rows) == 0: + return None + else: + return rows[0][0] + + return Registry.DBPOOL.runQuery('UPDATE backend_connection_id SET connection_id = connection_id + 1 RETURNING connection_id;').addCallback(gotResult) + + + Registry.register(ServiceConnection, SubConnection) diff --git a/opennsa/setup.py b/opennsa/setup.py index b2347468810413dee1da036967d138a481435730..3c63019224a1935a92ffd77fdcedf7b0d835e6e3 100644 --- a/opennsa/setup.py +++ b/opennsa/setup.py @@ -41,6 +41,10 @@ def setupBackend(backend_cfg, network_name, nrm_ports, parent_requester): from opennsa.backends import juniperex BackendConstructer = juniperex.JuniperEXBackend + elif backend_type == config.BLOCK_JUNIPER_VPLS: + from opennsa.backends import junipervpls + BackendConstructer = junipervpls.JuniperVPLSBackend + elif backend_type == config.BLOCK_BROCADE: from opennsa.backends import brocade BackendConstructer = brocade.BrocadeBackend @@ -152,7 +156,7 @@ class OpenNSAService(twistedservice.MultiService): vc[config.HOST] = socket.getfqdn() # database - database.setupDatabase(vc[config.DATABASE], vc[config.DATABASE_USER], vc[config.DATABASE_PASSWORD]) + database.setupDatabase(vc[config.DATABASE], vc[config.DATABASE_USER], vc[config.DATABASE_PASSWORD], vc[config.SERVICE_ID_START]) service_endpoints = []