feat: execute durable syncthing route setup
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user