├── exasrv ├── README.md ├── check_command ├── Makefile ├── exabgp.default ├── exasrv.conf ├── simulator.go └── exasrv.py ├── exaprefixdb ├── README.md ├── exabgp.conf ├── exaprefixdb.conf └── exaprefixdb.py ├── exastress ├── README.md └── exastress.py ├── README.md └── LICENSE /exasrv/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /exaprefixdb/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /exastress/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /exasrv/check_command: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo default, group1,,, 4 | exit 57 5 | -------------------------------------------------------------------------------- /exasrv/Makefile: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | simulator: simulator.go exasrv.py exasrv.conf 4 | go run simulator.go 10.0.2.1 10.0.3.1 5 | 6 | run: exasrv.py exasrv.conf 7 | ./exasrv.py exasrv.conf supervise 10.0.2.1 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A collection of various Python scripts interfacing with ExaBGP for various purposes. 2 | 3 | - exasrv: BGP-based full-featured applicative service advertising and management 4 | - exaprefixdb: BGP routing tables dump utility 5 | - exastress: BGP stack stress-test utility 6 | -------------------------------------------------------------------------------- /exasrv/exabgp.default: -------------------------------------------------------------------------------- 1 | USER=root 2 | 3 | if [ -z "$ETC" ] 4 | then 5 | ETC=/etc/exabgp 6 | fi 7 | 8 | if [ "$1" = "start" -o "$UPSTART_STATE" = "start" ] 9 | then 10 | if [ -s "$ETC/processes/exasrv.conf" -a -x "$ETC/processes/exasrv.py" ] 11 | then 12 | rm -f "$ETC/exabgp.conf" "$ETC/exabgp.env" 13 | "$ETC/processes/exasrv.py" "$ETC/processes/exasrv.conf" configure "$ETC/exabgp.conf" 14 | fi 15 | fi 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-2019 Pierre-Yves Kerembellec 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /exaprefixdb/exabgp.conf: -------------------------------------------------------------------------------- 1 | group peers { 2 | process prefixdb { 3 | encoder json; 4 | peer-updates; 5 | neighbor-changes; 6 | receive-routes; 7 | run /etc/exabgp/processes/exaprefixdb.py /etc/exabgp/processes/exaprefixdb.conf; 8 | } 9 | # core-01.loc1 10 | neighbor 1.2.3.4 { 11 | router-id 10.1.2.3; 12 | local-address 10.1.23; 13 | local-as 12345; 14 | peer-as 12345; 15 | family { 16 | inet4 unicast; 17 | } 18 | } 19 | # core-02.loc1 20 | neighbor 1.2.3.5 { 21 | router-id 10.1.2.3; 22 | local-address 10.1.2.3; 23 | local-as 12345; 24 | peer-as 12345; 25 | family { 26 | inet4 unicast; 27 | } 28 | } 29 | # core-01.loc2 30 | neighbor 4.3.2.1 { 31 | router-id 10.1.2.3; 32 | local-address 10.1.2.3; 33 | local-as 12345; 34 | peer-as 12345; 35 | family { 36 | inet4 unicast; 37 | } 38 | } 39 | # core-01.loc3 40 | neighbor 5.4.3.2 { 41 | router-id 10.1.2.3; 42 | local-address 10.1.2.3; 43 | local-as 12345; 44 | peer-as 12345; 45 | family { 46 | inet4 unicast; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /exasrv/exasrv.conf: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "peers": 4 | { 5 | "10.0.2.1": 6 | { 7 | "local": 8 | { 9 | "interface": "eth2", 10 | "address": "10.0.2.10/24", 11 | "asnum": "65501" 12 | }, 13 | "remote": 14 | { 15 | "address": "10.0.2.1", 16 | "asnum": "65500" 17 | } 18 | }, 19 | "10.0.3.1": 20 | { 21 | "local": 22 | { 23 | "interface": "eth3", 24 | "address": "10.0.3.10/24", 25 | "asnum": "65501" 26 | }, 27 | "remote": 28 | { 29 | "address": "10.0.3.1", 30 | "asnum": "65500" 31 | } 32 | } 33 | }, 34 | "routes": 35 | { 36 | "0.0.0.0/0": { "ignore": true } 37 | }, 38 | "service": 39 | { 40 | "addresses": 41 | { 42 | "1.2.3.3": { "alwaysup": true }, 43 | "1.2.3.4": { "autoremove": true, "weight": "primary", "aspath": "AS12345 AS54321", "group": "group1" }, 44 | "1.2.3.5": { "weight": "secondary" }, 45 | "1.2.3.6": { "weight": 150, "community": "whatnot", "group": "group1" } 46 | }, 47 | "check": 48 | { 49 | "command": "./check_command", 50 | "timeout": 2, 51 | "interval": 5, 52 | "finterval": 1, 53 | "rise": 3, 54 | "fall": 3 55 | }, 56 | "actions": 57 | { 58 | "up": "./up_command", 59 | "down": "./down_command", 60 | } 61 | } 62 | } 63 | ] 64 | -------------------------------------------------------------------------------- /exasrv/simulator.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | var bgp = []string{ 14 | `{"type":"state","neighbor":{"ip":"[PEER]","address":{"local":"127.0.0.1","peer":"[PEER]"},"state":"connected"}}`, 15 | `{"type":"state","neighbor":{"ip":"[PEER]","address":{"local":"127.0.0.1","peer":"[PEER]"},"state":"up"}}`, 16 | `{"type":"update","neighbor":{"ip":"[PEER]","address":{"local":"127.0.0.1","peer":"[PEER]"},"message":{"update":{"announce":{"ipv4 unicast":{"[PEER]":{"172.16.0.0/24":{},"192.168.0.0/24":{}}}}}}}}`, 17 | `{"type":"update","neighbor":{"ip":"[PEER]","address":{"local":"127.0.0.1","peer":"[PEER]"},"message":{"update":{"announce":{"ipv4 unicast":{"[PEER]":[{"nlri":"172.16.0.0/24"},{"nlri":"192.168.0.0/24"}]}}}}}}`, 18 | } 19 | 20 | func peer(value string) { 21 | command := exec.Command("./exasrv.py", "exasrv.conf", "supervise", value) 22 | if handle, err := command.StdinPipe(); err == nil { 23 | go func(handle io.WriteCloser, value string) { 24 | for index := 0; index < len(bgp); index++ { 25 | time.Sleep(time.Second) 26 | line := strings.Replace(bgp[index], "[PEER]", value, -1) + "\n" 27 | handle.Write([]byte(line)) 28 | fmt.Printf("\x1b[34m>>> %s\x1b[0m", line) 29 | } 30 | select {} 31 | }(handle, value) 32 | } 33 | if handle, err := command.StdoutPipe(); err == nil { 34 | go func(handle io.ReadCloser) { 35 | reader := bufio.NewReader(handle) 36 | for { 37 | if line, err := reader.ReadString('\n'); err != nil { 38 | break 39 | } else { 40 | fmt.Printf("\x1b[36m<<< %s\x1b[0m", line) 41 | } 42 | } 43 | }(handle) 44 | } 45 | if handle, err := command.StderrPipe(); err == nil { 46 | go func(handle io.ReadCloser) { 47 | reader := bufio.NewReader(handle) 48 | for { 49 | if line, err := reader.ReadString('\n'); err != nil { 50 | break 51 | } else { 52 | fmt.Printf("\x1b[33m<<< %s\x1b[0m", line) 53 | } 54 | } 55 | }(handle) 56 | } 57 | command.Run() 58 | } 59 | 60 | func main() { 61 | for index := 1; index < len(os.Args); index++ { 62 | go peer(os.Args[index]) 63 | } 64 | select {} 65 | } 66 | -------------------------------------------------------------------------------- /exastress/exastress.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | example: exastress.py --peer-ip 10.140.0.1 --local-cidr 10.140.10.0/24 --local-interface eth0 --announce-cidr 10.141.10.0/24 --sessions 250 4 | """ 5 | import sys, os 6 | from argparse import ArgumentParser 7 | from ipaddr import IPAddress, IPNetwork 8 | 9 | # parse command-line options 10 | argparser = ArgumentParser(description='BGP stack stress-test utility') 11 | argparser.add_argument('--peer-ip', metavar='ADDRESS', required=True, dest='peer_ip', help='BGP peer address (no default)') 12 | argparser.add_argument('--peer-as', metavar='ASNUM', dest='peer_as', default='65500', help='BGP peer AS number (default: 65500)') 13 | argparser.add_argument('--local-cidr', metavar='CIDR', required=True, dest='local_cidr', help='BGP sessions source addresses block (no default)') 14 | argparser.add_argument('--local-as', metavar='ASNUM', dest='local_as', default='65501', help='BGP local AS number (default: 65501)') 15 | argparser.add_argument('--local-interface', metavar='INTERFACE', dest='local_interface', help='local interface used for BGP sessions source addresses (default: none)') 16 | argparser.add_argument('--announce-cidr', metavar='CIDR', dest='announce_cidr', help='BGP /32 announces block (default: none)') 17 | argparser.add_argument('--sessions', metavar='COUNT', dest='sessions', default=1, help='number of BGP sessions to establish (default: 1)') 18 | args = argparser.parse_args() 19 | 20 | # validate command-line options 21 | try: 22 | peer_ip = IPAddress(args.peer_ip) 23 | except: 24 | print 'invalid peer address "%s" - aborting' % args.peer_ip 25 | sys.exit(1) 26 | try: 27 | local_cidr = IPNetwork(args.local_cidr) 28 | except: 29 | print 'invalid local addresses block "%s" - aborting' % args.local_cidr 30 | sys.exit(1) 31 | if args.announce_cidr: 32 | try: 33 | announce_cidr = IPNetwork(args.announce_cidr) 34 | announce_cidr = announce_cidr.iterhosts() 35 | except: 36 | print 'invalid announce addresses block %s - aborting' % args.announce_cidr 37 | sys.exit(1) 38 | 39 | # create ExaBGP configuration + setup address aliases if required 40 | configuration = 'group peers {\n' 41 | sessions = 0 42 | for address in local_cidr.iterhosts(): 43 | configuration += (' neighbor %s {\n' 44 | ' router-id %s;\n' 45 | ' local-address %s;\n' 46 | ' local-as %s;\n' 47 | ' peer-as %s;\n' 48 | ' family {\n' 49 | ' inet4 unicast;\n' 50 | ' }\n') % (peer_ip, address, address, args.local_as, args.peer_as) 51 | if args.announce_cidr: 52 | try: 53 | configuration += (' static {\n' 54 | ' route %s/32 {\n' 55 | ' next-hop %s;\n' 56 | ' }\n' 57 | ' }\n') % (announce_cidr.next(), address) 58 | except: 59 | pass 60 | configuration += ' }\n' 61 | if args.local_interface: 62 | os.system('ifconfig %s:%s %s netmask %s up 2>/dev/null' % (args.local_interface, IPAddress(address).__hex__()[2:], address, local_cidr.netmask)) 63 | sessions += 1 64 | if sessions >= int(args.sessions): 65 | break 66 | configuration += '}\n' 67 | 68 | # start ExaBGP with temporary configuration 69 | path = 'exabgp-%d.conf' % os.getpid() 70 | handle = open(path, 'w') 71 | handle.write(configuration); 72 | handle.close() 73 | os.system('exabgp %s' % path); 74 | os.remove(path) 75 | 76 | # remove address aliases if required 77 | if args.local_interface: 78 | sessions = 0 79 | for address in local_cidr.iterhosts(): 80 | os.system('ifconfig %s:%s down 2>/dev/null' % (args.local_interface, IPAddress(address).__hex__()[2:])) 81 | sessions += 1 82 | if sessions >= int(args.sessions): 83 | break 84 | -------------------------------------------------------------------------------- /exaprefixdb/exaprefixdb.conf: -------------------------------------------------------------------------------- 1 | { 2 | "interval": 180, 3 | "backups": 5, 4 | "basepath": "/var/run/prefixdb", 5 | "aliases": 6 | { 7 | "1.2.3.4": "core-01.loc1", 8 | "1.2.3.5": "core-02.loc1", 9 | "4.3.2.1": "core-01.loc2", 10 | "5.4.3.2": "core-01.loc3" 11 | }, 12 | "exports": 13 | [ 14 | { "database": "loc1.pni_private1", "aspath": "^5476 ", "community": [ "12345:1022" ], "peer": [ "core-01.loc1", "core-02.loc1" ] }, 15 | { "database": "loc1.pni_private3", "aspath": "^9271 ", "peer": [ "core-01.loc1", "core-02.loc1" ] }, 16 | { "database": "loc1.pni_private3", "aspath": "^3023 ", "community": [ "12345:1022" ], "peer": [ "core-01.loc1", "core-02.loc1" ] }, 17 | { "database": "loc1.ppi_ix1", "community": [ "12345:202" ] }, 18 | { "database": "loc1.ppi_ix2", "community": [ "12345:201" ] }, 19 | { "database": "loc1.ppi_ix3", "community": [ "12345:205" ] }, 20 | { "database": "loc1.ppi_ix4", "community": [ "12345:204" ] }, 21 | { "database": "loc1.ppi_ix5", "community": [ "12345:203" ] }, 22 | { "database": "loc1.ppi_ix6", "community": [ "12345:211" ] }, 23 | { "database": "loc1.trs_transit1", "aspath": "^6757 ", "peer": [ "core-01.loc1", "core-02.loc1" ] }, 24 | { "database": "loc1.trs_transit2", "aspath": "^6453 ", "peer": [ "core-01.loc1", "core-02.loc1" ] }, 25 | { "database": "loc1.trs_transit3", "aspath": "^3356 ", "peer": [ "core-01.loc1", "core-02.loc1" ] }, 26 | { "database": "loc1.trs_transit4", "aspath": "^1532 ", "peer": [ "core-01.loc1", "core-02.loc1" ] }, 27 | { "database": "loc1.trs_transit5", "aspath": "^1166 ", "peer": [ "core-01.loc1", "core-02.loc1" ] }, 28 | { "database": "loc1.trs_transit6", "aspath": "^4192 ", "peer": [ "core-01.loc1", "core-02.loc1" ] }, 29 | { "database": "loc1.trs_transit7", "aspath": "^5723 ", "peer": [ "core-01.loc1", "core-02.loc1" ] }, 30 | { "database": "loc1.ppi_transit8", "community": [ "12345:210" ] }, 31 | 32 | { "database": "loc2.ppi_ix1", "community": [ "12345:207" ] }, 33 | { "database": "loc2.trs_transit1", "aspath": "^13299 ", "peer": [ "core-01.loc2" ] }, 34 | { "database": "loc2.trs_transit2", "aspath": "^31356 ", "peer": [ "core-01.loc2" ] }, 35 | { "database": "loc2.trs_transit3", "aspath": "^5732 ", "community": [ "12345:307" ], "peer": [ "core-01.loc2" ] }, 36 | 37 | { "database": "loc3.pni_private1", "aspath": "^25156 ", "peer": [ "core-01.loc3" ] }, 38 | { "database": "loc3.pni_private2", "aspath": "^4725 ", "peer": [ "core-01.loc3" ] }, 39 | { "database": "loc3.pni_private3", "aspath": "^1676 ", "peer": [ "core-01.loc3" ] }, 40 | { "database": "loc3.pni_private4", "aspath": "^9860 ", "peer": [ "core-01.loc3" ] }, 41 | { "database": "loc3.pni_private5", "aspath": "^4676 ", "peer": [ "core-01.loc3" ] }, 42 | { "database": "loc3.ppi_ix1", "community": [ "12345:208" ] }, 43 | { "database": "loc3.trs_transit1", "aspath": "^1929 ", "peer": [ "core-01.loc3" ] }, 44 | { "database": "loc3.trs_transit2", "aspath": "^2914 ", "peer": [ "core-01.loc3" ] }, 45 | { "database": "loc3.trs_transit3", "aspath": "^3356 ", "peer": [ "core-01.loc3" ] } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /exaprefixdb/exaprefixdb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import print_function 4 | import sys, os, re, time, select, syslog, signal 5 | try: 6 | import simplejson as json 7 | except ImportError: 8 | import json 9 | 10 | if len(sys.argv) < 2: 11 | print('usage: %s [configuration]' % os.path.basename(sys.argv[0]), file=sys.stderr) 12 | sys.exit(1) 13 | configuration_path = os.path.realpath(sys.argv[1]) 14 | try: 15 | configuration = json.load(open(configuration_path)) 16 | except: 17 | print('invalid configuration file "%s" - aborting' % configuration_path, file=sys.stderr) 18 | sys.exit(2) 19 | syslog.openlog(re.sub(r'^(.+?)\..+$', r'\1', os.path.basename(sys.argv[0])), logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) 20 | 21 | debug = configuration.get('debug', 0) 22 | def handle_debug(signum, frame): 23 | global debug 24 | debug = 1 - debug 25 | syslog.syslog('debug mode %s' % 'on' if debug else 'off') 26 | signal.signal(signal.SIGUSR1, handle_debug) 27 | 28 | exports = {} 29 | for export in configuration.get('exports', []): 30 | exports[export.get('database', '')] = {} 31 | 32 | last = time.time() 33 | while True: 34 | try: 35 | ready, _, _ = select.select([sys.stdin], [], [], 10) 36 | except: 37 | continue 38 | if ready: 39 | line = sys.stdin.readline().strip() 40 | try: 41 | message = json.loads(line) 42 | if (message.get('type', '') == 'update'): 43 | neighbor = message.get('neighbor', {}) 44 | peer = neighbor.get('ip', '') 45 | peer = configuration.get('aliases', {}).get(peer, peer) 46 | prefixes = [] 47 | aspath = '' 48 | communities = [] 49 | atype = '' 50 | 51 | for type, params in neighbor.get('message', {}).get('update', {}).items(): 52 | if type == 'announce': 53 | atype = 'announce' 54 | for family, announce in params.items(): 55 | if family == 'ipv4 unicast' or family == 'ipv6 unicast': 56 | for target, networks in announce.items(): 57 | prefixes = networks.keys() 58 | 59 | elif type == 'withdraw': 60 | atype = 'withdraw' 61 | for family, withdraw in params.items(): 62 | if family == 'ipv4 unicast' or family == 'ipv6 unicast': 63 | prefixes = withdraw.keys() 64 | 65 | elif type == 'attribute': 66 | aspath = ' '.join(map(str, params.get('as-path', []))) + ' ' 67 | communities = ['%s:%s' % (x[0], x[1]) for x in params.get('community', [])] 68 | 69 | if atype == 'announce': 70 | for path, export in exports.items(): 71 | for prefix in prefixes: 72 | if export.get(prefix) == peer: 73 | del export[prefix] 74 | if debug: 75 | syslog.syslog('remove %s:%s from %s' % (prefix, peer, path)) 76 | matched = False 77 | for export in configuration.get('exports', []): 78 | filter = export.get('peer', []) 79 | if len(filter) and not peer in filter: 80 | continue 81 | 82 | filter = export.get('community', []) 83 | if len(filter) and len(set(filter) & set(communities)) == 0: 84 | continue 85 | 86 | filter = export.get('aspath', '') 87 | if filter != '' and not re.match(filter, aspath): 88 | continue 89 | 90 | exports[export.get('database', '')].update(dict(zip(prefixes, [peer] * len(prefixes)))) 91 | matched= True 92 | if debug: 93 | syslog.syslog('add %s:%s to %s' % (json.dumps(prefixes), peer, export.get('database', ''))) 94 | break 95 | 96 | if not matched and len(prefixes): 97 | syslog.syslog('unmatched peer=[%s] aspath=[%s] communities=%s prefixes=[%d]' % (peer, aspath, str(communities), len(prefixes))) 98 | 99 | elif atype == 'withdraw': 100 | for path, export in exports.items(): 101 | for prefix in prefixes: 102 | if export.get(prefix) == peer: 103 | del export[prefix] 104 | if debug: 105 | syslog.syslog('withdraw %s:%s from %s' % (prefix, peer, path)) 106 | 107 | except Exception, e: 108 | pass 109 | 110 | if time.time() - last >= configuration.get('interval', 600): 111 | last = time.time() 112 | for export, prefixes in exports.items(): 113 | base = '%s/%s.db' % (configuration.get('basepath', '/tmp'), export) 114 | for backup in range(configuration.get('backups', 3), 0, -1): 115 | source = ('%s.%d' % (base, backup - 1)) if backup > 1 else base 116 | target = '%s.%d' % (base, backup) 117 | try: 118 | os.rename(source, target) 119 | except: 120 | pass 121 | 122 | try: 123 | os.umask(022) 124 | handle = open(base, 'w') 125 | handle.write("\n".join(sorted(prefixes.keys())) + "\n") 126 | handle.close() 127 | syslog.syslog('exported %s (%d prefixes)' % (export, len(prefixes))) 128 | except: 129 | pass 130 | -------------------------------------------------------------------------------- /exasrv/exasrv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # mandatory imports 4 | from __future__ import print_function 5 | from signal import signal, SIGINT, SIGTERM, SIGHUP 6 | import sys, os, re, subprocess, time, select, fcntl, hashlib, syslog 7 | try: 8 | import simplejson as json 9 | except ImportError: 10 | import json 11 | 12 | version = '1.4.6' 13 | 14 | # log message to both syslog and stderr 15 | syslog.openlog(re.sub(r'^(.+?)\..+$', r'\1', os.path.basename(sys.argv[0])), logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) 16 | def log(message): 17 | syslog.syslog(message) 18 | print(message, file=sys.stderr) 19 | 20 | # log fatal message and exit 21 | def abort(message): 22 | log('[fatal] %s' % message) 23 | time.sleep(1) 24 | sys.exit(1) 25 | 26 | # load configuration 27 | if len(sys.argv) < 3: 28 | abort('usage: %s CONFIGURATION ACTION [ARGUMENTS]' % os.path.basename(sys.argv[0])) 29 | self_path = os.path.realpath(sys.argv[0]) 30 | conf_path = os.path.realpath(sys.argv[1]) 31 | conf_last = 0 32 | conf_hash = '' 33 | conf = {} 34 | 35 | def load_configuration(): 36 | global conf_path, conf_last, conf_hash, conf 37 | 38 | now = time.time() 39 | if now - conf_last >= 10: 40 | conf_last = now 41 | try: 42 | handle = open(conf_path, 'r') 43 | content = handle.read(65536) 44 | handle.close() 45 | content = re.sub(r'^\s*(#|//).+?$', '', content, flags = re.M) 46 | content = re.sub(r'/\*[^\*]*\*/', '', content) 47 | while True: 48 | matcher = re.match(r'(?P^.*?)\{\{<\s*(?P[^\}\s]+?)\s*\}\}(?P.*)$', content, flags = re.S) 49 | if not matcher: 50 | break 51 | include = '' 52 | try: 53 | handle = open(matcher.group('include'), 'r') 54 | include = handle.read(65536) 55 | handle.close() 56 | except: 57 | pass 58 | content = matcher.group('before') + include + matcher.group('after') 59 | content = re.sub(r',(\s*[\}\]])', r'\1', content) 60 | hash = hashlib.md5(content).hexdigest() 61 | if hash != conf_hash: 62 | log('[conf] %sloaded configuration file' % ('re' if conf_hash != '' else '')) 63 | conf_hash = hash 64 | conf = json.loads(content) 65 | return True 66 | except Exception as e: 67 | log('[conf] invalid configuration file "%s" / %s' % (conf_path, e)) 68 | return False 69 | 70 | # match address inet family 71 | def inet4(address): 72 | return re.match(r'^[0-9.]+(/[0-9]+)?$', address) 73 | def inet6(address): 74 | return re.match(r'^[0-9a-f:]+(/[0-9]+)?$', address) 75 | 76 | # add local address 77 | def add_address(address, interface): 78 | matcher = re.match(r'(?P
\d[\da-f.:]+)/(?P\d+)', address) 79 | if not matcher: 80 | return 81 | address = matcher.group('address') 82 | netmask = matcher.group('netmask') 83 | rinterface = re.sub(r'^(vlan\d+)@.+$', r'\1', interface) 84 | try: 85 | for line in subprocess.check_output(str('ip addr show %s' % rinterface).split(), shell=False).split('\n'): 86 | matcher = re.match(r'^\s*inet6?\s+(?P
\d[\da-f.:]+)/(?P\d+)\s+', line) 87 | if matcher: 88 | if (matcher.group('address') + '/' + matcher.group('netmask')) == (address + '/' + netmask): 89 | return 90 | if (matcher.group('address') == address): 91 | subprocess.call(str('ip addr delete %s dev %s' % (address, rinterface)).split()) 92 | log('[ip] removed address %s/%s from interface %s' % (address, matcher.group('netmask'), rinterface)) 93 | except: 94 | pass 95 | matcher = re.match(r'^(?P[^\.]+?)\.(?P\d+)$', interface) 96 | if matcher: 97 | subprocess.call(str('ip link add link %s name %s type vlan id %s' % (matcher.group('interface'), interface, matcher.group('vlan'))).split()) 98 | matcher = re.match(r'^vlan(?P\d+)@(?P.+)$', interface) 99 | if matcher: 100 | subprocess.call(str('ip link add link %s name vlan%s type vlan id %s' % (matcher.group('interface'), matcher.group('vlan'), matcher.group('vlan'))).split()) 101 | subprocess.call(str('ip link set %s up' % rinterface).split()) 102 | if inet6(address): 103 | subprocess.call(str('ip -6 addr add %s/%s dev %s' % (address, netmask, rinterface)).split()) 104 | else: 105 | subprocess.call(str('ip addr add %s/%s broadcast + dev %s' % (address, netmask, rinterface)).split()) 106 | log('[ip] added address %s/%s to interface %s' % (address, netmask, rinterface)) 107 | 108 | # remove local address 109 | def remove_address(address, interface): 110 | for line in subprocess.check_output(str('ip addr show %s' % interface).split(), shell=False).split('\n'): 111 | matcher = re.match(r'^\s*inet6?\s+(?P
\d[\da-f.:]+/\d+)\s+', line) 112 | if matcher: 113 | if (matcher.group('address') == address): 114 | subprocess.call(str('ip addr delete %s dev %s' % (address, interface)).split()) 115 | log('[ip] removed address %s from interface %s' % (address, interface)) 116 | 117 | # set local route 118 | def set_route(prefix, nexthop, options = {}, remove = False): 119 | rtable = {} 120 | rkey = None 121 | for inet in [4, 6]: 122 | for line in subprocess.check_output(('ip -%d route list scope global table all' % inet).split(), shell=False).split('\n'): 123 | matcher = re.match(r'^(?P\S+)(?:\s+via\s+(?P\S+))?(?:\s*(?P.+?)\s*)?$', line) 124 | if matcher: 125 | if matcher.group('prefix') != 'default': 126 | lprefix = matcher.group('prefix') 127 | elif inet == 6: 128 | lprefix = '::/0' 129 | else: 130 | lprefix = '0.0.0.0/0' 131 | lgateway = matcher.group('gateway') 132 | loptions = matcher.group('options').split() 133 | loptions = dict(zip(loptions[::2], loptions[1::2])) 134 | loptions.pop('dev', None) 135 | rkey = lprefix + '-' + str(loptions.get('metric', '0')) 136 | if not rtable.get(rkey, None): 137 | rtable[rkey] = {'options': loptions, 'nexthops': {}} 138 | if lgateway: 139 | rtable[rkey]['nexthops'][lgateway] = 1 140 | rkey = None 141 | else: 142 | matcher = re.match(r'^\s*nexthop\s+via\s+(?P\S+)(?:\s+(?P.+?)\s*)?$', line) 143 | if matcher and rkey: 144 | lgateway = matcher.group('gateway') 145 | loptions = matcher.group('options').split() 146 | loptions = dict(zip(loptions[::2], loptions[1::2])) 147 | loptions.pop('dev', None) 148 | rtable[rkey]['nexthops'][lgateway] = int(loptions.get('weight', 1)) 149 | 150 | defaultmetric = '1024' if inet6(prefix) else '0' 151 | rkey = prefix + '-' + (str(options.get('metric', defaultmetric)) if options else defaultmetric) 152 | info = rtable.get(rkey, None) 153 | weight = int(options.get('weight', 1)) 154 | if info: 155 | options.update(info['options']) 156 | options.pop('weight', None) 157 | options = ' '.join(str(k) + ' ' + str(v) for k, v in options.items()) 158 | if remove: 159 | if not info: 160 | return 161 | if not info['nexthops'].get(nexthop, None): 162 | return 163 | if len(info['nexthops']) <= 1: 164 | command = 'ip route delete %s proto 57 %s' % (prefix, options) 165 | else: 166 | command = 'ip route replace %s proto 57 %s' % (prefix, options) 167 | for lnexthop, weight in info['nexthops'].items(): 168 | if lnexthop != nexthop: 169 | command += ' nexthop via %s weight %d' % (lnexthop, weight) 170 | subprocess.call(command.split()) 171 | log("[ip] removed nexthop %s from %s %s" % (nexthop, prefix, options)) 172 | else: 173 | if info and info['nexthops'].get(nexthop, None) and info['nexthops'].get(nexthop) == weight: 174 | return 175 | command = 'ip route replace %s proto 57 %s nexthop via %s weight %d' % (prefix, options, nexthop, weight) 176 | if info: 177 | for lnexthop, weight in info['nexthops'].items(): 178 | if lnexthop != nexthop: 179 | command += ' nexthop via %s weight %d' % (lnexthop, weight) 180 | subprocess.call(command.split()) 181 | log("[ip] added nexthop %s to %s %s" % (nexthop, prefix, options)) 182 | 183 | # remove all local routes under exasrv control 184 | def cleanup_exit(): 185 | for inet in [4, 6]: 186 | for line in subprocess.check_output(('ip -%d route list scope global table all' % inet).split(), shell=False).split('\n'): 187 | if line.find('proto 57') >= 0 or line.find('proto exa') >= 0: 188 | command = 'ip -%d route delete %s' % (inet, line) 189 | subprocess.call(command.split()) 190 | log('[local] exit version %s peer %s' % (version, sys.argv[3])) 191 | os._exit(0) 192 | 193 | def signal_handler(sig, frame): 194 | cleanup_exit() 195 | 196 | # generate ExaBGP configuration 197 | if sys.argv[2] == 'configure': 198 | version = 3 199 | for line in subprocess.check_output('exabgp --version'.split(), shell=False).split('\n'): 200 | matcher = re.match(r'^ExaBGP\s+:\s+(?P\d+)', line, re.IGNORECASE) 201 | if matcher: 202 | version = int(matcher.group('version')) 203 | break 204 | load_configuration() 205 | content = '' 206 | supervise = 1 207 | for group in conf: 208 | for name, peer in group.get('peers', {}).items(): 209 | local = peer.get('local', {}) 210 | remote = peer.get('remote', {}) 211 | if version >= 4: 212 | content += ( 213 | 'process supervise%d {\n' 214 | ' encoder json;\n' 215 | ' run %s %s supervise %s;\n' 216 | '}\n' 217 | 'neighbor %s {\n' 218 | ' router-id %s;\n' 219 | ' local-address %s;\n' 220 | ' local-as %s;\n' 221 | ' peer-as %s;\n' 222 | ' family {\n' 223 | ' ipv4 unicast;\n' 224 | ' ipv6 unicast;\n' 225 | ' }\n' 226 | ' api {\n' 227 | ' processes [ supervise%d ];\n' 228 | ' neighbor-changes;\n' 229 | ' receive {\n' 230 | ' parsed;\n' 231 | ' update;\n' 232 | ' }\n' 233 | ' }\n' 234 | '}\n') % ( 235 | supervise, 236 | self_path, 237 | conf_path, 238 | name, 239 | name, 240 | re.sub(r'^(.+?)(/\d+)$', r'\1', 241 | str(local.get('address', '0.0.0.0'))), 242 | re.sub(r'^(.+?)(/\d+)$', r'\1', str(local.get('address', '0.0.0.0'))), 243 | str(local.get('asnum', '0')), 244 | str(remote.get('asnum', '0')), 245 | supervise, 246 | ) 247 | else: 248 | content += ('neighbor %s {\n' 249 | ' router-id %s;\n' 250 | ' local-address %s;\n' 251 | ' local-as %s;\n' 252 | ' peer-as %s;\n' 253 | ' family {\n' 254 | ' ipv4 unicast;\n' 255 | ' ipv6 unicast;\n' 256 | ' }\n' 257 | ' process supervise%d {\n' 258 | ' encoder json;\n' 259 | ' peer-updates;\n' 260 | ' neighbor-changes;\n' 261 | ' receive-routes;\n' 262 | ' run %s %s supervise %s;\n' 263 | ' }\n' 264 | '}\n') % ( 265 | name, 266 | re.sub(r'^(.+?)(/\d+)$', r'\1', 267 | str(local.get('address', '0.0.0.0'))), 268 | re.sub(r'^(.+?)(/\d+)$', r'\1', str(local.get('address', '0.0.0.0'))), 269 | str(local.get('asnum', '0')), 270 | str(remote.get('asnum', '0')), 271 | supervise, 272 | self_path, 273 | conf_path, 274 | name 275 | ) 276 | supervise += 1 277 | if len(sys.argv) > 3: 278 | mcontent = '' 279 | try: 280 | mcontent = open(sys.argv[3], 'r').read(65536) 281 | except: 282 | pass 283 | if content != mcontent: 284 | log('[conf] wrote ExaBGP configuration in %s (%d bytes)' % (sys.argv[3], len(content))) 285 | open(sys.argv[3], 'w').write(content) 286 | else: 287 | print(content, end='') 288 | log('[conf] wrote ExaBGP configuration to standard output (%d bytes)' % len(content)) 289 | 290 | # supervise application, announce service addresses and add/remove routes based on peers announces 291 | elif sys.argv[2] == 'supervise': 292 | if len(sys.argv) <= 3: 293 | abort('missing peer argument for supervise action - aborting') 294 | 295 | signal(SIGINT, signal_handler) 296 | signal(SIGTERM, signal_handler) 297 | signal(SIGHUP, signal_handler) 298 | 299 | log('[local] start version %s peer %s' % (version, sys.argv[3])) 300 | name = sys.argv[3] 301 | peer = service = None 302 | interface_status = 'up' 303 | routes_last = ip_last = service_last = service_checks = 0 304 | service_groups = {} 305 | service_interval = 0 306 | routes = {'announce':{}, 'withdraw':{}} 307 | addresses = {'announce':{}, 'withdraw':{}} 308 | fcntl.fcntl(sys.stdin.fileno(), fcntl.F_SETFL, fcntl.fcntl(sys.stdin.fileno(), fcntl.F_GETFL) | os.O_NONBLOCK) 309 | while True: 310 | 311 | # reload configuration 312 | if load_configuration(): 313 | for group in conf: 314 | peer = group.get('peers', {}).get(name, None) 315 | if peer: 316 | break 317 | if not peer: 318 | abort('peer "%s" not found in configuration - aborting' % name) 319 | service = group.get('service', {}) 320 | check = service.get('check', {}) 321 | check_command = str(check.get('command', '')) 322 | check_interval = max(1, min(20, int(check.get('interval', 5)))) 323 | check_finterval = max(1, min(5, int(check.get('finterval', 1)))) 324 | check_timeout = max(1, min(10, int(check.get('timeout', 2)))) 325 | check_rise = max(1, min(5, int(check.get('rise', 3)))) 326 | check_fall = max(1, min(5, int(check.get('fall', 3)))) 327 | actions = service.get('actions', {}) 328 | action_up = str(actions.get('up', '')) 329 | action_down = str(actions.get('down', '')) 330 | addresses['announce'] = {} 331 | for address, options in service.get('addresses', {}).items(): 332 | mask = '/128' if inet6(address) else '/32' 333 | addresses['announce'][address + ('' if re.search(r'/[0-9]+$', address) else mask)] = options 334 | addresses['withdraw'].update(addresses['announce']) 335 | 336 | # receive and interpret BGP announces/withdraws from BGP peers 337 | try: 338 | ready, _, _ = select.select([sys.stdin], [], [], 1) 339 | except Exception as e: 340 | pass 341 | 342 | if ready: 343 | while True: 344 | try: 345 | line = sys.stdin.readline().strip() 346 | if line == '': 347 | cleanup_exit() 348 | message = json.loads(line) 349 | neighbor = message.get('neighbor', {}) 350 | type = str(message.get('type', '')) 351 | if str(neighbor.get('address', {}).get('peer', '')) == name: 352 | if type == 'state': 353 | log('[bgp] peer %s is %s' % (name, str(neighbor.get('state', 'up')))) 354 | if str(neighbor.get('state', 'up')) == 'down': 355 | routes_last = 0 356 | for route in routes['announce']: 357 | routes['withdraw'][route] = routes['announce'][route] 358 | routes['announce'].clear() 359 | else: 360 | statics = {} 361 | for route, options in group.get('routes', {}).items(): 362 | if options.get('static', False): 363 | statics[route] = options 364 | for route, options in peer.get('routes', {}).items(): 365 | if options.get('static', False): 366 | statics[route] = options 367 | for route in statics.keys(): 368 | key = '%s-0' % route 369 | routes['announce'][key] = [name, 0] 370 | routes['withdraw'].pop(key, None) 371 | 372 | elif type == 'update': 373 | routes_last = 0 374 | metric = 0 375 | defaultmetric = 0 376 | actions = [] 377 | for key1, value1 in neighbor.get('message', {}).get('update', {}).items(): 378 | if key1 in ['announce', 'withdraw']: 379 | for key2, value2 in value1.items(): 380 | if key2 == 'ipv6 unicast': 381 | defaultmetric = 1024 382 | if key2 in ['ipv4 unicast', 'ipv6 unicast']: 383 | for key3, value3 in value2.items(): 384 | if key3 != 'null': 385 | if key1 == 'announce': 386 | for value4 in value3: 387 | if isinstance(value4, dict): 388 | value4 = value4.get('nlri', '') 389 | actions.append([key1, value4, key3]) 390 | else: 391 | actions.append([key1, key3, name]) 392 | elif key1 == 'attribute': 393 | for key2, value2 in value1.items(): 394 | if key2 == 'med': 395 | metric = value2 396 | for action in actions: 397 | if action[0] == 'announce': 398 | if metric == 0: 399 | metric = defaultmetric 400 | key = '%s-%d' % (action[1], metric) 401 | routes['announce'][key] = [action[2], metric] 402 | routes['withdraw'].pop(key, None) 403 | else: 404 | for route in routes['announce']: 405 | if action[1] == re.sub(r'^(.+?)-\d+$', r'\1', route): 406 | metric = int(re.sub(r'^.+?-(\d+)$', r'\1', route)) 407 | if metric == 0: 408 | metric = defaultmetric 409 | key = '%s-%d' % (action[1], metric) 410 | routes['withdraw'][key] = [action[2], metric] 411 | routes['announce'].pop(key, None) 412 | break 413 | log('[bgp] %s %s metric %d via %s learned from peer %s' % (action[0], action[1], metric, action[2], name)) 414 | 415 | elif type == 'notification' and str(message.get('notification', '')) == 'shutdown': 416 | routes_last = 0 417 | for route in routes['announce']: 418 | routes['withdraw'][route] = routes['announce'][route] 419 | routes['announce'].clear() 420 | log('[local] shutdown') 421 | cleanup_exit() 422 | 423 | except: 424 | break 425 | 426 | # check physical network interface status 427 | content = '' 428 | try: 429 | handle = open('/sys/class/net/%s/operstate' % str(peer.get('local', {}).get('interface', '')), 'r') 430 | content = handle.read(256) 431 | handle.close() 432 | except: 433 | pass 434 | status = 'down' if content.find('down') >= 0 else 'up' 435 | if interface_status != status: 436 | log('[local] peer physical network interface is %s' % status) 437 | interface_status = status 438 | 439 | # push learned routes to local routing 440 | now = time.time() 441 | if now - routes_last >= 3: 442 | routes_last = now 443 | for action in ['announce', 'withdraw']: 444 | for route in routes[action]: 445 | prefix = re.sub(r'^(.+?)-\d+$', r'\1', route) 446 | options = {'metric': routes[action][route][1]} 447 | options.update(group.get('routes', {}).get(prefix, {})) 448 | options.update(peer.get('routes', {}).get(prefix, {})) 449 | if (bool(options.get('ignore', False))): 450 | continue 451 | options.pop('ignore', None) 452 | cascade = options.get('cascade', []) 453 | options.pop('cascade', None) 454 | options.pop('static', None) 455 | set_route(prefix, routes[action][route][0], options, action == 'withdraw' or interface_status == 'down') 456 | for prefix in cascade: 457 | set_route(prefix, routes[action][route][0], options, action == 'withdraw' or interface_status == 'down') 458 | 459 | # ensure needed local addresses are properly configured 460 | if now - ip_last >= 3: 461 | ip_last = now 462 | address = peer.get('local', {}).get('address', None) 463 | if address: 464 | if peer.get('local', {}).get('auto', True): 465 | add_address(address, str(peer.get('local', {}).get('interface', 'lo'))) 466 | if service: 467 | for address, options in addresses['announce'].items(): 468 | if (inet4(address) and address.endswith('/32')) or (inet6(address) and address.endswith('/128')): 469 | if options.get('alwaysup', False): 470 | add_address(address, options.get('interface', 'lo')) 471 | else: 472 | if options.get('auto', True): 473 | sgroup = options.get('group', 'default') 474 | if sgroup in service_groups and service_groups[sgroup] != 0: 475 | add_address(address, options.get('interface', 'lo')) 476 | elif options.get('autoremove', False): 477 | remove_address(address, options.get('interface', 'lo')) 478 | 479 | # announce addresses based on service healthcheck 480 | if service_interval == 0: 481 | service_interval = check_finterval 482 | if service and now - service_last >= service_interval: 483 | service_last = now 484 | 485 | # probe service groups using the provided command 486 | check_groups = ['default'] 487 | if check_command != '': 488 | check_groups = '' 489 | try: 490 | with open(os.devnull, 'w') as void: 491 | command = subprocess.Popen(check_command.split(), stdout = subprocess.PIPE, stderr = void, close_fds = True) 492 | now = time.time() 493 | while time.time() - now < check_timeout: 494 | status = command.poll() 495 | if status != None: 496 | if status == 57: 497 | check_groups = [sgroup for sgroup in command.stdout.read().strip().replace(' ', '').split(',') if sgroup != ''] 498 | else: 499 | check_groups = ['default'] if status == 0 else [] 500 | if status != 0: 501 | log('[service] probe [%s] exited with status %d' % (check_command, status)) 502 | break 503 | time.sleep(0.1) 504 | else: 505 | os.kill(command.pid, 9) 506 | log('[service] probe [%s] was killed after %d second(s)' % (check_command, check_timeout)) 507 | except Exception: 508 | pass 509 | 510 | # run state-machine to determine service groups statuses 511 | for sgroup in check_groups: 512 | if sgroup not in service_groups: 513 | service_groups[sgroup] = 0 514 | if service_groups[sgroup] < check_rise: 515 | service_groups[sgroup] += 1 516 | log('[service] service group %s is rising (%d successful check%s sofar)' % (sgroup, service_groups[sgroup], 's' if service_groups[sgroup] > 1 else '')) 517 | if service_groups[sgroup] >= check_rise: 518 | log('[service] service group %s is up' % sgroup) 519 | if action_up != '': 520 | log('[service] running command [%s %s]' % (action_up, sgroup)) 521 | try: 522 | subprocess.call((action_up + ' ' + sgroup).split()) 523 | except: 524 | pass 525 | for sgroup in service_groups: 526 | if sgroup not in check_groups: 527 | if service_groups[sgroup] > 0: 528 | service_groups[sgroup] -= 1 529 | log('[service] service group %s is falling (%d unsuccessful check%s sofar)' % (sgroup, check_rise - service_groups[sgroup], 's' if check_rise - service_groups[sgroup] > 1 else '')) 530 | if service_groups[sgroup] <= check_rise - check_fall or service_groups[sgroup] == 0: 531 | service_groups[sgroup] = 0 532 | log('[service] service group %s is down' % sgroup) 533 | if action_down != '': 534 | log('[service] running command [%s %s]' % (action_down, sgroup)) 535 | try: 536 | subprocess.call((action_down + ' ' + sgroup).split()) 537 | except: 538 | pass 539 | service_interval = check_interval 540 | for sgroup in service_groups: 541 | if service_groups[sgroup] != 0 and service_groups[sgroup] != check_rise: 542 | service_interval = check_finterval 543 | 544 | # announce or withdraw addresses based on service groups states 545 | for address, options in addresses['announce'].items(): 546 | sgroup = options.get('group', 'default') 547 | weight = options.get('weight', 0) 548 | alwaysup = options.get('alwaysup', False) 549 | if weight == 'primary': 550 | weight = 100 551 | elif weight == 'secondary': 552 | weight = 200 553 | else: 554 | try: 555 | weight = int(weight) 556 | except: 557 | weight = 0 558 | laddress = peer.get('local', {}).get('address', None) 559 | nexthop = peer.get('local', {}).get('nexthop6' if inet6(address) else 'nexthop', 'self') 560 | if (inet6(laddress) and inet4(address) and (nexthop == 'self' or inet6(nexthop))) or (inet4(laddress) and inet6(address) and (nexthop == 'self' or inet4(nexthop))): 561 | continue 562 | line = '' 563 | if alwaysup or (sgroup in service_groups and service_groups[sgroup] >= check_rise): 564 | line = 'neighbor %s announce route %s next-hop %s' % (name, address, nexthop) 565 | if weight > 0: 566 | line += ' med %d' % weight 567 | community = str(options.get('community', '')) 568 | if community != '': 569 | line += ' community [ %s ]' % community 570 | aspath = str(options.get('aspath', '')) 571 | if aspath != '': 572 | line += ' as-path [ %s ]' % aspath 573 | elif sgroup in service_groups and service_groups[sgroup] <= (check_rise - check_fall): 574 | line = 'neighbor %s withdraw route %s' % (name, address) 575 | if line != '': 576 | print(line) 577 | for address in addresses['withdraw'].iterkeys(): 578 | if not address in addresses['announce']: 579 | line = 'neighbor %s withdraw route %s' % (name, address) 580 | print(line) 581 | sys.stdout.flush() 582 | 583 | else: 584 | abort('unknown action "%s" - aborting' % sys.argv[2]) 585 | --------------------------------------------------------------------------------