Files
brain-template/scripts/brain-pair-server.py

198 lines
5.9 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""brain-pair server — desktop side (ADR-041)
Generates a 6-digit code, broadcasts on LAN, waits for client handshake.
Exchanges: API key, SSH pubkey, peer config. Never MYSECRETS.
Usage: python3 brain-pair-server.py <brain_root>
"""
import json
import os
import random
import socket
import sys
import threading
import time
import subprocess
BRAIN_ROOT = sys.argv[1]
PAIR_PORT = 7710 # TCP handshake port
BROADCAST_PORT = 7711 # UDP broadcast port
CODE_TTL = 120 # seconds
TEST_CODE = os.environ.get("BRAIN_PAIR_TEST_CODE") # force code for testing
def get_machine_info():
"""Read local machine config."""
import yaml
compose_path = os.path.join(BRAIN_ROOT, "brain-compose.local.yml")
with open(compose_path) as f:
compose = yaml.safe_load(f)
machine = compose.get("machine", "unknown")
local_ip = get_local_ip()
# Read brain API key
instances = compose.get("instances", {})
api_key = None
for name, inst in instances.items():
if inst.get("active"):
api_key = inst.get("brain_api_key")
break
return {
"machine": machine,
"ip": local_ip,
"brain_engine_port": 7700,
"api_key": api_key,
}
def get_local_ip():
"""Get the LAN IP of this machine."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
finally:
s.close()
def broadcast_presence(code, stop_event):
"""Broadcast pairing availability on LAN via UDP."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.settimeout(1)
local_ip = get_local_ip()
msg = json.dumps({
"type": "brain-pair",
"ip": local_ip,
"port": PAIR_PORT,
}).encode()
while not stop_event.is_set():
try:
sock.sendto(msg, ("<broadcast>", BROADCAST_PORT))
except OSError:
pass
time.sleep(1)
sock.close()
def handle_client(conn, addr, code, machine_info):
"""Handle a pairing handshake from a client."""
conn.settimeout(30)
try:
data = conn.recv(4096).decode()
request = json.loads(data)
# Verify code
if request.get("code") != code:
conn.sendall(json.dumps({"status": "error", "msg": "Invalid code"}).encode())
print(f"❌ Code invalide depuis {addr[0]}")
return False
client_machine = request.get("machine", "unknown")
client_ssh_pubkey = request.get("ssh_pubkey", "")
print(f"✅ Code vérifié — pairing avec {client_machine} ({addr[0]})")
# Build response (what we send to the client)
response = {
"status": "ok",
"machine": machine_info["machine"],
"ip": machine_info["ip"],
"brain_engine_port": machine_info["brain_engine_port"],
"api_key": machine_info["api_key"],
}
conn.sendall(json.dumps(response).encode())
# Add client SSH key to authorized_keys
if client_ssh_pubkey:
ak_path = os.path.expanduser("~/.ssh/authorized_keys")
comment = f" # brain-pair:{client_machine}"
key_line = client_ssh_pubkey.strip() + comment + "\n"
# Check if already present
existing = ""
if os.path.exists(ak_path):
with open(ak_path) as f:
existing = f.read()
if client_ssh_pubkey.strip().split()[1] not in existing:
with open(ak_path, "a") as f:
f.write(key_line)
print(f" ✅ Clé SSH de {client_machine} ajoutée à authorized_keys")
else:
print(f" Clé SSH de {client_machine} déjà présente")
# Add peer to brain-compose.local.yml
import yaml
compose_path = os.path.join(BRAIN_ROOT, "brain-compose.local.yml")
with open(compose_path) as f:
compose = yaml.safe_load(f)
if "peers" not in compose:
compose["peers"] = {}
compose["peers"][client_machine] = {
"url": f"http://{addr[0]}:7700",
"active": True,
}
with open(compose_path, "w") as f:
yaml.dump(compose, f, default_flow_style=False, allow_unicode=True)
print(f" ✅ Peer {client_machine} ajouté à brain-compose.local.yml")
return True
except Exception as e:
print(f"❌ Erreur handshake : {e}")
return False
finally:
conn.close()
def main():
code = TEST_CODE or f"{random.randint(0, 999999):06d}"
machine_info = get_machine_info()
print(f"🔗 brain-pair — en attente de connexion")
print(f" Machine : {machine_info['machine']} ({machine_info['ip']})")
print(f"")
print(f" Code : {code}")
print(f"")
print(f" Sur l'autre machine : brain-pair.sh join {code}")
print(f" Expire dans {CODE_TTL}s...")
print()
# Start broadcast
stop_event = threading.Event()
broadcast_thread = threading.Thread(target=broadcast_presence, args=(code, stop_event), daemon=True)
broadcast_thread.start()
# Listen for TCP connection
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.settimeout(CODE_TTL)
server.bind(("0.0.0.0", PAIR_PORT))
server.listen(1)
try:
conn, addr = server.accept()
success = handle_client(conn, addr, code, machine_info)
if success:
print(f"\n✅ Pairing terminé avec succès !")
print(f" Vérifier : bash scripts/bsi-query.sh peers")
else:
print(f"\n❌ Pairing échoué")
except socket.timeout:
print(f"\n⏱ Code expiré ({CODE_TTL}s) — relancer brain-pair.sh start")
finally:
stop_event.set()
server.close()
if __name__ == "__main__":
main()