From e8b0f961c7b8724fe00af1ee69b0cd07c665a6f6 Mon Sep 17 00:00:00 2001 From: sudolulo Date: Tue, 16 Jun 2026 16:41:26 +0000 Subject: [PATCH] Fix B2 restic URL: use colon separator (b2:bucket:path) for restic 0.16.x restic 0.16.x changed the B2 URL format to use a colon between bucket and path (b2:bucket:prefix) instead of a slash. The middlewared URL builder produces b2:/bucket/path; restic then validates the full string after 'b2:' as a bucket name, which fails because the slash is not in [a-z0-9-]. Fix the restic.py wrapper to strip the leading slash and replace the first slash with a colon: b2:/bucket/path -> b2:bucket:path. Also fix the hasattr bug in sitecustomize.py _patch_b2: hasattr() returns True for methods inherited from the base class (which raises NotImplementedError), causing the patch to be silently skipped. Use 'get_restic_config' not in cls.__dict__ instead. --- README.md | 4 +-- patch/apply.sh | 46 ++++++++++++++++++------------ patch/sitecustomize.py | 65 +++++++++++++++++++++++------------------- 3 files changed, 65 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 6360bfa..2135d3e 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ on every update) and are therefore re-applied automatically on every boot. | Layer | What changes | Technique | |---|---|---| -| **Backend** | `B2RcloneRemote` gains `get_restic_config()`. `restic.py` URL builder is fixed for providers with no hostname component (`b2:bucket/path` vs the broken `b2:/bucket/path`). | `sitecustomize.py` — Python's standard startup hook; no middleware files are modified on disk | +| **Backend** | `B2RcloneRemote` gains `get_restic_config()`. `restic.py` URL builder is fixed: strips the stray leading slash and converts the slash separator to a colon (`b2:bucket:path`), which is the format restic 0.16.x expects. | Direct file patch in the overlay (primary) + `sitecustomize.py` import hook (belt-and-suspenders) | | **UI** | The Angular bundle's `filterByProviders` binding is widened from `["STORJ_IX"]` to `["STORJ_IX","S3","B2"]` | In-place text replacement in the compiled JS chunk; original is backed up | Both changes are **fail-safe**: if a patch cannot be applied (e.g. TrueNAS @@ -202,7 +202,7 @@ You need three things from the task you created: export B2_ACCOUNT_ID="your-key-id" export B2_ACCOUNT_KEY="your-application-key" export RESTIC_PASSWORD="your-repo-password" -REPO="b2:your-bucket/your-folder" +REPO="b2:your-bucket:your-folder" # restic 0.16.x uses colon, not slash ``` **S3-compatible (AWS S3, Wasabi, Cloudflare R2, MinIO, etc.):** diff --git a/patch/apply.sh b/patch/apply.sh index 4994bd5..d07c393 100755 --- a/patch/apply.sh +++ b/patch/apply.sh @@ -239,25 +239,35 @@ def get_restic_config(cloud_backup): cmd = list(result.cmd) for i, part in enumerate(cmd): if part.startswith("--repo=") or part.startswith("--repository="): - prefix, _, url = part.partition("=") - prefix += "=" - scheme, sep, rest = url.partition(":") - if sep and rest.startswith("/") and not rest.startswith("//"): - cmd[i] = f"{prefix}{scheme}:{rest[1:]}" - try: - return _dc.replace(result, cmd=cmd) - except TypeError: - return result._replace(cmd=cmd) - break - if i and cmd[i - 1] in ("-r", "--repo", "--repository"): - scheme, sep, rest = part.partition(":") - if sep and rest.startswith("/") and not rest.startswith("//"): - cmd[i] = f"{scheme}:{rest[1:]}" - try: - return _dc.replace(result, cmd=cmd) - except TypeError: - return result._replace(cmd=cmd) + pfx, _, url = part.partition("=") + pfx += "=" + elif i and cmd[i - 1] in ("-r", "--repo", "--repository"): + pfx = None + url = part + else: + continue + scheme, sep, rest = url.partition(":") + if not sep: break + changed = False + # Strip stray leading slash: b2:/bucket -> b2:bucket + if rest.startswith("/") and not rest.startswith("//"): + rest = rest[1:] + changed = True + # restic 0.16.x B2 uses colon to separate bucket from path: + # b2:bucket:prefix (not b2:bucket/prefix) + # middlewared builds the slash form; fix the separator. + if scheme == "b2" and "/" in rest: + rest = rest.replace("/", ":", 1) + changed = True + if changed: + new_url = scheme + ":" + rest + cmd[i] = pfx + new_url if pfx is not None else new_url + try: + return _dc.replace(result, cmd=cmd) + except TypeError: + return result._replace(cmd=cmd) + break return result get_restic_config._truecloud_patched = True diff --git a/patch/sitecustomize.py b/patch/sitecustomize.py index f5d75d1..b649abf 100644 --- a/patch/sitecustomize.py +++ b/patch/sitecustomize.py @@ -7,13 +7,14 @@ Hooks two middlewared module imports using the find_spec / exec_module API middlewared.rclone.remote.b2 Adds get_restic_config() so the native restic B2 backend works. - Restic repo URL: b2:/ + Restic repo URL: b2:: (colon separator, restic 0.16.x) Auth: B2_ACCOUNT_ID, B2_ACCOUNT_KEY middlewared.plugins.cloud_backup.restic - Fixes the URL builder for providers with no hostname component. - Stock code: f"{rclone_type}:{url}/{remote_path}" → "b2:/bucket/path" (broken) - Patched: "b2:bucket/path" when url == "" + Fixes the URL builder for providers with no hostname component, and + converts the slash separator to a colon for B2 (restic 0.16.x format): + Stock code: f"{rclone_type}:{url}/{remote_path}" → "b2:/bucket/path" + Patched: "b2:bucket:path" (leading slash stripped, / → : for B2) Both patches are no-ops if the module already provides the functionality (i.e. a future TrueNAS version adds native support). All errors are caught @@ -134,7 +135,7 @@ class _Loader: def _patch_b2(module): cls = module.B2RcloneRemote - if hasattr(cls, "get_restic_config"): + if "get_restic_config" in cls.__dict__: # A future TrueNAS version already added native B2 restic support. _record_status("middlewared.rclone.remote.b2", ok=True, detail="native support present; patch not needed") @@ -168,42 +169,46 @@ def _patch_restic(module): _orig = module.get_restic_config def get_restic_config(cloud_backup): - # Call the original — it handles transfer_setting, cache, RESTIC_PASSWORD, - # env construction, and everything else we don't own. + # Call the original — it handles cache, RESTIC_PASSWORD, env, etc. result = _orig(cloud_backup) - # Fix stray leading slash in repo URL: "b2:/bucket" → "b2:bucket". + # Fix the repo URL in the restic command. + # Stock middlewared builds: b2:/bucket/path + # restic 0.16.x B2 expects: b2:bucket:path (colon separator, no leading slash) # "scheme://path" (Storj) must not be touched. - # Covers all flag forms restic accepts (--repository is a documented synonym): - # -r --repo --repo= - # --repository --repository= cmd = list(result.cmd) for i, part in enumerate(cmd): if part.startswith("--repo=") or part.startswith("--repository="): - prefix, _, url = part.partition("=") - prefix += "=" - scheme, sep, rest = url.partition(":") - if sep and rest.startswith("/") and not rest.startswith("//"): - cmd[i] = f"{prefix}{scheme}:{rest[1:]}" - try: - return dataclasses.replace(result, cmd=cmd) - except TypeError: - return result._replace(cmd=cmd) - break - if i and cmd[i - 1] in ("-r", "--repo", "--repository"): - scheme, sep, rest = part.partition(":") - if sep and rest.startswith("/") and not rest.startswith("//"): - cmd[i] = f"{scheme}:{rest[1:]}" - try: - return dataclasses.replace(result, cmd=cmd) - except TypeError: - return result._replace(cmd=cmd) + pfx, _, url = part.partition("=") + pfx += "=" + elif i and cmd[i - 1] in ("-r", "--repo", "--repository"): + pfx = None + url = part + else: + continue + scheme, sep, rest = url.partition(":") + if not sep: break + changed = False + if rest.startswith("/") and not rest.startswith("//"): + rest = rest[1:] + changed = True + if scheme == "b2" and "/" in rest: + rest = rest.replace("/", ":", 1) + changed = True + if changed: + new_url = scheme + ":" + rest + cmd[i] = pfx + new_url if pfx is not None else new_url + try: + return dataclasses.replace(result, cmd=cmd) + except TypeError: + return result._replace(cmd=cmd) + break return result get_restic_config._truecloud_patched = True module.get_restic_config = get_restic_config - sys.stderr.write("[truecloud-patch] restic URL fix applied\n") + sys.stderr.write("[truecloud-patch] restic B2 URL fix applied (b2:bucket:path)\n") _record_status("middlewared.plugins.cloud_backup.restic", ok=True)