server: service discovery over UDP.

implement RFC-005
- server broadcasts once on startup and answers to broadcasts
- small tool for listening on the port and sending broadcasts

Change-Id: I02d1184d6be62bef6f964eb9d238220aef062e94
Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/34851
Tested-by: Jenkins Automated Tests <pedersen+jenkins@frm2.tum.de>
Reviewed-by: Georg Brandl <g.brandl@fz-juelich.de>
This commit is contained in:
Alexander Zaft 2024-10-22 14:56:40 +02:00 committed by Markus Zolliker
parent 82881049c4
commit 25ff96873b
5 changed files with 323 additions and 0 deletions

128
bin/frappy-scan Executable file
View File

@ -0,0 +1,128 @@
#!/usr/bin/env python3
# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Module authors:
# Alexander Zaft <a.zaft@fz-juelich.de>
#
# *****************************************************************************
"""SEC node autodiscovery tool."""
import argparse
import json
import os
import select
import socket
import sys
from collections import namedtuple
from time import time as currenttime
UDP_PORT = 10767
Answer = namedtuple('Answer',
'address, port, equipment_id, firmware, description')
def decode(msg, addr):
msg = msg.decode('utf-8')
try:
data = json.loads(msg)
except Exception:
return None
if not isinstance(data, dict):
return None
if data.get('SECoP') != 'node':
return None
try:
eq_id = data['equipment_id']
fw = data['firmware']
desc = data['description']
port = data['port']
except KeyError:
return None
addr, _scanport = addr
return Answer(addr, port, eq_id, fw, desc)
def print_answer(answer, *, short=False):
if short:
# NOTE: keep this easily parseable!
print(f'{answer.equipment_id} {answer.address}:{answer.port}')
return
print(f'Found {answer.equipment_id} at {answer.address}:')
print(f' Port: {answer.port}')
print(f' Firmware: {answer.firmware}')
desc = answer.description.replace('\n', '\n ')
print(f' Node description: {desc}')
print()
def scan(max_wait=1.0):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
# send a general broadcast
try:
s.sendto(json.dumps(dict(SECoP='discover')).encode('utf-8'),
('255.255.255.255', UDP_PORT))
except OSError as e:
print('could not send the broadcast:', e)
# we still keep listening for self-announcements
start = currenttime()
seen = set()
while currenttime() < start + max_wait:
res = select.select([s], [], [], 0.1)
if res[0]:
try:
msg, addr = s.recvfrom(1024)
except socket.error: # pragma: no cover
continue
answer = decode(msg, addr)
if answer is None:
continue
if (answer.address, answer.equipment_id, answer.port) in seen:
continue
seen.add((answer.address, answer.equipment_id, answer.port))
yield answer
def listen(*, short=False):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if os.name == 'nt':
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
else:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
s.bind(('0.0.0.0', UDP_PORT))
while True:
try:
msg, addr = s.recvfrom(1024)
except KeyboardInterrupt:
break
answer = decode(msg, addr)
if answer:
print_answer(answer, short=short)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('-l', '--listen', action='store_true',
help='Print short info. '
'Keep listening after the broadcast.')
args = parser.parse_args(sys.argv[1:])
for answer in scan():
print_answer(answer, short=args.listen)
if args.listen:
listen(short=args.listen)

View File

@ -0,0 +1,108 @@
# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Module authors:
# Alexander Zaft <a.zaft@fz-juelich.de>
#
# *****************************************************************************
"""Discovery via UDP broadcasts."""
import os
import json
import socket
from frappy.lib import closeSocket
from frappy.protocol.interface.tcp import format_address
from frappy.version import get_version
UDP_PORT = 10767
MAX_MESSAGE_LEN = 508
class UDPListener:
def __init__(self, equipment_id, description, ifaces, logger, *,
startup_broadcast=True):
self.equipment_id = equipment_id
self.log = logger
self.description = description or ''
self.firmware = 'FRAPPY ' + get_version()
self.ports = [int(iface.split('://')[1])
for iface in ifaces if iface.startswith('tcp')]
self.running = False
self.is_enabled = True
self.startup_broadcast = startup_broadcast
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if os.name == 'nt':
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
else:
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
if startup_broadcast:
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
self.sock.bind(('0.0.0.0', UDP_PORT))
available = MAX_MESSAGE_LEN - len(self._getMessage(2**16-1))
if available < 0:
desc_length = len(self.description.encode('utf-8'))
if available + desc_length < 0:
self.log.warn('Equipment id and firmware name exceed 430 byte '
'limit, not answering to udp discovery')
self.is_enabled = False
else:
self.log.debug('truncating description for udp discovery')
# with errors='ignore', cutting insite a utf-8 glyph will not
# report an error but remove the rest of the glyph from the
# output.
self.description = self.description \
.encode('utf-8')[:available] \
.decode('utf-8', errors='ignore')
def _getMessage(self, port):
return json.dumps({
'SECoP': 'node',
'port': port,
'equipment_id': self.equipment_id,
'firmware': self.firmware,
'description': self.description,
}, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
def run(self):
if self.startup_broadcast:
self.log.debug('Sending startup UDP broadcast.')
for port in self.ports:
self.sock.sendto(self._getMessage(port),
('255.255.255.255', UDP_PORT))
self.running = True
while self.running and self.is_enabled:
try:
msg, addr = self.sock.recvfrom(1024)
except socket.error:
return
try:
request = json.loads(msg.decode('utf-8'))
except json.JSONDecodeError:
continue
if 'SECoP' not in request or request['SECoP'] != 'discover':
continue
self.log.debug('Answering UDP broadcast from: %s',
format_address(addr))
for port in self.ports:
self.sock.sendto(self._getMessage(port), addr)
def shutdown(self):
self.log.debug('shut down of discovery listener')
self.running = False
closeSocket(self.sock)

View File

@ -54,8 +54,17 @@ class SecNode:
self.name = name self.name = name
def add_secnode_property(self, prop, value): def add_secnode_property(self, prop, value):
"""Add SECNode property. If starting with an underscore, it is exported
in the description."""
self.nodeprops[prop] = value self.nodeprops[prop] = value
def get_secnode_property(self, prop):
"""Get SECNode property.
Returns None if not present.
"""
return self.nodeprops.get(prop)
def get_module(self, modulename): def get_module(self, modulename):
""" Returns a fully initialized module. Or None, if something went """ Returns a fully initialized module. Or None, if something went
wrong during instatiating/initializing the module.""" wrong during instatiating/initializing the module."""

View File

@ -37,6 +37,7 @@ from frappy.lib.multievent import MultiEvent
from frappy.logging import init_remote_logging from frappy.logging import init_remote_logging
from frappy.params import PREDEFINED_ACCESSIBLES from frappy.params import PREDEFINED_ACCESSIBLES
from frappy.secnode import SecNode from frappy.secnode import SecNode
from frappy.protocol.discovery import UDPListener
try: try:
from daemon import DaemonContext from daemon import DaemonContext
@ -117,6 +118,8 @@ class Server:
signal.signal(signal.SIGINT, self.signal_handler) signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler) signal.signal(signal.SIGTERM, self.signal_handler)
self.discovery = None
def signal_handler(self, num, frame): def signal_handler(self, num, frame):
if hasattr(self, 'interfaces') and self.interfaces: if hasattr(self, 'interfaces') and self.interfaces:
self.shutdown() self.shutdown()
@ -165,6 +168,7 @@ class Server:
print(formatException(verbose=True)) print(formatException(verbose=True))
raise raise
# client interfaces
self.interfaces = {} self.interfaces = {}
iface_threads = [] iface_threads = []
# default_timeout 12 sec: TCPServer might need up to 10 sec to wait for Address no longer in use # default_timeout 12 sec: TCPServer might need up to 10 sec to wait for Address no longer in use
@ -197,6 +201,16 @@ class Server:
self.secnode.add_secnode_property('_interfaces', list(self.interfaces)) self.secnode.add_secnode_property('_interfaces', list(self.interfaces))
self.log.info('startup done with interface(s) %s', self.log.info('startup done with interface(s) %s',
', '.join(self.interfaces)) ', '.join(self.interfaces))
# start discovery interface when we know where we listen
self.discovery = UDPListener(
self.secnode.equipment_id,
self.secnode.get_secnode_property('description'),
list(self.interfaces),
self.log.getChild('discovery')
)
mkthread(self.discovery.run)
if systemd: if systemd:
systemd.daemon.notify("READY=1\nSTATUS=accepting requests") systemd.daemon.notify("READY=1\nSTATUS=accepting requests")
@ -237,6 +251,8 @@ class Server:
def shutdown(self): def shutdown(self):
self._restart = False self._restart = False
if self.discovery:
self.discovery.shutdown()
for iface in self.interfaces.values(): for iface in self.interfaces.values():
iface.shutdown() iface.shutdown()

62
test/test_discovery.py Normal file
View File

@ -0,0 +1,62 @@
# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Module authors:
# Alexander Zaft <a.zaft@fz-juelich.de>
#
# *****************************************************************************
"""Test discovery messages"""
from test.test_modules import LoggerStub
from frappy.protocol.discovery import UDPListener, MAX_MESSAGE_LEN
from frappy.version import get_version
def test_empty():
logger = LoggerStub()
udp = UDPListener('', '', ['tcp://0'], logger)
udp.firmware = ''
# 78 is the maximum overhead
assert 78 == len(udp._getMessage(2**16-1))
def test_basic():
logger = LoggerStub()
udp = UDPListener('eq', 'desc', ['tcp://1234'], logger)
assert udp.description == 'desc'
assert udp.equipment_id == 'eq'
assert udp.ports == [1234]
assert udp.firmware == 'FRAPPY ' + get_version()
def test_ascii_truncation():
logger = LoggerStub()
desc = 'a' * MAX_MESSAGE_LEN
udp = UDPListener('eq', desc, ['tcp://1234'], logger)
assert MAX_MESSAGE_LEN == len(udp._getMessage(65535))
fw = len(('FRAPPY ' + get_version()).encode('utf-8'))
expected_length = 430 - fw - 2
assert expected_length == len(udp.description)
def test_unicode_truncation():
logger = LoggerStub()
desc = '\U0001f604' * 400
udp = UDPListener('eq', desc, ['tcp://1234'], logger)
fw = len(('FRAPPY ' + get_version()).encode('utf-8'))
# 4 bytes per symbol, rounded down for the potential cut
expected_length = (430 - fw - 2) // 4
assert expected_length == len(udp.description)