CuteBoard/controller/app.py

508 lines
17 KiB
Python

#!/usr/bin/env python3
import ipaddress
import json
import os
import socket
import struct
import threading
import time
import concurrent.futures
from collections import OrderedDict
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
import requests
from flask import Flask, jsonify, render_template, request
CTRL_PORT = 50742
DISCOVERY_PORT = 50744
BOARD_PORT = 50743
SCAN_TIMEOUT = 1.5
POLL_INTERVAL = 3
app = Flask(__name__)
boards = OrderedDict() # display_id -> board record
boards_lock = threading.Lock()
ip_index = {} # ip -> display_id
ip_identity = {} # ip -> {"id": identity, "name": display_name}
offline_threshold = POLL_INTERVAL * 3
conflicts = OrderedDict() # display_id -> conflict record
conflicts_lock = threading.Lock()
def get_local_ips():
ips = set()
try:
_, _, ip_list = socket.gethostbyname_ex(socket.gethostname())
ips.update(ip_list)
except:
pass
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
addr = info[4][0]
if addr != "127.0.0.1":
ips.add(addr)
except:
pass
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("10.255.255.255", 1))
ips.add(s.getsockname()[0])
except:
pass
finally:
s.close()
return sorted(ips)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "config.toml")
def load_scan_subnets():
try:
with open(CONFIG_PATH, "rb") as f:
data = tomllib.load(f)
val = data.get("scan", {}).get("subnets")
if isinstance(val, str):
val = [val]
return val
except Exception:
return None
def resolve_subnets(patterns):
if patterns is None:
return None
networks = []
for pat in patterns:
pat = str(pat).strip()
if not pat or pat.lower() == "all":
return None
try:
if "*" in pat:
prefix = 0
cidr_parts = []
for part in pat.split("."):
if part == "*":
break
cidr_parts.append(part)
prefix += 8
if prefix == 0:
continue
padded = cidr_parts + ["0"] * (4 - len(cidr_parts))
networks.append(ipaddress.ip_network(
".".join(padded) + "/" + str(prefix), strict=False))
elif "/" in pat:
networks.append(ipaddress.ip_network(pat, strict=False))
else:
networks.append(ipaddress.ip_network(pat + "/32", strict=False))
except ValueError:
print(f" Ignoring invalid subnet pattern: {pat}")
return networks
_scan_networks_cache = None
def get_scan_networks():
global _scan_networks_cache
if _scan_networks_cache is None:
_scan_networks_cache = resolve_subnets(load_scan_subnets())
return _scan_networks_cache
def ip_allowed(ip):
networks = get_scan_networks()
if networks is None:
return True
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
return any(addr in net for net in networks)
def build_scan_targets(networks):
targets = []
if networks is None:
subnets = set()
for ip in get_local_ips():
subnets.add(".".join(ip.split(".")[:3]))
for subnet in sorted(subnets):
for i in range(1, 255):
targets.append(f"{subnet}.{i}")
else:
for net in networks:
for ip in net.hosts():
targets.append(str(ip))
return targets
def check_port(ip, port, timeout=SCAN_TIMEOUT):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
s.connect((ip, port))
s.close()
return ip
except:
return None
def fetch_status(ip, port):
try:
r = requests.get(f"http://{ip}:{port}/api/status", timeout=3)
if r.status_code == 200:
return r.json()
except:
pass
return None
def board_identity(status):
"""Static identity of a board so multi-homed IPs can be told apart
from distinct physical boards that share a display_id."""
if not status:
return None
cfg = status.get("config") or {}
return (
status.get("display_id"),
status.get("display_name"),
status.get("header"),
json.dumps(cfg.get("left"), sort_keys=True, default=str),
json.dumps(cfg.get("right"), sort_keys=True, default=str),
)
def record_conflict(did, ip, status, other_ips):
"""Record that multiple physical boards are using the same display_id."""
now = time.time()
incoming = board_identity(status)
with conflicts_lock:
groups = {}
for other in set(list(other_ips) + [ip]):
info = ip_identity.get(other)
ident = info["id"] if info else None
name = info["name"] if info else ""
if other == ip and incoming is not None:
ident = incoming
name = (status or {}).get("display_name", "")
if ident is None:
continue
groups.setdefault(ident, []).append({"ip": other, "display_name": name})
if len(groups) < 2:
conflicts.pop(did, None)
return
rec = {"display_id": did, "boards": [], "last_seen": now}
for entries in groups.values():
for e in entries:
rec["boards"].append({
"ip": e["ip"],
"display_name": e["display_name"],
"last_seen": now,
})
conflicts[did] = rec
def register_board(did, ip, port, display_name="", status=None):
identity = board_identity(status)
conflict_ips = None
with boards_lock:
now = time.time()
prev_did = ip_index.get(ip)
if prev_did is not None and prev_did != did and prev_did in boards:
old = boards[prev_did]
if ip in old["ips"]:
old["ips"].remove(ip)
if not old["ips"]:
boards.pop(prev_did, None)
if did in boards:
board = boards[did]
if identity is not None:
for other in list(board["ips"]):
other_info = ip_identity.get(other)
if other_info and other_info["id"] != identity:
conflict_ips = list(board["ips"])
break
if ip not in board["ips"]:
board["ips"].append(ip)
print(f" Board {did} also reachable at {ip}:{port}")
if display_name and not board["display_name"]:
board["display_name"] = display_name
board["port"] = port
board["last_seen"] = now
board["online"] = True
board["active_ip"] = ip
if status is not None:
board["status"] = status
if status.get("display_name"):
board["display_name"] = status["display_name"]
boards.move_to_end(did)
else:
boards[did] = {
"display_id": did,
"display_name": display_name or f"Board ({ip})",
"ips": [ip],
"port": port,
"last_seen": now,
"online": True,
"status": status,
"active_ip": ip,
}
print(f" Discovered board: {boards[did]['display_name']} at {ip}:{port}")
if identity is not None:
ip_identity[ip] = {"id": identity, "name": display_name or (status or {}).get("display_name", "")}
ip_index[ip] = did
if conflict_ips is not None:
record_conflict(did, ip, status, conflict_ips)
def udp_listener():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(1)
sock.bind(("0.0.0.0", DISCOVERY_PORT))
while True:
try:
data, addr = sock.recvfrom(2048)
msg = json.loads(data)
port = msg.get("port", BOARD_PORT)
did = msg.get("display_id")
ips = msg.get("ips") or []
if not ips:
ips = [msg.get("ip")] if msg.get("ip") else [addr[0]]
for ip in ips:
if not ip_allowed(ip):
print(f" Ignoring {ip}: outside scan subnets")
continue
register_board(did or ip, ip, port, msg.get("display_name", ""))
except socket.timeout:
pass
except:
pass
def scan_subnets():
patterns = load_scan_subnets()
networks = resolve_subnets(patterns)
targets = build_scan_targets(networks)
if networks is not None:
print(f" Scanning subnets: {', '.join(str(n) for n in networks)}")
else:
print(" Scanning local subnets (all)")
print(f" Scanning {len(targets)} hosts")
found = []
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as pool:
futures = {}
for target in targets:
futures[pool.submit(check_port, target, BOARD_PORT)] = target
for future in concurrent.futures.as_completed(futures):
ip = future.result()
if ip:
found.append(ip)
print(f" Scan found IPs: {', '.join(sorted(found))}")
for ip in found:
status = fetch_status(ip, BOARD_PORT)
if status and status.get("display_id"):
did = status["display_id"]
with boards_lock:
known = did in boards
if known:
print(f" Deduplicated: {ip} -> display_id {did} (already registered)")
register_board(did, ip, BOARD_PORT,
status.get("display_name", ""), status)
else:
with boards_lock:
known = ip in ip_index
if known:
print(f" Deduplicated: {ip} (already registered)")
register_board(ip, ip, BOARD_PORT, f"Board ({ip})", status)
return found
def refresh_board_cache(ip, port):
status = fetch_status(ip, port)
if status is None:
return
did = status.get("display_id") or ip
register_board(did, ip, port, status.get("display_name", ""), status)
def refresh_conflicts():
"""Rebuild conflict records from current ip -> identity data so
conflicts disappear once display_ids are made unique."""
now = time.time()
with boards_lock:
by_did = {}
for ip, did in list(ip_index.items()):
info = ip_identity.get(ip)
if info:
by_did.setdefault(did, []).append((ip, info["id"], info["name"]))
with conflicts_lock:
for did in list(conflicts.keys()):
if did not in by_did:
conflicts.pop(did, None)
for did, entries in by_did.items():
idents = {ident for _, ident, _ in entries}
if len(idents) >= 2:
conflicts[did] = {
"display_id": did,
"boards": [{"ip": ip, "display_name": name, "last_seen": now}
for ip, _ident, name in entries],
"last_seen": now,
}
else:
conflicts.pop(did, None)
def poll_boards():
while True:
with boards_lock:
snapshot = list(boards.values())
for board in snapshot:
status = None
active_ip = None
for ip in list(board["ips"]):
status = fetch_status(ip, board["port"])
if status is None:
continue
reported_did = status.get("display_id")
if reported_did is not None and reported_did != board["display_id"]:
with boards_lock:
rec = boards.get(board["display_id"])
if rec and ip in rec["ips"]:
rec["ips"].remove(ip)
if rec and not rec["ips"]:
boards.pop(board["display_id"], None)
status = None
continue
active_ip = ip
break
with boards_lock:
if board["display_id"] not in boards:
continue
rec = boards[board["display_id"]]
if status is not None:
rec["status"] = status
rec["online"] = True
rec["last_seen"] = time.time()
rec["active_ip"] = active_ip
if status.get("display_name"):
rec["display_name"] = status["display_name"]
if active_ip in rec["ips"]:
rec["ips"].remove(active_ip)
rec["ips"].insert(0, active_ip)
else:
rec["online"] = False
with boards_lock:
now = time.time()
dead = [did for did, b in list(boards.items())
if not b.get("online") and (now - b["last_seen"]) > 600]
for did in dead:
boards.pop(did, None)
for ip in [ip for ip, d in list(ip_index.items()) if d in dead]:
ip_index.pop(ip, None)
for ip in [ip for ip in list(ip_identity.keys()) if ip not in ip_index]:
ip_identity.pop(ip, None)
refresh_conflicts()
time.sleep(POLL_INTERVAL)
threading.Thread(target=udp_listener, daemon=True).start()
threading.Thread(target=poll_boards, daemon=True).start()
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/boards")
def api_boards():
now = time.time()
with boards_lock:
result = []
for did, board in list(boards.items()):
stale = (now - board["last_seen"]) > offline_threshold
status = board.get("status") or {}
scores = status.get("scores") or {}
config_data = status.get("config") or {}
result.append({
"display_id": did,
"display_name": board["display_name"],
"ip": board.get("active_ip") or (board["ips"][0] if board["ips"] else None),
"ips": list(board["ips"]),
"port": board["port"],
"online": board["online"] and not stale,
"scores": scores,
"config": config_data,
})
return jsonify(result)
@app.route("/api/conflicts")
def api_conflicts():
with conflicts_lock:
return jsonify(list(conflicts.values()))
@app.route("/api/scan", methods=["POST"])
def api_scan():
def do_scan():
found = scan_subnets()
print(f" Scan complete: {len(found)} board IPs found")
threading.Thread(target=do_scan, daemon=True).start()
return jsonify({"status": "scanning"})
@app.route("/api/boards/<ip>/<path:endpoint>", methods=["GET", "POST", "PUT", "OPTIONS"])
def api_proxy(ip, endpoint):
port = BOARD_PORT
with boards_lock:
did = ip_index.get(ip)
if did is not None and did in boards:
board = boards[did]
port = board["port"]
if board.get("active_ip"):
ip = board["active_ip"]
url = f"http://{ip}:{port}/{endpoint}"
method = request.method
headers = {"Content-Type": "application/json"} if request.is_json else {}
try:
if method == "GET":
r = requests.get(url, timeout=5, headers=headers)
elif method == "POST":
r = requests.post(url, timeout=5, json=request.get_json(silent=True), headers=headers)
elif method == "PUT":
r = requests.put(url, timeout=5, json=request.get_json(silent=True), headers=headers)
else:
r = requests.options(url, timeout=5, headers=headers)
resp = app.make_response((r.content, r.status_code, dict(r.headers)))
resp.headers["Access-Control-Allow-Origin"] = "*"
# Immediately refresh cached status after any score-modifying action
if method in ("POST", "PUT"):
threading.Thread(target=refresh_board_cache, args=(ip, port), daemon=True).start()
return resp
except Exception as e:
return jsonify({"error": str(e)}), 502
@app.after_request
def add_cors(resp):
resp.headers["Access-Control-Allow-Origin"] = "*"
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
resp.headers["Access-Control-Allow-Headers"] = "Content-Type"
return resp
if __name__ == "__main__":
print(f" CuteBoard Controller")
print(f" URL: http://0.0.0.0:{CTRL_PORT}")
app.run(host="0.0.0.0", port=CTRL_PORT, debug=False, threaded=True)