198 lines
5.9 KiB
Python
198 lines
5.9 KiB
Python
#!/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()
|