From 16a923f58f8b56333625fcd0aa138f1e870ec754 Mon Sep 17 00:00:00 2001
From: Daniel Barlow <dan@telent.net>
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 5b022b43c..162b56c17 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 a028f5012..000000000
--- 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 000000000..7a895c236
--- /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 000000000..7952c1664
--- /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 fe2fc26c6..160f62189 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