#!/usr/bin/env python3 """Two throwaway Syncthing instances on this one machine, for testing Cassette without a friend on the other end. python3 scripts/cassette_pair.py --out /tmp/pair # start, print, wait python3 scripts/cassette_pair.py --out /tmp/pair --stop # stop them Each instance gets its own home (cert, config, database), GUI port and listen port, with global discovery, relays, NAT traversal and usage reporting off: two Syncthings on loopback find each other through local discovery and nothing ever leaves the machine. Neither one knows the other until Cassette introduces them, which is exactly the situation an invite code starts from. Point a LinTunes at one of them with ``syncthing_api_key`` / ``syncthing_address`` in its config.json (``XDG_CONFIG_HOME`` keeps each LinTunes' config separate). ``tests/test_round65.py`` drives the pair through a whole handshake when run with ``-m syncthing``. """ from __future__ import annotations import argparse import json import os import shutil import signal import subprocess import sys import time import xml.etree.ElementTree as ET from dataclasses import asdict, dataclass from pathlib import Path import requests NAMES = ("alice", "bob") BASE_GUI_PORT = 28384 BASE_LISTEN_PORT = 28400 @dataclass class Instance: name: str home: str gui_port: int listen_port: int api_key: str device_id: str = "" pid: int = 0 @property def address(self) -> str: return f"127.0.0.1:{self.gui_port}" @property def listen_address(self) -> str: return f"tcp://127.0.0.1:{self.listen_port}" def _set(parent, tag, text): node = parent.find(tag) if node is None: node = ET.SubElement(parent, tag) node.text = text def _configure(home: Path, gui_port: int, listen_port: int) -> str: """Rewrite a generated config for an isolated loopback instance. Returns the API key.""" path = home / "config.xml" tree = ET.parse(path) root = tree.getroot() gui = root.find("gui") _set(gui, "address", f"127.0.0.1:{gui_port}") api_key = gui.findtext("apikey") options = root.find("options") for listen in options.findall("listenAddress"): options.remove(listen) ET.SubElement(options, "listenAddress").text = f"tcp://127.0.0.1:{listen_port}" for tag, value in (("globalAnnounceEnabled", "false"), ("localAnnounceEnabled", "true"), ("relaysEnabled", "false"), ("natEnabled", "false"), ("startBrowser", "false"), ("urAccepted", "-1"), ("crashReportingEnabled", "false"), ("autoUpgradeIntervalH", "0")): _set(options, tag, value) tree.write(path) return api_key def _wait_up(instance: Instance, timeout: float = 30) -> str: deadline = time.time() + timeout while time.time() < deadline: try: response = requests.get( f"http://{instance.address}/rest/system/status", headers={"X-API-Key": instance.api_key}, timeout=2) if response.ok: return response.json()["myID"] except requests.RequestException: pass time.sleep(0.3) raise RuntimeError(f"{instance.name}'s Syncthing didn't come up") def start(out: Path, names=NAMES) -> list[Instance]: if shutil.which("syncthing") is None: raise RuntimeError("syncthing isn't installed") out.mkdir(parents=True, exist_ok=True) instances = [] for i, name in enumerate(names): home = out / name / "syncthing" if not (home / "config.xml").exists(): subprocess.run(["syncthing", "generate", f"--home={home}", "--no-default-folder", "--skip-port-probing"], check=True, capture_output=True) gui_port, listen_port = BASE_GUI_PORT + i, BASE_LISTEN_PORT + i api_key = _configure(home, gui_port, listen_port) log = open(out / name / "syncthing.log", "ab") proc = subprocess.Popen( ["syncthing", "serve", f"--home={home}", "--no-browser", "--no-restart", "--no-upgrade"], stdout=log, stderr=subprocess.STDOUT, start_new_session=True, env={**os.environ, "STNOUPGRADE": "1"}) instances.append(Instance(name, str(home), gui_port, listen_port, api_key, pid=proc.pid)) for instance in instances: instance.device_id = _wait_up(instance) (out / "pair.json").write_text( json.dumps([asdict(i) for i in instances], indent=2)) return instances def stop(out: Path): path = out / "pair.json" if not path.exists(): return for raw in json.loads(path.read_text()): try: os.killpg(raw["pid"], signal.SIGTERM) except (ProcessLookupError, PermissionError): pass path.unlink() def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) parser.add_argument("--out", type=Path, required=True) parser.add_argument("--stop", action="store_true") args = parser.parse_args() if args.stop: stop(args.out) return for instance in start(args.out): print(f"{instance.name}: GUI http://{instance.address} " f"key {instance.api_key} id {instance.device_id[:7]}") print(f"Stop with: {sys.argv[0]} --out {args.out} --stop") if __name__ == "__main__": main()