#!/usr/bin/python3 import configparser import copy import json import os import smtplib import subprocess import sys import time from email.message import EmailMessage from pprint import pprint rundir=os.path.realpath(os.path.dirname(sys.argv[0])) os.chdir(rundir) sys.path.append(os.path.join(rundir,'sysbus/src')) from sysbus import sysbus from ovh import ovh def load_conf(): global zone_filename, log_filename global wan_hostname, zone_domain, zone_subdomain, zone_timeout global hosts_list, nat_list, pinhole_list global mail_from, mail_to, mail_ignore_list conf = configparser.ConfigParser(allow_no_value=True) conf.read('home.conf') zone_filename = conf['Files']['zonefile'] log_filename = conf['Files']['logfile'] wan_hostname = conf['Wan']['hostname'].lower() zone_domain = conf['Zone']['domain'].lower() zone_subdomain = conf['Zone']['subdomain'].lower() mail_from = conf['Mail']['from'] mail_to = conf['Mail']['to'] mail_ignore_list = [ host.lower() for host in conf['MailIgnore'] ] mail_ignore_list = [ '.'.join([h, zone_subdomain]) if not h.endswith('.'+zone_subdomain) else h for h in mail_ignore_list ] hosts_list = [ host.lower() for host in conf['Hosts'] ] nat_list = {} for e in conf.items('PortsNat'): if e[1]: nat_list[e[0]] = e[1].split(',') else: nat_list[e[0]] = [] pinhole_list = {} for e in conf.items('Firewall'): if e[1]: pinhole_list[e[0]] = e[1].split(',') else: pinhole_list[e[0]] = [] zone_timeout = conf['Zone']['keep'] if zone_timeout[-1] == 's': zone_timeout = int(zone_timeout[:-1]) elif zone_timeout[-1] == 'm': zone_timeout = int(zone_timeout[:-1]) * 60 elif zone_timeout[-1] == 'h': zone_timeout = int(zone_timeout[:-1]) * 3600 elif zone_timeout[-1] == 'd': zone_timeout = int(zone_timeout[:-1]) * 86400 else: zone_timeout = int(zone_timeout) def ping(hostname): cmd = "ping -4 -c1 -w3 %s" % hostname ret = subprocess.run(cmd.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) return 0 if ret.returncode else 1 def get_hosts(): r = sysbus.requete('Devices:get', silent=True) hosts = { 'A': {}, 'AAAA': {} } if not r: return hosts for h in r['status']: ns = {} for n in h['Names']: ns[n['Source']] = n['Name'] hostname = h['Name'] hostname = ns.get('mdns', hostname) hostname = ns.get('dhcp', hostname) hostname = ns.get('webui', hostname) hostname = hostname.lower() for a in h.get('IPv6Address', []): if a['Scope'] != 'global' or a['Status'] != 'reachable': continue if not hostname in hosts['AAAA']: hosts['AAAA'][hostname] = [] hosts['AAAA'][hostname].append(a['Address']) for a in h.get('IPv4Address', []): if a['Scope'] != 'global' or a['Status'] != 'reachable': continue if not hostname in hosts['A']: hosts['A'][hostname] = [] hosts['A'][hostname].append(a['Address']) return hosts def get_wan_addr(): r = sysbus.requete('NMC:getWANStatus', silent=True) wan = dict() if not r or not r.get('data') or not r['data'].get('IPAddress') or not r['data'].get('IPv6Address'): log('get_wan_addr: {}'.format(r)) return None wan['ipv4'] = r['data']['IPAddress'] wan['ipv6'] = r['data']['IPv6Address'] if wan['ipv4'] == '0.0.0.0': wan['ipv4'] = '' return wan def full_name(host, domain): host = host.replace('_', '-') return '.'.join([host, domain]) def zone_add_entry(zone, prot, key, value, stamp): #print('zone_add_entry: {} {} {} {}'.format(prot, key, value, stamp)) if not zone.get(prot): zone[prot] = {} if not zone[prot].get(key): zone[prot][key] = {} if not zone[prot][key].get(value): zone[prot][key][value] = { 'first': stamp } zone[prot][key][value]['last'] = stamp def populate_zone(zone, wan_hostname, wan_addr, hosts, hosts_list, hosts_nat, pinhole_list, domain, stamp): wan_hostname = full_name(wan_hostname, domain) if not zone.get('A'): zone['A'] = {} if not zone.get('AAAA'): zone['AAAA'] = {} if wan_addr['ipv4'] != '': zone_add_entry(zone, 'A', wan_hostname, wan_addr['ipv4'], stamp) for host in hosts_nat: if hosts['A'].get(host): zone_add_entry(zone, 'A', full_name(host, domain), wan_addr['ipv4'], stamp) # we can only add PortNat entry for on Address, # so let's arbitrarely take the 1st one for port in hosts_nat[host]: zone_add_entry(zone, 'nat', hosts['A'][host][0], port, stamp) if wan_addr['ipv6'] != '': zone_add_entry(zone, 'AAAA', wan_hostname, wan_addr['ipv6'], stamp) for host in hosts_list: for addr in hosts['AAAA'].get(host, []): zone_add_entry(zone, 'AAAA', full_name(host, domain), addr, stamp) for port in pinhole_list.get(host, []): zone_add_entry(zone, 'pin', addr, port, stamp) def process_zone(zone, stamp, grace_period, sync_zone): update = { 'add': [], 'delete': [] } for prot in zone: for name in zone[prot]: active = False for addr in zone[prot][name]: e = zone[prot][name][addr] if e['first'] == stamp: update['add'].append([prot, name, addr]) if e['last'] == stamp: active = True for addr in zone[prot][name]: e = zone[prot][name][addr] if active and e['last'] < stamp: update['delete'].append([prot, name, addr]) elif not active and stamp - e['last'] > grace_period: update['delete'].append([prot, name, addr]) elif sync_zone: update['add'].append([prot, name, addr]) if not update['add'] and not update['delete']: update = None return update def read_zone_list(zone_filename): try: with open(zone_filename) as jsonfile: zone = json.load(jsonfile) return zone except FileNotFoundError: return {} def write_zone_list(zone_filename, zone, update): zone = copy.deepcopy(zone) if update: for prot, name, addr in update['delete']: del zone[prot][name][addr] if not zone[prot][name]: del zone[prot][name] with open(zone_filename, 'w') as jsonfile: json.dump(zone, jsonfile, indent=2, sort_keys=True) def log(msg): stamp = time.strftime("%Y-%m-%d %H:%M:%S") if sys.stdout.isatty(): for line in msg.split('\n'): print("%s - %s" % (stamp, msg)) else: with open(log_filename, 'a') as logfile: for line in msg.split('\n'): logfile.write("%s - %s\n" % (stamp, msg)) def log_update_zone(update): op_str = { 'add': '', 'delete': '[DEL]' } for op in update: for prot, name, addr in update[op]: op = op_str.get(op, op) log('%5s %-20s %-6s %s' % (op, name, prot, addr)) def ovh_update_zone(domain, zone, update, sync_zone): try: client = ovh.Client() if sync_zone: for prot in ['A', 'AAAA']: for name in zone.get(prot, []): result = client.get('/domain/zone/{}/record'.format(domain), fieldType=prot, subDomain=name) for id in result: log('OVH: delete entry #{} {} {}'.format(id, name, prot)) client.delete('/domain/zone/{}/record/{}'.format(domain, id)) for prot, name, addr in update['delete']: if not prot in ['A', 'AAAA']: continue result = client.get('/domain/zone/%s/record' % domain, fieldType=prot, subDomain=name) for id in result: r = client.get('/domain/zone/%s/record/%d' % (domain, id)) if r['fieldType'] == prot and r['target'] == addr: log("OVH: delete entry for %s %s %s (#%s)" % (name, prot, addr, id)) client.delete('/domain/zone/%s/record/%d' % (domain, id)) for prot, name, addr in update['add']: if not prot in ['A', 'AAAA']: continue log("OVH: create entry for %s %s %s" % (name, prot, addr)) client.post('/domain/zone/%s/record' % domain, fieldType=prot, subDomain=name, target=addr, ttl=60) log("OVH: Refresh zone %s" % domain) client.post('/domain/zone/%s/refresh' % domain) return True except: log('OVH update error\n') return False def livebox_rule_id(words): id='dyndomain_{}'.format('_'.join(words)) id = id.replace('.', '_') return id def livebox_delete_port_nat(port, addr): log('livebox: delete PortNat {} to {}'.format(port, addr)) id = livebox_rule_id([port]) r = sysbus.requete('Firewall:deletePortForwarding', { 'id': id, 'origin': 'webui' }, silent=True) def livebox_delete_pinhole(port, addr): log('livebox: delete pinhole {} to {}'.format(port, addr)) id = livebox_rule_id([port, addr]) r = sysbus.requete('Firewall:deletePinhole', { 'id': id, 'origin': 'webui' }, silent=True) def livebox_add_port_nat(port, addr): log('livebox: add PortNat {} to {}'.format(port, addr)) id = livebox_rule_id([port]) a = { 'id': id, 'origin': 'webui', 'sourceInterface': 'data', 'destinationIPAddress': addr, 'protocol': '6,17', 'internalPort': port, 'enable': True, } r = sysbus.requete('Firewall:setPortForwarding', a, silent=True) def livebox_add_pinhole(port, addr): log('livebox: add pinhole {} to {}'.format(port, addr)) id = livebox_rule_id([port, addr]) a = { 'id': id, 'origin': 'webui', 'sourceInterface': 'data', 'destinationPort': port, 'destinationIPAddress': addr, 'protocol': '6,17', 'ipversion': '6', 'enable': True, } r = sysbus.requete('Firewall:setPinhole', a, silent=True) def livebox_update_fw(zone, update, sync_zone): for prot, addr, port in update['delete']: if prot == 'nat': livebox_delete_port_nat(port, addr) elif prot == 'pin': livebox_delete_pinhole(port, addr) for prot, addr, port in update['add']: if prot == 'nat': livebox_add_port_nat(port, addr) elif prot == 'pin': livebox_add_pinhole(port, addr) def send_update_mail(mail_to, mail_from, zone_domain, update, mail_ignore_list, wan): okmail=False for op in update: for prot,name,addr in update[op]: if not name in mail_ignore_list: okmail = True break if not okmail: return log('Send email to %s' % mail_to) msg = EmailMessage() msg['Subject'] = "Livebox update in %s" % zone_domain msg['From'] = mail_from msg['To' ] = mail_to txt = "Livebox update\n\n" if updated['add']: txt = txt + "\nUpdated entries:\n" for prot,name,addr in update['add']: txt = txt + " %-20s %-4s %s\n" % (name,prot,addr) if update['delete']: txt = txt + "\nRemoved entries:\n" for prot,name,addr in update['delete']: txt = txt + " %-20s %-4s %s\n" % (name,prot,addr) txt = txt + '\n' txt = txt + "WAN IPv4 : %s\n" % wan['ipv4'] txt = txt + "WAN IPv6 : %s\n" % wan['ipv6'] msg.set_content(txt) s = smtplib.SMTP('localhost') s.send_message(msg) s.quit() load_conf() if not ping(wan_hostname): log("%s is down" % wan_hostname) sys.exit(0) sysbus.load_conf() try: r = sysbus.auth(False) if not r: log('Error: cannot authenticate on livebox') sys.exit(1) except Exception as e: log('Error: %s'.format(e)) sys.exit(1) hosts = get_hosts() wan = get_wan_addr() if not wan: sys.exit(0) zone = read_zone_list(zone_filename) sync_zone = False if not zone: sync_zone = True stamp = int(time.time()) populate_zone(zone, wan_hostname, wan, hosts, hosts_list, nat_list, pinhole_list, zone_subdomain, stamp) update = process_zone(zone, stamp, zone_timeout, sync_zone) if update: log_update_zone(update) success = ovh_update_zone(zone_domain, zone, update, sync_zone) livebox_update_fw(zone, update, sync_zone) if success: try: send_update_mail(mail_to, mail_from, zone_domain, update, mail_ignore_list, wan) except: pass write_zone_list(zone_filename, zone, update)