- Connections ▸ Share Library with a Friend… makes a one-time invite code (device id, display name, token) and the inviter's send-only folder. - Connections ▸ Add Friend Library… takes the pasted code: adds the inviter, shares our folder, and accepts theirs receive-only when it's offered back. - Syncthing hides an unknown device's folders (verified on 1.30), so the inviter probes a new knock only while an invite is open: added with nothing shared, completed on the right token, otherwise removed and never probed again. Used and cancelled codes go nowhere. - Sync Settings lists open invites (Cancel Invite) and gives each friend a page: avatar, plain-language status with a next step, last seen, last synced, Remove Friend. Removals are staged until OK. - scripts/cassette_pair.py runs throwaway Syncthings on loopback; pytest --syncthing drives a real three-machine handshake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
5.4 KiB
Python
163 lines
5.4 KiB
Python
#!/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()
|