Compare commits

...

3 commits

Author SHA1 Message Date
Franzi f1a775b5c9
add junos device management 2022-12-22 15:45:50 +01:00
Franzi 6ae90733c3
add libs/juniper 2022-12-22 13:05:33 +01:00
Franzi 4f75c95c20
add scripts/netbox-dump 2022-12-22 13:05:11 +01:00
9 changed files with 1882 additions and 0 deletions

View file

@ -22,3 +22,6 @@ indent_size = unset
[*.vault]
end_of_line = unset
insert_final_newline = unset
[*.json]
insert_final_newline = unset

141
configs/junos-template.conf Normal file
View file

@ -0,0 +1,141 @@
version ${'.'.join(node.metadata.get('junos/version'))};
system {
host-name ${node.name.split('.')[-1]};
time-zone GMT;
root-authentication {
encrypted-password "$5$1hGrR8Kk$lx3CIdxqvesBrZUtDftROEoyXQuMENEu62JVtHw6WGD"; ## SECRET-DATA
}
name-server {
% for srv in repo.libs.defaults.nameservers_ipv4:
${srv};
% endfor
}
login {
% for uid, (uname, uconfig) in enumerate(sorted(users.items())):
user ${uname} {
full-name ${uname};
uid ${1000+uid};
class super-user;
authentication {
% for pubkey in sorted(uconfig['ssh_pubkey']):
${pubkey.split(' ', 1)[0]} "${pubkey}";
% endfor
}
}
% endfor
}
services {
ssh {
protocol-version v2;
}
netconf {
ssh;
}
# web-management {
# http;
# }
}
syslog {
user * {
any emergency;
}
file messages {
any notice;
authorization info;
}
file interactive-commands {
interactive-commands any;
}
}
ntp {
% for srv in sorted(ntp_servers):
server ${srv};
% endfor;
}
}
interfaces {
% for iface, config in sorted(interfaces.items()):
${iface} {
unit 0 {
% if not config['enabled']:
disable;
% endif
% if config['mode'] == 'trunk':
family ethernet-switching {
port-mode trunk;
vlan {
members [ ${' '.join(sorted(config['tagged_vlans']))} ];
}
% if config['untagged_vlan']:
native-vlan-id ${config['untagged_vlan']};
% endif
}
% else:
family ethernet-switching;
% endif
}
}
% endfor
vlan {
% for idx, (vlan, vconfig) in enumerate(sorted(vlans.items())):
% if vconfig['ip_address']:
unit ${idx} {
family inet {
address ${vconfig['ip_address']};
}
}
% endif
% endfor
}
}
snmp {
contact "${repo.libs.defaults.hostmaster_email}";
community public {
authorization read-only;
}
}
routing-options {
static {
route 0.0.0.0/0 next-hop ${gateway};
}
}
protocols {
igmp-snooping {
vlan all;
}
rstp;
lldp {
interface all;
}
lldp-med {
interface all;
}
}
ethernet-switching-options {
voip;
storm-control {
interface all;
}
}
vlans {
% for idx, (vlan, vconfig) in enumerate(sorted(vlans.items())):
${vlan} {
% if vconfig['id']:
vlan-id ${vconfig['id']};
% endif
interface {
% for iface, iconfig in sorted(interfaces.items()):
% if iconfig['untagged_vlan'] == vlan:
${iface}.0;
% endif
% endfor
}
% if vconfig['ip_address']:
l3-interface vlan.${idx};
% endif
}
% endfor
}
poe {
interface all;
}

View file

@ -88,3 +88,10 @@ groups['debian-bullseye'] = {
groups['debian-sid'] = {
'os_version': (99,)
}
groups['junos'] = {
'dummy': True,
'cmd_wrapper_outer': '{}',
'cmd_wrapper_inner': '{}',
'os': 'freebsd',
}

149
libs/juniper.py Normal file
View file

@ -0,0 +1,149 @@
import random
# copied from https://github.com/peering-manager/peering-manager/blob/main/devices/crypto/juniper.py
# This code is the result of the attempt at converting a Perl module, the expected
# result might not actually be what we really want it to be ¯\_(ツ)_/¯
#
# https://metacpan.org/pod/Crypt::Juniper
MAGIC = "$9$"
FAMILY = [
"QzF3n6/9CAtpu0O",
"B1IREhcSyrleKvMW8LXx",
"7N-dVbwsY2g4oaJZGUDj",
"iHkq.mPf5T",
]
EXTRA = {}
for counter, value in enumerate(FAMILY):
for character in value:
EXTRA[character] = 3 - counter
NUM_ALPHA = [x for x in "".join(FAMILY)]
ALPHA_NUM = {NUM_ALPHA[x]: x for x in range(0, len(NUM_ALPHA))}
ENCODING = [
[1, 4, 32],
[1, 16, 32],
[1, 8, 32],
[1, 64],
[1, 32],
[1, 4, 16, 128],
[1, 32, 64],
]
def __nibble(cref, length):
nib = cref[0:length]
rest = cref[length:]
if len(nib) != length:
raise Exception(f"Ran out of characters: hit '{nib}', expecting {length} chars")
return nib, rest
def __gap(c1, c2):
return (ALPHA_NUM[str(c2)] - ALPHA_NUM[str(c1)]) % (len(NUM_ALPHA)) - 1
def __gap_decode(gaps, dec):
num = 0
if len(gaps) != len(dec):
raise Exception("Nibble and decode size not the same.")
for x in range(0, len(gaps)):
num += gaps[x] * dec[x]
return chr(num % 256)
def __reverse(current):
reversed = list(current)
reversed.reverse()
return reversed
def __gap_encode(pc, prev, encode):
__ord = ord(pc)
crypt = ""
gaps = []
for mod in __reverse(encode):
gaps.insert(0, int(__ord / mod))
__ord %= mod
for gap in gaps:
gap += ALPHA_NUM[prev] + 1
prev = NUM_ALPHA[gap % len(NUM_ALPHA)]
crypt += prev
return crypt
def __randc(counter=0):
return_value = ""
for _ in range(counter):
return_value += NUM_ALPHA[random.randrange(len(NUM_ALPHA))]
return return_value
def is_encrypted(value):
return value.startswith(MAGIC)
def decrypt(value):
if not value:
return ""
if not is_encrypted(value):
return value
chars = value.split("$9$", 1)[1]
first, chars = __nibble(chars, 1)
toss, chars = __nibble(chars, EXTRA[first])
previous = first
decrypted = ""
while chars:
decode = ENCODING[len(decrypted) % len(ENCODING)]
nibble, chars = __nibble(chars, len(decode))
gaps = []
for i in nibble:
g = __gap(previous, i)
previous = i
gaps += [g]
decrypted += __gap_decode(gaps, decode)
return decrypted
def encrypt(value, salt=None):
if not value:
return ""
if not isinstance(value, str):
value = str(value)
if is_encrypted(value):
return value
if not salt:
salt = __randc(1)
rand = __randc(EXTRA[salt])
position = 0
previous = salt
crypted = MAGIC + salt + rand
for x in value:
encode = ENCODING[position % len(ENCODING)]
crypted += __gap_encode(x, previous, encode)
previous = crypted[-1]
position += 1
return crypted

1322
netbox_dump.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
hostname = "172.19.138.4"
groups = ["junos"]
[metadata.junos]
version = ["15", "1R5", "5"]

View file

@ -1,3 +1,4 @@
bundlewrap~=4.16.0
PyNaCl
bundlewrap-pass
pynetbox==7.0.0

137
scripts/junos-update-config Executable file
View file

@ -0,0 +1,137 @@
#!/usr/bin/env python3
from json import load
from os import environ
from os.path import join
from sys import argv, exit
from tempfile import gettempdir
from mako.template import Template
from bundlewrap.repo import Repository
from bundlewrap.utils.text import bold
from bundlewrap.utils.ui import io
NTP_SERVERS = {
# pool.ntp.org
'148.251.54.81',
'162.159.200.123',
'213.209.109.44',
'54.36.110.36',
}
try:
node_name = argv[1]
except Exception:
print(f'Usage: {argv[0]} <node name>')
exit(1)
path = environ.get('BW_REPO_PATH', '.')
repo = Repository(path)
node = repo.get_node(node_name)
try:
io.activate()
interfaces = {}
users = {}
vlans = {
'default': {
'id': None,
'ip_address': '169.254.254.254/24',
},
}
tmpfile = join(gettempdir(), f'{node.name}.conf')
gw_split = node.hostname.split('.')
gw_split[3] = '1'
gateway = '.'.join(gw_split)
with io.job('reading netbox_dump.json'):
with open(join(repo.path, 'netbox_dump.json'), 'r') as f:
json = load(f)[node.metadata.get('location')]
for vlan, vid in json['vlans'].items():
vlans[vlan] = {
'id': vid,
'ip_address': None,
}
for iface, iconfig in json['devices'][node.name].items():
if iface in vlans:
# If the interface name is the same as a vlan name, this
# means the ip assigned to this interface should get
# assigned to that vlan.
vlans[iface]['ip_address'] = iconfig['ip_addresses'][0]
else:
interfaces[iface] = {
'enabled': bool(
iconfig['enabled']
and iconfig['mode']
and (
iconfig['vlans']['tagged']
or iconfig['vlans']['untagged']
)
),
'description': iconfig['description'],
'untagged_vlan': iconfig['vlans']['untagged'],
}
if iconfig['mode'] and iconfig['mode'].startswith('tagged'):
interfaces[iface]['mode'] = 'trunk'
else:
interfaces[iface]['mode'] = 'access'
tagged_vlans = set()
for vlan in iconfig['vlans']['tagged']:
tagged_vlans.add(str(vlans[vlan]['id']))
interfaces[iface]['tagged_vlans'] = tagged_vlans
with io.job('reading users.json'):
with open(join(repo.path, 'users.json'), 'r') as f:
json = load(f)
users = {}
for uname, config in json.items():
if config.get('is_admin', False):
users[uname] = {
'password': repo.vault.password_for(f'{node.name} {uname} login'),
'ssh_pubkey': set(config['ssh_pubkey']),
}
with io.job(f'{bold(node.name)} rendering config template to {tmpfile}'):
with open(join(repo.path, 'configs', 'junos-template.conf')) as f:
template = Template(
f.read().encode('utf-8'),
input_encoding='utf-8',
output_encoding='utf-8',
)
content = template.render(
gateway=gateway,
interfaces=interfaces,
node=node,
ntp_servers=NTP_SERVERS,
repo=repo,
users=users,
vlans=vlans,
)
with open(tmpfile, 'w+') as f:
f.write(content.decode('utf-8'))
with io.job(f'{bold(node.name)} updating configuration on device'):
node.upload(tmpfile, '/tmp/bundlewrap.conf')
result = node.run(
'configure exclusive ; load override /tmp/bundlewrap.conf ; commit',
log_output=True,
)
if 'commit complete' in result.stdout.decode():
node.run(
'request system configuration rescue save',
log_output=True,
)
finally:
io.deactivate()

117
scripts/netbox-dump Executable file
View file

@ -0,0 +1,117 @@
#!/usr/bin/env python3
from json import dump
from os import environ
from os.path import join
from sys import exit
from bundlewrap.utils.ui import QUIT_EVENT, io
from bundlewrap.utils.text import bold, yellow, validate_name
from pynetbox import api as netbox_api
BW_REPO_PATH = environ.get('BW_REPO_PATH', '.')
netbox = netbox_api(
environ.get('NETBOX_HOST', 'https://netbox.franzi.business'),
token=environ.get('NETBOX_TOKEN', None),
)
result = {
# 'my_site_name': {
# 'vlans': {
# 'my_vlan_name': 10,
# 'other_vlan_name': 11,
# 'yet_another_vlan_name': 12,
# },
# 'devices': {
# 'my_switch': {
# 'port1': {
# 'description': 'foo',
# 'type': '1000base-t', # or 'lag'
# 'mode': None, # or 'access', 'tagged', 'tagged-all'
# 'lag': 'none', # or 'LAG1'
# 'vlan': {
# 'untagged': 'my_vlan_name',
# 'tagged': [
# 'other_vlan_name',
# 'yet_another_vlan_name',
# ],
# },
# },
# },
# },
# },
}
errors = False
try:
io.activate()
for site in netbox.dcim.sites.all():
site_name = site.name.lower()
result[site_name] = {
'vlans': {},
'devices': {},
}
with io.job(f'{bold(site_name)} getting vlans'):
for vlan in netbox.ipam.vlans.filter(site_id=site.id):
if vlan.name in result[site_name]['vlans'].keys() and result[site_name]['vlans'][vlan.name] != vlan.id:
raise Exception(f"vlan {result[site_name]['vlans'][vlan.name]} and {vlan.id} both have the name {vlan.name}")
result[site_name]['vlans'][vlan.name] = vlan.vid
for interface in netbox.dcim.interfaces.filter(site_id=site.id):
if QUIT_EVENT.is_set():
exit(0)
with io.job(f'{bold(site_name)} {bold(interface.device.name)} interface {yellow(interface.name)}'):
if not interface.device.name:
# Unnamed device. Probably not managed by bw.
continue
elif not validate_name(interface.device.name):
# bundlewrap does not consider this device name to be a valid
# node name. Ignore it, we don't manage it
continue
has_valid_description = False
if interface.description:
description = interface.description
has_valid_description = True
elif interface.connected_endpoints:
description = f'{sorted(interface.connected_endpoints)[0].device.display} ({sorted(interface.connected_endpoints)[0].display})'
has_valid_description = True
elif interface.link_peers:
description = f'{sorted(interface.link_peers)[0].device.display} ({sorted(interface.link_peers)[0].display})'
else:
description = ''
if not description.isascii():
errors = True
io.stderr(f'{bold(interface.device.name)} {bold(interface.name)} description "{description}" contains non-ascii characters, this isn\'t supported')
result[site_name]['devices'].setdefault(interface.device.name, {})[interface.name] = {
'description': description,
'enabled': interface.enabled,
'ip_addresses': sorted(set() if interface.count_ipaddresses == 0 else {
ip.address for ip in
netbox.ipam.ip_addresses.filter(interface_id=interface.id)
}),
'mode': interface.mode.value if interface.mode else None,
'type': interface.type.value,
'lag': interface.lag.name if interface.lag else None,
'vlans': {
'untagged': interface.untagged_vlan.name if interface.untagged_vlan else None,
'tagged': sorted(vlan.name for vlan in interface.tagged_vlans),
},
}
if errors:
exit(1)
with io.job('dumping result to netbox_dump.json'):
with open(join(BW_REPO_PATH, 'netbox_dump.json'), 'w') as f:
dump(result, f, indent=4, sort_keys=True)
finally:
io.deactivate()