frappy-cli: connect to servers on localhost by default

--scan option: specify where to scan if not on localhost

Change-Id: I51a694eb3cb045e7d18c19a332db8e6ba063009b
This commit is contained in:
2025-06-02 15:22:00 +02:00
parent 745e15c709
commit c0f6569f1b
3 changed files with 117 additions and 88 deletions

View File

@ -24,12 +24,14 @@
import sys import sys
import argparse import argparse
import socket
from pathlib import Path from pathlib import Path
# Add import path for inplace usage # Add import path for inplace usage
sys.path.insert(0, str(Path(__file__).absolute().parents[1])) sys.path.insert(0, str(Path(__file__).absolute().parents[1]))
from frappy.client.interactive import init, run, clientenv, interact from frappy.client.interactive import init, run, clientenv, interact
from frappy.protocol.discovery import scan
def parseArgv(argv): def parseArgv(argv):
@ -37,6 +39,9 @@ def parseArgv(argv):
parser.add_argument('-i', '--include', parser.add_argument('-i', '--include',
help='file to execute after connecting to the clients', metavar='file', help='file to execute after connecting to the clients', metavar='file',
type=Path, action='append', default=[]) 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', parser.add_argument('-o', '--only-execute',
help='Do not go into interactive mode after executing files. \ help='Do not go into interactive mode after executing files. \
Has no effect without --include.', action='store_true') Has no effect without --include.', action='store_true')
@ -46,9 +51,38 @@ def parseArgv(argv):
return parser.parse_args(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:]) 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 = '' run_error = ''
file_success = False file_success = False

View File

@ -23,54 +23,17 @@
"""SEC node autodiscovery tool.""" """SEC node autodiscovery tool."""
import argparse import argparse
import json
import os
import select
import socket
import sys import sys
from collections import namedtuple from frappy.protocol.discovery import scan, listen
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): 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: if short:
# NOTE: keep this easily parseable! # NOTE: keep this easily parseable!
print(f'{answer.equipment_id} {address}:{answer.port}') print(f'{answer.equipment_id} {answer.hostname}:{answer.port}')
return 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' Port: {answer.port}')
print(f' Firmware: {answer.firmware}') print(f' Firmware: {answer.firmware}')
desc = answer.description.replace('\n', '\n ') desc = answer.description.replace('\n', '\n ')
@ -78,51 +41,6 @@ def print_answer(answer, *, short=False):
print('-' * 80) 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__': if __name__ == '__main__':
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('-l', '--listen', action='store_true', parser.add_argument('-l', '--listen', action='store_true',
@ -136,4 +54,5 @@ if __name__ == '__main__':
for answer in scan(): for answer in scan():
print_answer(answer, short=short) print_answer(answer, short=short)
if args.listen: if args.listen:
listen(short=short) for answer in listen():
print_answer(short=short)

View File

@ -23,6 +23,9 @@
import os import os
import json import json
import socket import socket
import select
from time import monotonic
from collections import namedtuple
from frappy.lib import closeSocket from frappy.lib import closeSocket
from frappy.protocol.interface.tcp import format_address from frappy.protocol.interface.tcp import format_address
@ -32,6 +35,79 @@ UDP_PORT = 10767
MAX_MESSAGE_LEN = 508 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: class UDPListener:
def __init__(self, equipment_id, description, ifaces, logger, *, def __init__(self, equipment_id, description, ifaces, logger, *,
startup_broadcast=True): startup_broadcast=True):