#!/usr/bin/python3 import argparse import librouteros import ssl import os import yaml import re import dns.resolver, dns.reversename import ipaddress # comment format: # hostname.example.com [aM flag1 flag2 key1=value1 key2=option3:value3,option4:value4] selector.example.com # # flags: # static - do not auto-enable or auto-disable this address (loopbacks, linknets) # home - this is the normal home-location of the IP # standby - this is an active interface, but not intended as primary route # teardown - being destroyed # # keys: # evacuated=router - this address has been moved from this host to router # evacuee=router - this is an evacuated address originally from router aM1 = re.compile( r"\[aM ?([^]]*)\]" ) def parse_comment( comment ): bits = aM1.split( comment ) if len( bits ) == 3: hostname = bits[ 0 ].strip() selector = bits[ 2 ].strip() if not selector: selector = hostname rval = {} for bit in bits[ 1 ].split(): try: ( k, v ) = bit.split( "=", 1 ) except ValueError: if bit: rval[ bit ] = True else: if ':' in v: rval[ k ] = {} for subbit in v.split( "," ): ( subk, subv ) = subbit.split( ":", 1 ) rval[ k ][ subk ] = subv else: rval[ k ] = v return ( selector, rval, hostname ) else: return ( None, None, comment ) def reverse_dns( address ): try: return str( dns.resolver.resolve( dns.reversename.from_address( address ), 'PTR' )[ 0 ] ) except: return address def make_comment( selector, rval, hostname ): rvalbits = [ "aM" ] for ( k, v ) in rval.items(): if v is True: rvalbits.append( k ) elif isinstance( v, dict ): rvalbits.append( "%s=%s" % ( k, ",".join( "%s:%s" % vi for vi in v.items() ) ) ) elif v: rvalbits.append( "%s=%s" % ( k, v ) ) if selector == hostname or selector is None: return "%s [%s]" % ( hostname, " ".join( rvalbits ) ) else: return "%s [%s] %s" % ( hostname, " ".join( rvalbits ), selector ) def connection( host, username = None, password = None, port = 8729 ): if ':' in host: ( host, port ) = host.split( ":", 1 ) port = int( port ) kwargs = { 'username': username or os.environ.get( 'AUTOMONTY_USERNAME', None ), 'password': password or os.environ.get( 'AUTOMONTY_PASSWORD', None ), 'host': host, 'port': port, } kwargs[ 'ssl' ] = True ssl_ctx = ssl.create_default_context() ssl_ctx.check_hostname = False # XXX figure out how ssl_option = 'CERT_REQUIRED' ssl_ctx.verify_mode = getattr( ssl, ssl_option, ssl.CERT_REQUIRED ) ssl_ctx.set_ciphers( 'DHE-RSA-AES256-GCM-SHA384' ) kwargs[ 'ssl_wrapper' ] = ssl_ctx.wrap_socket return librouteros.connect( **kwargs ) def router_in_groups( options, groups ): if not groups: return False for group in groups: if group in options.get( 'group', [] ): return True return False def connect_routers( config, args ): routers = config.get( 'router', {} ) rval = {} for ( name, options ) in routers.items(): if ( not args.only_router and not args.only_group ) or ( name in args.only_router ) or router_in_groups( options, args.only_group ): kwargs = {} kwargs.update( options.get( 'connection', {} ) ) if 'host' not in kwargs: kwargs[ 'host' ] = name rval[ name ] = connection( **kwargs ) return rval def monty_check( config, args, routers ): for addr in args.addr: for ( name, api ) in routers.items(): ip_address = api.path( 'ip', 'address' ) ipv6_address = api.path( 'ipv6', 'address' ) for item in ip_address: ( sel, rval, hst ) = parse_comment( item.get( 'comment', '' ) ) active = not item[ 'disabled' ] and not item[ 'invalid' ] if sel == addr or hst == addr: print( name, ": v4", 'ENABLED' if active else 'disabled', item[ 'interface' ] ) for item in ipv6_address: ( sel, rval, hst ) = parse_comment( item.get( 'comment', '' ) ) active = not item[ 'disabled' ] and not item[ 'invalid' ] if sel == addr or hst == addr: print( name, ": v6", 'ENABLED' if active else 'disabled', item[ 'interface' ] ) def monty_fixup( config, args, routers ): reverse = {} statics = {} for ( name, api ) in routers.items(): ip_address = api.path( 'ip', 'address' ) ipv6_address = api.path( 'ipv6', 'address' ) for item in ip_address: ( sel, rval, hst ) = parse_comment( item.get( 'comment', '' ) ) if sel is None: rval = {} active = not item[ 'disabled' ] and not item[ 'invalid' ] if item[ 'address' ] in config[ 'loopbacks' ]: if active: rval[ 'home' ] = True else: rval[ 'static' ] = True statics[ item[ 'interface' ] ] = True if not hst: hst = reverse_dns( item[ 'network' ] ) while hst.endswith( "." ): hst = hst[ :-1 ] if not sel: if item[ 'interface' ] not in reverse: rev = reverse_dns( item[ 'network' ] ) while rev.endswith( "." ): rev = rev[ :-1 ] reverse[ item[ 'interface' ] ] = rev sel = reverse[ item[ 'interface' ] ] new_comment = make_comment( sel, rval, hst ) if args.dry_run: print( '/ip address set [find where interface="%s" and network="%s"] comment="%s"' % ( item[ 'interface' ], item[ 'network' ], new_comment ) ) else: print( name, ":", item[ 'interface' ], new_comment ) ip_address.update( **{ 'comment': new_comment, '.id': item[ '.id' ] } ) addr = ipaddress.ip_interface( item[ 'address' ] ) if item[ 'address' ] in config[ 'loopbacks' ]: addr = ipaddress.ip_interface( item[ 'network' ] ) _adjust_filter( config, api, addr.network, hst, sel, home = rval.get( 'home', False ), standby = False, static = rval.get( 'static', False ), rval = rval ) for item in ipv6_address: if item[ 'interface' ].startswith( "loop" ): continue if item[ 'dynamic' ]: continue ( sel, rval, hst ) = parse_comment( item.get( 'comment', '' ) ) if sel is None: rval = {} active = not item[ 'disabled' ] and not item[ 'invalid' ] if statics.get( item[ 'interface' ], False ): rval[ 'static' ] = True elif active: rval[ 'home' ] = True if not sel: sel = reverse.get( item[ 'interface' ], "XXX " + item[ 'interface' ] ) if not hst: hst = reverse.get( item[ 'interface' ], "XXX " + item[ 'interface' ] ) new_comment = make_comment( sel, rval, hst ) if args.dry_run: print( '/ipv6 address set [find where interface="%s" and address="%s" ] comment="%s"' % ( item[ 'interface' ], item[ 'address' ], new_comment ) ) else: print( name, ":", item[ 'interface' ], new_comment ) ipv6_address.update( **{ 'comment': new_comment, '.id': item[ '.id' ] } ) addr = ipaddress.ip_interface( item[ 'address' ] ) _adjust_filter( config, api, addr.network, hst, sel, home = rval.get( 'home', False ), standby = False, static = rval.get( 'static', False ), rval = rval ) def _adjust_filter( config, api, network, hostname, selector = None, home = None, standby = None, static = None, delete = False, rval = None, disabled = False ): def _make_filter_attrs( comment, disabled, home, static, standby ): attrs = { 'action': 'accept', 'comment': comment, 'disabled': 'yes' if disabled else 'no', } if home or static: attrs[ '!set-bgp-prepend' ] = '' attrs[ 'set-bgp-local-pref' ] = 980 elif standby: attrs[ 'set-bgp-prepend' ] = 1 attrs[ 'set-bgp-local-pref' ] = 970 else: attrs[ 'set-bgp-prepend' ] = 2 attrs[ 'set-bgp-local-pref' ] = 960 return attrs if isinstance( network, ( ipaddress.IPv4Interface, ipaddress.IPv6Interface ) ): network = network.network filters = api.path( 'routing', 'filter' ) found = False if rval is None: rval = {} else: _rval = rval rval = {} rval.update( _rval ) if home is not None: rval[ 'home' ] = home if standby is not None: rval[ 'standby' ] = standby if static is not None: rval[ 'static' ] = static comment = make_comment( selector, rval, hostname ) for f in filters: if f[ 'chain' ] == config[ 'routing_filter_chain' ]: match = str( network ) if isinstance( network, ipaddress.IPv4Network ) and network.prefixlen == 32: match = match.split( "/", 1 )[ 0 ] elif isinstance( network, ipaddress.IPv6Network ) and network.prefixlen == 128: match = match.split( "/", 1 )[ 0 ] if f[ 'prefix' ] == match: if delete: filters.delete( f[ '.id' ] ) else: update = _make_filter_attrs( comment, disabled, home, static, standby ) update[ '.id' ] = f[ '.id' ] filters.update( **update ) found = True if not found and not delete: create = _make_filter_attrs( comment, disabled, home, static, standby ) create[ 'chain' ] = config[ 'routing_filter_chain' ] create[ 'prefix' ] = str( network ) filters.add( **create ) return rval def monty_provision( config, args, routers ): reverse = {} statics = {} if args.dry_run: print() for ( name, api ) in routers.items(): if args.dry_run: print( '#' * ( len( name ) + 2 ) ) print( '#', name ) print() vlans = api.path( 'interface', 'vlan' ) ip_address = api.path( 'ip', 'address' ) ipv6_address = api.path( 'ipv6', 'address' ) for vlan in vlans: if vlan[ 'vlan-id' ] == args.vlan: rval = {} if args.static: rval[ 'static' ] = True disabled = 'no' elif name in args.home: rval[ 'home' ] = True disabled = 'no' elif name in args.standby: rval[ 'standby' ] = True disabled = 'no' else: disabled = 'yes' comment = make_comment( args.selector, rval, args.hostname ) for addr in args.address: if args.dry_run: if isinstance( addr, ipaddress.IPv4Interface ): if addr.network.prefixlen == 32: print( '/ip address add interface="%s" address="%s" network="%s" disabled=%s comment="%s"' % ( vlan[ 'name' ], config[ 'default_ipv4_loopback' ], addr.network.network_address, disabled, comment ) ) else: print( '/ip address add interface="%s" address="%s" disabled=%s comment="%s"' % ( vlan[ 'name' ], addr, disabled, comment ) ) elif isinstance( addr, ipaddress.IPv6Interface ): if args.static: print( '/ipv6 address add interface="%s" address="%s" disabled=%s comment="%s" advertise=no' % ( vlan[ 'name' ], addr, disabled, comment ) ) else: print( '/ipv6 address add interface="%s" address="%s" disabled=%s comment="%s" eui-64=yes advertise=yes' % ( vlan[ 'name' ], addr.network, disabled, comment ) ) else: addr = ipaddress.ip_interface( addr ) prefix = _extract_bgp_announcement( addr, addr ) _adjust_filter( config, api, prefix, args.hostname, home = ( name in args.home ), standby = ( name not in args.home ) and ( name in args.standby ), selector = args.selector, rval = rval ) if isinstance( addr, ipaddress.IPv4Interface ): v4addr = { 'interface': vlan[ 'name' ], 'disabled': disabled, 'comment': comment, } if addr.network.prefixlen == 32: v4addr[ 'address' ] = config[ 'default_ipv4_loopback' ] v4addr[ 'network' ] = str( addr.network.network_address ) else: v4addr[ 'address' ] = str( addr ) ip_address.add( **v4addr ) elif isinstance( addr, ipaddress.IPv6Interface ): v6addr = { 'interface': vlan[ 'name' ], 'disabled': disabled, 'comment': comment, } if args.static: v6addr[ 'address' ] = str( addr ) else: v6addr[ 'address' ] = str( addr.network ) v6addr[ 'eui-64' ] = 'yes' v6addr[ 'advertise' ] = 'yes' ipv6_address.add( **v6addr ) if args.dry_run: print() def monty_teardown( config, args, routers ): reverse = {} statics = {} if args.dry_run: print() for ( name, api ) in routers.items(): if args.dry_run: print( '#' * ( len( name ) + 2 ) ) print( '#', name ) print() vlans = api.path( 'interface', 'vlan' ) ip_address = api.path( 'ip', 'address' ) ipv6_address = api.path( 'ipv6', 'address' ) for addr in ip_address: ( sel, rval, hst ) = parse_comment( addr.get( 'comment', '' ) ) if sel == args.hostname or hst == args.hostname: if args.delete: if rval.get( 'teardown', False ): if args.dry_run: print( '/ip address remove [find where interface="%s" and network="%s" and comment~"teardown" and comment~"%s" ]' % ( addr[ 'interface' ], addr[ 'network' ], hst ) ) else: print( "deleting %s %s from %s on %s" % ( addr[ 'address' ], addr[ 'network' ], addr[ 'interface' ], name ) ) ip_address.remove( addr[ '.id' ] ) else: if args.dry_run: print( "# ignoring %s %s on %s on %s" % ( addr[ 'address' ], addr[ 'network' ], addr[ 'interface' ], name ) ) else: print( "ignoring %s %s on %s on %s" % ( addr[ 'address' ], addr[ 'network' ], addr[ 'interface' ], name ) ) else: rval[ 'teardown' ] = True comment = make_comment( sel, rval, hst ) if args.dry_run: print( '/ip address set [find where interface="%s" and network="%s" and comment~"%s" ] disabled=yes comment="%s"' % ( addr[ 'interface' ], addr[ 'network' ], hst, comment ) ) else: print( "disabling %s %s on %s on %s" % ( addr[ 'address' ], addr[ 'network' ], addr[ 'interface' ], name ) ) ip_address.update( **{ '.id': addr[ '.id' ], 'disabled': 'yes', 'comment': comment } ) for addr in ipv6_address: ( sel, rval, hst ) = parse_comment( addr.get( 'comment', '' ) ) if sel == args.hostname or hst == args.hostname: if args.delete: if rval.get( 'teardown', False ): if args.dry_run: print( '/ipv6 address remove [find where interface="%s" and address="%s" and comment="%s" ]' % ( addr[ 'interface' ], addr[ 'address' ], addr.get( 'comment', '' ) ) ) else: print( "deleting %s from %s on %s" % ( addr[ 'address' ], addr[ 'interface' ], name ) ) ipv6_address.remove( addr[ '.id' ] ) else: if args.dry_run: print( "# ignoring %s on %s on %s" % ( addr[ 'address' ], addr[ 'interface' ], name ) ) else: print( "ignoring %s on %s on %s" % ( addr[ 'address' ], addr[ 'interface' ], name ) ) else: rval[ 'teardown' ] = True comment = make_comment( sel, rval, hst ) if args.dry_run: print( '/ipv6 address set [find where interface="%s" and address="%s" and comment="%s" ] disabled=yes comment="%s"' % ( addr[ 'interface' ], addr[ 'address' ], addr.get( 'comment', '' ), comment ) ) else: print( "disabling %s on %s on %s" % ( addr[ 'address' ], addr[ 'interface' ], name ) ) ipv6_address.update( **{ '.id': addr[ '.id' ], 'disabled': 'yes', 'comment': comment } ) if args.dry_run: print() def _extract_bgp_announcement( address, network = None ): addr = ipaddress.ip_interface( address ) if isinstance( addr, ipaddress.IPv4Interface ): if addr.network.prefixlen == 32: return ipaddress.ip_interface( network ) return addr.network elif isinstance( addr, ipaddress.IPv6Interface ): return addr.network def monty_promote( config, args, routers ): for new_home in args.new_home: if new_home not in routers: raise ValueError( 'cannot find new home "%s" in routers' % new_home ) for standby in args.standby: if standby not in routers: raise ValueError( 'cannot find new standby "%s" in routers' % standby ) done_work = False for ( name, api ) in routers.items(): vlans = api.path( 'interface', 'vlan' ) ip_address = api.path( 'ip', 'address' ) ipv6_address = api.path( 'ipv6', 'address' ) spare_vlans = {} for vlan in vlans: if vlan[ 'vlan-id' ] == args.vlan: spare_vlans[ vlan[ 'name' ] ] = True for af in ( ip_address, ipv6_address ): for addr in af: ( sel, rval, hst ) = parse_comment( addr.get( 'comment', '' ) ) if sel == args.hostname or hst == args.hostname: if addr.get( 'disabled', False ): if ( name in args.standby ) or ( name in args.new_home ): address = _extract_bgp_announcement( addr[ 'address' ], addr.get( 'network', None ) ) rval = _adjust_filter( config, api, address, hst, selector = sel, home = ( name in args.new_home ), standby = ( name not in args.new_home ) and ( name in args.standby ), static = args.static, rval = rval, ) new_comment = make_comment( sel, rval, hst ) af.update( **{ '.id': addr[ '.id' ], 'disabled': False, 'comment': new_comment, } ) done_work = True else: if ( name in args.new_home ): address = _extract_bgp_announcement( addr[ 'address' ], addr.get( 'network', None ) ) rval = _adjust_filter( config, api, address, hst, selector = sel, home = ( name in args.new_home ), standby = False, static = args.static, rval = rval, ) new_comment = make_comment( sel, rval, hst ) af.update( **{ '.id': addr[ '.id' ], 'disabled': False, 'comment': new_comment, } ) done_work = True spare_vlans.pop( addr[ 'interface' ], None ) for vlan in spare_vlans: spare.append( ( name, ip_address, ipv6_address, vlan ) ) if args.create: for ( name, ip_address, ipv6_address, vlan ) in spare: rval = {} disabled = 'yes' if name in args.new_home: rval[ 'home' ] = True disabled = 'no' elif name in args.standby: disabled = 'no' new_comment = make_comment( args.selector, rval, args.hostname ) for addr in args.create: rval = _adjust_filter( config, api, addr.network, args.hostname, selector = args.selector, home = ( name in args.new_home ), standby = ( name not in args.new_home ) and ( name in args.standby ), static = args.static, rval = rval, ) if isinstance( addr, ipnetwork.IPv4Interface ): print( "creating address on interface" ) v4addr = { 'interface': vlan, 'disabled': disabled, 'comment': new_comment, } if addr.network.prefixlen == 32: v4addr[ 'address' ] = config[ 'default_ipv4_loopback' ] v4addr[ 'network' ] = str( addr.network.network_address ) else: v4addr[ 'address' ] = str( addr ) ip_address.create( **v4addr ) done_work = True elif isinstance( addr, ipnetwork.IPv6Interface ): print( "creating address on interface" ) v6addr = { 'interface': vlan, 'disabled': disabled, 'comment': comment, } if args.static: v6addr[ 'address' ] = str( addr ) else: v6addr[ 'address' ] = str( addr.network ) v6addr[ 'eui-64' ] = 'yes' v6addr[ 'advertise' ] = 'yes' ipv6_address.create( **v6addr ) done_work = True return done_work def monty_demote( config, args, routers ): for disable in args.disable: if disable not in routers: raise ValueError( 'cannot find disable "%s" in routers' % disable ) for standby in args.standby: if standby not in routers: raise ValueError( 'cannot find standby "%s" in routers' % standby ) done_work = False for ( name, api ) in routers.items(): vlans = api.path( 'interface', 'vlan' ) ip_address = api.path( 'ip', 'address' ) ipv6_address = api.path( 'ipv6', 'address' ) for af in ( ip_address, ipv6_address ): for addr in af: ( sel, rval, hst ) = parse_comment( addr.get( 'comment', '' ) ) if sel == args.hostname or hst == args.hostname: if ( name in args.standby ) or ( name in args.disable ): address = _extract_bgp_announcement( addr[ 'address' ], addr.get( 'network', None ) ) rval = _adjust_filter( config, api, address, hst, selector = sel, home = False, standby = ( name in args.standby ), rval = rval, ) new_comment = make_comment( sel, rval, hst ) af.update( **{ '.id': addr[ '.id' ], 'disabled': ( name in args.disable ), 'comment': new_comment, } ) done_work = True def monty_migrate( config, args, routers ): if monty_promote( config, args, routers ): monty_demote( config, args, routers ) def load_configuration(): try: config = open( os.path.expanduser( '~/.automonty.yaml' ), 'r' ) except IOError: return {} return yaml.load( config.read(), yaml.SafeLoader ) def main(): config = load_configuration() parser = argparse.ArgumentParser( prog = "automonty", description = 'AutoMonty (re-)configures routers', ) subparsers = parser.add_subparsers() parser.add_argument( '--only-router', action = 'append', default = [] ) parser.add_argument( '--only-group', action = 'append', default = [] ) parser.add_argument( '-n', '--dry-run', action = 'store_true', default = False ) parser_check = subparsers.add_parser( 'check' ) parser_check.add_argument( 'addr', nargs = "*" ) parser_check.set_defaults( func = monty_check ) parser_fixup = subparsers.add_parser( 'fixup' ) parser_fixup.set_defaults( func = monty_fixup ) parser_provision = subparsers.add_parser( 'provision' ) parser_provision.add_argument( '--home', action = 'append', default = [] ) parser_provision.add_argument( '--standby', action = 'append', default = [] ) parser_provision.add_argument( '-s', '--static', action = 'store_true', default = False ) parser_provision.add_argument( '--selector', type = str, default = None ) parser_provision.add_argument( 'hostname' ) parser_provision.add_argument( 'vlan', type = int ) parser_provision.add_argument( 'address', type = ipaddress.ip_interface, nargs = "+" ) parser_provision.set_defaults( func = monty_provision ) parser_promote = subparsers.add_parser( 'promote' ) parser_promote.add_argument( '-e', '--enable', action = 'store_true', default = False ) parser_promote.add_argument( '-c', '--create', type = ipaddress.ip_interface, default = [], action = 'append' ) parser_promote.add_argument( '-s', '--static', action = 'store_true', default = False ) parser_promote.add_argument( '--standby', action = 'append', default = [] ) parser_promote.add_argument( '--selector', type = str, default = None ) parser_promote.add_argument( 'hostname' ) parser_promote.add_argument( 'vlan', type = int ) parser_promote.add_argument( 'new_home', nargs = "*" ) parser_promote.set_defaults( func = monty_promote ) parser_demote = subparsers.add_parser( 'demote' ) parser_demote.add_argument( '--standby', action = 'append', default = [] ) parser_demote.add_argument( 'hostname' ) parser_demote.add_argument( 'vlan', type = int ) parser_demote.add_argument( 'disable', nargs = "*" ) parser_demote.set_defaults( func = monty_demote ) parser_migrate = subparsers.add_parser( 'migrate' ) parser_migrate.add_argument( '-e', '--enable', action = 'store_true', default = False ) parser_migrate.add_argument( '-c', '--create', type = ipaddress.ip_interface, default = [], action = 'append' ) parser_migrate.add_argument( '-s', '--static', action = 'store_true', default = False ) parser_migrate.add_argument( '--selector', type = str, default = None ) parser_migrate.add_argument( '--standby', action = 'append', default = [] ) parser_migrate.add_argument( '--disable', action = 'append', default = [] ) parser_migrate.add_argument( 'hostname' ) parser_migrate.add_argument( 'vlan', type = int ) parser_migrate.add_argument( 'new_home', nargs = "*" ) parser_migrate.set_defaults( func = monty_migrate ) parser_teardown = subparsers.add_parser( 'teardown' ) parser_teardown.add_argument( '--delete', action = 'store_true', default = False ) parser_teardown.add_argument( 'hostname' ) parser_teardown.add_argument( 'vlan', type = int, default = None, nargs = "?" ) parser_teardown.set_defaults( func = monty_teardown ) args = parser.parse_args() routers = connect_routers( config, args ) args.func( config, args, routers ) if __name__ == '__main__': main()