#!/usr/bin/python3 """install.py - copy files from to_system into system directories - set host name / network settings from boxtools/cfg file """ if bytes == str: raise NotImplementedError('python2 not supported') import sys import os import filecmp import shutil import serial import types import socket from subprocess import Popen, PIPE from glob import glob from ipaddress import IPv4Interface from configparser import ConfigParser from os.path import join, getmtime, exists, basename from utils import get_config, ifname_mapping if os.geteuid() != 0: exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.") TOOLS = '/home/l_samenv/boxtools' BOX_TYPES = { '00:0d:b9': 'apu', # bare apu or control box 'b8:27:eb': 'cm3', # iono pi 'd8:3a:dd': 'cm4', # dual-eth-rpi } more_info = False DEL = '__to_delete__' CFGPATH = f'{TOOLS}/cfg/%s_%6.6x.cfg' STARTUP_TEXT = f'{TOOLS}/startup_display.txt' COMMENT = "; please refer to README.md for help" CONFIG_TEMPLATE = f"""[NETWORK] {COMMENT} enp1s0=192.168.127.254 enp2s0=192.168.2.2 enp3s0=192.168.3.3 enp4s0=dhcp [ROUTER] {COMMENT} 3001=192.168.127.254:3001 """ CONTROLBOX_TEMPLATE = f"""[NETWORK] {COMMENT} enp1s0=dhcp enp2s0=192.168.1.1 enp3s0=192.168.2.2 enp4s0=192.168.3.3 [DISPLAY] {COMMENT} line0=startup... line1=HOST line2=ADDR """ DHCP_HEADER = """ default-lease-time 600; max-lease-time 7200; authoritative; """ ROUTER_TEMPLATE = f"""[Unit] Description = Routing to locally connected hardware After = network.target [Service] ExecStart = /usr/bin/python3 {TOOLS}/router.py [Install] WantedBy = multi-user.target """ FRAPPY_TEMPLATE = """[Unit] Description = Running frappy server After = network.target [Service] User = l_samenv ExecStart = /usr/bin/python3 /home/l_samenv/frappy/bin/frappy-server %s [Install] WantedBy = multi-user.target """ DISPLAY_TEMPLATE = f"""[Unit] Description = status display After = network.target [Service] User = root ExecStart = /usr/bin/python3 {TOOLS}/display.py -d [Install] WantedBy = multi-user.target """ pip_requirements = { 'root': {}, 'l_samenv': {}, } net_addr = {} # dict of dhcp_server_cfg = [] # configuration for dhcp main_info = { # info to be determined depending on cfg 'ifname': '', # name of interface of wan (dhcp) port 'addr': '', # addr of wan port 'hostname': socket.gethostname() # effective or given host name } for netif in os.scandir('/sys/class/net'): if netif.name != 'lo': # do not consider loopback interface with open(os.path.join(netif.path, 'address')) as f: addr = f.read().strip().lower() net_addr[netif.name] = addr sorted_if = sorted(net_addr) boxaddr = net_addr[sorted_if[0]] boxid = int(''.join(boxaddr.split(':')[-3:]), 16) & 0xffffff boxtype = BOX_TYPES.get(boxaddr[:8]) if boxtype != 'apu': ifname_mapping.clear() TO_SYSTEM = f'{TOOLS}/to_{boxtype}' if not exists(TO_SYSTEM): TO_SYSTEM = f'{TOOLS}/to_system' os.chdir(TO_SYSTEM) def frappy(cfg=None, port=None, requirements='', **kwds): if not cfg: return None req = pip_requirements['l_samenv'] req[cfg] = '\n'.join(requirements.split(',')) if port: cfg = '-p %s %s' % (port, cfg) with open('/home/l_samenv/frappy/requirements.txt') as f: req['frappy main'] = f.read() return FRAPPY_TEMPLATE % cfg def router(**opts): if not opts: return None try: os.remove(join(TO_SYSTEM, 'etc/nftables.conf')) with open(f'{TOOLS}/requirements.txt') as f: pip_requirements['root']['tools'] = f.read() except FileNotFoundError: pass return ROUTER_TEMPLATE def display_update(cfg): text = '\n'.join(cfg.get('startup_text', '').split('|')[:3]) text = text.replace('HOST', main_info['hostname']) \ .replace('ADDR', main_info['addr']) + '\n' if write_when_new(STARTUP_TEXT, text): print('change startup text') if doit: os.rename(STARTUP_TEXT, STARTUP_TEXT + '.todo') return True return False def display(**opts): if not opts: return None return DISPLAY_TEMPLATE def pip(): for user, requirements in pip_requirements.items(): if user == 'root': tmpname = join('/root', 'pip_requirements.tmp') pipcmd = 'pip3 install -r %s' % tmpname else: tmpname = join('/home', user, 'pip_requirements.tmp') pipcmd = 'sudo --user %s pip3 install --user -r %s' % (user, tmpname) filename = tmpname.replace('.tmp', '.txt') content = ''.join('# --- for %s ---\n%s\n' % kv for kv in requirements.items()) if write_when_new(filename, content, True): if doit: os.rename(filename, tmpname) if os.system(pipcmd) == 0: os.rename(tmpname, filename) else: os.remove(tmpname) else: print(pipcmd) unix_cmd(pipcmd, stdout=None) show.dirty = True SERVICES = dict(router=router, frappy=frappy, display=display) def unix_cmd(command, always=False, stdout=PIPE): if doit or always: if not always: print('$ %s' % command) result = Popen(command.split(), stdout=stdout).communicate()[0] return (result or b'').decode() else: print('> %s' % command) def write_when_new(filename, content, ignore_reduction=False): if content is None: lines = [] else: if not content.endswith('\n'): content += '\n' lines = content.split('\n') try: with open(filename) as fil: old = fil.read() except FileNotFoundError: old = None if old == content: return False if ignore_reduction: content_set = set(v for v in lines if not v.startswith('#')) old_set = set(v for v in (old or '').split('\n') if not v.startswith('#')) content_set -= old_set if content_set: print(f"missing in {filename}: {(', '.join(content_set - old_set))}") else: return False if doit: if lines: with open(filename, 'w') as fil: fil.write(content) else: os.remove(filename) elif more_info: print('.' * 80) print('changes in', filename) old = [] if old is None else old.split('\n') top_lines = 0 # in case of empty loop for top_lines, (ol, ll) in enumerate(zip(old, lines)): if ol != ll: break bottom_lines = 0 for bottom_lines, (ol, ll) in enumerate(zip(reversed(old[top_lines:]), reversed(lines[top_lines:]))): if ol != ll: break if bottom_lines == 0: old.append('') lines.append('') bottom_lines += 1 print("===") print('\n'.join(old[:top_lines])) print('<<<') print('\n'.join(old[top_lines:-bottom_lines])) print('---') print('\n'.join(lines[top_lines:-bottom_lines])) print('>>>') print('\n'.join(old[-bottom_lines:-1])) print("===") print('.' * 80) return content def create_if(name, cfg): if cfg == 'off': result = None elif cfg.startswith('wan') or cfg == 'dhcp': if main_info.get('mainif', name) != name: raise ValueError('can not have more than one WAN/DHCP port') main_info['mainif'] = name main_info['addr'] = net_addr[name] # default: all <= 192.0.0.0 # others have to be added explicitly dhcp_server_cfg.append(('0.0.0.0/128.0.0.0', [])) dhcp_server_cfg.append(('128.0.0.0/192.0.0.0', [])) for nw in cfg.split(',')[1:]: nw = IPv4Interface(nw).network dhcp_server_cfg.append((nw.with_netmask, [])) result = f"allow-hotplug {name}\niface {name} inet dhcp" else: cfgip = IPv4Interface(cfg) network = cfgip.network print('N', network, network.prefixlen) if network.prefixlen == 32: # or no prefix specified otherip = IPv4Interface('%s/24' % cfgip.ip) network = otherip.network if str(cfgip.ip).endswith('.1'): cfgip = network.network_address + 2 else: cfgip = network.network_address + 1 dhcp_server_cfg.append((network.with_netmask, [(str(otherip.ip), str(otherip.ip))])) addr = str(cfgip) else: # subnet with multiple adresses -> static adresses only. dhcp range not yet implemented addr = str(cfgip.ip) # result['NETMASK'] = network.netmask result = f"allow-hotplug {name}\niface {name} inet static\n address {addr}/{network.prefixlen}" if result: return result + '\n' def walk(action): for dirpath, _, files in os.walk('.'): syspath = dirpath[1:] # remove leading '.' action.dirpath = dirpath action.syspath = syspath if files: match, mismatch, missing = filecmp.cmpfiles(dirpath, syspath, files) if mismatch: newer = [f for f in mismatch if getmtime(join(syspath, f)) > getmtime(join(dirpath, f))] if newer: action.newer(newer) if len(newer) < len(mismatch): newer = set(newer) action.older([f for f in mismatch if f not in newer]) if missing: if DEL in missing: missing.remove(DEL) with open(join(dirpath, DEL)) as fil: to_delete = [] for fname in fil: fname = fname.strip() if fname and exists(join(syspath, fname)): if exists(join(dirpath, fname)): print('ERROR: %s in %s, but also in repo -> ignored' % (fname, DEL)) else: to_delete.append(fname) action.delete(to_delete) if missing: action.missing(missing) class Walker: def __init__(self): self.dirpath = None self.syspath = None class Show(Walker): dirty = False def diff(self, title, files): self.dirty = True if more_info: for f in files: if f.endswith(more_info): print('diff %s %s' % (join(self.dirpath, f), join(self.syspath, f))) print('.' * 80) else: print('%s %s:\n %s' % (title, self.syspath, ' '.join(files))) def show(self, title, dirpath, files): self.dirty = True if more_info: for f in files: print('cat %s' % join(dirpath, f)) print('.' * 80) else: print('%s %s:\n %s' % (title, self.syspath, ' '.join(files))) def newer(self, files): self.show('get from', self.dirpath, files) def older(self, files): self.show('replace in', self.dirpath, files) def missing(self, files): self.show('install in', self.dirpath, files) def delete(self, files): self.show('remove from', self.syspath, files) class Do(Walker): def newer(self, files): for file in files: shutil.copy(join(self.syspath, file), join(self.dirpath, file)) def older(self, files): for file in files: shutil.copy(join(self.dirpath, file), join(self.syspath, file)) def missing(self, files): self.older(files) def delete(self, files): for file in files: os.remove(join(self.syspath, file)) def handle_config(): cfgfile = None cfgfiles = [] dhcp_server_cfg.clear() for file in glob(CFGPATH % ('*', boxid)): cfgfiles.append(file) if boxtype == 'apu': for i in [1, 2, 3]: bad = glob(CFGPATH % ('*', apuid+i)) if bad: print('cfg files found with bad apu id (use net addr of leftmost plug)') print(bad) return False newhostname = main_info['hostname'] if not cfgfiles: # determine if display is present disp = serial.Serial('/dev/ttyS1', baudrate=115200, timeout=1) disp.write(b'\x1b\x1b\x01\xf3') display_available = disp.read(8)[0:4] == b'\x1b\x1b\x05\xf3' if display_available: # leftmost interface labelled 'eth0' # main_info['mainif'] = sorted_if[0] typ = 'controlbox' template = CONTROLBOX_TEMPLATE else: # rightmost interface # main_info['mainif'] = sorted_if[-1] typ = 'bare apu' template = CONFIG_TEMPLATE print('no cfg file found for this', typ, f'with id {boxid:%6.6x} (hostname={hostname})') newhostname = input('enter host name: ') if not newhostname: print('no hostname given') return False cfgfile = CFGPATH % (newhostname, boxid) with open(cfgfile, 'w') as f: f.write(template) elif len(cfgfiles) > 1: print('ERROR: ambiguous cfg files: %s' % ', '.join(cfgfiles)) else: cfgfile = cfgfiles[0] if cfgfile != CFGPATH % (newhostname, boxid): if cfgfile: newhostname = basename(cfgfile).rpartition('_')[0] if doit: os.system('sh sethostname.sh') else: if cfgfile: print('replace host name %r by %r' % (main_info['hostname'], newhostname)) show.dirty = True main_info['hostname'] = newhostname if cfgfile is None: return False to_start = {} ifname = '' config = get_config() try: netcfg = config['NETWORK'] for ifname in net_addr: content = create_if(ifname, netcfg.get(ifname, 'off')) # content = '\n'.join('%s=%s' % kv for kv in content.items()) todo = write_when_new(f'/etc/network/interfaces.d/{ifname}', content) if todo and not more_info: print('change', ifname) show.dirty = True to_start[ifname] = 'if_restart' if dhcp_server_cfg: print(dhcp_server_cfg) content = [DHCP_HEADER] for subnet, rangelist in dhcp_server_cfg: try: adr, mask = subnet.split('/') except Exception as e: print(subnet, repr(e)) continue content.append('subnet %s netmask %s {\n' % (adr, mask)) #content.append(' option netmask %s;\n' % mask) for rng in rangelist: content.append(' range %s %s;\n' % rng) content.append('}\n') content = ''.join(content) todo = write_when_new('/etc/dhcp/dhcpd.conf', content) if todo: print('change dhcpd.conf') to_start['isc-dhcp-server'] = 'restart' show.dirty = True elif doit: unix_cmd('systemctl stop isc-dhcp-server') unix_cmd('systemctl disable isc-dhcp-server') content = [] dirty = False for section, section_dict in config.items(): if COMMENT not in section_dict: dirty = True content.append('[%s]' % section) content.append(COMMENT) if section == 'DISPLAY': if display_update(section_dict): to_start['display'] = 'restart' for key, value in section_dict.items(): content.append('%s=%s' % (key, value)) content.append('') if dirty: print(cfgfile) print('\n'.join(content)) #with open(cfgfile, 'w') as f: # f.write('\n'.join(content)) except Exception as e: print('ERROR: can not handle %s %s: %r' % (main_info['hostname'], ifname, e)) raise return False reload_systemd = False for service, service_func in SERVICES.items(): section = service.upper() section_dict = config.get(section, {}) servicecfg = service_func(**section_dict) result = unix_cmd('systemctl show -p WantedBy -p ActiveState %s' % service, True) active = False enabled = False for line in result.split('\n'): if line.startswith('WantedBy=m'): enabled = True elif line.strip() == 'ActiveState=active': active = True if servicecfg is None: if active: unix_cmd('systemctl stop %s' % service) show.dirty = True if enabled: unix_cmd('systemctl disable %s' % service) show.dirty = True if service == 'router' and active or enabled: if doit: shutil.copy(join(TOOLS, 'nftables.conf'), '/etc/nftables.conf') else: print('cp nftables.conf /etc/nftables.conf') unix_cmd('systemctl restart nftables') else: if not enabled: to_start[service] = 'enable' elif not active: to_start[service] = 'restart' if write_when_new('/etc/systemd/system/%s.service' % service, servicecfg): show.dirty = True reload_systemd = True if servicecfg and to_start.get('service') is None: to_start[service] = 'restart' pip() if reload_systemd: unix_cmd('systemctl daemon-reload') for service, action in to_start.items(): show.dirty = True if action == 'if_restart': unix_cmd('ifdown %s' % service) for service, action in to_start.items(): if action == 'if_restart': unix_cmd('ifup %s' % service) else: if action == 'restart': unix_cmd('systemctl restart %s' % service) unix_cmd('systemctl enable %s' % service) result = [f'config file:\n {cfgfile}'] for section, section_dict in config.items(): result.append(section) for key, value in section_dict.items(): result.append(f' {key} = {value}') result.append('') return '\n'.join(result) more_info = False doit = False print(' ') show = Show() walk(show) result = handle_config() if not result: print('fix first above errors') else: if show.dirty: print('---') answer = input('enter "y(es)" to do above or "m(ore)" for more info: ') if not answer: print('cancel') elif 'yes'.startswith(answer.lower()): doit = True handle_config() walk(Do()) with open(f'{TOOLS}/current', 'w') as f: f.write(result) elif 'more'.startswith(answer.lower()): more_info = True walk(show) result = handle_config() else: print('nothing to do')