Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57acc90363 |
@@ -2,7 +2,7 @@ name: archive-control-archive
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
archive-client:
|
archive-client:
|
||||||
image: sodium/archive-clients:v0.1.5
|
image: sodium/archive-clients:v0.1.6
|
||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["--config", "/etc/archive-control/client.toml"]
|
command: ["--config", "/etc/archive-control/client.toml"]
|
||||||
|
|||||||
@@ -39,4 +39,7 @@ endpoint = "http://127.0.0.1:8384"
|
|||||||
api_key_file = "/run/secrets/syncthing_api_key"
|
api_key_file = "/run/secrets/syncthing_api_key"
|
||||||
api_root = "/var/syncthing"
|
api_root = "/var/syncthing"
|
||||||
local_root = "/data/sync"
|
local_root = "/data/sync"
|
||||||
|
# This existing folder is physically inside the qB data tree. Map it through
|
||||||
|
# that same client bind mount so source staging can use hardlinks.
|
||||||
|
local_path_overrides = { "/var/syncthing/DownloadsSync" = "/data/qb/Sync" }
|
||||||
advertised_addresses = ["dynamic"]
|
advertised_addresses = ["dynamic"]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: archive-control-cache
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
archive-client:
|
archive-client:
|
||||||
image: sodium/archive-clients:v0.1.5
|
image: sodium/archive-clients:v0.1.6
|
||||||
user: "1001:1001"
|
user: "1001:1001"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -16,4 +16,3 @@ services:
|
|||||||
- ./backups:/var/backups/archive-control
|
- ./backups:/var/backups/archive-control
|
||||||
- /home/ubuntu/Downloads:/data/qb
|
- /home/ubuntu/Downloads:/data/qb
|
||||||
- /home/ubuntu/compose/syncthing/st_home:/data/sync
|
- /home/ubuntu/compose/syncthing/st_home:/data/sync
|
||||||
- /home/ubuntu/Downloads/Sync:/data/sync/DownloadsSync
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: archive-control-cache
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
archive-client:
|
archive-client:
|
||||||
image: sodium/archive-clients:v0.1.5
|
image: sodium/archive-clients:v0.1.6
|
||||||
user: "1001:1001"
|
user: "1001:1001"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ local_root = "/data/sync"
|
|||||||
advertised_addresses = ["dynamic"]
|
advertised_addresses = ["dynamic"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When an existing Syncthing folder is physically nested in the qB data root,
|
||||||
|
use a `local_path_overrides` entry to map that exact Syncthing API path through
|
||||||
|
the same client bind mount. This enables hardlinks without creating two Docker
|
||||||
|
mount boundaries for the same host files.
|
||||||
|
|
||||||
The remaining node examples omit optional `[connection]`, `[jobs]`, and
|
The remaining node examples omit optional `[connection]`, `[jobs]`, and
|
||||||
`[backup]` tables and therefore use these same defaults; deployments may
|
`[backup]` tables and therefore use these same defaults; deployments may
|
||||||
override them per node.
|
override them per node.
|
||||||
@@ -213,7 +218,7 @@ cache/archive routes according to policy.
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
archive-client:
|
archive-client:
|
||||||
image: sodium/archive-clients:v0.1.5
|
image: sodium/archive-clients:v0.1.6
|
||||||
user: "1001:1001"
|
user: "1001:1001"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["archive-client", "--config", "/etc/archive-control/client.toml"]
|
command: ["archive-client", "--config", "/etc/archive-control/client.toml"]
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "archive-clients"
|
name = "archive-clients"
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["protobuf==7.35.1", "websockets==16.0"]
|
dependencies = ["protobuf==7.35.1", "websockets==16.0"]
|
||||||
|
|
||||||
|
|||||||
@@ -27,17 +27,28 @@ _ENV = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|||||||
class RootMapping:
|
class RootMapping:
|
||||||
api_root: PurePosixPath
|
api_root: PurePosixPath
|
||||||
local_root: Path
|
local_root: Path
|
||||||
|
local_path_overrides: tuple[tuple[PurePosixPath, Path], ...] = ()
|
||||||
|
|
||||||
def api_to_local(self, api_path: str) -> Path:
|
def api_to_local(self, api_path: str) -> Path:
|
||||||
candidate = PurePosixPath(api_path)
|
candidate = PurePosixPath(api_path)
|
||||||
try:
|
for api_root, local_root in self.local_path_overrides:
|
||||||
relative = candidate.relative_to(self.api_root)
|
relative = _safe_relative(candidate, api_root)
|
||||||
except ValueError as exc:
|
if relative is not None:
|
||||||
raise ConfigError("API path is outside its configured root") from exc
|
return local_root.joinpath(*relative.parts)
|
||||||
if any(part in {"", ".", ".."} for part in relative.parts):
|
relative = _safe_relative(candidate, self.api_root)
|
||||||
raise ConfigError("API path contains an unsafe component")
|
if relative is None:
|
||||||
|
raise ConfigError("API path is outside its configured root")
|
||||||
return self.local_root.joinpath(*relative.parts)
|
return self.local_root.joinpath(*relative.parts)
|
||||||
|
|
||||||
|
def local_root_for_api(self, api_path: str) -> Path:
|
||||||
|
candidate = PurePosixPath(api_path)
|
||||||
|
for api_root, local_root in self.local_path_overrides:
|
||||||
|
if _safe_relative(candidate, api_root) is not None:
|
||||||
|
return local_root
|
||||||
|
if _safe_relative(candidate, self.api_root) is None:
|
||||||
|
raise ConfigError("API path is outside its configured root")
|
||||||
|
return self.local_root
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ServiceConfig:
|
class ServiceConfig:
|
||||||
@@ -48,10 +59,13 @@ class ServiceConfig:
|
|||||||
password_file: Path | None = None
|
password_file: Path | None = None
|
||||||
api_key_file: Path | None = None
|
api_key_file: Path | None = None
|
||||||
advertised_addresses: tuple[str, ...] = ()
|
advertised_addresses: tuple[str, ...] = ()
|
||||||
|
local_path_overrides: tuple[tuple[PurePosixPath, Path], ...] = ()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def roots(self) -> RootMapping:
|
def roots(self) -> RootMapping:
|
||||||
return RootMapping(self.api_root, self.local_root)
|
return RootMapping(
|
||||||
|
self.api_root, self.local_root, self.local_path_overrides
|
||||||
|
)
|
||||||
|
|
||||||
def read_password(self) -> str | None:
|
def read_password(self) -> str | None:
|
||||||
return (
|
return (
|
||||||
@@ -160,7 +174,7 @@ def _service(value: Any, name: str) -> ServiceConfig:
|
|||||||
raise ConfigError(f"{name} must be a table")
|
raise ConfigError(f"{name} must be a table")
|
||||||
allowed = {
|
allowed = {
|
||||||
"endpoint", "api_root", "local_root", "username", "password_file",
|
"endpoint", "api_root", "local_root", "username", "password_file",
|
||||||
"api_key_file", "advertised_addresses",
|
"api_key_file", "advertised_addresses", "local_path_overrides",
|
||||||
}
|
}
|
||||||
_keys(value, allowed, name)
|
_keys(value, allowed, name)
|
||||||
api_root = PurePosixPath(_string(value, "api_root"))
|
api_root = PurePosixPath(_string(value, "api_root"))
|
||||||
@@ -182,6 +196,7 @@ def _service(value: Any, name: str) -> ServiceConfig:
|
|||||||
raise ConfigError("qbittorrent username and password_file are required")
|
raise ConfigError("qbittorrent username and password_file are required")
|
||||||
if name == "syncthing" and "api_key_file" not in value:
|
if name == "syncthing" and "api_key_file" not in value:
|
||||||
raise ConfigError("syncthing api_key_file is required")
|
raise ConfigError("syncthing api_key_file is required")
|
||||||
|
overrides = _local_path_overrides(value, api_root, name)
|
||||||
return ServiceConfig(
|
return ServiceConfig(
|
||||||
_endpoint(value, "endpoint", {"http", "https"}), api_root,
|
_endpoint(value, "endpoint", {"http", "https"}), api_root,
|
||||||
_absolute_path(value, "local_root"), username,
|
_absolute_path(value, "local_root"), username,
|
||||||
@@ -189,10 +204,46 @@ def _service(value: Any, name: str) -> ServiceConfig:
|
|||||||
if "password_file" in value else None,
|
if "password_file" in value else None,
|
||||||
_absolute_path(value, "api_key_file")
|
_absolute_path(value, "api_key_file")
|
||||||
if "api_key_file" in value else None,
|
if "api_key_file" in value else None,
|
||||||
tuple(addresses),
|
tuple(addresses), overrides,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_path_overrides(
|
||||||
|
value: dict[str, Any], api_root: PurePosixPath, name: str
|
||||||
|
) -> tuple[tuple[PurePosixPath, Path], ...]:
|
||||||
|
raw = value.get("local_path_overrides", {})
|
||||||
|
if name != "syncthing" and raw:
|
||||||
|
raise ConfigError(f"{name}.local_path_overrides is unsupported")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ConfigError(f"{name}.local_path_overrides must be a table")
|
||||||
|
parsed: list[tuple[PurePosixPath, Path]] = []
|
||||||
|
for raw_api_path, raw_local_path in raw.items():
|
||||||
|
if not isinstance(raw_api_path, str) or not isinstance(raw_local_path, str):
|
||||||
|
raise ConfigError(f"{name}.local_path_overrides entries must be strings")
|
||||||
|
candidate = PurePosixPath(raw_api_path)
|
||||||
|
if not candidate.is_absolute() or ".." in candidate.parts:
|
||||||
|
raise ConfigError(f"{name}.local_path_overrides API path is invalid")
|
||||||
|
if _safe_relative(candidate, api_root) is None:
|
||||||
|
raise ConfigError(f"{name}.local_path_overrides API path is outside root")
|
||||||
|
local = Path(raw_local_path)
|
||||||
|
if not local.is_absolute():
|
||||||
|
raise ConfigError(f"{name}.local_path_overrides local path is invalid")
|
||||||
|
parsed.append((candidate, local))
|
||||||
|
return tuple(sorted(parsed, key=lambda item: len(item[0].parts), reverse=True))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_relative(
|
||||||
|
candidate: PurePosixPath, root: PurePosixPath
|
||||||
|
) -> PurePosixPath | None:
|
||||||
|
try:
|
||||||
|
relative = candidate.relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if any(part in {"", ".", ".."} for part in relative.parts):
|
||||||
|
raise ConfigError("API path contains an unsafe component")
|
||||||
|
return relative
|
||||||
|
|
||||||
|
|
||||||
def _connection(value: Any) -> ConnectionConfig:
|
def _connection(value: Any) -> ConnectionConfig:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ConfigError("connection must be a table")
|
raise ConfigError("connection must be a table")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
import threading
|
import threading
|
||||||
@@ -893,6 +894,7 @@ class ClientJobExecutor:
|
|||||||
if (
|
if (
|
||||||
not stat.S_ISREG(metadata.st_mode)
|
not stat.S_ISREG(metadata.st_mode)
|
||||||
or metadata.st_dev != destination_device
|
or metadata.st_dev != destination_device
|
||||||
|
or _mount_id(source) != _mount_id(destination_root)
|
||||||
):
|
):
|
||||||
required += logical_bytes
|
required += logical_bytes
|
||||||
return required
|
return required
|
||||||
@@ -1150,3 +1152,41 @@ def _job_error_code(error: Exception) -> int:
|
|||||||
if isinstance(error, EvictionError):
|
if isinstance(error, EvictionError):
|
||||||
return common_pb2.ERROR_CODE_PRECONDITION_FAILED
|
return common_pb2.ERROR_CODE_PRECONDITION_FAILED
|
||||||
return common_pb2.ERROR_CODE_INTERNAL
|
return common_pb2.ERROR_CODE_INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
def _mount_id(path: Path) -> str | None:
|
||||||
|
"""Return Linux's effective mount ID for a path when procfs is available.
|
||||||
|
|
||||||
|
Bind mounts can share ``st_dev`` while still rejecting ``link(2)`` with
|
||||||
|
``EXDEV``. Mount IDs distinguish that case without creating probe files
|
||||||
|
inside a Syncthing folder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
target = os.path.realpath(path)
|
||||||
|
best: tuple[int, str] | None = None
|
||||||
|
with open("/proc/self/mountinfo", encoding="utf-8") as source:
|
||||||
|
for line in source:
|
||||||
|
fields = line.rstrip("\n").split(" ")
|
||||||
|
if len(fields) < 5:
|
||||||
|
continue
|
||||||
|
mountpoint = _unescape_mount_path(fields[4])
|
||||||
|
if target != mountpoint and not target.startswith(
|
||||||
|
mountpoint.rstrip("/") + "/"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
candidate = (len(mountpoint), fields[0])
|
||||||
|
if best is None or candidate[0] > best[0]:
|
||||||
|
best = candidate
|
||||||
|
return None if best is None else best[1]
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unescape_mount_path(value: str) -> str:
|
||||||
|
return (
|
||||||
|
value.replace("\\040", " ")
|
||||||
|
.replace("\\011", "\t")
|
||||||
|
.replace("\\012", "\n")
|
||||||
|
.replace("\\134", "\\")
|
||||||
|
)
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ def discover_routes(
|
|||||||
relative = normalized_api_path.relative_to(roots.api_root)
|
relative = normalized_api_path.relative_to(roots.api_root)
|
||||||
except (ConfigError, ValueError):
|
except (ConfigError, ValueError):
|
||||||
continue
|
continue
|
||||||
if not _lexically_within(local_path, roots.local_root):
|
if not _lexically_within(
|
||||||
|
local_path, roots.local_root_for_api(normalized_api_path.as_posix())
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
if relative == PurePosixPath("."):
|
if relative == PurePosixPath("."):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -356,7 +356,9 @@ class SyncthingRouteManager:
|
|||||||
local_path = self.config.roots.api_to_local(api_path)
|
local_path = self.config.roots.api_to_local(api_path)
|
||||||
except ConfigError as exc:
|
except ConfigError as exc:
|
||||||
raise RoutePathConflict("route path is outside the sync root") from exc
|
raise RoutePathConflict("route path is outside the sync root") from exc
|
||||||
resolved_root = self.config.local_root.resolve(strict=False)
|
resolved_root = self.config.roots.local_root_for_api(api_path).resolve(
|
||||||
|
strict=False
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
local_path.resolve(strict=False).relative_to(resolved_root)
|
local_path.resolve(strict=False).relative_to(resolved_root)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
+16
-1
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path, PurePosixPath
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from archive_clients.config import ClientConfig, ConfigError, RootMapping
|
from archive_clients.config import ClientConfig, ConfigError, RootMapping
|
||||||
@@ -63,6 +63,21 @@ class ConfigTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ConfigError):
|
with self.assertRaises(ConfigError):
|
||||||
mapping.api_to_local("/elsewhere/file")
|
mapping.api_to_local("/elsewhere/file")
|
||||||
|
|
||||||
|
def test_mapping_can_override_one_syncthing_folder_locally(self):
|
||||||
|
mapping = RootMapping(
|
||||||
|
PurePosixPath("/sync"),
|
||||||
|
Path("/local/sync"),
|
||||||
|
((PurePosixPath("/sync/DownloadsSync"), Path("/local/qb/Sync")),),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mapping.api_to_local("/sync/DownloadsSync/job/ready.json"),
|
||||||
|
Path("/local/qb/Sync/job/ready.json"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mapping.local_root_for_api("/sync/DownloadsSync"),
|
||||||
|
Path("/local/qb/Sync"),
|
||||||
|
)
|
||||||
|
|
||||||
def test_endpoint_scheme_and_job_keys_are_strict(self):
|
def test_endpoint_scheme_and_job_keys_are_strict(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|||||||
@@ -78,6 +78,25 @@ class ClientJobHappyPathTests(unittest.TestCase):
|
|||||||
content=b"x" * 4096,
|
content=b"x" * 4096,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_mount_boundary_requires_copy_space_even_with_same_device(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source_root = root / "source"
|
||||||
|
destination_root = root / "destination"
|
||||||
|
source_root.mkdir()
|
||||||
|
destination_root.mkdir()
|
||||||
|
(source_root / "fixture.bin").write_bytes(b"fixture")
|
||||||
|
with patch(
|
||||||
|
"archive_clients.jobs._mount_id",
|
||||||
|
side_effect=("source-mount", "destination-mount"),
|
||||||
|
):
|
||||||
|
required = ClientJobExecutor._copy_required_bytes(
|
||||||
|
source_root,
|
||||||
|
destination_root,
|
||||||
|
(("fixture.bin", 7),),
|
||||||
|
)
|
||||||
|
self.assertEqual(required, 7)
|
||||||
|
|
||||||
def _run_transfer(
|
def _run_transfer(
|
||||||
self,
|
self,
|
||||||
root: Path,
|
root: Path,
|
||||||
|
|||||||
@@ -62,6 +62,32 @@ class RouteDiscoveryTests(unittest.TestCase):
|
|||||||
self.assertEqual(routes[0].local_relative_path, "DownloadsSync")
|
self.assertEqual(routes[0].local_relative_path, "DownloadsSync")
|
||||||
self.assertEqual(routes[0].state, route_pb2.ROUTE_STATE_DISCOVERED)
|
self.assertEqual(routes[0].state, route_pb2.ROUTE_STATE_DISCOVERED)
|
||||||
|
|
||||||
|
def test_discovery_accepts_a_safe_local_folder_override(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
override = root / "qb/Sync"
|
||||||
|
override.mkdir(parents=True)
|
||||||
|
roots = RootMapping(
|
||||||
|
PurePosixPath("/var/syncthing"),
|
||||||
|
root / "sync",
|
||||||
|
((PurePosixPath("/var/syncthing/DownloadsSync"), override),),
|
||||||
|
)
|
||||||
|
routes = discover_routes(
|
||||||
|
{"folders": [{
|
||||||
|
"id": "DownloadsSync",
|
||||||
|
"path": "~/DownloadsSync",
|
||||||
|
"type": "sendreceive",
|
||||||
|
"devices": [
|
||||||
|
{"deviceID": "LOCAL"},
|
||||||
|
{"deviceID": "ARCHIVE"},
|
||||||
|
],
|
||||||
|
}]},
|
||||||
|
"LOCAL",
|
||||||
|
roots,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
self.assertEqual([route.route_id for route in routes], ["DownloadsSync"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user