feat: execute durable syncthing route setup

This commit is contained in:
2026-07-23 02:27:17 +00:00
parent 4058b6d3c8
commit 10e803eaee
8 changed files with 1189 additions and 11 deletions
+381
View File
@@ -0,0 +1,381 @@
"""Scoped Syncthing route provisioning and nonce verification."""
from __future__ import annotations
import json
import os
import time
import uuid
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any, Protocol
from urllib import error, parse, request
from archive_clients.config import ConfigError, ServiceConfig
from archive_control.v1 import route_pb2
class RouteSetupError(RuntimeError):
pass
class RoutePathConflict(RouteSetupError):
pass
class RouteSetupTimeout(RouteSetupError):
pass
class SyncthingTransport(Protocol):
def get_json(self, path: str) -> dict[str, Any]: ...
def put_json(self, path: str, payload: dict[str, Any]) -> None: ...
def post(self, path: str) -> None: ...
class SyncthingHttp:
def __init__(self, config: ServiceConfig, timeout: float = 10):
self.endpoint = config.endpoint.rstrip("/")
self.api_key = config.read_api_key() or ""
self.timeout = timeout
self.opener = request.build_opener(_NoRedirect())
def get_json(self, path: str) -> dict[str, Any]:
response = self._open(path, "GET")
try:
value = json.loads(response.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise RouteSetupError("Syncthing returned invalid JSON") from exc
if not isinstance(value, dict):
raise RouteSetupError("Syncthing returned a non-object response")
return value
def put_json(self, path: str, payload: dict[str, Any]) -> None:
self._open(
path,
"PUT",
json.dumps(payload, separators=(",", ":")).encode("utf-8"),
)
def post(self, path: str) -> None:
self._open(path, "POST", b"")
def _open(self, path: str, method: str, body: bytes | None = None) -> bytes:
headers = {"X-API-Key": self.api_key}
if body is not None:
headers["Content-Type"] = "application/json"
req = request.Request(
f"{self.endpoint}{path}", data=body, headers=headers, method=method
)
try:
with self.opener.open(req, timeout=self.timeout) as response:
return response.read()
except error.HTTPError as exc:
if exc.code in {401, 403}:
raise PermissionError("Syncthing authentication failed") from exc
raise RouteSetupError(
f"Syncthing HTTP request failed with status {exc.code}"
) from exc
except (error.URLError, OSError, UnicodeError, json.JSONDecodeError) as exc:
raise RouteSetupError("Syncthing API is unavailable") from exc
@dataclass(frozen=True)
class ConfiguredRoute:
local_device_id: str
local_path: Path
local_route: route_pb2.LocalRoute
created_device: bool
created_folder: bool
class SyncthingRouteManager:
def __init__(
self,
config: ServiceConfig,
sparse_supported: bool,
transport: SyncthingTransport | None = None,
poll_interval: float = 1,
):
self.config = config
self.sparse_supported = sparse_supported
self.transport = transport or SyncthingHttp(config)
self.poll_interval = poll_interval
def configure(
self, spec: route_pb2.EnsureRouteSpec, deadline: float
) -> ConfiguredRoute:
local_path, api_path = self._route_paths(spec.local_relative_path)
status = self.transport.get_json("/rest/system/status")
local_device_id = status.get("myID")
if not isinstance(local_device_id, str) or not local_device_id:
raise RouteSetupError("Syncthing returned no local device ID")
configuration = self.transport.get_json("/rest/config")
devices = _object_list(configuration, "devices")
folders = _object_list(configuration, "folders")
folder = _by_key(folders, "id", spec.route_id)
if folder is not None:
self._validate_folder(
folder,
api_path,
local_device_id,
spec.peer_syncthing_device_id,
)
peer = _by_key(devices, "deviceID", spec.peer_syncthing_device_id)
created_device = peer is None
if peer is None:
addresses = list(spec.peer_addresses) or ["dynamic"]
self.transport.put_json(
"/rest/config/devices/"
+ parse.quote(spec.peer_syncthing_device_id, safe=""),
{
"deviceID": spec.peer_syncthing_device_id,
"name": spec.peer_client_id,
"addresses": addresses,
},
)
if folder is None:
self._prepare_new_directory(local_path)
self.transport.put_json(
"/rest/config/folders/" + parse.quote(spec.route_id, safe=""),
{
"id": spec.route_id,
"label": f"archive-control:{spec.route_id}",
"path": api_path,
"type": "sendreceive",
"devices": [
{"deviceID": local_device_id},
{"deviceID": spec.peer_syncthing_device_id},
],
},
)
else:
local_path.mkdir(parents=True, exist_ok=True)
while True:
current = self.transport.get_json("/rest/config")
current_folder = _by_key(
_object_list(current, "folders"), "id", spec.route_id
)
if current_folder is not None:
self._validate_folder(
current_folder,
api_path,
local_device_id,
spec.peer_syncthing_device_id,
)
break
self._wait(deadline)
route = route_pb2.LocalRoute(
route_id=spec.route_id,
local_relative_path=spec.local_relative_path,
folder_type=route_pb2.SYNCTHING_FOLDER_TYPE_SEND_RECEIVE,
local_syncthing_device_id=local_device_id,
peer_syncthing_device_ids=[spec.peer_syncthing_device_id],
state=route_pb2.ROUTE_STATE_PROVISIONING,
writable=True,
sparse_supported=self.sparse_supported,
archive_control_created=folder is None,
detail="Syncthing device and folder configuration verified",
)
route.observed_at.GetCurrentTime()
return ConfiguredRoute(
local_device_id,
local_path,
route,
created_device,
folder is None,
)
def verify(
self,
configured: ConfiguredRoute,
spec: route_pb2.EnsureRouteSpec,
local_client_id: str,
nonce: str,
deadline: float,
) -> tuple[bool, bool]:
local_nonce = configured.local_path / _nonce_name(local_client_id)
_atomic_json(
local_nonce,
{
"route_id": spec.route_id,
"client_id": local_client_id,
"nonce": nonce,
},
)
peer_seen_locally = False
local_seen_by_peer = False
while not (peer_seen_locally and local_seen_by_peer):
peer_nonce_path = configured.local_path / _nonce_name(
spec.peer_client_id
)
peer_nonce = _read_nonce(
peer_nonce_path, spec.route_id, spec.peer_client_id
)
if peer_nonce is not None:
peer_seen_locally = True
_atomic_json(
configured.local_path
/ _ack_name(spec.peer_client_id, local_client_id),
{
"route_id": spec.route_id,
"nonce": peer_nonce,
},
)
acknowledgement = _read_ack(
configured.local_path
/ _ack_name(local_client_id, spec.peer_client_id),
spec.route_id,
)
local_seen_by_peer = acknowledgement == nonce
if peer_seen_locally and local_seen_by_peer:
break
try:
self.transport.post(
"/rest/db/scan?" + parse.urlencode({"folder": spec.route_id})
)
except RouteSetupError:
pass
self._wait(deadline)
return local_seen_by_peer, peer_seen_locally
def _route_paths(self, relative_path: str) -> tuple[Path, str]:
relative = PurePosixPath(relative_path)
if (
not relative_path
or relative.is_absolute()
or any(part in {"", ".", ".."} for part in relative.parts)
):
raise RoutePathConflict("route path is unsafe")
api_path = (self.config.api_root / relative).as_posix()
try:
local_path = self.config.roots.api_to_local(api_path)
except ConfigError as exc:
raise RoutePathConflict("route path is outside the sync root") from exc
resolved_root = self.config.local_root.resolve(strict=False)
try:
local_path.resolve(strict=False).relative_to(resolved_root)
except ValueError as exc:
raise RoutePathConflict("route path escapes through a symlink") from exc
return local_path, api_path
@staticmethod
def _prepare_new_directory(path: Path) -> None:
if path.exists() and any(path.iterdir()):
raise RoutePathConflict("new route path contains unrelated data")
path.mkdir(parents=True, exist_ok=True)
@staticmethod
def _validate_folder(
folder: dict[str, Any],
api_path: str,
local_device_id: str,
peer_device_id: str,
) -> None:
raw_devices = folder.get("devices")
device_ids = {
item.get("deviceID")
for item in raw_devices
if isinstance(item, dict) and isinstance(item.get("deviceID"), str)
} if isinstance(raw_devices, list) else set()
if folder.get("path") != api_path:
raise RoutePathConflict("existing route ID uses a different path")
if folder.get("type") != "sendreceive":
raise RouteSetupError("existing route folder is not sendreceive")
if device_ids != {local_device_id, peer_device_id}:
raise RouteSetupError("existing route folder has different devices")
if folder.get("paused") is True:
raise RouteSetupError("existing route folder is paused")
def _wait(self, deadline: float) -> None:
if time.monotonic() >= deadline:
raise RouteSetupTimeout("route setup timed out")
time.sleep(min(self.poll_interval, max(0, deadline - time.monotonic())))
def _object_list(value: dict[str, Any], key: str) -> list[dict[str, Any]]:
items = value.get(key)
if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
raise RouteSetupError(f"Syncthing configuration {key} is invalid")
return items
def _by_key(
items: list[dict[str, Any]], key: str, wanted: str
) -> dict[str, Any] | None:
matches = [item for item in items if item.get(key) == wanted]
if len(matches) > 1:
raise RouteSetupError(f"Syncthing configuration contains duplicate {key}")
return matches[0] if matches else None
def _nonce_name(client_id: str) -> str:
return f".archive-control-route-nonce.{client_id}"
def _ack_name(nonce_owner: str, observer: str) -> str:
return f".archive-control-route-ack.{nonce_owner}.{observer}"
def _atomic_json(path: Path, value: dict[str, Any]) -> None:
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"))
temporary = path.with_name(
f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
)
with temporary.open("x", encoding="utf-8") as target:
target.write(encoded)
target.flush()
os.fsync(target.fileno())
os.replace(temporary, path)
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
def _read_nonce(path: Path, route_id: str, client_id: str) -> str | None:
value = _read_json(path)
if value is None:
return None
if value.get("route_id") != route_id or value.get("client_id") != client_id:
raise RouteSetupError("peer route nonce identity is invalid")
nonce = value.get("nonce")
if not isinstance(nonce, str) or not nonce:
raise RouteSetupError("peer route nonce is invalid")
return nonce
def _read_ack(path: Path, route_id: str) -> str | None:
value = _read_json(path)
if value is None:
return None
if value.get("route_id") != route_id:
raise RouteSetupError("peer route acknowledgement identity is invalid")
nonce = value.get("nonce")
if not isinstance(nonce, str) or not nonce:
raise RouteSetupError("peer route acknowledgement is invalid")
return nonce
def _read_json(path: Path) -> dict[str, Any] | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise RouteSetupError("route verification file is unreadable") from exc
if not isinstance(value, dict):
raise RouteSetupError("route verification file is invalid")
return value
class _NoRedirect(request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None