├── .gitignore ├── templates ├── constants.jinja2 └── protocols.jinja2 ├── bin ├── regen.sh └── generate.py ├── README.md ├── example.json ├── static ├── bird_constants.conf └── bird_functions.conf └── schema.json /.gitignore: -------------------------------------------------------------------------------- 1 | config.json* 2 | output 3 | -------------------------------------------------------------------------------- /templates/constants.jinja2: -------------------------------------------------------------------------------- 1 | define MY_AS = {{ config.asn }}; -------------------------------------------------------------------------------- /bin/regen.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/sh 2 | MYDIR="$(dirname "$(readlink -f "$0")")" 3 | python3 $MYDIR/generate.py --config $MYDIR/../config.json --outputPath $MYDIR/../output 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bird-config-generator 2 | 3 | A utility for generating a valid configuration file for the [bird](https://bird.network.cz/) routing daemon from a json config file. 4 | 5 | ## ⚠ Disclaimer ⚠ 6 | This tool is not production-ready. It doesn't even have a real name. Use it at your own risk. (But if you do use it, please let me know how it goes!) 7 | 8 | ## Requirements 9 | python3 and some modules: 10 | 11 | `pip3 install jinja2 ipaddress difflib jsonschema wasabi` 12 | 13 | A working installation of bird2 (excluding config) is assumed. 14 | 15 | ## Usage 16 | Put your routing configuration data in a json file (like `example.json`) and run `python3 generate.py`. The tool will parse the config file, generate an output, and depending on the mode of operation, it may write it to a file. For more details on the modes of operation, see [Modes of Operation](#modes-of-operation). 17 | 18 | To override the default mode or set the input/output locations, use the CLI argument as specified below: 19 | ``` 20 | usage: generate.py [-h] --config CONFIG --outputPath OUTPUTPATH 21 | [--mode {dryrun,prompt,overwrite}] 22 | 23 | optional arguments: 24 | -h, --help show this help message and exit 25 | --config CONFIG json file with information used to generate router 26 | config (default 'config.json') 27 | --outputPath OUTPUTPATH 28 | path of generated file (default '.') 29 | --mode {dryrun,prompt,overwrite} 30 | whether to overwrite the existing config. options are 31 | "dryrun", "prompt", "overwrite". (default 'prompt') 32 | ``` 33 | 34 | ## Validation 35 | Validation is done in two parts. First, it checks the config file against the schema for basic things (e.g. "is this field the right data type?"). Then it checks more complex rules (e.g. "is at least one of {ipv4,ipv6} defined?"). If either step fails, it will print a descriptive error and exit, and no files will be created or modified. 36 | 37 | ## Modes of Operation 38 | There are 3 modes of operation: `dryrun`, `prompt`, and `overwrite`. In each mode, the tool will validate the config file, generate the "output" config, and diff the existing on-disk config (if it exists) with the config it just generated. After that, the functionality diverges. 39 | 40 | * `dryrun` prints the diff and exits 41 | * `prompt` prints the diff and asks if you want to overwrite the generated configs (defaulting to no) 42 | * `overwrite` overwrites the generated configs and exits 43 | 44 | ## Roadmap 45 | The short-term goal is to support generating all config that I could reasonably need. This includes things like: 46 | * more complete BGP support 47 | * more complex routing policy capabilities 48 | * support for OSPF 49 | * support for BFD 50 | 51 | In the medium-term, I also want to introduce some quality-of-life features: 52 | * support for IRR-based filter generation 53 | * support for pulling data from PeeringDB 54 | * config backup/rollback 55 | * update checking 56 | 57 | In the long-term, I would like to make this tool into a module that I can call from other code. That would enable me to generate routing config dynamically (e.g. from a database), feed it to this tool, and then create files on disk. 58 | 59 | ## Contribution 60 | Pull requests and issues with feature requests are welcome. I work on this project in my spare time, so it may take some time for me to review. -------------------------------------------------------------------------------- /example.json: -------------------------------------------------------------------------------- 1 | { 2 | "hostname": "example_router", 3 | "asn": 64512, 4 | "bgp_sessions": [ 5 | { 6 | "name": "my_other_router", 7 | "type": "internal", 8 | "ipv4": { 9 | "peer_ip": "192.0.2.1" 10 | }, 11 | "ipv6": { 12 | "peer_ip": "2001:db8:6000:ff00::2" 13 | } 14 | }, 15 | { 16 | "name": "Transit1", 17 | "type": "transit", 18 | "ipv4": { 19 | "peer_ip": "192.0.2.250" 20 | }, 21 | "ipv6": { 22 | "peer_ip": "2001:db8:900:a::31" 23 | }, 24 | "asn": 65000, 25 | "local_pref": 50 26 | }, 27 | { 28 | "name": "Transit2", 29 | "type": "transit", 30 | "ipv4": { 31 | "peer_ip": "192.0.2.251" 32 | }, 33 | "ipv6": { 34 | "peer_ip": "2001:db8:3000:30::1" 35 | }, 36 | "asn": 65001, 37 | "local_pref": 50 38 | }, 39 | { 40 | "name": "Customer1", 41 | "type": "customer", 42 | "filtering_method": "prefix-list", 43 | "ipv4": { 44 | "peer_ip": "192.0.2.9", 45 | "password": "hunter2", 46 | "prefixes": ["198.51.100.0/24", "203.0.113.0/24"], 47 | "prefix_limit": 50 48 | }, 49 | "ipv6": { 50 | "peer_ip": "2001:db8:6000:ff03::2", 51 | "password": "hunter2", 52 | "prefixes": ["2001:db8:c000::/40", "2001:db8:7000::/36"], 53 | "prefix_limit": 100 54 | }, 55 | "asn": 65002, 56 | "local_pref": 120 57 | }, 58 | { 59 | "name": "Customer2", 60 | "type": "customer", 61 | "filtering_method": "prefix-list", 62 | "ipv6": { 63 | "peer_ip": "2001:db8:6000:101::23", 64 | "password": "hunter3", 65 | "prefixes": ["2001:db8:7812::/48"], 66 | "prefix_limit": 10 67 | }, 68 | "asn": 65003, 69 | "local_pref": 120 70 | }, 71 | { 72 | "name": "Collector1", 73 | "type": "collector", 74 | "multihop": true, 75 | "ipv4": { 76 | "peer_ip": "192.0.2.130", 77 | "source_ip": "192.0.2.67" 78 | }, 79 | "ipv6": { 80 | "peer_ip": "2001:db8:2001::130", 81 | "source_ip": "2001:db8:6000:100::3" 82 | }, 83 | "asn": 65004, 84 | "local_pref": 120 85 | } 86 | 87 | ], 88 | "rpki_protocols": [ 89 | { 90 | "name": "gortr", 91 | "roa4_table": "roa_v4", 92 | "roa6_table": "roa_v6", 93 | "ip": "192.0.2.100", 94 | "port": 8282, 95 | "retry_time": 90, 96 | "retry_keep": true, 97 | "refresh_time": 900, 98 | "refresh_keep": true, 99 | "expire_time": 172800, 100 | "expire_keep": true 101 | } 102 | ], 103 | "static_routes_v4": { 104 | "filter_name": "static_filter", 105 | "routes": [ 106 | { 107 | "destination": "198.18.0.0/15", 108 | "blackhole": true 109 | }, 110 | { 111 | "destination": "240.0.0.0/4", 112 | "next_hop": "23.147.64.117" 113 | } 114 | ] 115 | }, 116 | "static_routes_v6": { 117 | "filter_name": "static_filter", 118 | "routes": [ 119 | { 120 | "destination": "2001:db8:7820::/48", 121 | "blackhole": true 122 | }, 123 | { 124 | "destination": "2001:db8:7830:0200::/56", 125 | "next_hop": "2001:db8:7820:0101::13" 126 | } 127 | ] 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /static/bird_constants.conf: -------------------------------------------------------------------------------- 1 | define BOGON_ASNS = [ 0, # RFC 7607 2 | 23456, # RFC 4893 AS_TRANS 3 | 64496..64511, # RFC 5398 and documentation/example ASNs 4 | 64512..65534, # RFC 6996 Private ASNs 5 | 65535, # RFC 7300 Last 16 bit ASN 6 | 65536..65551, # RFC 5398 and documentation/example ASNs 7 | 65552..131071, # RFC IANA reserved ASNs 8 | 4200000000..4294967294, # RFC 6996 Private ASNs 9 | 4294967295 ]; # RFC 7300 Last 32 bit ASN 10 | define BOGON_PREFIXESv4 = [ 0.0.0.0/8+, # RFC 1122 'this' network 11 | 10.0.0.0/8+, # RFC 1918 private space 12 | 100.64.0.0/10+, # RFC 6598 Carrier grade nat space 13 | 127.0.0.0/8+, # RFC 1122 localhost 14 | 169.254.0.0/16+, # RFC 3927 link local 15 | 172.16.0.0/12+, # RFC 1918 private space 16 | 192.0.2.0/24+, # RFC 5737 TEST-NET-1 17 | 192.88.99.0/24+, # RFC 7526 6to4 anycast relay 18 | 192.168.0.0/16+, # RFC 1918 private space 19 | 198.18.0.0/15+, # RFC 2544 benchmarking 20 | 198.51.100.0/24+, # RFC 5737 TEST-NET-2 21 | 203.0.113.0/24+, # RFC 5737 TEST-NET-3 22 | 224.0.0.0/4+, # multicast 23 | 240.0.0.0/4+ ]; # reserved 24 | define BOGON_PREFIXESv6 = [ ::/8+, # RFC 4291 IPv4-compatible, loopback, et al 25 | 0100::/64+, # RFC 6666 Discard-Only 26 | 2001:2::/48+, # RFC 5180 BMWG 27 | 2001:10::/28+, # RFC 4843 ORCHID 28 | 2001:db8::/32+, # RFC 3849 documentation 29 | 2002::/16+, # RFC 7526 6to4 anycast relay 30 | 3ffe::/16+, # RFC 3701 old 6bone 31 | fc00::/7+, # RFC 4193 unique local unicast 32 | fe80::/10+, # RFC 4291 link local unicast 33 | fec0::/10+, # RFC 3879 old site local unicast 34 | ff00::/8+ ]; # RFC 4291 multicast 35 | define TRANSIT_ASNS = [ 174, # Cogent 36 | 209, # Lumen (CenturyLink) 37 | 701, # UUNET 38 | 1299, # Telia 39 | 2914, # NTT Ltd. 40 | 3257, # GTT Backbone 41 | 3320, # Deutsche Telekom AG (DTAG) 42 | 3356, # Lumen (Level3) 43 | 3491, # PCCW 44 | 4134, # Chinanet 45 | 5511, # Orange opentransit 46 | 6453, # Tata Communications 47 | 6461, # Zayo Bandwidth 48 | 6762, # Seabone / Telecom Italia 49 | 6830, # Liberty Global 50 | 7018 ]; # AT&T 51 | 52 | # second number in the community 53 | # indicates the kind of info being supplied 54 | define INFO_SOURCE = 10; 55 | define INFO_AS = 20; 56 | define INFO_FILTER_REASON = 30; 57 | 58 | # second number in the community 59 | # indicates the action being taken 60 | define DO_NOT_ANNOUNCE = 100; 61 | define PREPEND_1 = 101; 62 | define PREPEND_2 = 102; 63 | define PREPEND_3 = 103; 64 | define ALLOW_SMALL_PREFIX = 777; 65 | 66 | # third number in the community 67 | # these go with INFO_SOURCE 68 | define FROM_TRANSIT = 100; 69 | define FROM_PUBLIC_PEER = 200; 70 | define FROM_PRIVATE_PEER = 300; 71 | define FROM_CUSTOMER = 400; 72 | define ORIGINATED = 500; 73 | 74 | # third number in the community 75 | # these go with INFO_FILTER_REASON 76 | define NOT_IN_STATIC_PREFIX_LIST = 1; 77 | define NOT_IN_IRR_PREFIX_LIST = 2; 78 | define HAS_BOGON_ASN = 3; 79 | define IS_BOGON_PREFIX = 4; 80 | define ASPATH_TOO_LONG = 5; 81 | define PREFIX_TOO_LONG = 6; 82 | define IS_DEFAULT = 7; 83 | define IS_RPKI_INVALID = 8; 84 | define NEXTHOP_IP_NOT_MATCH_PEER = 9; 85 | define FIRST_ASN_NOT_MATCH_PEER = 10; 86 | define PREFIX_NOT_ALLOWED = 11; 87 | define HAS_TRANSIT_ASN = 12; 88 | -------------------------------------------------------------------------------- /static/bird_functions.conf: -------------------------------------------------------------------------------- 1 | function check_has_bogon_asn() 2 | { 3 | return ( bgp_path ~ BOGON_ASNS ); 4 | } 5 | 6 | function check_is_bogon_prefix() 7 | { 8 | if ( net.type = NET_IP4 ) then { 9 | return (net ~ BOGON_PREFIXESv4); 10 | } else { 11 | return (net ~ BOGON_PREFIXESv6); 12 | } 13 | } 14 | 15 | function check_is_small_prefix() 16 | { 17 | if ( net.type = NET_IP4 ) then { 18 | return (net.len > 24); 19 | } else { 20 | return (net.len > 48); 21 | } 22 | } 23 | 24 | function check_is_default() 25 | { 26 | return (net.len = 0); 27 | } 28 | 29 | function check_has_long_aspath() 30 | { 31 | return ( bgp_path.len > 100 ); 32 | } 33 | 34 | function check_is_rpki_invalid() 35 | { 36 | if ( net.type = NET_IP4 ) then { 37 | return roa_check(roa_v4, net, bgp_path.last) = ROA_INVALID; 38 | } else { 39 | return roa_check(roa_v6, net, bgp_path.last) = ROA_INVALID; 40 | } 41 | } 42 | 43 | function check_has_transit_asn() 44 | { 45 | return (bgp_path ~ TRANSIT_ASNS); 46 | } 47 | 48 | function import_customer_peer(prefix set allowed_prefixes) 49 | { 50 | if check_has_bogon_asn() then { 51 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,HAS_BOGON_ASN)); 52 | } 53 | if check_is_bogon_prefix() then { 54 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,IS_BOGON_PREFIX)); 55 | } 56 | if check_has_long_aspath() then { 57 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,ASPATH_TOO_LONG)); 58 | } 59 | if check_is_small_prefix() then { 60 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,PREFIX_TOO_LONG)); 61 | } 62 | if check_is_default() then { 63 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,IS_DEFAULT)); 64 | } 65 | if check_is_rpki_invalid() then { 66 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,IS_RPKI_INVALID)); 67 | } 68 | if bgp_next_hop != from then { 69 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,NEXTHOP_IP_NOT_MATCH_PEER)); 70 | } 71 | if ! (net ~ allowed_prefixes) then { 72 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,PREFIX_NOT_ALLOWED)); 73 | } 74 | if check_has_transit_asn() then { 75 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,HAS_TRANSIT_ASN)); 76 | } 77 | 78 | if (bgp_large_community ~ [(MY_AS,INFO_FILTER_REASON,*)]) then { 79 | return false; 80 | } 81 | return true; 82 | } 83 | 84 | function import_customer(prefix set allowed_prefixes) 85 | { 86 | return import_customer_peer(allowed_prefixes); 87 | } 88 | 89 | function import_peer(prefix set allowed_prefixes) 90 | { 91 | return import_customer_peer(allowed_prefixes); 92 | } 93 | 94 | function import_transit() 95 | { 96 | if check_has_bogon_asn() then { 97 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,HAS_BOGON_ASN)); 98 | } 99 | if check_is_bogon_prefix() then { 100 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,IS_BOGON_PREFIX)); 101 | } 102 | if check_has_long_aspath() then { 103 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,ASPATH_TOO_LONG)); 104 | } 105 | if check_is_small_prefix() then { 106 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,PREFIX_TOO_LONG)); 107 | } 108 | if check_is_default() then { 109 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,IS_DEFAULT)); 110 | } 111 | if check_is_rpki_invalid() then { 112 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,IS_RPKI_INVALID)); 113 | } 114 | if bgp_next_hop != from then { 115 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,NEXTHOP_IP_NOT_MATCH_PEER)); 116 | } 117 | 118 | if (bgp_large_community ~ [(MY_AS,INFO_FILTER_REASON,*)]) then { 119 | return false; 120 | } 121 | return true; 122 | } 123 | 124 | function import_route_server() 125 | { 126 | if check_has_bogon_asn() then { 127 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,HAS_BOGON_ASN)); 128 | } 129 | if check_is_bogon_prefix() then { 130 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,IS_BOGON_PREFIX)); 131 | } 132 | if check_has_long_aspath() then { 133 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,ASPATH_TOO_LONG)); 134 | } 135 | if check_is_small_prefix() then { 136 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,PREFIX_TOO_LONG)); 137 | } 138 | if check_is_default() then { 139 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,IS_DEFAULT)); 140 | } 141 | if check_is_rpki_invalid() then { 142 | bgp_large_community.add((MY_AS,INFO_FILTER_REASON,IS_RPKI_INVALID)); 143 | } 144 | 145 | if (bgp_large_community ~ [(MY_AS,INFO_FILTER_REASON,*)]) then { 146 | return false; 147 | } 148 | return true; 149 | } 150 | 151 | function export_customer_full(bool send_default) 152 | { 153 | if check_has_bogon_asn() then return false; 154 | if check_is_bogon_prefix() then return false; 155 | if check_has_long_aspath() then return false; 156 | if check_is_small_prefix() then return false; 157 | if !send_default then { 158 | if check_is_default() then return false; 159 | } 160 | if check_is_rpki_invalid() then return false; 161 | 162 | return true; 163 | } 164 | 165 | function export_customer_default() 166 | { 167 | if check_is_default() then return true; 168 | 169 | return false; 170 | } 171 | 172 | function export_peer_transit() 173 | { 174 | if check_has_bogon_asn() then return false; 175 | if check_is_bogon_prefix() then return false; 176 | if check_has_long_aspath() then return false; 177 | if ((MY_AS,ALLOW_SMALL_PREFIX,0) ~ bgp_large_community) then { 178 | bgp_large_community.delete([(MY_AS, ALLOW_SMALL_PREFIX, 0)]); 179 | } else { 180 | if check_is_small_prefix() then return false; 181 | } 182 | if check_is_default() then return false; 183 | 184 | # allow originated prefixes before checking RPKI validity 185 | if (MY_AS,INFO_SOURCE,ORIGINATED) ~ bgp_large_community then return true; 186 | 187 | if check_is_rpki_invalid() then return false; 188 | 189 | if (MY_AS,INFO_SOURCE,FROM_CUSTOMER) ~ bgp_large_community then return true; 190 | 191 | return false; 192 | } 193 | 194 | function export_peer() 195 | { 196 | return export_peer_transit(); 197 | } 198 | 199 | function export_transit() 200 | { 201 | return export_peer_transit(); 202 | } 203 | 204 | function apply_export_communities(int peer_asn) 205 | { 206 | if ((MY_AS,DO_NOT_ANNOUNCE,0) ~ bgp_large_community || (MY_AS,DO_NOT_ANNOUNCE,peer_asn) ~ bgp_large_community) then { 207 | reject; 208 | } else if ((MY_AS,PREPEND_3,0) ~ bgp_large_community || (MY_AS,PREPEND_3,peer_asn) ~ bgp_large_community) then { 209 | bgp_path.prepend(MY_AS); 210 | bgp_path.prepend(MY_AS); 211 | bgp_path.prepend(MY_AS); 212 | } else if ((MY_AS,PREPEND_2,0) ~ bgp_large_community || (MY_AS,PREPEND_2,peer_asn) ~ bgp_large_community) then { 213 | bgp_path.prepend(MY_AS); 214 | bgp_path.prepend(MY_AS); 215 | } else if ((MY_AS,PREPEND_1,0) ~ bgp_large_community || (MY_AS,PREPEND_1,peer_asn) ~ bgp_large_community) then { 216 | bgp_path.prepend(MY_AS); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /templates/protocols.jinja2: -------------------------------------------------------------------------------- 1 | {% if config.device is defined %} 2 | {% if not config.device.disabled %} 3 | protocol device { 4 | {% if config.device.scan_time is defined %} 5 | scan time {{ config.device.scan_time }}; 6 | {% endif %} 7 | } 8 | {% endif %} 9 | {% else %} 10 | protocol device { 11 | scan time 10; 12 | } 13 | {% endif %} 14 | 15 | {% if config.kernel is defined %} 16 | {% if not config.kernel.disabled %} 17 | protocol kernel { 18 | ipv4 { 19 | export all; 20 | }; 21 | {% if config.kernel.scan_time is defined %} 22 | scan time {{ config.kernel.scan_time }}; 23 | {% endif %} 24 | } 25 | 26 | protocol kernel { 27 | ipv6 { 28 | export all; 29 | }; 30 | {% if config.kernel.scan_time is defined %} 31 | scan time {{ config.kernel.scan_time }}; 32 | {% endif %} 33 | } 34 | {% endif %} 35 | {% else %} 36 | protocol kernel { 37 | ipv4 { 38 | export all; 39 | }; 40 | scan time 10; 41 | } 42 | 43 | protocol kernel { 44 | ipv6 { 45 | export all; 46 | }; 47 | scan time 10; 48 | } 49 | {% endif %} 50 | 51 | 52 | filter static_in { 53 | 54 | {% for route in config.static_routes_v4 %} 55 | {% if route.communities is defined or route.large_communities is defined %} 56 | if net ~ [ {{ route.destination }} ] then { 57 | {% for community in route.communities %} 58 | bgp_community.add(({{ community[0] }}, {{ community[1] }})); 59 | {% endfor %} 60 | {% for community in route.large_communities %} 61 | bgp_large_community.add(({{ community[0] }}, {{ community[1] }}, {{ community[2] }})); 62 | {% endfor %} 63 | } 64 | {% endif %} 65 | 66 | {% endfor %} 67 | 68 | {% for route in config.static_routes_v6 %} 69 | {% if route.communities is defined or route.large_communities is defined %} 70 | if net ~ [ {{ route.destination }} ] then { 71 | {% for community in route.communities %} 72 | bgp_community.add(({{ community[0] }}, {{ community[1] }})); 73 | {% endfor %} 74 | {% for community in route.large_communities %} 75 | bgp_large_community.add(({{ community[0] }}, {{ community[1] }}, {{ community[2] }})); 76 | {% endfor %} 77 | } 78 | {% endif %} 79 | {% endfor %} 80 | 81 | accept; 82 | } 83 | 84 | {% if config.static_routes_v4 is defined %} 85 | protocol static static_v4 { 86 | ipv4 { 87 | import filter static_in; 88 | }; 89 | 90 | {% for route in config.static_routes_v4 %} 91 | {% if route.blackhole %} 92 | route {{ route.destination }} reject; 93 | {% else %} 94 | route {{ route.destination }} via {{ route.next_hop }}; 95 | {% endif %} 96 | {% endfor %} 97 | } 98 | {% endif %} 99 | 100 | {% if config.static_routes_v6 is defined %} 101 | protocol static static_v6 { 102 | ipv6 { 103 | import filter static_in; 104 | }; 105 | 106 | {% for route in config.static_routes_v6 %} 107 | {% if route.blackhole %} 108 | route {{ route.destination }} reject; 109 | {% else %} 110 | route {{ route.destination }} via {{ route.next_hop }}; 111 | {% endif %} 112 | {% endfor %} 113 | } 114 | {% endif %} 115 | 116 | filter direct_in { 117 | 118 | {% for route in config.connected_route_communities %} 119 | if net ~ [ {{ route.destination }} ] then { 120 | {% for community in route.communities %} 121 | bgp_community.add(({{ community[0] }}, {{ community[1] }})); 122 | {% endfor %} 123 | {% for community in route.large_communities %} 124 | bgp_large_community.add(({{ community[0] }}, {{ community[1] }}, {{ community[2] }})); 125 | {% endfor %} 126 | } 127 | {% endfor %} 128 | 129 | accept; 130 | } 131 | 132 | protocol direct { 133 | ipv4 { 134 | import filter direct_in; 135 | }; 136 | ipv6 { 137 | import filter direct_in; 138 | }; 139 | } 140 | 141 | 142 | filter ospf_in { 143 | 144 | {% for route in config.ospf_route_communities %} 145 | if net ~ [ {{ route.destination }} ] then { 146 | {% for community in route.communities %} 147 | bgp_community.add(({{ community[0] }}, {{ community[1] }})); 148 | {% endfor %} 149 | {% for community in route.large_communities %} 150 | bgp_large_community.add(({{ community[0] }}, {{ community[1] }}, {{ community[2] }})); 151 | {% endfor %} 152 | } 153 | {% endfor %} 154 | 155 | accept; 156 | } 157 | 158 | {% for rpki in config.rpki_protocols %} 159 | protocol rpki rpki_{{ rpki.name }} { 160 | roa4 { table {{ rpki.roa4_table }}; }; 161 | roa6 { table {{ rpki.roa6_table }}; }; 162 | {% if rpki.port is defined %} 163 | remote "{{ rpki.ip }}" port {{ rpki.port }}; 164 | {% else %} 165 | remote "{{ rpki.ip }}"; 166 | {% endif %} 167 | {% if rpki.retry_keep is defined %} 168 | retry keep {{ rpki.retry_time }}; 169 | {% else %} 170 | retry {{ rpki.retry_time }}; 171 | {% endif %} 172 | {% if rpki.refresh_keep is defined %} 173 | refresh keep {{ rpki.refresh_time }}; 174 | {% else %} 175 | refresh {{ rpki.refresh_time }}; 176 | {% endif %} 177 | {% if rpki.expire_keep is defined %} 178 | expire keep {{ rpki.expire_time }}; 179 | {% else %} 180 | expire {{ rpki.expire_time }}; 181 | {% endif %} 182 | } 183 | {% endfor %} 184 | 185 | {% for session in config.bgp_sessions %} 186 | {% if session.type != "internal" and session.type != "collector" %} 187 | filter bgp_in_{{ session.type }}_{{ session.name }} { 188 | bgp_large_community.delete([(MY_AS, INFO_FILTER_REASON, *)]); 189 | bgp_large_community.delete([(MY_AS, INFO_SOURCE, *)]); 190 | bgp_large_community.delete([(MY_AS, INFO_AS, *)]); 191 | {% if session.type == "transit" %} 192 | bgp_large_community.add((MY_AS,INFO_SOURCE,FROM_TRANSIT)); 193 | {% elif session.type == "customer" %} 194 | bgp_large_community.add((MY_AS,INFO_SOURCE,FROM_CUSTOMER)); 195 | {% elif session.type == "peer" %} 196 | bgp_large_community.add((MY_AS,INFO_SOURCE,FROM_PUBLIC_PEER)); 197 | {% endif %} 198 | bgp_large_community.add((MY_AS,INFO_AS,{{ session.asn }})); 199 | 200 | {% if session.type == "transit" %} 201 | if ! import_transit() then reject; 202 | {% elif session.type == "customer" %} 203 | if ( net.type = NET_IP4 ) then { 204 | {% if session.ipv4 is defined %} 205 | if ! import_customer([{{ session.ipv4.prefixes_str }}]) 206 | then reject; 207 | {% else %} 208 | reject; 209 | {% endif %} 210 | } else { 211 | {% if session.ipv6 is defined %} 212 | if ! import_customer([{{ session.ipv6.prefixes_str }}]) 213 | then reject; 214 | {% else %} 215 | reject; 216 | {% endif %} 217 | } 218 | {% elif session.type == "peer" %} 219 | if ( net.type = NET_IP4 ) then { 220 | {% if session.ipv4 is defined %} 221 | if ! import_peer([{{ session.ipv4.prefixes_str }}]) 222 | then reject; 223 | {% else %} 224 | reject; 225 | {% endif %} 226 | } else { 227 | {% if session.ipv6 is defined %} 228 | if ! import_peer([{{ session.ipv6.prefixes_str }}]) 229 | then reject; 230 | {% else %} 231 | reject; 232 | {% endif %} 233 | } 234 | {% endif %} 235 | 236 | {% if session.communities_on_ingress is defined %} 237 | {% for comm in session.communities_on_ingress %} 238 | {% if comm.prefix == "all" %} 239 | {% for community in comm.communities %} 240 | bgp_community.add(({{ community[0] }}, {{ community[1] }})); 241 | {% endfor %} 242 | {% for community in comm.large_communities %} 243 | bgp_large_community.add(({{ community[0] }}, {{ community[1] }}, {{ community[2] }})); 244 | {% endfor %} 245 | {% else %} 246 | if net ~ [ {{ comm.prefix }} ] then { 247 | {% for community in comm.communities %} 248 | bgp_community.add(({{ community[0] }}, {{ community[1] }})); 249 | {% endfor %} 250 | {% for community in comm.large_communities %} 251 | bgp_large_community.add(({{ community[0] }}, {{ community[1] }}, {{ community[2] }})); 252 | {% endfor %} 253 | } 254 | {% endif %} 255 | {% endfor %} 256 | {% endif %} 257 | 258 | bgp_local_pref = {{ session.local_pref }}; 259 | 260 | accept; 261 | } 262 | {% endif %} 263 | 264 | {% if session.type != "internal" %} 265 | filter bgp_out_{{ session.type }}_{{ session.name }} { 266 | {% if session.type == "transit" %} 267 | if ! export_transit() then reject; 268 | {% if session.backup %} 269 | bgp_path.prepend(MY_AS); 270 | {% endif %} 271 | {% elif session.type == "customer" %} 272 | {% if session.export_policy == "default_only" %} 273 | if ! export_customer_default() then reject; 274 | {% elif session.export_policy == "full_routes" %} 275 | if ! export_customer_full(false) then reject; 276 | {% elif session.export_policy == "full_routes_plus_default" %} 277 | if ! export_customer_full(true) then reject; 278 | {% else %} 279 | reject; 280 | {% endif %} 281 | {% elif session.type == "collector" %} 282 | if ! export_customer_full(false) then reject; 283 | {% elif session.type == "peer" %} 284 | if ! export_peer() then reject; 285 | {% endif %} 286 | 287 | apply_export_communities({{ session.asn }}); 288 | 289 | {% if session.communities_on_egress is defined %} 290 | {% for comm in session.communities_on_egress %} 291 | {% if comm.prefix == "all" %} 292 | {% for community in comm.communities %} 293 | bgp_community.add(({{ community[0] }}, {{ community[1] }})); 294 | {% endfor %} 295 | {% for community in comm.large_communities %} 296 | bgp_large_community.add(({{ community[0] }}, {{ community[1] }}, {{ community[2] }})); 297 | {% endfor %} 298 | {% else %} 299 | if net ~ [ {{ comm.prefix }} ] then { 300 | {% for community in comm.communities %} 301 | bgp_community.add(({{ community[0] }}, {{ community[1] }})); 302 | {% endfor %} 303 | {% for community in comm.large_communities %} 304 | bgp_large_community.add(({{ community[0] }}, {{ community[1] }}, {{ community[2] }})); 305 | {% endfor %} 306 | } 307 | {% endif %} 308 | {% endfor %} 309 | {% endif %} 310 | 311 | {% if session.prepends is defined %} 312 | {% for n in range(session.prepends) %} 313 | bgp_path.prepend(MY_AS); 314 | {% endfor %} 315 | {% endif %} 316 | 317 | accept; 318 | } 319 | {% endif %} 320 | 321 | {% if session.ipv4 is defined %} 322 | protocol bgp bgp_{{ session.type }}_{{ session.name }}_v4 { 323 | {% if session.multihop %} 324 | multihop; 325 | {% endif %} 326 | ipv4 { 327 | {% if session.stop_bgp %} 328 | import table; 329 | import keep filtered; 330 | import none; 331 | export none; 332 | {% else %} 333 | {% if session.type == "internal" %} 334 | import all; 335 | export all; 336 | {% elif session.type == "collector" %} 337 | import none; 338 | export filter bgp_out_{{ session.type }}_{{ session.name }}; 339 | {% else %} 340 | import table; 341 | import keep filtered; 342 | import filter bgp_in_{{ session.type }}_{{ session.name }}; 343 | {% if session.ipv4.prefix_limit is defined %} 344 | import limit {{ session.ipv4.prefix_limit }} action block; 345 | {% endif %} 346 | export filter bgp_out_{{ session.type }}_{{ session.name }}; 347 | {% endif %} 348 | {% endif %} 349 | {% if session.ipv4.next_hop_self %} 350 | next hop self; 351 | {% endif %} 352 | }; 353 | 354 | {% if session.ipv4.password is defined %} 355 | password "{{ session.ipv4.password }}"; 356 | {% endif %} 357 | local as MY_AS; 358 | {% if session.ipv4.peer_port is defined %} 359 | neighbor port {{ session.ipv4.peer_port }}; 360 | {% endif %} 361 | {% if session.type == "internal" %} 362 | neighbor {{ session.ipv4.peer_ip }} as MY_AS; 363 | {% else %} 364 | neighbor {{ session.ipv4.peer_ip }} as {{ session.asn }}; 365 | {% endif %} 366 | {% if session.ipv4.source_ip is defined %} 367 | source address {{ session.ipv4.source_ip }}; 368 | {% endif %} 369 | {% if session.asn == config.asn %} 370 | rr client; 371 | {% endif %} 372 | {% if session.hold_time is defined %} 373 | hold time {{ session.hold_time }}; 374 | {% endif %} 375 | {% if session.keepalive_time is defined %} 376 | keepalive time {{ session.keepalive_time }}; 377 | {% endif %} 378 | {% if session.type != "route_server" and session.type != "internal" %} 379 | enforce first as; 380 | {% endif %} 381 | } 382 | {% endif %} 383 | 384 | {% if session.ipv6 is defined %} 385 | protocol bgp bgp_{{ session.type }}_{{ session.name }}_v6 { 386 | {% if session.multihop %} 387 | multihop; 388 | {% endif %} 389 | ipv6 { 390 | {% if session.stop_bgp %} 391 | import table; 392 | import keep filtered; 393 | import none; 394 | export none; 395 | {% else %} 396 | {% if session.type == "internal" %} 397 | import all; 398 | export all; 399 | {% elif session.type == "collector" %} 400 | import none; 401 | export filter bgp_out_{{ session.type }}_{{ session.name }}; 402 | {% else %} 403 | import table; 404 | import keep filtered; 405 | import filter bgp_in_{{ session.type }}_{{ session.name }}; 406 | {% if session.ipv6.prefix_limit is defined %} 407 | import limit {{ session.ipv6.prefix_limit }} action block; 408 | {% endif %} 409 | export filter bgp_out_{{ session.type }}_{{ session.name }}; 410 | {% endif %} 411 | {% endif %} 412 | {% if session.ipv6.next_hop_self %} 413 | next hop self; 414 | {% endif %} 415 | }; 416 | 417 | {% if session.ipv6.password is defined %} 418 | password "{{ session.ipv6.password }}"; 419 | {% endif %} 420 | local as MY_AS; 421 | {% if session.ipv6.peer_port is defined %} 422 | neighbor port {{ session.ipv6.peer_port }}; 423 | {% endif %} 424 | {% if session.type == "internal" %} 425 | neighbor {{ session.ipv6.peer_ip }} as MY_AS; 426 | {% else %} 427 | neighbor {{ session.ipv6.peer_ip }} as {{ session.asn }}; 428 | {% endif %} 429 | {% if session.ipv6.source_ip is defined %} 430 | source address {{ session.ipv6.source_ip }}; 431 | {% endif %} 432 | {% if session.asn == config.asn %} 433 | rr client; 434 | {% endif %} 435 | {% if session.hold_time is defined %} 436 | hold time {{ session.hold_time }}; 437 | {% endif %} 438 | {% if session.keepalive_time is defined %} 439 | keepalive time {{ session.keepalive_time }}; 440 | {% endif %} 441 | {% if session.type != "route_server" and session.type != "internal" %} 442 | enforce first as; 443 | {% endif %} 444 | } 445 | {% endif %} 446 | {% endfor %} -------------------------------------------------------------------------------- /bin/generate.py: -------------------------------------------------------------------------------- 1 | from jinja2 import Template 2 | import jsonschema 3 | import argparse, difflib, ipaddress, json, os, re, sys, wasabi 4 | 5 | # What to indent with 6 | INDENT_WITH = " " * 4 # 4 spaces 7 | INDENT_PLUS = ["{"] # Characters that trigger indentation level+ 8 | INDENT_MINUS = ["}"] # Characters that trigger indentation level- 9 | 10 | # thanks to FHR#6025 on BGPeople for this function 11 | def bird_indent(conf): 12 | level = 0 13 | out = "" 14 | for line in conf.split('\n'): 15 | line = line.strip() 16 | 17 | # indent minus 18 | if sum(map(lambda x: x in line, INDENT_MINUS)) > 0: 19 | level -= 1 20 | 21 | out += f"{level * INDENT_WITH}{line}\n" 22 | 23 | # indent plus 24 | if sum(map(lambda x: x in line, INDENT_PLUS)) > 0: 25 | level += 1 26 | 27 | return(out) 28 | 29 | # thanks to StackOverflow user fmark (https://stackoverflow.com/a/3041990) for this function 30 | def query_yes_no(question, default="no"): 31 | """Ask a yes/no question via raw_input() and return their answer. 32 | 33 | "question" is a string that is presented to the user. 34 | "default" is the presumed answer if the user just hits . 35 | It must be "yes" (the default), "no" or None (meaning 36 | an answer is required of the user). 37 | 38 | The "answer" return value is True for "yes" or False for "no". 39 | """ 40 | valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False} 41 | if default is None: 42 | prompt = " [y/n] " 43 | elif default == "yes": 44 | prompt = " [Y/n] " 45 | elif default == "no": 46 | prompt = " [y/N] " 47 | else: 48 | raise ValueError("invalid default answer: '%s'" % default) 49 | 50 | while True: 51 | sys.stdout.write(question + prompt) 52 | choice = input().lower() 53 | if default is not None and choice == "": 54 | return valid[default] 55 | elif choice in valid: 56 | return valid[choice] 57 | else: 58 | sys.stdout.write("Please respond with 'yes' or 'no' " "(or 'y' or 'n').\n") 59 | 60 | # this function is borrowed (with some small modifications) from the difflib package 61 | def colored_unified_diff(a, b, fromfile='', tofile='', fromfiledate='', 62 | tofiledate='', n=3, lineterm='\n'): 63 | difflib._check_types(a, b, fromfile, tofile, fromfiledate, tofiledate, lineterm) 64 | started = False 65 | for group in difflib.SequenceMatcher(None,a,b).get_grouped_opcodes(n): 66 | if not started: 67 | started = True 68 | fromdate = '\t{}'.format(fromfiledate) if fromfiledate else '' 69 | todate = '\t{}'.format(tofiledate) if tofiledate else '' 70 | yield wasabi.color('--- {}{}{}'.format(fromfile, fromdate, lineterm), fg=15) 71 | yield wasabi.color('+++ {}{}{}'.format(tofile, todate, lineterm), fg=15) 72 | 73 | first, last = group[0], group[-1] 74 | file1_range = difflib._format_range_unified(first[1], last[2]) 75 | file2_range = difflib._format_range_unified(first[3], last[4]) 76 | yield wasabi.color('@@ -{} +{} @@{}'.format(file1_range, file2_range, lineterm), fg="cyan") 77 | 78 | for tag, i1, i2, j1, j2 in group: 79 | if tag == 'equal': 80 | for line in a[i1:i2]: 81 | yield ' ' + line 82 | elif tag == 'delete': 83 | for line in a[i1:i2]: 84 | yield wasabi.color('-' + line, fg="red") 85 | elif tag == 'insert': 86 | for line in b[j1:j2]: 87 | yield wasabi.color('+' + line, fg="green") 88 | elif tag == 'replace': 89 | # ideally we do something intelligent here to highlight changed words 90 | for line in a[i1:i2]: 91 | yield wasabi.color('-' + line, fg="red") 92 | for line in b[j1:j2]: 93 | yield wasabi.color('+' + line, fg="green") 94 | 95 | def get_json_from_file(filename): 96 | with open(filename, 'r') as file: 97 | text = file.read() 98 | return json.loads(text) 99 | 100 | def get_text_from_file(filename): 101 | with open(filename, 'r') as file: 102 | return file.read() 103 | 104 | def generate_protocol_config(config, template_text): 105 | # validate config against "business" logic 106 | for session in config['bgp_sessions']: 107 | # need at least one ip protocol version defined 108 | if not "ipv4" in session and not "ipv6" in session: 109 | print("ERROR: session \"" + session['name'] + "\" must define at least one of ipv4,ipv6") 110 | sys.exit(1) 111 | 112 | # no spaces in session name 113 | if " " in session['name']: 114 | print("ERROR: session \"" + session['name'] + "\" name must not contain spaces") 115 | sys.exit(1) 116 | 117 | # no dashes in session name 118 | if "-" in session['name']: 119 | print("ERROR: session \"" + session['name'] + "\" name must not contain \"-\"") 120 | sys.exit(1) 121 | 122 | # filtering is set up right 123 | if session['type'] == "customer" or session['type'] == "peer": 124 | if not "filtering_method" in session: 125 | print("\"filtering_method\" must be set for session of type " + session['type']) 126 | sys.exit(1) 127 | if session['filtering_method'] == "irr-as-set": 128 | if "as-set" in session: 129 | #TODO: generate prefix list from IRR 130 | print("filtering with IRR is not implemented yet, sorry") 131 | sys.exit(1) 132 | else: 133 | print("ERROR: session \"" + session['name'] + "\" filtering method set to \"irr-as-set\". \"as-set\" must be defined.") 134 | sys.exit(1) 135 | elif session['filtering_method'] == "irr-autnum": 136 | if "as-set" in session: 137 | #TODO: generate prefix list from IRR 138 | print("filtering with IRR is not implemented yet, sorry") 139 | sys.exit(1) 140 | else: 141 | print("ERROR: session \"" + session['name'] + "\" filtering method set to \"irr-autnum\". \"autnum\" must be defined.") 142 | sys.exit(1) 143 | elif session['filtering_method'] == "prefix-list": 144 | if "ipv4" in session: 145 | # validate prefixes 146 | if "prefixes" in session['ipv4']: 147 | prefixes_with_more_specifics = [] 148 | for prefix in session['ipv4']['prefixes']: 149 | try: 150 | prefix_parsed = ipaddress.IPv4Network(prefix, strict=True) 151 | except ValueError: 152 | print("ERROR: session \"" + session['name'] + "\" ipv4 prefix \"" + prefix + "\" is not valid") 153 | sys.exit(1) 154 | if prefix_parsed.prefixlen < 24: 155 | prefixes_with_more_specifics.append(str(prefix_parsed) + "{" + str(prefix_parsed.prefixlen) + ",24}") 156 | else: 157 | prefixes_with_more_specifics.append(str(prefix_parsed)) 158 | session['ipv4']['prefixes_str'] = ", ".join(prefixes_with_more_specifics) 159 | else: 160 | print("ERROR: session \"" + session['name'] + "\" filtering method set to \"prefix-list\". ipv4 section is defined and therefore must contain prefix list.") 161 | sys.exit(1) 162 | if "ipv6" in session: 163 | # validate prefixes 164 | if "prefixes" in session['ipv6']: 165 | prefixes_with_more_specifics = [] 166 | for prefix in session['ipv6']['prefixes']: 167 | try: 168 | prefix_parsed = ipaddress.IPv6Network(prefix, strict=True) 169 | except ValueError: 170 | print("ERROR: session \"" + session['name'] + "\" ipv6 prefix \"" + prefix + "\" is not valid") 171 | sys.exit(1) 172 | if prefix_parsed.prefixlen < 48: 173 | prefixes_with_more_specifics.append(str(prefix_parsed) + "{" + str(prefix_parsed.prefixlen) + ",48}") 174 | else: 175 | prefixes_with_more_specifics.append(str(prefix_parsed)) 176 | session['ipv6']['prefixes_str'] = ", ".join(prefixes_with_more_specifics) 177 | else: 178 | print("ERROR: session \"" + session['name'] + "\" filtering method set to \"prefix-list\". ipv4 section is defined and therefore must contain prefix list.") 179 | sys.exit(1) 180 | else: 181 | print("ERROR: session \"" + session['name'] + "\" filtering method set to unrecognised value. this should have been caught by json schema validation.") 182 | sys.exit(1) 183 | 184 | # peer IPs should be valid 185 | if "ipv4" in session: 186 | try: 187 | ipaddress.IPv4Address(session['ipv4']['peer_ip']) 188 | except ValueError: 189 | print("ERROR: session \"" + session['name'] + "\" ipv4 peer IP \"" + session['ipv4']['peer_ip'] + "\" is not valid") 190 | sys.exit(1) 191 | if "ipv6" in session: 192 | try: 193 | ipaddress.IPv6Address(session['ipv6']['peer_ip']) 194 | except ValueError: 195 | print("ERROR: session \"" + session['name'] + "\" ipv6 peer IP \"" + session['ipv6']['peer_ip'] + "\" is not valid") 196 | sys.exit(1) 197 | 198 | # source IPs should be valid 199 | if "ipv4" in session and "source_ip" in session['ipv4']: 200 | try: 201 | ipaddress.IPv4Address(session['ipv4']['source_ip']) 202 | except ValueError: 203 | print("ERROR: session \"" + session['name'] + "\" ipv4 source IP \"" + session['ipv4']['source_ip'] + "\" is not valid") 204 | sys.exit(1) 205 | if "ipv6" in session and "source_ip" in session['ipv6']: 206 | try: 207 | ipaddress.IPv6Address(session['ipv6']['source_ip']) 208 | except ValueError: 209 | print("ERROR: session \"" + session['name'] + "\" ipv6 source IP \"" + session['ipv6']['source_ip'] + "\" is not valid") 210 | sys.exit(1) 211 | 212 | # ASN is required if session type is not internal 213 | if session['type'] != "internal": 214 | if "asn" not in session: 215 | print("ERROR: session \"" + session['name'] + "\" is type \"" + session['type'] + "\" and therefore must have asn defined") 216 | sys.exit(1) 217 | 218 | # local_pref is required if session type is not internal or collector 219 | if session['type'] != "internal" and session['type'] != "collector": 220 | if "local_pref" not in session: 221 | print("ERROR: session \"" + session['name'] + "\" is type \"" + session['type'] + "\" and therefore must have local_pref defined") 222 | sys.exit(1) 223 | 224 | # export_policy must be set iff session type is customer 225 | if session['type'] == "customer": 226 | if "export_policy" not in session: 227 | print("ERROR: session \"" + session['name'] + "\" is type \"" + session['type'] + "\" and therefore must have export_policy defined") 228 | sys.exit(1) 229 | else: 230 | if "export_policy" in session: 231 | print("ERROR: session \"" + session['name'] + "\" is type \"" + session['type'] + "\" and therefore must not have export_policy defined") 232 | sys.exit(1) 233 | 234 | # validate BGP communities 235 | if "communities_for_announced_routes" in session: 236 | for comm in session["communities_for_announced_routes"]: 237 | # need at least one of {communities,large_communities} 238 | if ("communities" not in comm) and ("large_communities" not in comm): 239 | print("ERROR: session \"" + session['name'] + "\" \"communities_for_announced_routes\" specifies prefix \"" + comm["prefix"] + "\" but no communities") 240 | sys.exit(1) 241 | # prefix must be valid prefix or "all" 242 | if comm["prefix"] != "all": 243 | try: 244 | ipaddress.IPv4Network(comm["prefix"] , strict=True) 245 | except ValueError: 246 | try: 247 | ipaddress.IPv6Network(comm["prefix"] , strict=True) 248 | except ValueError: 249 | print("ERROR: session \"" + session['name'] + "\" \"communities_for_announced_routes\" specifies prefix \"" + comm["prefix"] + "\" which is not valid") 250 | sys.exit(1) 251 | 252 | 253 | for rpki in config['rpki_protocols']: 254 | # need at least one roa table version defined 255 | if not "roa4_table" in rpki and not "roa6_table" in rpki: 256 | print("ERROR: rpki protocol \"" + rpki['name'] + "\" must define at least one of roa4_table,roa6_table") 257 | sys.exit(1) 258 | 259 | # no spaces in protocol name 260 | if " " in rpki['name']: 261 | print("ERROR: rpki protocol \"" + rpki['name'] + "\" name must not contain spaces") 262 | sys.exit(1) 263 | # no dashes in protocol name 264 | if "-" in rpki['name']: 265 | print("ERROR: rpki protocol \"" + rpki['name'] + "\" name must not contain \"-\"") 266 | sys.exit(1) 267 | 268 | # ip should be valid 269 | try: 270 | ipaddress.IPv4Address(rpki['ip']) 271 | except ValueError: 272 | print("ERROR: rpki protocol \"" + rpki['name'] + "\" has invalid IP \"" + rpki['ip'] + "\"") 273 | sys.exit(1) 274 | 275 | for route in config['static_routes_v4']: 276 | # exactly one of {blackhole,next_hop} should be defined 277 | if (not "blackhole" in route and not "next_hop" in route) or ("blackhole" in route and "next_hop" in route): 278 | print("ERROR: static IPv4 route for \"" + route['destination'] + "\" must define exactly one of blackhole,next_hop") 279 | sys.exit(1) 280 | 281 | # destination and next_hop (if it exists) should be valid 282 | try: 283 | prefix_parsed = ipaddress.IPv4Network(route['destination'], strict=True) 284 | except ValueError: 285 | print("ERROR: static IPv4 route destination \"" + route['destination'] + "\" is not valid") 286 | sys.exit(1) 287 | 288 | # next_hop (if it exists) should be valid 289 | if "next_hop" in route: 290 | try: 291 | ipaddress.IPv4Address(route['next_hop']) 292 | except ValueError: 293 | print("ERROR: static IPv4 route \"" + route['destination'] + "\" has invalid next_hop \"" + route['next_hop'] + "\"") 294 | sys.exit(1) 295 | 296 | for route in config['static_routes_v6']: 297 | # exactly one of {blackhole,next_hop} should be defined 298 | if (not "blackhole" in route and not "next_hop" in route) or ("blackhole" in route and "next_hop" in route): 299 | print("ERROR: static IPv6 route for \"" + route['destination'] + "\" must define exactly one of blackhole,next_hop") 300 | sys.exit(1) 301 | 302 | # destination should be valid 303 | try: 304 | prefix_parsed = ipaddress.IPv6Network(route['destination'], strict=True) 305 | except ValueError: 306 | print("ERROR: static IPv6 route destination \"" + route['destination'] + "\" is not valid") 307 | sys.exit(1) 308 | 309 | # next_hop (if it exists) should be valid 310 | if "next_hop" in route: 311 | try: 312 | ipaddress.IPv6Address(route['next_hop']) 313 | except ValueError: 314 | print("ERROR: static IPv6 route \"" + route['destination'] + "\" has invalid next_hop \"" + route['next_hop'] + "\"") 315 | sys.exit(1) 316 | 317 | # render template 318 | t = Template(template_text) 319 | 320 | output = "" 321 | 322 | output = t.render(config=config, trim_blocks=True, lstrip_blocks=True) 323 | output = re.sub(r'\n\s*\n', '\n', output) 324 | output = re.sub(r'}\n', '}\n\n', output) 325 | output = bird_indent(output) 326 | 327 | return output 328 | 329 | def generate_constants_config(config, template_text): 330 | # Since ASNs are 4 bytes, and python integers are 4 bytes, 331 | # we don't have to check that the ASN is in the allowed range. 332 | # Note: we're not checking for bogon ASNs here. A user may want to 333 | # operate with a private ASN or something. 334 | 335 | # render template 336 | t = Template(template_text) 337 | 338 | output = "" 339 | 340 | output = t.render(config=config, trim_blocks=True, lstrip_blocks=True) 341 | output = re.sub(r'\n\s*\n', '\n', output) 342 | output = re.sub(r'}\n', '}\n\n', output) 343 | output = bird_indent(output) 344 | 345 | return output 346 | 347 | if __name__ == "__main__": 348 | parser=argparse.ArgumentParser() 349 | 350 | parser.add_argument('--config', help='json file with information used to generate router config (default \'config.json\')', type=str, required=True, default='config.json') 351 | parser.add_argument('--outputPath', help='path of generated file (default \'.\')', type=str, required=True, default='.') 352 | parser.add_argument('--mode', help='whether to overwrite the existing config. options are "dryrun", "prompt", "overwrite". (default \'prompt\')', type=str, default='prompt', choices=['dryrun', 'prompt', 'overwrite']) 353 | 354 | args=parser.parse_args() 355 | 356 | # get absolute path of this tool 357 | base_folder_path = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) 358 | 359 | # get config object 360 | config = get_json_from_file(args.config) 361 | 362 | # get schema object 363 | schema = get_json_from_file(os.path.join(base_folder_path, "schema.json")) 364 | 365 | try: 366 | # validate config against schema 367 | jsonschema.validate(instance=config, schema=schema) 368 | except json.decoder.JSONDecodeError: 369 | print("invalid JSON") 370 | sys.exit(1) 371 | except jsonschema.exceptions.ValidationError as e: 372 | print("ERROR: JSON does not validate against schema") 373 | print(e.message) 374 | print("On instance " + jsonschema._utils.format_as_index(e.relative_path)) 375 | print(e.instance) 376 | sys.exit(1) 377 | 378 | 379 | protocols_template = get_text_from_file(os.path.join(os.path.join(base_folder_path, "templates"), "protocols.jinja2")) 380 | constants_template = get_text_from_file(os.path.join(os.path.join(base_folder_path, "templates"), "constants.jinja2")) 381 | 382 | protocols_output = generate_protocol_config(config, protocols_template) 383 | constants_output = generate_constants_config(config, constants_template) 384 | 385 | files_to_compare = [ 386 | (os.path.join(args.outputPath, "protocols.conf"), protocols_output), 387 | (os.path.join(args.outputPath, "user_constants.conf"), constants_output) 388 | ] 389 | files_with_changes = [] 390 | 391 | for path, generated in files_to_compare: 392 | # read existing config 393 | existing_config = '' 394 | file_exists = True 395 | try: 396 | with open(path, 'r') as file: 397 | existing_config = file.read() 398 | except FileNotFoundError: 399 | file_exists = False 400 | else: 401 | # collect changes into a list so we can count them easily 402 | lines = list(colored_unified_diff(existing_config.split("\n"), generated.split("\n"), fromfile="a/"+path, tofile="b/"+path, lineterm="")) 403 | 404 | # if there are no changes, tell the user and exit 405 | if len(lines) == 0: 406 | print(wasabi.color("** " + path + " has no changes", fg=11)) 407 | continue 408 | else: 409 | files_with_changes.append(path) 410 | 411 | # compare with output 412 | for line in lines: 413 | print(line) 414 | 415 | # add a newline for readability 416 | print() 417 | 418 | # if the file isn't there, tell the user 419 | if not file_exists: 420 | files_with_changes.append(path) 421 | print(wasabi.color("** " + path + " will be a new file", fg=11)) 422 | 423 | 424 | if args.mode == 'dryrun': 425 | # just exit 426 | print(wasabi.color("** mode is dryrun; exiting without writing files", fg=11)) 427 | sys.exit(0) 428 | 429 | write = False 430 | if args.mode == 'prompt' and len(files_with_changes)>0: 431 | # prompt for confirmation 432 | if query_yes_no(wasabi.color("** proceed with changes?", fg=11)): 433 | write = True 434 | 435 | # if we decided above to write the files, or if we are in overwrite mode, then write the files 436 | if write or args.mode == 'overwrite': 437 | for path, generated in files_to_compare: 438 | # only write a file if it has changed 439 | if path in files_with_changes: 440 | with open(path, 'w') as writer: 441 | writer.write(generated) 442 | print(wasabi.color("** wrote file " + path, fg=11)) 443 | -------------------------------------------------------------------------------- /schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema":"http://json-schema.org/draft-07/schema#", 3 | "$id":"http://example.com/product.schema.json", 4 | "title":"BGP Config", 5 | "description":"", 6 | "type":"object", 7 | "properties":{ 8 | "hostname":{ 9 | "type":"string" 10 | }, 11 | "asn":{ 12 | "type":"integer" 13 | }, 14 | "device":{ 15 | "type":"object", 16 | "properties":{ 17 | "disabled":{ 18 | "type":"boolean" 19 | }, 20 | "scan_time":{ 21 | "type":"integer", 22 | "minimum":1, 23 | "maximum":65535 24 | } 25 | }, 26 | "additionalProperties": false 27 | }, 28 | "kernel":{ 29 | "type":"object", 30 | "properties":{ 31 | "disabled":{ 32 | "type":"boolean" 33 | }, 34 | "scan_time":{ 35 | "type":"integer", 36 | "minimum":1, 37 | "maximum":65535 38 | } 39 | }, 40 | "additionalProperties": false 41 | }, 42 | "bgp_sessions":{ 43 | "type":"array", 44 | "items":{ 45 | "type":"object", 46 | "properties":{ 47 | "name":{ 48 | "type":"string" 49 | }, 50 | "type":{ 51 | "type":"string", 52 | "enum":[ 53 | "customer", 54 | "peer", 55 | "route_server", 56 | "transit", 57 | "collector", 58 | "internal" 59 | ] 60 | }, 61 | "asn":{ 62 | "type":"integer" 63 | }, 64 | "as-set":{ 65 | "type":"string" 66 | }, 67 | "autnum":{ 68 | "type":"string" 69 | }, 70 | "local_pref":{ 71 | "type":"integer" 72 | }, 73 | "stop_bgp":{ 74 | "type":"boolean" 75 | }, 76 | "multihop":{ 77 | "type":"boolean" 78 | }, 79 | "export_policy":{ 80 | "type":"string", 81 | "enum":[ 82 | "default_only", 83 | "full_routes", 84 | "full_routes_plus_default" 85 | ] 86 | }, 87 | "hold_time":{ 88 | "type":"integer", 89 | "minimum":0, 90 | "maximum":65535 91 | }, 92 | "keepalive_time":{ 93 | "type":"integer", 94 | "minimum":0, 95 | "maximum":65535 96 | }, 97 | "ipv4":{ 98 | "type":"object", 99 | "properties":{ 100 | "peer_ip":{ 101 | "type":"string" 102 | }, 103 | "peer_port":{ 104 | "type":"integer", 105 | "minimum":1, 106 | "maximum":65535 107 | }, 108 | "source_ip":{ 109 | "type":"string" 110 | }, 111 | "password":{ 112 | "type":"string" 113 | }, 114 | "prefixes":{ 115 | "type":"array", 116 | "items":{ 117 | "type":"string" 118 | }, 119 | "uniqueItems":true, 120 | "minItems":1 121 | }, 122 | "prefix_limit":{ 123 | "type":"integer" 124 | }, 125 | "next_hop_self":{ 126 | "type":"boolean" 127 | } 128 | }, 129 | "required":[ 130 | "peer_ip" 131 | ], 132 | "additionalProperties": false 133 | }, 134 | "ipv6":{ 135 | "type":"object", 136 | "properties":{ 137 | "peer_ip":{ 138 | "type":"string" 139 | }, 140 | "peer_port":{ 141 | "type":"integer", 142 | "minimum":1, 143 | "maximum":65535 144 | }, 145 | "source_ip":{ 146 | "type":"string" 147 | }, 148 | "password":{ 149 | "type":"string" 150 | }, 151 | "prefixes":{ 152 | "type":"array", 153 | "items":{ 154 | "type":"string" 155 | }, 156 | "uniqueItems":true, 157 | "minItems":1 158 | }, 159 | "prefix_limit":{ 160 | "type":"integer" 161 | }, 162 | "next_hop_self":{ 163 | "type":"boolean" 164 | } 165 | }, 166 | "required":[ 167 | "peer_ip" 168 | ], 169 | "additionalProperties": false 170 | }, 171 | "filtering_method":{ 172 | "type":"string", 173 | "enum":[ 174 | "prefix-list", 175 | "irr-as-set", 176 | "irr-autnum" 177 | ] 178 | }, 179 | "prepends":{ 180 | "type":"integer", 181 | "minimum": 1 182 | }, 183 | "communities_on_egress":{ 184 | "type": "array", 185 | "items":{ 186 | "type":"object", 187 | "properties":{ 188 | "prefix": { 189 | "type": "string" 190 | }, 191 | "communities": { 192 | "type": "array", 193 | "items":{ 194 | "items" : [ 195 | { 196 | "type" : "integer", 197 | "minimum": 0, 198 | "maximum": 65535 199 | }, { 200 | "type" : "integer", 201 | "minimum": 0, 202 | "maximum": 65535 203 | } 204 | ], 205 | "minItems":2, 206 | "maxItems":2 207 | }, 208 | "uniqueItems":true, 209 | "minItems":1 210 | }, 211 | "large_communities": { 212 | "type": "array", 213 | "items":{ 214 | "items" : [ 215 | { 216 | "type" : "integer", 217 | "minimum": 0, 218 | "maximum": 4294967296 219 | }, { 220 | "type" : "integer", 221 | "minimum": 0, 222 | "maximum": 4294967296 223 | }, { 224 | "type" : "integer", 225 | "minimum": 0, 226 | "maximum": 4294967296 227 | } 228 | ], 229 | "minItems":3, 230 | "maxItems":3 231 | }, 232 | "uniqueItems":true, 233 | "minItems":1 234 | } 235 | }, 236 | "required": [ 237 | "prefix" 238 | ], 239 | "additionalProperties": false 240 | } 241 | }, 242 | "communities_on_ingress":{ 243 | "type": "array", 244 | "items":{ 245 | "type":"object", 246 | "properties":{ 247 | "prefix": { 248 | "type": "string" 249 | }, 250 | "communities": { 251 | "type": "array", 252 | "items":{ 253 | "items" : [ 254 | { 255 | "type" : "integer", 256 | "minimum": 0, 257 | "maximum": 65535 258 | }, { 259 | "type" : "integer", 260 | "minimum": 0, 261 | "maximum": 65535 262 | } 263 | ], 264 | "minItems":2, 265 | "maxItems":2 266 | }, 267 | "uniqueItems":true, 268 | "minItems":1 269 | }, 270 | "large_communities": { 271 | "type": "array", 272 | "items":{ 273 | "items" : [ 274 | { 275 | "type" : "integer", 276 | "minimum": 0, 277 | "maximum": 4294967296 278 | }, { 279 | "type" : "integer", 280 | "minimum": 0, 281 | "maximum": 4294967296 282 | }, { 283 | "type" : "integer", 284 | "minimum": 0, 285 | "maximum": 4294967296 286 | } 287 | ], 288 | "minItems":3, 289 | "maxItems":3 290 | }, 291 | "uniqueItems":true, 292 | "minItems":1 293 | } 294 | }, 295 | "required": [ 296 | "prefix" 297 | ], 298 | "additionalProperties": false 299 | } 300 | } 301 | }, 302 | "required":[ 303 | "name", 304 | "type" 305 | ], 306 | "additionalProperties": false 307 | }, 308 | "minItems":1, 309 | "uniqueItems":true 310 | }, 311 | "rpki_protocols":{ 312 | "type":"array", 313 | "items":{ 314 | "type":"object", 315 | "properties":{ 316 | "name":{ 317 | "type":"string" 318 | }, 319 | "roa4_table":{ 320 | "type":"string" 321 | }, 322 | "roa6_table":{ 323 | "type":"string" 324 | }, 325 | "ip":{ 326 | "type":"string" 327 | }, 328 | "port":{ 329 | "type":"integer" 330 | }, 331 | "retry_time":{ 332 | "type":"integer" 333 | }, 334 | "retry_keep":{ 335 | "type":"boolean" 336 | }, 337 | "refresh_time":{ 338 | "type":"integer" 339 | }, 340 | "refresh_keep":{ 341 | "type":"boolean" 342 | }, 343 | "expire_time":{ 344 | "type":"integer" 345 | }, 346 | "expire_keep":{ 347 | "type":"boolean" 348 | } 349 | }, 350 | "required": [ 351 | "name", 352 | "ip" 353 | ], 354 | "additionalProperties": false 355 | } 356 | }, 357 | "static_routes_v4":{ 358 | "type": "array", 359 | "items":{ 360 | "type":"object", 361 | "properties":{ 362 | "destination": { 363 | "type": "string" 364 | }, 365 | "blackhole": { 366 | "type": "boolean" 367 | }, 368 | "next_hop": { 369 | "type": "string" 370 | }, 371 | "communities": { 372 | "type": "array", 373 | "items":{ 374 | "items" : [ 375 | { 376 | "type" : "integer", 377 | "minimum": 0, 378 | "maximum": 65535 379 | }, { 380 | "type" : "integer", 381 | "minimum": 0, 382 | "maximum": 65535 383 | } 384 | ], 385 | "minItems":2, 386 | "maxItems":2 387 | }, 388 | "uniqueItems":true, 389 | "minItems":1 390 | }, 391 | "large_communities": { 392 | "type": "array", 393 | "items":{ 394 | "items" : [ 395 | { 396 | "type" : "integer", 397 | "minimum": 0, 398 | "maximum": 4294967296 399 | }, { 400 | "type" : "integer", 401 | "minimum": 0, 402 | "maximum": 4294967296 403 | }, { 404 | "type" : "integer", 405 | "minimum": 0, 406 | "maximum": 4294967296 407 | } 408 | ], 409 | "minItems":3, 410 | "maxItems":3 411 | }, 412 | "uniqueItems":true, 413 | "minItems":1 414 | } 415 | }, 416 | "required": [ 417 | "destination" 418 | ], 419 | "additionalProperties": false 420 | } 421 | }, 422 | "static_routes_v6":{ 423 | "type": "array", 424 | "items":{ 425 | "type":"object", 426 | "properties":{ 427 | "destination": { 428 | "type": "string" 429 | }, 430 | "blackhole": { 431 | "type": "boolean" 432 | }, 433 | "next_hop": { 434 | "type": "string" 435 | }, 436 | "communities": { 437 | "type": "array", 438 | "items":{ 439 | "items" : [ 440 | { 441 | "type" : "integer", 442 | "minimum": 0, 443 | "maximum": 65535 444 | }, { 445 | "type" : "integer", 446 | "minimum": 0, 447 | "maximum": 65535 448 | } 449 | ], 450 | "minItems":2, 451 | "maxItems":2 452 | }, 453 | "uniqueItems":true, 454 | "minItems":1 455 | }, 456 | "large_communities": { 457 | "type": "array", 458 | "items":{ 459 | "items" : [ 460 | { 461 | "type" : "integer", 462 | "minimum": 0, 463 | "maximum": 4294967296 464 | }, { 465 | "type" : "integer", 466 | "minimum": 0, 467 | "maximum": 4294967296 468 | }, { 469 | "type" : "integer", 470 | "minimum": 0, 471 | "maximum": 4294967296 472 | } 473 | ], 474 | "minItems":3, 475 | "maxItems":3 476 | }, 477 | "uniqueItems":true, 478 | "minItems":1 479 | } 480 | }, 481 | "required": [ 482 | "destination" 483 | ], 484 | "additionalProperties": false 485 | } 486 | }, 487 | "connected_route_communities":{ 488 | "type": "array", 489 | "items":{ 490 | "type":"object", 491 | "properties":{ 492 | "destination": { 493 | "type": "string" 494 | }, 495 | "communities": { 496 | "type": "array", 497 | "items":{ 498 | "items" : [ 499 | { 500 | "type" : "integer", 501 | "minimum": 0, 502 | "maximum": 65535 503 | }, { 504 | "type" : "integer", 505 | "minimum": 0, 506 | "maximum": 65535 507 | } 508 | ], 509 | "minItems":2, 510 | "maxItems":2 511 | }, 512 | "uniqueItems":true, 513 | "minItems":1 514 | }, 515 | "large_communities": { 516 | "type": "array", 517 | "items":{ 518 | "items" : [ 519 | { 520 | "type" : "integer", 521 | "minimum": 0, 522 | "maximum": 4294967296 523 | }, { 524 | "type" : "integer", 525 | "minimum": 0, 526 | "maximum": 4294967296 527 | }, { 528 | "type" : "integer", 529 | "minimum": 0, 530 | "maximum": 4294967296 531 | } 532 | ], 533 | "minItems":3, 534 | "maxItems":3 535 | }, 536 | "uniqueItems":true, 537 | "minItems":1 538 | } 539 | }, 540 | "required": [ 541 | "destination" 542 | ], 543 | "additionalProperties": false 544 | } 545 | }, 546 | "ospf_route_communities":{ 547 | "type": "array", 548 | "items":{ 549 | "type":"object", 550 | "properties":{ 551 | "destination": { 552 | "type": "string" 553 | }, 554 | "communities": { 555 | "type": "array", 556 | "items":{ 557 | "items" : [ 558 | { 559 | "type" : "integer", 560 | "minimum": 0, 561 | "maximum": 65535 562 | }, { 563 | "type" : "integer", 564 | "minimum": 0, 565 | "maximum": 65535 566 | } 567 | ], 568 | "minItems":2, 569 | "maxItems":2 570 | }, 571 | "uniqueItems":true, 572 | "minItems":1 573 | }, 574 | "large_communities": { 575 | "type": "array", 576 | "items":{ 577 | "items" : [ 578 | { 579 | "type" : "integer", 580 | "minimum": 0, 581 | "maximum": 4294967296 582 | }, { 583 | "type" : "integer", 584 | "minimum": 0, 585 | "maximum": 4294967296 586 | }, { 587 | "type" : "integer", 588 | "minimum": 0, 589 | "maximum": 4294967296 590 | } 591 | ], 592 | "minItems":3, 593 | "maxItems":3 594 | }, 595 | "uniqueItems":true, 596 | "minItems":1 597 | } 598 | }, 599 | "required": [ 600 | "destination" 601 | ], 602 | "additionalProperties": false 603 | } 604 | } 605 | }, 606 | "required":[ 607 | "hostname", 608 | "asn" 609 | ], 610 | "additionalProperties": false 611 | } --------------------------------------------------------------------------------