commit 16a923f58f8b56333625fcd0aa138f1e870ec754
parent 00aa42b80320ad42e2958e71fc7231ccd7020b8f
Author: Daniel Barlow <dan@telent.net>
Date: Sun, 5 Feb 2023 17:35:41 +0000
convert routeros pppoe service to a derivation
and make it configure itself automatically instead of starting
out blank
Diffstat:
5 files changed, 337 insertions(+), 42 deletions(-)
diff --git 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
-
-* 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
-
-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
-
-config.networking.firewall.extraCommands = ''
-ip46tables -A nixos-fw -m pkttype --pkt-type multicast -p udp --dport 1234:1236 -j nixos-fw-accept
-'';
-
-## 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
+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.
+
+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:
+
+
+```
+ networking.firewall.extraCommands = ''
+ ip46tables -A nixos-fw -m pkttype --pkt-type multicast -p udp --dport 1234:1236 -j nixos-fw-accept
+ '';
+```
+
+## Provenance
+
+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
@@ -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
@@ -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
@@ -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
@@ -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