From 16a923f58f8b56333625fcd0aa138f1e870ec754 Mon Sep 17 00:00:00 2001 From: Daniel Barlow Date: Sun, 5 Feb 2023 17:35:41 +0000 Subject: [PATCH] convert routeros pppoe service to a derivation and make it configure itself automatically instead of starting out blank --- tests/support/ppp-server/README.md | 40 ++- tests/support/ppp-server/chr.sh | 12 - tests/support/ppp-server/default.nix | 53 ++++ tests/support/ppp-server/ros-exec-script.py | 260 ++++++++++++++++++++ tests/support/ppp-server/routeros.config | 4 - 5 files changed, 332 insertions(+), 37 deletions(-) delete mode 100755 tests/support/ppp-server/chr.sh create mode 100644 tests/support/ppp-server/default.nix create mode 100644 tests/support/ppp-server/ros-exec-script.py diff --git a/tests/support/ppp-server/README.md b/tests/support/ppp-server/README.md index 5b022b4..162b56c 100644 --- a/tests/support/ppp-server/README.md +++ b/tests/support/ppp-server/README.md @@ -1,28 +1,26 @@ # ppp-server -To test a router, we need an upstream connection. In this directory, -find +To test a router, we need an upstream connection. This directory +contains a derivation to download, start and configure a RouterOS +"Cloud Hosted Router" instance in a Qemu VM. It is currently +set up for automated tests only, and may require some manual +frobbing to run interactively. -* chr.sh, a script that will start a RouterOS image in qemu. - Login when prompted, username is "admin", blank password -* routeros.config, a set of commands you can feed into routeros - to set up PPPoE +Note that you need to open some multicast ports if you're using the +NixOS firewall (or probably, any other firewall). For iptables you can +accomplish this by editing your configuration.nix or some module it +calls: -To get the chr-7.5.img image, visit https://mikrotik.com/download and -look in the section titled "Cloud Hosted Router" for "Raw disk image" -You may need to open your firewall a bit to allow multicast packets -so that the upstream and the liminix qemu instances may communicate +``` + networking.firewall.extraCommands = '' + ip46tables -A nixos-fw -m pkttype --pkt-type multicast -p udp --dport 1234:1236 -j nixos-fw-accept + ''; +``` -config.networking.firewall.extraCommands = '' -ip46tables -A nixos-fw -m pkttype --pkt-type multicast -p udp --dport 1234:1236 -j nixos-fw-accept -''; +## Provenance -## To connect to the routeros serial - -The Qemu instance running RouterOS is headless, but it creates -two unix sockets for serial port and monitor. - - socat -,raw,echo=0,icanon=0,isig=0,icrnl=0,escape=0x0f tests/support/ppp-server/qemu-console - - socat -,raw,echo=0,icanon=0,isig=0,icrnl=0,escape=0x0f tests/support/ppp-server/qemu-monitor +The chr-7.x.img image is taken from https://mikrotik.com/download - +look in the section titled "Cloud Hosted Router" for "Raw disk image". +Note that this is proprietary software: please read the license +information and make sure you're using it legally. diff --git a/tests/support/ppp-server/chr.sh b/tests/support/ppp-server/chr.sh deleted file mode 100755 index a028f50..0000000 --- a/tests/support/ppp-server/chr.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env sh -/nix/store/ydwiiagdhczynh2lbqh418rglibv93rv-qemu-host-cpu-only-7.0.0/bin/qemu-kvm \ - -M q35 -display none \ - -m 1024 \ - -accel kvm \ - -daemonize \ - -serial unix:qemu-console,server,nowait -monitor unix:qemu-monitor,server,nowait \ - -drive file=chr-7.5.img,format=raw,if=virtio \ - -netdev socket,id=access,mcast=230.0.0.1:1234 \ - -device virtio-net-pci,disable-legacy=on,disable-modern=off,netdev=access,mac=ba:ad:1d:ea:11:02 \ - -netdev socket,id=world,mcast=230.0.0.1:1236 \ - -device virtio-net-pci,disable-legacy=on,disable-modern=off,netdev=world,mac=ba:ad:1d:ea:11:01 diff --git a/tests/support/ppp-server/default.nix b/tests/support/ppp-server/default.nix new file mode 100644 index 0000000..7a895c2 --- /dev/null +++ b/tests/support/ppp-server/default.nix @@ -0,0 +1,53 @@ +{ + stdenv +, python3 +, qemu +, fetchzip +, writeShellApplication +}: +let + chr-image = fetchzip { + url = "https://download.mikrotik.com/routeros/7.7/chr-7.7.img.zip"; + hash = "sha256-utBQMUgNvl/UTG+GjnQShlGgVtHmRKtnhSTWW/JyeiY="; + curlOpts = "-L"; + }; + ros-exec-script = stdenv.mkDerivation { + name = "ros-exec-script"; + src = ./.; + buildInputs = [python3]; + buildPhase = ":"; + installPhase = '' + mkdir -p $out/bin + cp ros-exec-script.py $out/bin/ros-exec-script + chmod +x $out/bin/ros-exec-script + ''; + }; + routeros = writeShellApplication { + name = "routeros"; + runtimeInputs = [ qemu ros-exec-script ]; + text = '' + RUNTIME_DIRECTORY=$1 + test -d "$RUNTIME_DIRECTORY" || exit 1 + ${qemu}/bin/qemu-system-x86_64 \ + -M q35 \ + -m 1024 \ + -accel kvm \ + -display none \ + -daemonize \ + -pidfile "$RUNTIME_DIRECTORY/pid" \ + -serial "unix:$RUNTIME_DIRECTORY/console,server,nowait"\ + -monitor "unix:$RUNTIME_DIRECTORY/monitor,server,nowait" \ + -snapshot -drive file=${chr-image}/chr-7.7.img,format=raw,if=virtio \ + -chardev "socket,path=$RUNTIME_DIRECTORY/qmp,server=on,wait=off,id=qga0" \ + -device virtio-serial \ + -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0 \ + -netdev socket,id=access,mcast=230.0.0.1:1234,localaddr=127.0.0.1 \ + -device virtio-net-pci,disable-legacy=on,disable-modern=off,netdev=access,mac=ba:ad:1d:ea:11:02 \ + -netdev socket,id=world,mcast=230.0.0.1:1236,localaddr=127.0.0.1 \ + -device virtio-net-pci,disable-legacy=on,disable-modern=off,netdev=world,mac=ba:ad:1d:ea:11:01 + ros-exec-script "$RUNTIME_DIRECTORY/qmp" ${./routeros.config} + ''; + }; +in { + inherit routeros ros-exec-script; +} diff --git a/tests/support/ppp-server/ros-exec-script.py b/tests/support/ppp-server/ros-exec-script.py new file mode 100644 index 0000000..7952c16 --- /dev/null +++ b/tests/support/ppp-server/ros-exec-script.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python + +import os,time,base64,json,socket,select,errno,sys +# FIXME: this script is adapted from +# https://wiki.mikrotik.com/wiki/Manual:CHR#Provisioning + +# I don't know if it is freely usable/redistributable + +class GuestAgent(object): + ''' + Qemu guest agent interface + runScript and runFile commands are tailored for ROS agent implementation + Transport provided by derived classes (transact method) + ''' + + def __init__(self,**kwargs): + # Due to file contents being passed as base64 inside json: + # - large chunk sizes may slow down guest-side parsing. + # - small chunk sizes result in additional message fragmentation overhead. + # Default value is a guestimate. + self.__chunkSize = kwargs.get('chunkSize', 4096) + + def _qmpError(self,cls,msg): + ''' Generic callback to log qmp errors before (optionally) raising an exception ''' + print(cls) + for line in msg.split('\n'): + print(line) + # raise RuntimeError() + + def _error(self,msg,*a): + ''' Generic callback to misc errors before (optionally) raising an exception ''' + print(msg.format(*a)) + # raise RuntimeError() + + def _info(self,msg,*a): + ''' Generic callback to log info ''' + print(msg.format(*a)) + + def _monitorJob(self,pid): + ''' Block untill script job completes, echo output. Returns None on failure ''' + ret = self.transact('guest-exec-status',{'pid':pid}) + if ret is None: + return None + + while not bool(ret['exited']): + time.sleep(1) + ret = self.transact('guest-exec-status',{'pid':pid}) + if ret is None: + return None + + # err-data is never sent + out = [] + if 'out-data' in ret.keys(): + out = base64.b64decode(ret['out-data']).decode('utf-8').split('\n') + if not out[-1]: + out = out[:-1] + + exitcode = int(ret['exitcode']) + return exitcode, out + + def putFile(self,src,dst): + ''' Upload file ''' + src = os.path.expanduser(src) + if not os.path.exists(src) or not os.path.isfile(src): + self._error('File does not exist: \'{}\'', src) + return None + + ret = self.transact('guest-file-open', {'path':dst,'mode':'w'}) + if ret is None: + return None + + handle = int(ret) + + file = open(src, 'rb') + for chunk in iter(lambda: file.read(self.__chunkSize), b''): + count = len(chunk) + chunk = base64.b64encode(chunk).decode('ascii') + + ret = self.transact('guest-file-write',{'handle':handle,'buf-b64':chunk,'count':count}) + if ret is None: + return None + self.transact('guest-file-flush',{'handle':handle}) + ret = self.transact('guest-file-close',{'handle':handle}) + return True + + def getFile(self,src,dst): + ''' Download file ''' + dst = os.path.expanduser(dst) + + ret = self.transact('guest-file-open',{'path':src,'mode':'rb'}) + if ret is None: + return None + + handle = int(ret) + data = '' + size = 0 + + while True: + ret = self.transact('guest-file-read',{'handle':handle,'count':self.__chunkSize}) + if ret is None: + return None + data += ret['buf-b64'] + size += int(ret['count']) + if bool(ret['eof']): + break + + ret = self.transact('guest-file-close',{'handle':handle}) + data = base64.b64decode(data.encode('ascii')) + with open(dst,'wb') as f: + f.write(data) + return True + + def ping(self): + ret = self.transact('guest-ping',{}) + if ret is None: + return None + return ret + + def runFile(self,fileName): + ''' Execute file (on guest) as script ''' + ret = self.transact('guest-exec',{'path':fileName, 'capture-output':True}) + if ret is None: + return None + + pid = ret['pid'] + return self._monitorJob(pid) + + def runSource(self,cmd): + ''' Execute script ''' + if isinstance(cmd,list): + cmd = '\n'.join(cmd) + cmd += '\n' + cmd = base64.b64encode(cmd.encode('utf-8')).decode('ascii') + + ret = self.transact('guest-exec',{'input-data':cmd, 'capture-output':True}) + if ret is None: + return None + + pid = ret['pid'] + return self._monitorJob(pid) + + def shutdown(self,mode='powerdown'): + ''' + Execut shutdown command + mode == 'reboot' - reboot guest + mode == 'shutdown' or mode == 'halt' - shutdown guest + ''' + ret = self.transact('guest-shutdown',{'mode':mode}) + return ret + +class SocketAgent(GuestAgent): + ''' + GuestAgent using unix/tcp sockets for communication. + ''' + def __init__(self): + GuestAgent.__init__(self,chunkSize= 32 * 65536) + + @staticmethod + def unix(dev): + ''' Connect using unix socket ''' + self = SocketAgent() + self.__af = socket.AF_UNIX + self.__args = dev + self.__wait = True + return self + + @staticmethod + def tcp(ip,port,wait = True): + ''' Connect using tcp socket ''' + self = SocketAgent() + self.__af = socket.AF_INET + self.__args = (ip,port) + self.__wait = wait + return self + + def __enter__(self): + self._sock = socket.socket(self.__af, socket.SOCK_STREAM) + if self.__wait: + self._info('Waiting for guest ...') + # Wait for hyper to create channel + while True: + try: + self._sock.connect(self.__args) + break + except socket.error as e: + print("error connecting", e) + if e.errno == errno.EHOSTUNREACH or e.errno == errno.ECONNREFUSED: + time.sleep(1) + else: + self._sock.close() + raise + + #Wait for guest agent to initialize and sync + while True: + import random + key = random.randint(0, 0xffffffff) + msg = json.dumps({'execute':'guest-sync-delimited','arguments':{'id':key}},separators=(',',':'),sort_keys=True) + self._sock.send(msg.encode('ascii')) + + self._sock.setblocking(0) + response = b'' + if (select.select([self._sock],[],[])[0]): + response += self._sock.recv(65536) + else: + raise RuntimeError() + self._sock.setblocking(1) + + sentinel = b'\xff' + response = response.split(sentinel)[-1] + if not response: + time.sleep(3) + continue + response = json.loads(response.decode('utf-8').strip()) + if 'return' in response.keys(): + if int(response['return']) == key: + break + time.sleep(3) + else: + self._sock.connect(self.__args) + + return self + + def __exit__(self,*a): + self._sock.close() + + def transact(self,cmd,args={}): + ''' Exchange a single command with guest agent ''' + timeout = 2 + msg = json.dumps({'execute':cmd,'arguments':args},separators=(',',':'),sort_keys=True) + self._sock.send(msg.encode('ascii')) + self._sock.setblocking(0) + response = b'' + if (select.select([self._sock],[],[],timeout)[0]): + response += self._sock.recv(65536) + self._sock.setblocking(1) + if not response: + response = None + else: + if response[0] == 255: # sync + response = response[1:] + print(response.decode('utf-8').strip()) + response = json.loads(response.decode('utf-8').strip()) + if 'error' in response.keys(): + self._qmpError(response['error']['class'],response['error']['desc']) + response = None + elif 'return' in response: + response = response['return'] + return response + +#------------------------------------------------------------------------------- + +if __name__ == '__main__': + socketpath,filename=sys.argv[1:] + script = open(filename,"r").readlines() + + with SocketAgent.unix(socketpath) as agent: + ret,out = agent.runSource(script) + print('ret = {}'.format(ret)) + for line in out: + print(line) diff --git a/tests/support/ppp-server/routeros.config b/tests/support/ppp-server/routeros.config index fe2fc26..160f621 100644 --- a/tests/support/ppp-server/routeros.config +++ b/tests/support/ppp-server/routeros.config @@ -6,8 +6,6 @@ /interface ethernet set [ find default-name=ether1 ] disable-running-check=no name=access set [ find default-name=ether2 ] disable-running-check=no name=world -/disk -set sata1 disabled=no /interface wireless security-profiles set [ find default=yes ] supplicant-identity=MikroTik /ip pool @@ -18,7 +16,5 @@ set 0 name=serial0 add local-address=192.168.100.1 name=pppoe-profile remote-address=pppoe-pool /interface pppoe-server server add default-profile=pppoe-profile disabled=no interface=access service-name=internet -/ip dhcp-client -add interface=*1 /ppp secret add name=db123@a.1 password=NotReallyTheSecret profile=pppoe-profile service=pppoe