From c0f6569f1bedb75a5335b3ede3a97f29882282a3 Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Mon, 2 Jun 2025 15:22:00 +0200 Subject: [PATCH] frappy-cli: connect to servers on localhost by default --scan option: specify where to scan if not on localhost Change-Id: I51a694eb3cb045e7d18c19a332db8e6ba063009b --- bin/frappy-cli | 36 +++++++++++++- bin/frappy-scan | 93 +++--------------------------------- frappy/protocol/discovery.py | 76 +++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 88 deletions(-) diff --git a/bin/frappy-cli b/bin/frappy-cli index f6081e9..c9785f9 100755 --- a/bin/frappy-cli +++ b/bin/frappy-cli @@ -24,12 +24,14 @@ import sys import argparse +import socket from pathlib import Path # Add import path for inplace usage sys.path.insert(0, str(Path(__file__).absolute().parents[1])) from frappy.client.interactive import init, run, clientenv, interact +from frappy.protocol.discovery import scan def parseArgv(argv): @@ -37,6 +39,9 @@ def parseArgv(argv): parser.add_argument('-i', '--include', help='file to execute after connecting to the clients', metavar='file', type=Path, action='append', default=[]) + parser.add_argument('-s', '--scan', + help='hosts to scan for (-s subnet for all nodes in subnet)', + action='append', default=[]) parser.add_argument('-o', '--only-execute', help='Do not go into interactive mode after executing files. \ Has no effect without --include.', action='store_true') @@ -46,9 +51,38 @@ def parseArgv(argv): return parser.parse_args(argv) +def own_ip(): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0) + try: + # doesn't even have to be reachable + s.connect(('10.254.254.254', 1)) + return s.getsockname()[0] + except Exception: + return '127.0.0.1' + finally: + s.close() + + args = parseArgv(sys.argv[1:]) -success = init(*args.node) +nodes = args.node +hosts = args.scan +if not nodes and not hosts: + host = ['localhost'] +if hosts: + answers = [] + for host in hosts: + ans = scan() + if host == 'subnet': # all in subnet + answers.extend(ans) + else: # filter by ip + ip = socket.gethostbyname(host) + if ip == '127.0.0.1': + ip = own_ip() + answers.extend(a for a in ans if a.address == ip) + nodes.extend(f'{h.hostname}:{h.port}' for h in answers) +success = init(*nodes) run_error = '' file_success = False diff --git a/bin/frappy-scan b/bin/frappy-scan index b25b988..5fa04e5 100755 --- a/bin/frappy-scan +++ b/bin/frappy-scan @@ -23,54 +23,17 @@ """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) +from frappy.protocol.discovery import scan, listen def print_answer(answer, *, short=False): - try: - hostname = socket.gethostbyaddr(answer.address)[0] - address = hostname - numeric = f' ({answer.address})' - except Exception: - address = answer.address - numeric = '' if short: # NOTE: keep this easily parseable! - print(f'{answer.equipment_id} {address}:{answer.port}') + print(f'{answer.equipment_id} {answer.hostname}:{answer.port}') return - print(f'Found {answer.equipment_id} at {address}{numeric}:') + numeric = f' ({answer.address})' if answer.address == answer.hostname else '' + print(f'Found {answer.equipment_id} at {answer.hostname}{numeric}:') print(f' Port: {answer.port}') print(f' Firmware: {answer.firmware}') desc = answer.description.replace('\n', '\n ') @@ -78,51 +41,6 @@ def print_answer(answer, *, short=False): print('-' * 80) -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', @@ -136,4 +54,5 @@ if __name__ == '__main__': for answer in scan(): print_answer(answer, short=short) if args.listen: - listen(short=short) + for answer in listen(): + print_answer(short=short) diff --git a/frappy/protocol/discovery.py b/frappy/protocol/discovery.py index aa1abcd..ba612b2 100644 --- a/frappy/protocol/discovery.py +++ b/frappy/protocol/discovery.py @@ -23,6 +23,9 @@ import os import json import socket +import select +from time import monotonic +from collections import namedtuple from frappy.lib import closeSocket from frappy.protocol.interface.tcp import format_address @@ -32,6 +35,79 @@ UDP_PORT = 10767 MAX_MESSAGE_LEN = 508 +Answer = namedtuple('Answer', + 'address, hostname, 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 + try: + hostname = socket.gethostbyaddr(addr[0])[0] + except Exception: + hostname = addr[0] + return Answer(addr[0], hostname, port, eq_id, fw, desc) + + +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 + seen = set() + start = monotonic() + while monotonic() < 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(): + 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: + yield answer + + class UDPListener: def __init__(self, equipment_id, description, ifaces, logger, *, startup_broadcast=True):