Add WebDAV backup, statistics, progress and dashboard

- WebDAV uploader: concurrency, request and bandwidth limits, retry with
  backoff, offline catch-up, encrypted manifests, durability tracking
- Automatic unique remote directory claimed with a conditional PUT
- AES-256-GCM client-side encryption with keyed blob names
- /stats, /progress, /progress/stream and a /dashboard page
- Own ctypes inotify binding with a compact watch tree (263 MB -> 76 MB RSS)
- Directory-path ignore patterns and a forget command
- Indexed range queries instead of LIKE for path prefixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
retoor
2026-10-08 20:24:04 +02:00
co-authored by Claude Opus 5.5
parent ddf09bea88
commit 7e05e3d26c
18 changed files with 1992 additions and 208 deletions
+201
View File
@@ -0,0 +1,201 @@
"""WebDAV client and the automatic unique remote directory."""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import re
import secrets
import socket
import time
from dataclasses import dataclass
from typing import Any
import httpx
INSTALL_APP_ID = b"versiond:4c1b9e0f7a2d4e8b"
FORMAT_VERSION = 1
class RemoteError(Exception):
def __init__(self, code: str, detail: str):
super().__init__(detail)
self.code = code
self.detail = detail
@dataclass
class RemoteSettings:
url: str
username: str
password: str
base_path: str = "/versioned/"
verify_tls: bool = True
timeout_seconds: float = 30.0
directory: str = ""
@property
def configured(self) -> bool:
return bool(self.url and self.username and self.password)
def installation_id() -> str:
"""Stable per-user, per-machine id; the raw machine-id never leaves the host.
Same construction as systemd's sd_id128_get_machine_app_specific():
HMAC-SHA256 keyed by the machine id.
"""
try:
machine_id = open("/etc/machine-id").read().strip()
except OSError:
machine_id = ""
if not machine_id:
return secrets.token_hex(8)
msg = INSTALL_APP_ID + b":" + str(os.getuid()).encode()
return hmac.new(bytes.fromhex(machine_id), msg, hashlib.sha256).hexdigest()[:16]
def hostname_slug() -> str:
slug = re.sub(r"[^a-z0-9-]+", "-", socket.gethostname().lower()).strip("-")
return slug[:40] or "host"
def join(*parts: str) -> str:
"""Join remote path segments into '/a/b/c' (no trailing slash)."""
cleaned = [p.strip("/") for p in parts if p and p.strip("/")]
return "/" + "/".join(cleaned)
class WebDAV:
def __init__(self, settings: RemoteSettings, concurrency: int = 4,
transport: httpx.AsyncBaseTransport | None = None):
self.settings = settings
self.client = httpx.AsyncClient(
base_url=settings.url.rstrip("/"),
auth=(settings.username, settings.password),
verify=settings.verify_tls,
timeout=settings.timeout_seconds,
limits=httpx.Limits(max_connections=concurrency, max_keepalive_connections=concurrency),
transport=transport,
headers={"User-Agent": "versiond"},
)
self._known_dirs: set[str] = set()
async def close(self) -> None:
await self.client.aclose()
@staticmethod
def _check(resp: httpx.Response, *ok: int) -> httpx.Response:
if resp.status_code in ok:
return resp
if resp.status_code == 401:
raise RemoteError("remote-auth", "WebDAV server rejected the username or password")
if resp.status_code == 507:
raise RemoteError("remote-full", "WebDAV server is out of storage")
raise RemoteError("remote-http", f"{resp.request.method} {resp.request.url.path} -> HTTP {resp.status_code}")
async def request(self, method: str, path: str, **kwargs) -> httpx.Response:
try:
return await self.client.request(method, path, **kwargs)
except httpx.HTTPError as exc:
raise RemoteError("remote-unreachable", f"{method} {path}: {exc.__class__.__name__}: {exc}") from exc
async def exists(self, path: str) -> bool:
resp = await self.request("PROPFIND", path, headers={"Depth": "0"})
if resp.status_code == 404:
return False
self._check(resp, 207, 200)
return True
async def mkcol(self, path: str) -> None:
if path in self._known_dirs:
return
resp = await self.request("MKCOL", path + "/")
# 405: already exists. 301 is sent by some servers for existing collections.
self._check(resp, 201, 405, 301)
self._known_dirs.add(path)
async def makedirs(self, path: str) -> None:
current = ""
for part in path.strip("/").split("/"):
current += "/" + part
await self.mkcol(current)
async def put(self, path: str, data: bytes, if_none_match: bool = False) -> bool:
"""Upload; returns False if if_none_match was set and the object already exists."""
headers = {"Content-Type": "application/octet-stream"}
if if_none_match:
headers["If-None-Match"] = "*"
resp = await self.request("PUT", path, content=data, headers=headers)
if if_none_match and resp.status_code == 412:
return False
self._check(resp, 200, 201, 204)
return True
async def get(self, path: str) -> bytes | None:
resp = await self.request("GET", path)
if resp.status_code == 404:
return None
self._check(resp, 200)
return resp.content
async def delete(self, path: str) -> None:
resp = await self.request("DELETE", path)
self._check(resp, 200, 204, 404)
async def check_connection(dav: WebDAV) -> None:
"""Fail with a clear RemoteError unless we can list, create and delete."""
base = join(dav.settings.base_path)
await dav.makedirs(base)
probe = join(base, f".versiond-probe-{secrets.token_hex(4)}")
await dav.put(probe, b"probe")
await dav.delete(probe)
async def claim_directory(dav: WebDAV, key_id: str) -> dict[str, Any]:
"""Pick and claim this installation's unique directory below base_path.
Returns {"directory": ..., "adopted": bool}. Never takes over a directory that
belongs to a different installation id.
"""
base = join(dav.settings.base_path)
await dav.makedirs(base)
install_id = installation_id()
name = f"{hostname_slug()}-{install_id[:8]}"
for _ in range(5):
directory = join(base, name)
owner_path = join(directory, "meta", "owner.json")
existing = await dav.get(owner_path)
if existing is None:
for sub in ("meta", "blobs", "manifests"):
await dav.makedirs(join(directory, sub))
owner = {
"installation_id": install_id,
"hostname": socket.gethostname(),
"user": os.environ.get("USER", ""),
"created_at": time.time(),
"key_id": key_id,
}
if not await dav.put(owner_path, json.dumps(owner, indent=2).encode(), if_none_match=True):
continue # someone created it between our GET and PUT; re-read
fmt = {"format": FORMAT_VERSION, "encryption": "aes-256-gcm", "compression": "zstd",
"blob_layout": "blobs/<hmac[:2]>/<hmac>", "key_id": key_id}
await dav.put(join(directory, "meta", "format.json"), json.dumps(fmt, indent=2).encode())
return {"directory": directory, "adopted": False}
try:
owner = json.loads(existing)
except ValueError:
owner = {}
if owner.get("installation_id") == install_id:
if owner.get("key_id") not in (None, key_id):
raise RemoteError(
"key-mismatch",
f"{directory} was written with a different backup key; restore that key "
"(~/.config/versiond/backup.key) or remove the directory",
)
return {"directory": directory, "adopted": True}
name = f"{hostname_slug()}-{install_id[:8]}-{secrets.token_hex(2)}"
raise RemoteError("claim-failed", "could not claim a unique remote directory")