feat: execute durable syncthing route setup
This commit is contained in:
@@ -26,6 +26,12 @@ Durable inventory commands now stream bounded atomic summary, lookup, and
|
|||||||
content-tree chunks. Page tokens are guarded by timestamp-independent snapshot
|
content-tree chunks. Page tokens are guarded by timestamp-independent snapshot
|
||||||
revisions, stale trees fail explicitly, and slow scans run outside the socket
|
revisions, stale trees fail explicitly, and slow scans run outside the socket
|
||||||
reader so heartbeat acknowledgements remain responsive.
|
reader so heartbeat acknowledgements remain responsive.
|
||||||
|
Durable `EnsureRoute` commands use scoped Syncthing device/folder REST updates,
|
||||||
|
safe API/local path mapping, exact pairwise configuration read-back, and
|
||||||
|
bidirectional nonce/acknowledgement files. Route attempt nonces, ownership, and
|
||||||
|
ordered updates survive restart; accepted attempts resume on reconnect without
|
||||||
|
concurrent duplicate execution. Conflicting existing folders and foreign data
|
||||||
|
are reported without being overwritten.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
archive-client --config /etc/archive-control/client.toml --check-config
|
archive-client --config /etc/archive-control/client.toml --check-config
|
||||||
@@ -38,10 +44,10 @@ archive-client-backup --database /var/lib/archive-control/client.db \
|
|||||||
Secrets must be regular files without group/world permissions. The daemon never
|
Secrets must be regular files without group/world permissions. The daemon never
|
||||||
stores them in SQLite or sends the shared token after registration.
|
stores them in SQLite or sends the shared token after registration.
|
||||||
|
|
||||||
This foundation currently executes heartbeat and state-snapshot commands.
|
This foundation currently executes heartbeat, inventory, state-snapshot, and
|
||||||
Other mutation commands are durably rejected as unsupported until their
|
route-provisioning commands. Other mutation commands are durably rejected as
|
||||||
service and file-operation executors are added; they are never falsely
|
unsupported until their service and file-operation executors are added; they
|
||||||
acknowledged as accepted.
|
are never falsely acknowledged as accepted.
|
||||||
|
|
||||||
Run tests and build using containers:
|
Run tests and build using containers:
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from archive_clients.config import BackupConfig
|
from archive_clients.config import BackupConfig
|
||||||
from archive_clients.locking import DatabaseLease
|
from archive_clients.locking import DatabaseLease
|
||||||
|
from archive_clients.state import SCHEMA_VERSION
|
||||||
|
|
||||||
|
|
||||||
class BackupError(RuntimeError):
|
class BackupError(RuntimeError):
|
||||||
@@ -183,7 +184,7 @@ class SQLiteBackupManager:
|
|||||||
if connection.execute("PRAGMA foreign_key_check").fetchall():
|
if connection.execute("PRAGMA foreign_key_check").fetchall():
|
||||||
raise BackupError("SQLite foreign-key check failed")
|
raise BackupError("SQLite foreign-key check failed")
|
||||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version != 1:
|
if version != SCHEMA_VERSION:
|
||||||
raise BackupError(f"unsupported backup schema version {version}")
|
raise BackupError(f"unsupported backup schema version {version}")
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
raise BackupError("backup is not a readable SQLite database") from exc
|
raise BackupError("backup is not a readable SQLite database") from exc
|
||||||
|
|||||||
@@ -26,8 +26,14 @@ from archive_clients.protocol import (
|
|||||||
from archive_clients.qbittorrent import QBittorrentReader
|
from archive_clients.qbittorrent import QBittorrentReader
|
||||||
from archive_clients.services import ServiceProbe
|
from archive_clients.services import ServiceProbe
|
||||||
from archive_clients.state import ClientStore, CommandConflict
|
from archive_clients.state import ClientStore, CommandConflict
|
||||||
|
from archive_clients.syncthing import (
|
||||||
|
RoutePathConflict,
|
||||||
|
RouteSetupError,
|
||||||
|
RouteSetupTimeout,
|
||||||
|
SyncthingRouteManager,
|
||||||
|
)
|
||||||
from archive_control.v1 import (
|
from archive_control.v1 import (
|
||||||
client_pb2, common_pb2, control_pb2, inventory_pb2, job_pb2,
|
client_pb2, common_pb2, control_pb2, inventory_pb2, job_pb2, route_pb2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -41,6 +47,7 @@ class ArchiveClientDaemon:
|
|||||||
probes: list[FilesystemProbe],
|
probes: list[FilesystemProbe],
|
||||||
service_probes: list[ServiceProbe],
|
service_probes: list[ServiceProbe],
|
||||||
resource_reader: QBittorrentReader | None = None,
|
resource_reader: QBittorrentReader | None = None,
|
||||||
|
route_manager: SyncthingRouteManager | None = None,
|
||||||
):
|
):
|
||||||
if len(probes) != 2:
|
if len(probes) != 2:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -53,11 +60,25 @@ class ArchiveClientDaemon:
|
|||||||
InventoryService(resource_reader, config.client_id)
|
InventoryService(resource_reader, config.client_id)
|
||||||
if resource_reader is not None else None
|
if resource_reader is not None else None
|
||||||
)
|
)
|
||||||
|
healthy_syncthing = any(
|
||||||
|
probe.service == "syncthing"
|
||||||
|
and probe.state == common_pb2.HEALTH_STATE_HEALTHY
|
||||||
|
for probe in service_probes
|
||||||
|
)
|
||||||
|
self.routes = route_manager or (
|
||||||
|
SyncthingRouteManager(
|
||||||
|
config.syncthing,
|
||||||
|
sparse_supported=probes[1].sparse_files,
|
||||||
|
)
|
||||||
|
if healthy_syncthing and probes[1].writable
|
||||||
|
else None
|
||||||
|
)
|
||||||
self.store = ClientStore(config.state_db)
|
self.store = ClientStore(config.state_db)
|
||||||
self.backups = SQLiteBackupManager(
|
self.backups = SQLiteBackupManager(
|
||||||
config.state_db, config.backup_dir, config.backup
|
config.state_db, config.backup_dir, config.backup
|
||||||
)
|
)
|
||||||
self._lease = DatabaseLease(config.state_db)
|
self._lease = DatabaseLease(config.state_db)
|
||||||
|
self._active_route_commands: set[str] = set()
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
await asyncio.to_thread(self._lease.acquire)
|
await asyncio.to_thread(self._lease.acquire)
|
||||||
@@ -148,6 +169,7 @@ class ArchiveClientDaemon:
|
|||||||
writer = asyncio.create_task(self._writer(websocket, outbound))
|
writer = asyncio.create_task(self._writer(websocket, outbound))
|
||||||
command_tasks: set[asyncio.Task[None]] = set()
|
command_tasks: set[asyncio.Task[None]] = set()
|
||||||
try:
|
try:
|
||||||
|
await self._resume_route_commands(outbound, command_tasks)
|
||||||
async for frame in websocket:
|
async for frame in websocket:
|
||||||
await self._handle(decode(frame), outbound, command_tasks)
|
await self._handle(decode(frame), outbound, command_tasks)
|
||||||
finally:
|
finally:
|
||||||
@@ -214,6 +236,10 @@ class ArchiveClientDaemon:
|
|||||||
client_pb2.CLIENT_FEATURE_INVENTORY_CHUNKS,
|
client_pb2.CLIENT_FEATURE_INVENTORY_CHUNKS,
|
||||||
client_pb2.CLIENT_FEATURE_CONTENT_TREE,
|
client_pb2.CLIENT_FEATURE_CONTENT_TREE,
|
||||||
))
|
))
|
||||||
|
if self.routes is not None:
|
||||||
|
request.capabilities.features.append(
|
||||||
|
client_pb2.CLIENT_FEATURE_ROUTE_PROVISIONING
|
||||||
|
)
|
||||||
for cursor in self.store.list_active_job_cursors():
|
for cursor in self.store.list_active_job_cursors():
|
||||||
active = request.active_jobs.add()
|
active = request.active_jobs.add()
|
||||||
active.job_id = str(cursor["job_id"])
|
active.job_id = str(cursor["job_id"])
|
||||||
@@ -344,6 +370,67 @@ class ArchiveClientDaemon:
|
|||||||
completed, command_tasks
|
completed, command_tasks
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
elif (
|
||||||
|
accepted is not None
|
||||||
|
and accepted_for_execution
|
||||||
|
and command.WhichOneof("payload") == "ensure_route"
|
||||||
|
and self.routes is not None
|
||||||
|
):
|
||||||
|
self._schedule_route_command(
|
||||||
|
command,
|
||||||
|
envelope.message_id,
|
||||||
|
outbound,
|
||||||
|
command_tasks,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _resume_route_commands(
|
||||||
|
self,
|
||||||
|
outbound: asyncio.Queue[str],
|
||||||
|
command_tasks: set[asyncio.Task[None]],
|
||||||
|
) -> None:
|
||||||
|
for row in await asyncio.to_thread(self.store.list_accepted_commands):
|
||||||
|
acknowledgement = decode_message(
|
||||||
|
str(row["acknowledgement_json"]), control_pb2.CommandAck()
|
||||||
|
)
|
||||||
|
if acknowledgement.status != control_pb2.COMMAND_ACK_STATUS_ACCEPTED:
|
||||||
|
continue
|
||||||
|
command = decode_message(
|
||||||
|
str(row["command_json"]), control_pb2.Command()
|
||||||
|
)
|
||||||
|
if command.WhichOneof("payload") == "ensure_route":
|
||||||
|
self._schedule_route_command(
|
||||||
|
command, "", outbound, command_tasks
|
||||||
|
)
|
||||||
|
|
||||||
|
def _schedule_route_command(
|
||||||
|
self,
|
||||||
|
command: control_pb2.Command,
|
||||||
|
correlation_id: str,
|
||||||
|
outbound: asyncio.Queue[str],
|
||||||
|
command_tasks: set[asyncio.Task[None]],
|
||||||
|
) -> None:
|
||||||
|
if command.command_id in self._active_route_commands:
|
||||||
|
return
|
||||||
|
self._active_route_commands.add(command.command_id)
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._execute_route(command, correlation_id, outbound),
|
||||||
|
name=f"route-{command.ensure_route.route.route_id}",
|
||||||
|
)
|
||||||
|
command_tasks.add(task)
|
||||||
|
task.add_done_callback(
|
||||||
|
lambda completed: self._route_command_finished(
|
||||||
|
command.command_id, completed, command_tasks
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _route_command_finished(
|
||||||
|
self,
|
||||||
|
command_id: str,
|
||||||
|
task: asyncio.Task[None],
|
||||||
|
command_tasks: set[asyncio.Task[None]],
|
||||||
|
) -> None:
|
||||||
|
self._active_route_commands.discard(command_id)
|
||||||
|
self._command_finished(task, command_tasks)
|
||||||
|
|
||||||
async def _send_inventory(
|
async def _send_inventory(
|
||||||
self,
|
self,
|
||||||
@@ -359,6 +446,207 @@ class ArchiveClientDaemon:
|
|||||||
response.inventory_chunk.CopyFrom(chunk)
|
response.inventory_chunk.CopyFrom(chunk)
|
||||||
await outbound.put(encode(response))
|
await outbound.put(encode(response))
|
||||||
|
|
||||||
|
async def _execute_route(
|
||||||
|
self,
|
||||||
|
command: Any,
|
||||||
|
correlation_id: str,
|
||||||
|
outbound: asyncio.Queue[str],
|
||||||
|
) -> None:
|
||||||
|
assert self.routes is not None
|
||||||
|
spec = command.ensure_route.route
|
||||||
|
attempt = await asyncio.to_thread(
|
||||||
|
self.store.begin_route_attempt,
|
||||||
|
command.command_id,
|
||||||
|
spec.route_id,
|
||||||
|
encode_message(spec),
|
||||||
|
str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
for row in await asyncio.to_thread(
|
||||||
|
self.store.route_update_rows, command.command_id
|
||||||
|
):
|
||||||
|
response = new_envelope()
|
||||||
|
response.correlation_id = correlation_id
|
||||||
|
response.route_update.CopyFrom(
|
||||||
|
decode_message(str(row["update_json"]), control_pb2.RouteUpdate())
|
||||||
|
)
|
||||||
|
await outbound.put(encode(response))
|
||||||
|
if attempt["state"] in {"ready", "failed"}:
|
||||||
|
return
|
||||||
|
|
||||||
|
deadline = time.monotonic() + spec.setup_timeout_seconds
|
||||||
|
configured = None
|
||||||
|
try:
|
||||||
|
attempt = await asyncio.to_thread(
|
||||||
|
self.store.get_route_attempt, command.command_id
|
||||||
|
)
|
||||||
|
if int(attempt["last_sequence"]) == 0:
|
||||||
|
await self._emit_route_update(
|
||||||
|
command.command_id,
|
||||||
|
correlation_id,
|
||||||
|
outbound,
|
||||||
|
sequence=1,
|
||||||
|
state="provisioning",
|
||||||
|
local_route=self._local_route(
|
||||||
|
spec, route_pb2.ROUTE_STATE_PROVISIONING
|
||||||
|
),
|
||||||
|
nonce=str(attempt["nonce"]),
|
||||||
|
detail="route command accepted; configuring Syncthing",
|
||||||
|
)
|
||||||
|
configured = await asyncio.to_thread(
|
||||||
|
self.routes.configure, spec, deadline
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self.store.record_route_ownership,
|
||||||
|
command.command_id,
|
||||||
|
configured.created_device,
|
||||||
|
configured.created_folder,
|
||||||
|
)
|
||||||
|
attempt = await asyncio.to_thread(
|
||||||
|
self.store.get_route_attempt, command.command_id
|
||||||
|
)
|
||||||
|
configured.local_route.archive_control_created = bool(
|
||||||
|
attempt["created_folder"]
|
||||||
|
)
|
||||||
|
if int(attempt["last_sequence"]) < 2:
|
||||||
|
configured.local_route.state = route_pb2.ROUTE_STATE_VERIFYING
|
||||||
|
configured.local_route.observed_at.GetCurrentTime()
|
||||||
|
await self._emit_route_update(
|
||||||
|
command.command_id,
|
||||||
|
correlation_id,
|
||||||
|
outbound,
|
||||||
|
sequence=2,
|
||||||
|
state="verifying",
|
||||||
|
local_route=configured.local_route,
|
||||||
|
nonce=str(attempt["nonce"]),
|
||||||
|
detail="Syncthing configuration verified; exchanging nonces",
|
||||||
|
)
|
||||||
|
local_seen, peer_seen = await asyncio.to_thread(
|
||||||
|
self.routes.verify,
|
||||||
|
configured,
|
||||||
|
spec,
|
||||||
|
self.config.client_id,
|
||||||
|
str(attempt["nonce"]),
|
||||||
|
deadline,
|
||||||
|
)
|
||||||
|
attempt = await asyncio.to_thread(
|
||||||
|
self.store.get_route_attempt, command.command_id
|
||||||
|
)
|
||||||
|
if int(attempt["last_sequence"]) < 3:
|
||||||
|
configured.local_route.state = route_pb2.ROUTE_STATE_READY
|
||||||
|
configured.local_route.detail = "bidirectional nonce verification passed"
|
||||||
|
configured.local_route.observed_at.GetCurrentTime()
|
||||||
|
await self._emit_route_update(
|
||||||
|
command.command_id,
|
||||||
|
correlation_id,
|
||||||
|
outbound,
|
||||||
|
sequence=3,
|
||||||
|
state="ready",
|
||||||
|
local_route=configured.local_route,
|
||||||
|
nonce=str(attempt["nonce"]),
|
||||||
|
detail="bidirectional nonce verification passed",
|
||||||
|
local_nonce_seen_by_peer=local_seen,
|
||||||
|
peer_nonce_seen_locally=peer_seen,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
attempt = await asyncio.to_thread(
|
||||||
|
self.store.get_route_attempt, command.command_id
|
||||||
|
)
|
||||||
|
if attempt is None or attempt["state"] == "failed":
|
||||||
|
return
|
||||||
|
code, retryable = _route_error(exc)
|
||||||
|
detail = str(exc) or type(exc).__name__
|
||||||
|
local_route = (
|
||||||
|
configured.local_route
|
||||||
|
if configured is not None
|
||||||
|
else self._local_route(spec, route_pb2.ROUTE_STATE_UNHEALTHY)
|
||||||
|
)
|
||||||
|
local_route.state = route_pb2.ROUTE_STATE_UNHEALTHY
|
||||||
|
local_route.detail = detail
|
||||||
|
local_route.observed_at.GetCurrentTime()
|
||||||
|
await self._emit_route_update(
|
||||||
|
command.command_id,
|
||||||
|
correlation_id,
|
||||||
|
outbound,
|
||||||
|
sequence=int(attempt["last_sequence"]) + 1,
|
||||||
|
state="failed",
|
||||||
|
local_route=local_route,
|
||||||
|
nonce=str(attempt["nonce"]),
|
||||||
|
detail=detail,
|
||||||
|
error_code=code,
|
||||||
|
retryable=retryable,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _emit_route_update(
|
||||||
|
self,
|
||||||
|
command_id: str,
|
||||||
|
correlation_id: str,
|
||||||
|
outbound: asyncio.Queue[str],
|
||||||
|
*,
|
||||||
|
sequence: int,
|
||||||
|
state: str,
|
||||||
|
local_route: route_pb2.LocalRoute,
|
||||||
|
nonce: str,
|
||||||
|
detail: str,
|
||||||
|
local_nonce_seen_by_peer: bool = False,
|
||||||
|
peer_nonce_seen_locally: bool = False,
|
||||||
|
error_code: int = common_pb2.ERROR_CODE_UNSPECIFIED,
|
||||||
|
retryable: bool = False,
|
||||||
|
) -> None:
|
||||||
|
update = control_pb2.RouteUpdate(
|
||||||
|
command_id=command_id,
|
||||||
|
update_id=str(uuid.uuid4()),
|
||||||
|
sequence=sequence,
|
||||||
|
)
|
||||||
|
update.route.CopyFrom(local_route)
|
||||||
|
update.verification.route_id = local_route.route_id
|
||||||
|
update.verification.nonce = nonce
|
||||||
|
update.verification.local_nonce_seen_by_peer = local_nonce_seen_by_peer
|
||||||
|
update.verification.peer_nonce_seen_locally = peer_nonce_seen_locally
|
||||||
|
update.verification.state = local_route.state
|
||||||
|
update.verification.detail = detail
|
||||||
|
update.verification.observed_at.GetCurrentTime()
|
||||||
|
if error_code != common_pb2.ERROR_CODE_UNSPECIFIED:
|
||||||
|
update.error.code = error_code
|
||||||
|
update.error.message = detail
|
||||||
|
update.error.retryable = retryable
|
||||||
|
encoded_update = encode_message(update)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self.store.record_route_update,
|
||||||
|
command_id,
|
||||||
|
sequence,
|
||||||
|
state,
|
||||||
|
encoded_update,
|
||||||
|
)
|
||||||
|
response = new_envelope()
|
||||||
|
response.correlation_id = correlation_id
|
||||||
|
response.route_update.CopyFrom(update)
|
||||||
|
await outbound.put(encode(response))
|
||||||
|
|
||||||
|
def _local_route(
|
||||||
|
self, spec: route_pb2.EnsureRouteSpec, state: int
|
||||||
|
) -> route_pb2.LocalRoute:
|
||||||
|
device_id = next(
|
||||||
|
(
|
||||||
|
probe.device_id
|
||||||
|
for probe in self.service_probes
|
||||||
|
if probe.service == "syncthing"
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
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=device_id,
|
||||||
|
peer_syncthing_device_ids=[spec.peer_syncthing_device_id],
|
||||||
|
state=state,
|
||||||
|
writable=self.probes[1].writable,
|
||||||
|
sparse_supported=self.probes[1].sparse_files,
|
||||||
|
detail="route provisioning",
|
||||||
|
)
|
||||||
|
route.observed_at.GetCurrentTime()
|
||||||
|
return route
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _command_finished(
|
def _command_finished(
|
||||||
task: asyncio.Task[None], command_tasks: set[asyncio.Task[None]]
|
task: asyncio.Task[None], command_tasks: set[asyncio.Task[None]]
|
||||||
@@ -411,6 +699,24 @@ class ArchiveClientDaemon:
|
|||||||
acknowledgement.error.message = "inventory scope is unsupported"
|
acknowledgement.error.message = "inventory scope is unsupported"
|
||||||
else:
|
else:
|
||||||
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_ACCEPTED
|
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_ACCEPTED
|
||||||
|
elif command.WhichOneof("payload") == "ensure_route":
|
||||||
|
spec = command.ensure_route.route
|
||||||
|
if self.routes is None:
|
||||||
|
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_REJECTED
|
||||||
|
acknowledgement.error.code = common_pb2.ERROR_CODE_UNAVAILABLE
|
||||||
|
acknowledgement.error.message = "Syncthing route adapter is unavailable"
|
||||||
|
elif (
|
||||||
|
not spec.route_id
|
||||||
|
or not spec.peer_client_id
|
||||||
|
or not spec.peer_syncthing_device_id
|
||||||
|
or not spec.local_relative_path
|
||||||
|
or spec.setup_timeout_seconds <= 0
|
||||||
|
):
|
||||||
|
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_REJECTED
|
||||||
|
acknowledgement.error.code = common_pb2.ERROR_CODE_INVALID_ARGUMENT
|
||||||
|
acknowledgement.error.message = "ensure route specification is invalid"
|
||||||
|
else:
|
||||||
|
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_ACCEPTED
|
||||||
else:
|
else:
|
||||||
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_REJECTED
|
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_REJECTED
|
||||||
acknowledgement.error.code = common_pb2.ERROR_CODE_UNSUPPORTED
|
acknowledgement.error.code = common_pb2.ERROR_CODE_UNSUPPORTED
|
||||||
@@ -418,3 +724,15 @@ class ArchiveClientDaemon:
|
|||||||
"command is not supported by this client build"
|
"command is not supported by this client build"
|
||||||
)
|
)
|
||||||
return acknowledgement
|
return acknowledgement
|
||||||
|
|
||||||
|
|
||||||
|
def _route_error(exc: Exception) -> tuple[int, bool]:
|
||||||
|
if isinstance(exc, RoutePathConflict):
|
||||||
|
return common_pb2.ERROR_CODE_PATH_CONFLICT, False
|
||||||
|
if isinstance(exc, RouteSetupTimeout):
|
||||||
|
return common_pb2.ERROR_CODE_TIMEOUT, True
|
||||||
|
if isinstance(exc, PermissionError):
|
||||||
|
return common_pb2.ERROR_CODE_PERMISSION_DENIED, False
|
||||||
|
if isinstance(exc, RouteSetupError):
|
||||||
|
return common_pb2.ERROR_CODE_UNAVAILABLE, True
|
||||||
|
return common_pb2.ERROR_CODE_INTERNAL, False
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
class CommandConflict(RuntimeError):
|
class CommandConflict(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -40,12 +43,17 @@ class ClientStore:
|
|||||||
raise RuntimeError("client database file permissions are unsafe")
|
raise RuntimeError("client database file permissions are unsafe")
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version > 1:
|
if version > SCHEMA_VERSION:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"client database schema {version} is newer than supported"
|
f"client database schema {version} is newer than supported"
|
||||||
)
|
)
|
||||||
connection.executescript(_SCHEMA)
|
if version == 0:
|
||||||
connection.execute("PRAGMA user_version = 1")
|
connection.executescript(_SCHEMA_V1)
|
||||||
|
connection.execute("PRAGMA user_version = 1")
|
||||||
|
version = 1
|
||||||
|
if version == 1:
|
||||||
|
connection.executescript(_SCHEMA_V2)
|
||||||
|
connection.execute("PRAGMA user_version = 2")
|
||||||
if connection.execute("PRAGMA foreign_key_check").fetchall():
|
if connection.execute("PRAGMA foreign_key_check").fetchall():
|
||||||
raise RuntimeError("client database foreign-key check failed")
|
raise RuntimeError("client database foreign-key check failed")
|
||||||
if not existed:
|
if not existed:
|
||||||
@@ -93,6 +101,16 @@ class ClientStore:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def list_accepted_commands(self) -> list[dict[str, object]]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT command_id, command_json, acknowledgement_json
|
||||||
|
FROM commands ORDER BY rowid
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def job_snapshot_rows(
|
def job_snapshot_rows(
|
||||||
self, job_ids: list[str]
|
self, job_ids: list[str]
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
@@ -165,6 +183,133 @@ class ClientStore:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def begin_route_attempt(
|
||||||
|
self,
|
||||||
|
command_id: str,
|
||||||
|
route_id: str,
|
||||||
|
spec_json: str,
|
||||||
|
nonce: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
spec = _canonical(json.loads(spec_json))
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT * FROM route_attempts WHERE command_id = ?",
|
||||||
|
(command_id,),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
if (
|
||||||
|
existing["route_id"] != route_id
|
||||||
|
or existing["spec_json"] != spec
|
||||||
|
):
|
||||||
|
raise CommandConflict(
|
||||||
|
"route command ID was reused with different content"
|
||||||
|
)
|
||||||
|
return dict(existing)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO route_attempts (
|
||||||
|
command_id, route_id, spec_json, nonce, state,
|
||||||
|
last_sequence
|
||||||
|
) VALUES (?, ?, ?, ?, 'accepted', 0)
|
||||||
|
""",
|
||||||
|
(command_id, route_id, spec, nonce),
|
||||||
|
)
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM route_attempts WHERE command_id = ?",
|
||||||
|
(command_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def record_route_update(
|
||||||
|
self,
|
||||||
|
command_id: str,
|
||||||
|
sequence: int,
|
||||||
|
state: str,
|
||||||
|
update_json: str,
|
||||||
|
) -> None:
|
||||||
|
update = _canonical(json.loads(update_json))
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
existing = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT update_json FROM route_attempt_updates
|
||||||
|
WHERE command_id = ? AND sequence = ?
|
||||||
|
""",
|
||||||
|
(command_id, sequence),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
if existing["update_json"] != update:
|
||||||
|
raise CommandConflict(
|
||||||
|
"route update sequence has different content"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
attempt = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT last_sequence FROM route_attempts
|
||||||
|
WHERE command_id = ?
|
||||||
|
""",
|
||||||
|
(command_id,),
|
||||||
|
).fetchone()
|
||||||
|
if attempt is None:
|
||||||
|
raise CommandConflict("route attempt does not exist")
|
||||||
|
expected = attempt["last_sequence"] + 1
|
||||||
|
if sequence != expected:
|
||||||
|
raise CommandConflict(
|
||||||
|
f"route update sequence must be {expected}, received {sequence}"
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO route_attempt_updates (
|
||||||
|
command_id, sequence, update_json
|
||||||
|
) VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(command_id, sequence, update),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE route_attempts
|
||||||
|
SET state = ?, last_sequence = ?
|
||||||
|
WHERE command_id = ?
|
||||||
|
""",
|
||||||
|
(state, sequence, command_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_route_ownership(
|
||||||
|
self, command_id: str, created_device: bool, created_folder: bool
|
||||||
|
) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE route_attempts
|
||||||
|
SET created_device = MAX(created_device, ?),
|
||||||
|
created_folder = MAX(created_folder, ?)
|
||||||
|
WHERE command_id = ?
|
||||||
|
""",
|
||||||
|
(int(created_device), int(created_folder), command_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise CommandConflict("route attempt does not exist")
|
||||||
|
|
||||||
|
def get_route_attempt(self, command_id: str) -> dict[str, object] | None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM route_attempts WHERE command_id = ?",
|
||||||
|
(command_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def route_update_rows(self, command_id: str) -> list[dict[str, object]]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT sequence, update_json FROM route_attempt_updates
|
||||||
|
WHERE command_id = ? ORDER BY sequence
|
||||||
|
""",
|
||||||
|
(command_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
connection = sqlite3.connect(self.database, isolation_level=None, timeout=5)
|
connection = sqlite3.connect(self.database, isolation_level=None, timeout=5)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
@@ -179,7 +324,7 @@ def _canonical(value: object) -> str:
|
|||||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA_V1 = """
|
||||||
CREATE TABLE IF NOT EXISTS commands (
|
CREATE TABLE IF NOT EXISTS commands (
|
||||||
command_id TEXT PRIMARY KEY,
|
command_id TEXT PRIMARY KEY,
|
||||||
payload_sha256 TEXT NOT NULL,
|
payload_sha256 TEXT NOT NULL,
|
||||||
@@ -210,3 +355,23 @@ CREATE TABLE IF NOT EXISTS file_journal (
|
|||||||
state TEXT NOT NULL
|
state TEXT NOT NULL
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_SCHEMA_V2 = """
|
||||||
|
CREATE TABLE route_attempts (
|
||||||
|
command_id TEXT PRIMARY KEY REFERENCES commands(command_id),
|
||||||
|
route_id TEXT NOT NULL,
|
||||||
|
spec_json TEXT NOT NULL,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
last_sequence INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_device INTEGER NOT NULL DEFAULT 0 CHECK(created_device IN (0, 1)),
|
||||||
|
created_folder INTEGER NOT NULL DEFAULT 0 CHECK(created_folder IN (0, 1))
|
||||||
|
);
|
||||||
|
CREATE TABLE route_attempt_updates (
|
||||||
|
command_id TEXT NOT NULL REFERENCES route_attempts(command_id),
|
||||||
|
sequence INTEGER NOT NULL,
|
||||||
|
update_json TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(command_id, sequence)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
+122
-1
@@ -14,13 +14,130 @@ from archive_clients.config import ClientConfig, ConnectionConfig, ServiceConfig
|
|||||||
from archive_clients.daemon import ArchiveClientDaemon
|
from archive_clients.daemon import ArchiveClientDaemon
|
||||||
from archive_clients.probes import FilesystemProbe
|
from archive_clients.probes import FilesystemProbe
|
||||||
from archive_clients.services import ServiceProbe
|
from archive_clients.services import ServiceProbe
|
||||||
|
from archive_clients.syncthing import ConfiguredRoute
|
||||||
from archive_clients.protocol import decode, encode, encode_message, new_envelope
|
from archive_clients.protocol import decode, encode, encode_message, new_envelope
|
||||||
from archive_control.v1 import (
|
from archive_control.v1 import (
|
||||||
client_pb2, common_pb2, control_pb2, inventory_pb2, job_pb2,
|
client_pb2, common_pb2, control_pb2, inventory_pb2, job_pb2, route_pb2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DaemonTransportTests(unittest.IsolatedAsyncioTestCase):
|
class DaemonTransportTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_ensure_route_is_durable_and_duplicate_replays_updates(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
token = root / "token"
|
||||||
|
token.write_text("shared-secret", encoding="utf-8")
|
||||||
|
os.chmod(token, 0o600)
|
||||||
|
service = ServiceConfig(
|
||||||
|
"http://local", PurePosixPath("/sync"), root
|
||||||
|
)
|
||||||
|
config = ClientConfig(
|
||||||
|
"cache-1", "Cache 1", "cache", "ws://control", token,
|
||||||
|
root / "state.db", root / "backups", service, service,
|
||||||
|
)
|
||||||
|
local_route = route_pb2.LocalRoute(
|
||||||
|
route_id="route-1",
|
||||||
|
local_relative_path="routes/route-1",
|
||||||
|
folder_type=route_pb2.SYNCTHING_FOLDER_TYPE_SEND_RECEIVE,
|
||||||
|
local_syncthing_device_id="LOCAL",
|
||||||
|
peer_syncthing_device_ids=["PEER"],
|
||||||
|
state=route_pb2.ROUTE_STATE_PROVISIONING,
|
||||||
|
writable=True,
|
||||||
|
)
|
||||||
|
local_route.observed_at.GetCurrentTime()
|
||||||
|
manager = Mock()
|
||||||
|
manager.configure.return_value = ConfiguredRoute(
|
||||||
|
"LOCAL", root / "routes/route-1", local_route, True, True
|
||||||
|
)
|
||||||
|
manager.verify.return_value = (True, True)
|
||||||
|
probe = FilesystemProbe(root, True, True, True, True, True)
|
||||||
|
syncthing_probe = ServiceProbe(
|
||||||
|
"syncthing",
|
||||||
|
common_pb2.HEALTH_STATE_HEALTHY,
|
||||||
|
datetime.now(timezone.utc),
|
||||||
|
device_id="LOCAL",
|
||||||
|
)
|
||||||
|
daemon = ArchiveClientDaemon(
|
||||||
|
config,
|
||||||
|
[probe, probe],
|
||||||
|
[syncthing_probe],
|
||||||
|
route_manager=manager,
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(daemon.store.initialize)
|
||||||
|
command = new_envelope()
|
||||||
|
command.command.command_id = str(uuid4())
|
||||||
|
command.command.created_at.CopyFrom(command.sent_at)
|
||||||
|
spec = command.command.ensure_route.route
|
||||||
|
spec.route_id = "route-1"
|
||||||
|
spec.peer_client_id = "archive-1"
|
||||||
|
spec.peer_syncthing_device_id = "PEER"
|
||||||
|
spec.peer_addresses.append("dynamic")
|
||||||
|
spec.local_relative_path = "routes/route-1"
|
||||||
|
spec.setup_timeout_seconds = 1800
|
||||||
|
outbound = asyncio.Queue()
|
||||||
|
tasks = set()
|
||||||
|
await daemon._handle(command, outbound, tasks)
|
||||||
|
self.assertEqual(
|
||||||
|
decode(await outbound.get()).command_ack.status,
|
||||||
|
control_pb2.COMMAND_ACK_STATUS_ACCEPTED,
|
||||||
|
)
|
||||||
|
await next(iter(tasks))
|
||||||
|
first_updates = [decode(await outbound.get()).route_update for _ in range(3)]
|
||||||
|
self.assertEqual(
|
||||||
|
[update.sequence for update in first_updates], [1, 2, 3]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
first_updates[-1].verification.state,
|
||||||
|
route_pb2.ROUTE_STATE_READY,
|
||||||
|
)
|
||||||
|
self.assertTrue(first_updates[-1].route.archive_control_created)
|
||||||
|
|
||||||
|
duplicate = new_envelope()
|
||||||
|
duplicate.command.CopyFrom(command.command)
|
||||||
|
tasks = set()
|
||||||
|
await daemon._handle(duplicate, outbound, tasks)
|
||||||
|
self.assertEqual(
|
||||||
|
decode(await outbound.get()).command_ack.status,
|
||||||
|
control_pb2.COMMAND_ACK_STATUS_DUPLICATE,
|
||||||
|
)
|
||||||
|
await next(iter(tasks))
|
||||||
|
replay = [decode(await outbound.get()).route_update for _ in range(3)]
|
||||||
|
self.assertEqual(
|
||||||
|
[update.update_id for update in replay],
|
||||||
|
[update.update_id for update in first_updates],
|
||||||
|
)
|
||||||
|
self.assertEqual(manager.configure.call_count, 1)
|
||||||
|
|
||||||
|
accepted_before_crash = new_envelope()
|
||||||
|
accepted_before_crash.command.CopyFrom(command.command)
|
||||||
|
accepted_before_crash.command.command_id = str(uuid4())
|
||||||
|
acknowledgement = daemon._initial_acknowledgement(
|
||||||
|
accepted_before_crash.command, set(), False
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
daemon.store.accept_command,
|
||||||
|
accepted_before_crash.command.command_id,
|
||||||
|
encode_message(accepted_before_crash.command),
|
||||||
|
encode_message(acknowledgement),
|
||||||
|
)
|
||||||
|
restarted = ArchiveClientDaemon(
|
||||||
|
config,
|
||||||
|
[probe, probe],
|
||||||
|
[syncthing_probe],
|
||||||
|
route_manager=manager,
|
||||||
|
)
|
||||||
|
resumed_outbound = asyncio.Queue()
|
||||||
|
resumed_tasks = set()
|
||||||
|
await restarted._resume_route_commands(
|
||||||
|
resumed_outbound, resumed_tasks
|
||||||
|
)
|
||||||
|
await next(iter(resumed_tasks))
|
||||||
|
resumed = [
|
||||||
|
decode(await resumed_outbound.get()).route_update
|
||||||
|
for _ in range(3)
|
||||||
|
]
|
||||||
|
self.assertEqual([item.sequence for item in resumed], [1, 2, 3])
|
||||||
|
|
||||||
async def test_slow_inventory_does_not_block_heartbeat(self):
|
async def test_slow_inventory_does_not_block_heartbeat(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
@@ -213,6 +330,10 @@ class DaemonTransportTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
client_pb2.CLIENT_FEATURE_INVENTORY_CHUNKS,
|
client_pb2.CLIENT_FEATURE_INVENTORY_CHUNKS,
|
||||||
observed["features"],
|
observed["features"],
|
||||||
)
|
)
|
||||||
|
self.assertIn(
|
||||||
|
client_pb2.CLIENT_FEATURE_ROUTE_PROVISIONING,
|
||||||
|
observed["features"],
|
||||||
|
)
|
||||||
self.assertEqual(observed["heartbeat"], 7)
|
self.assertEqual(observed["heartbeat"], 7)
|
||||||
self.assertEqual(observed["first"], control_pb2.COMMAND_ACK_STATUS_ACCEPTED)
|
self.assertEqual(observed["first"], control_pb2.COMMAND_ACK_STATUS_ACCEPTED)
|
||||||
self.assertEqual(observed["second"], control_pb2.COMMAND_ACK_STATUS_DUPLICATE)
|
self.assertEqual(observed["second"], control_pb2.COMMAND_ACK_STATUS_DUPLICATE)
|
||||||
|
|||||||
@@ -51,6 +51,41 @@ class ClientStoreTests(unittest.TestCase):
|
|||||||
2, 3, False,
|
2, 3, False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_route_attempt_nonce_and_updates_survive_duplicate_commands(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
store = ClientStore(Path(directory) / "state.db")
|
||||||
|
store.initialize()
|
||||||
|
command_id = str(uuid4())
|
||||||
|
store.accept_command(command_id, '{"route":"one"}', '{"ok":true}')
|
||||||
|
first = store.begin_route_attempt(
|
||||||
|
command_id, "route-1", '{"routeId":"route-1"}', "nonce-1"
|
||||||
|
)
|
||||||
|
duplicate = store.begin_route_attempt(
|
||||||
|
command_id, "route-1", '{"routeId":"route-1"}', "new-nonce"
|
||||||
|
)
|
||||||
|
self.assertEqual(first["nonce"], "nonce-1")
|
||||||
|
self.assertEqual(duplicate["nonce"], "nonce-1")
|
||||||
|
store.record_route_update(
|
||||||
|
command_id, 1, "provisioning", '{"sequence":1}'
|
||||||
|
)
|
||||||
|
store.record_route_ownership(command_id, True, True)
|
||||||
|
store.record_route_update(
|
||||||
|
command_id, 1, "provisioning", '{"sequence":1}'
|
||||||
|
)
|
||||||
|
store.record_route_update(
|
||||||
|
command_id, 2, "ready", '{"sequence":2}'
|
||||||
|
)
|
||||||
|
self.assertEqual(store.get_route_attempt(command_id)["state"], "ready")
|
||||||
|
self.assertEqual(store.get_route_attempt(command_id)["created_folder"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
[row["sequence"] for row in store.route_update_rows(command_id)],
|
||||||
|
[1, 2],
|
||||||
|
)
|
||||||
|
with self.assertRaises(CommandConflict):
|
||||||
|
store.record_route_update(
|
||||||
|
command_id, 2, "ready", '{"sequence":2,"changed":true}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
from archive_clients.config import ServiceConfig
|
||||||
|
from archive_clients.syncthing import (
|
||||||
|
RoutePathConflict,
|
||||||
|
RouteSetupTimeout,
|
||||||
|
SyncthingRouteManager,
|
||||||
|
)
|
||||||
|
from archive_control.v1 import route_pb2
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTransport:
|
||||||
|
def __init__(self):
|
||||||
|
self.status = {"myID": "LOCAL"}
|
||||||
|
self.config = {"devices": [], "folders": []}
|
||||||
|
self.puts = []
|
||||||
|
self.posts = []
|
||||||
|
|
||||||
|
def get_json(self, path):
|
||||||
|
if path == "/rest/system/status":
|
||||||
|
return self.status
|
||||||
|
if path == "/rest/config":
|
||||||
|
return self.config
|
||||||
|
raise AssertionError(path)
|
||||||
|
|
||||||
|
def put_json(self, path, payload):
|
||||||
|
self.puts.append((path, payload))
|
||||||
|
if path.startswith("/rest/config/devices/"):
|
||||||
|
self.config["devices"].append(payload)
|
||||||
|
elif path.startswith("/rest/config/folders/"):
|
||||||
|
self.config["folders"].append(payload)
|
||||||
|
else:
|
||||||
|
raise AssertionError(path)
|
||||||
|
|
||||||
|
def post(self, path):
|
||||||
|
self.posts.append(path)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncthingRouteManagerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name) / "sync"
|
||||||
|
self.root.mkdir()
|
||||||
|
self.transport = FakeTransport()
|
||||||
|
self.manager = SyncthingRouteManager(
|
||||||
|
ServiceConfig(
|
||||||
|
"http://syncthing",
|
||||||
|
PurePosixPath("/sync"),
|
||||||
|
self.root,
|
||||||
|
),
|
||||||
|
sparse_supported=True,
|
||||||
|
transport=self.transport,
|
||||||
|
poll_interval=0,
|
||||||
|
)
|
||||||
|
self.spec = route_pb2.EnsureRouteSpec(
|
||||||
|
route_id="route-1",
|
||||||
|
peer_client_id="archive-1",
|
||||||
|
peer_syncthing_device_id="PEER",
|
||||||
|
peer_addresses=["tcp://archive:22000"],
|
||||||
|
local_relative_path="routes/route-1",
|
||||||
|
setup_timeout_seconds=1800,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_configure_adds_only_peer_and_pairwise_folder(self):
|
||||||
|
configured = self.manager.configure(
|
||||||
|
self.spec, time.monotonic() + 1
|
||||||
|
)
|
||||||
|
self.assertEqual(configured.local_device_id, "LOCAL")
|
||||||
|
self.assertTrue(configured.local_path.is_dir())
|
||||||
|
self.assertEqual(len(self.transport.puts), 2)
|
||||||
|
folder = self.transport.config["folders"][0]
|
||||||
|
self.assertEqual(folder["path"], "/sync/routes/route-1")
|
||||||
|
self.assertEqual(folder["type"], "sendreceive")
|
||||||
|
self.assertEqual(
|
||||||
|
{item["deviceID"] for item in folder["devices"]},
|
||||||
|
{"LOCAL", "PEER"},
|
||||||
|
)
|
||||||
|
self.assertTrue(configured.local_route.archive_control_created)
|
||||||
|
|
||||||
|
repeated = self.manager.configure(self.spec, time.monotonic() + 1)
|
||||||
|
self.assertEqual(len(self.transport.puts), 2)
|
||||||
|
self.assertFalse(repeated.local_route.archive_control_created)
|
||||||
|
|
||||||
|
def test_existing_folder_conflicts_are_never_overwritten(self):
|
||||||
|
self.transport.config["folders"].append(
|
||||||
|
{
|
||||||
|
"id": "route-1",
|
||||||
|
"path": "/somewhere-else",
|
||||||
|
"type": "sendreceive",
|
||||||
|
"devices": [{"deviceID": "LOCAL"}, {"deviceID": "PEER"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RoutePathConflict, "different path"):
|
||||||
|
self.manager.configure(self.spec, time.monotonic() + 1)
|
||||||
|
self.assertEqual(self.transport.puts, [])
|
||||||
|
|
||||||
|
def test_bidirectional_nonce_and_ack_are_required(self):
|
||||||
|
configured = self.manager.configure(self.spec, time.monotonic() + 1)
|
||||||
|
peer_nonce = configured.local_path / (
|
||||||
|
".archive-control-route-nonce.archive-1"
|
||||||
|
)
|
||||||
|
peer_nonce.write_text(
|
||||||
|
json.dumps({
|
||||||
|
"route_id": "route-1",
|
||||||
|
"client_id": "archive-1",
|
||||||
|
"nonce": "peer-nonce",
|
||||||
|
}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
acknowledgement = configured.local_path / (
|
||||||
|
".archive-control-route-ack.cache-1.archive-1"
|
||||||
|
)
|
||||||
|
acknowledgement.write_text(
|
||||||
|
json.dumps({"route_id": "route-1", "nonce": "local-nonce"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
verified = self.manager.verify(
|
||||||
|
configured,
|
||||||
|
self.spec,
|
||||||
|
"cache-1",
|
||||||
|
"local-nonce",
|
||||||
|
time.monotonic() + 1,
|
||||||
|
)
|
||||||
|
self.assertEqual(verified, (True, True))
|
||||||
|
peer_ack = configured.local_path / (
|
||||||
|
".archive-control-route-ack.archive-1.cache-1"
|
||||||
|
)
|
||||||
|
self.assertEqual(json.loads(peer_ack.read_text())["nonce"], "peer-nonce")
|
||||||
|
|
||||||
|
def test_verification_times_out_without_peer_evidence(self):
|
||||||
|
configured = self.manager.configure(self.spec, time.monotonic() + 1)
|
||||||
|
with self.assertRaises(RouteSetupTimeout):
|
||||||
|
self.manager.verify(
|
||||||
|
configured,
|
||||||
|
self.spec,
|
||||||
|
"cache-1",
|
||||||
|
"local-nonce",
|
||||||
|
time.monotonic() + 0.01,
|
||||||
|
)
|
||||||
|
self.assertTrue(self.transport.posts)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user