Files
boxtools/install.py
l_samenv 4bed174abd use sorted when iterating over network interfaces
this helps for unneeded rewrites if dhcpd.conf
2024-03-20 13:25:37 +01:00

613 lines
19 KiB
Python
Executable File

#!/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 re
import types
import socket
from subprocess import Popen, PIPE
from ipaddress import IPv4Interface
from configparser import ConfigParser
from os.path import join, getmtime, exists, basename
from utils import BoxInfo
if os.geteuid() != 0:
exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.")
TOOLS = BoxInfo.TOOLS
more_info = False
DEL = '__to_delete__'
STARTUP_TEXT = f'{TOOLS}/startup_display.txt'
COMMENT = "; please refer to README.md for help"
CONFIG_TEMPLATE = f"""[NETWORK]
{COMMENT}
eth0=wan
eth1=192.168.2.2
eth2=192.168.3.3
eth3=192.168.127.254
[ROUTER]
{COMMENT}
3001=192.168.127.254:3001
"""
CONTROLBOX_TEMPLATE = f"""[NETWORK]
{COMMENT}
eth0=dhcp
eth1=192.168.1.1
eth2=192.168.2.2
eth3=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': {},
}
box = BoxInfo()
dhcp_server_cfg = []
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', box.hostname) \
.replace('ADDR', box.get_macaddr()) + '\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 replace_in_file(filename, pat, repl):
"""find pattern <prev> in content of filename
find regexp pattern <pat> in content of file
- if it does not match exactly once, an error is raised
- exchange group 1 by repl
- return True when result is changed, or False when not
doit determines, whether the file is really changed
"""
with open(filename) as f:
content = f.read()
success = []
def fun(match, count=[]):
if success:
raise ValueError(f'{pat} matches multiple times in {filename}')
part = match.group(0)
match = match.re.match(part)
fr, to = match.span(1)
result = part[:fr] + repl + part[to:]
if result != part:
print(f'in {filename} replace {part} -> {result}')
success.append(result != part)
return result
newcontent = re.sub(pat, fun, content)
if not success:
raise ValueError(f'{pat} not in {filename}')
write_when_new(filename, newcontent)
return success[0]
def create_if(name, cfg):
if cfg == 'off':
result = None
elif cfg.startswith('wan') or cfg == 'dhcp':
if box.main_if != name:
print(box.main_if, name)
raise ValueError('can not have more than one WAN/DHCP port')
box.main_if = 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
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():
dhcp_server_cfg.clear()
config = box.read_config()
cfgfile = box.cfgfile
newhostname = box.hostname
if not cfgfile:
template = CONFIG_TEMPLATE
if box.typ == 'apu':
# 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:
typ = 'controlbox'
template = CONTROLBOX_TEMPLATE
else:
typ = 'bare apu'
else:
typ = 'rpi'
print('no cfg file found for this', typ,
f'with id {box.id:06x} (hostname={box.hostname})')
newhostname = input('enter host name: ')
if not newhostname:
print('no hostname given')
return False
cfgfile = BoxInfo.CFGPATH % (newhostname, box.id)
with open(cfgfile, 'w') as f:
f.write(template)
if cfgfile != BoxInfo.CFGPATH % (newhostname, box.id):
if cfgfile:
newhostname = basename(cfgfile).rpartition('_')[0]
if doit:
os.system('sh sethostname.sh')
else:
if cfgfile:
print('replace host name %r by %r' % (box.hostname, newhostname))
show.dirty = True
config = box.read_config()
box.hostname = newhostname
if cfgfile is None:
return False
to_start = {}
ifname = ''
try:
netcfg = config['NETWORK']
for name in netcfg:
if name not in box.macaddr:
print(f'{name} is not a valid network interface name')
raise RuntimeError('network interface name system does not match')
for ifname in box.macaddr:
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:
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
replace_in_file('/etc/default/isc-dhcp-server',
r'INTERFACESv4="(.*)"', ' '.join([n for n in box.macaddr if n != box.main_if]))
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' % (box.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)
if box.change_if_names:
print('interface name system has to be changed from enp*s0 to eth*')
if replace_in_file('/etc/default/grub',
r'GRUB_CMDLINE_LINUX_DEFAULT="quiet(.*)"',
' net.ifnames=0"'):
unix_cmd('update-grub')
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')