convert routeros pppoe service to a derivation

and make it configure itself automatically instead of starting
out blank
This commit is contained in:
Daniel Barlow 2023-02-05 17:35:41 +00:00
parent 00aa42b803
commit 16a923f58f
5 changed files with 332 additions and 37 deletions

View file

@ -1,28 +1,26 @@
# ppp-server # ppp-server
To test a router, we need an upstream connection. In this directory, To test a router, we need an upstream connection. This directory
find 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. Note that you need to open some multicast ports if you're using the
Login when prompted, username is "admin", blank password NixOS firewall (or probably, any other firewall). For iptables you can
* routeros.config, a set of commands you can feed into routeros accomplish this by editing your configuration.nix or some module it
to set up PPPoE 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 = '' ## Provenance
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 chr-7.x.img image is taken from https://mikrotik.com/download -
look in the section titled "Cloud Hosted Router" for "Raw disk image".
The Qemu instance running RouterOS is headless, but it creates Note that this is proprietary software: please read the license
two unix sockets for serial port and monitor. information and make sure you're using it legally.
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

View file

@ -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

View file

@ -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;
}

View file

@ -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)

View file

@ -6,8 +6,6 @@
/interface ethernet /interface ethernet
set [ find default-name=ether1 ] disable-running-check=no name=access set [ find default-name=ether1 ] disable-running-check=no name=access
set [ find default-name=ether2 ] disable-running-check=no name=world set [ find default-name=ether2 ] disable-running-check=no name=world
/disk
set sata1 disabled=no
/interface wireless security-profiles /interface wireless security-profiles
set [ find default=yes ] supplicant-identity=MikroTik set [ find default=yes ] supplicant-identity=MikroTik
/ip pool /ip pool
@ -18,7 +16,5 @@ set 0 name=serial0
add local-address=192.168.100.1 name=pppoe-profile remote-address=pppoe-pool add local-address=192.168.100.1 name=pppoe-profile remote-address=pppoe-pool
/interface pppoe-server server /interface pppoe-server server
add default-profile=pppoe-profile disabled=no interface=access service-name=internet add default-profile=pppoe-profile disabled=no interface=access service-name=internet
/ip dhcp-client
add interface=*1
/ppp secret /ppp secret
add name=db123@a.1 password=NotReallyTheSecret profile=pppoe-profile service=pppoe add name=db123@a.1 password=NotReallyTheSecret profile=pppoe-profile service=pppoe