From 9323bdb6f27e406d846617035cd7f6ce155628e8 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:13:37 +0100 Subject: [PATCH 1/7] fix(worker): let a tag-only upgrade take a new socket proxy policy in one step The policy lives in the operator's compose file, so an image bump never changes it and the worker only warned. The worker image now rewrites just the policy block of a compose file piped through it (no mounts, no network), and the warning, the status page and the update card give that command, the release it targets and the recreate that loads it. The proxy also carries the fingerprint: Compose does not recreate a container when only a config's content changes, so a new policy stayed unloaded while the worker's check said it was current. --- .github/workflows/test.yml | 6 ++ UPGRADING.md | 4 +- app/orchestrator.py | 44 ++++++++- app/socket_policy.py | 52 +++++++++++ app/static/js/app.js | 18 ++++ app/worker_api.py | 14 +-- .../casaos/CashPilot/docker-compose.yml | 4 + .../runtipi/cashpilot/docker-compose.yml | 4 + docker-compose.build.yml | 4 + docker-compose.fleet.yml | 4 + docker-compose.worker.yml | 4 + docker-compose.yml | 4 + docs/configuration.md | 2 +- docs/security-defaults.md | 22 ++++- scripts/proxy_policy_card_check.mjs | 64 +++++++++++++ scripts/socket_proxy_policy.py | 34 ++----- tests/test_socket_proxy.py | 92 ++++++++++++++++++- 17 files changed, 333 insertions(+), 43 deletions(-) create mode 100644 scripts/proxy_policy_card_check.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c7727bb4..b388762d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,6 +53,12 @@ jobs: - name: Settings failure path run: node scripts/settings_failure_check.mjs + # Also browser-free. The worker sends the commands that update a stale + # socket proxy policy; only running the card proves they arrive escaped + # and that a worker cannot plant a javascript: link. + - name: Proxy policy note + run: node scripts/proxy_policy_card_check.mjs + # Also browser-free. The claim modal has to give THREE different answers # for what looks like one empty result; only running it proves which. - name: Claim modal honesty diff --git a/UPGRADING.md b/UPGRADING.md index a0088015..41d46c53 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -299,7 +299,9 @@ no longer mounts the socket. worker behind it. The containers the worker deployed keep running. 5. Open the worker's status page: it should say *Docker access: socket proxy*. If it adds that the policy is not the one the image expects, a later - release changed the policy: take that release's compose file the same way. + release changed the policy. The message gives the command that updates + only the policy in your compose file, and the `docker compose up -d + --force-recreate cashpilot-socket-proxy cashpilot-worker` that loads it. **If something is refused.** The dashboard shows the proxy's reason, and `docker logs cashpilot-socket-proxy` lists every refused call. A bind outside diff --git a/app/orchestrator.py b/app/orchestrator.py index 7369c831..e6e47bc9 100644 --- a/app/orchestrator.py +++ b/app/orchestrator.py @@ -43,7 +43,7 @@ logger = logging.getLogger(__name__) -from app import socket_policy # noqa: E402 +from app import socket_policy, version # noqa: E402 from app.constants import ( # noqa: E402 CASHPILOT_DEPLOYED, CONTAINER_PREFIX, @@ -203,6 +203,47 @@ def proxy_policy_current() -> bool | None: return os.getenv("CASHPILOT_PROXY_POLICY", "").strip() == _policy_digest +#: Compose does not recreate a container when only its config's content +#: changes, so the proxy is named: a plain `up -d` can leave it on the old policy. +PROXY_RECREATE = f"docker compose up -d --force-recreate {PROXY_SERVICE} cashpilot-worker" + + +def proxy_policy_fix() -> dict[str, str] | None: + """How to bring the proxy's policy up to this image's, or None when it is. + + The worker never rewrites the policy itself: it is what the policy + restricts (app/socket_policy.py). It names this release and both ways to + take its policy, so the operator does it in one step. + """ + if proxy_policy_current() is not False: + return None + current = version.current() + release = current != version.UNKNOWN + tag = current if release else "latest" + ref = f"v{current}" if release else "main" + return { + "release": version.display(), + "command": ( + f"docker run --rm -i --network none drumsergio/cashpilot-worker:{tag} " + "python -m app.socket_policy < docker-compose.yml > docker-compose.new.yml" + ), + "url": f"https://github.com/GeiserX/CashPilot/blob/{ref}/docker-compose.worker.yml", + "recreate": PROXY_RECREATE, + } + + +def proxy_policy_fix_text(fix: dict[str, str]) -> str: + """The fix as one sentence for the log and the status page.""" + return ( + f"its policy is not the one this worker image ({fix['release']}) expects, so some calls may be " + "refused. An image update never changes the policy: it lives in your compose file. Update it with " + f"`{fix['command']}`, check the diff, use the new file in place of yours, then run " + f"`{PROXY_RECREATE}` (with your -f if you use one). " + "Or copy the lines from '# BEGIN socket proxy policy' to '# END socket proxy policy', and the " + f"CASHPILOT_PROXY_POLICY line, from {fix['url']} (the same in every compose file of the release)." + ) + + def proxy_refusal(exc: BaseException) -> str | None: """The proxy's reason for refusing a call, as a sentence, or None. @@ -2412,6 +2453,7 @@ def self_update_info() -> dict[str, Any]: "updater_running": False, "docker_access": docker_access(), "proxy_policy_current": proxy_policy_current(), + "proxy_policy_fix": proxy_policy_fix(), } if me is not None: project = _compose_project(me) diff --git a/app/socket_policy.py b/app/socket_policy.py index d3c6dbdb..23cb6a28 100644 --- a/app/socket_policy.py +++ b/app/socket_policy.py @@ -7,12 +7,25 @@ loaded is older than the image it runs (``digest`` against the CASHPILOT_PROXY_POLICY value the compose file gives it), and refuse a runtime the proxy would refuse before a running container is touched. + +The policy stays in the operator's compose file, never in the image: the image +is what the policy restricts, so an image update must not be able to widen it. +An operator who keeps their own compose file brings the policy up to date with +this image, without giving the image any access to that file: + + docker run --rm -i --network none drumsergio/cashpilot-worker: \ + python -m app.socket_policy < docker-compose.yml > docker-compose.new.yml + +It replaces the block between the policy markers and the fingerprint, changes +nothing else, and the operator reads the diff before using the new file. """ from __future__ import annotations import hashlib import json +import re +import sys from pathlib import Path import yaml @@ -230,3 +243,42 @@ def policy(services_dir: Path = SERVICES_DIR) -> str: def digest(services_dir: Path = SERVICES_DIR) -> str: """A short fingerprint of the policy, carried next to it in each compose file.""" return hashlib.sha256(policy(services_dir).encode()).hexdigest()[:16] + + +BEGIN = " # BEGIN socket proxy policy: written by scripts/socket_proxy_policy.py, do not edit by hand\n" +END = " # END socket proxy policy\n" +#: The worker's copy of the policy's fingerprint, so it can tell when its image +#: is newer than the policy the proxy loaded. +DIGEST_RE = re.compile(r"(CASHPILOT_PROXY_POLICY=)[0-9a-f]*") + + +def compose_block(services_dir: Path = SERVICES_DIR, passes: int = 1) -> str: + """The policy as it sits in a compose file: indented, each `$` doubled once + per interpolation pass the file goes through (`$$` for plain Compose).""" + body = "".join(f" {line}\n" if line else "\n" for line in policy(services_dir).splitlines()) + return BEGIN + body.replace("$", "$" * 2**passes) + END + + +def render(text: str, block: str, fingerprint: str) -> str: + """A compose file with its policy block and fingerprint replaced, nothing else.""" + start, end = text.find(BEGIN), text.find(END) + if start < 0 or end < start: + raise SystemExit("compose file has no socket proxy policy markers") + if not DIGEST_RE.search(text): + raise SystemExit("compose file gives the worker no CASHPILOT_PROXY_POLICY") + text = text[:start] + block + text[end + len(END) :] + return DIGEST_RE.sub(lambda m: m.group(1) + fingerprint, text) + + +def main() -> int: + """Read a compose file on stdin, write it with this image's policy on stdout.""" + try: + sys.stdout.write(render(sys.stdin.read(), compose_block(), digest())) + except SystemExit as exc: + print(f"{exc}; take the compose file of this release instead", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/static/js/app.js b/app/static/js/app.js index fbad2946..06099ebf 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -4405,6 +4405,15 @@ const CP = (() => { loadUpdateCard(); } + // The URL comes from the worker, so only a link into this project's + // repository becomes clickable; anything else is shown as text. + function proxyPolicyLink(url) { + const text = escapeHtml(url); + return String(url || '').startsWith('https://github.com/GeiserX/CashPilot/') + ? `${text}` + : text; + } + function renderUpdateCard(info, onlineWorkers) { updateCardWorkerOnly = !!info.ui_by_hand; const container = document.getElementById('update-card-container'); @@ -4431,6 +4440,15 @@ const CP = (() => { if (info.worker_image) html += line('Worker image', info.worker_image); if (info.ui_image) html += line('Dashboard image', info.ui_image); html += line('Latest release', latest || 'unknown — check disabled, not yet run, or GitHub unreachable'); + // The proxy's policy lives in the compose file, which no image update + // changes; the worker says which release's policy it needs and how to take it. + const fix = info.proxy_policy_fix; + if (fix) { + html += ` +

${escapeHtml(`The socket proxy's policy is older than this worker (${fix.release}) needs, so some Docker calls may be refused. An image update never changes it: it lives in your compose file. Run this next to your compose file, check the diff, use the new file in place of yours, then run the second command (with your -f if you use one):`)}

+
${escapeHtml(fix.command)}\n${escapeHtml(fix.recreate)}
+

${escapeHtml('Or copy the lines from "# BEGIN socket proxy policy" to "# END socket proxy policy", and the CASHPILOT_PROXY_POLICY lines, from')} ${proxyPolicyLink(fix.url)}

`; + } let action; if (info.updater_running) { diff --git a/app/worker_api.py b/app/worker_api.py index 729f410d..c2434c89 100644 --- a/app/worker_api.py +++ b/app/worker_api.py @@ -1059,8 +1059,8 @@ async def lifespan(app: FastAPI): logger.info( "Docker: %s (%s)", docker_mode, "through the socket proxy" if orchestrator.proxied() else "direct socket" ) - if orchestrator.proxy_policy_current() is False: - logger.warning("Docker socket proxy: %s", PROXY_POLICY_STALE) + if fix := orchestrator.proxy_policy_fix(): + logger.warning("Docker socket proxy: %s", orchestrator.proxy_policy_fix_text(fix)) # The fence goes in before anything else, so the gap after a reboot in # which earners run without it is as short as this worker can make it. @@ -1141,17 +1141,11 @@ def jsonable_errors(errors: Any) -> Any: # --------------------------------------------------------------------------- -PROXY_POLICY_STALE = ( - "its policy is not the one this worker image expects, so some calls may be refused. " - "Replace your compose file with the one from this release (keep your .env), then run docker compose up -d." -) - - def _docker_access_text() -> str: if not orchestrator.proxied(): return "direct socket (root on this host)" - if orchestrator.proxy_policy_current() is False: - return f"socket proxy: {PROXY_POLICY_STALE}" + if fix := orchestrator.proxy_policy_fix(): + return f"socket proxy: {orchestrator.proxy_policy_fix_text(fix)}" return "socket proxy" diff --git a/contrib/app-stores/casaos/CashPilot/docker-compose.yml b/contrib/app-stores/casaos/CashPilot/docker-compose.yml index 58bf53eb..33528c29 100644 --- a/contrib/app-stores/casaos/CashPilot/docker-compose.yml +++ b/contrib/app-stores/casaos/CashPilot/docker-compose.yml @@ -122,6 +122,10 @@ services: # into "$VAR" on import and then into an empty string, and an empty # entry would allow every folder. - SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOWED_BIND_MOUNTS=/dev/null/cashpilot-compose-dir-unset,/dev/null/cashpilot-bind-mounts-unset + # The policy's fingerprint. Compose does not recreate a container when + # only its config's content changes, so this line is what makes + # `docker compose up -d` restart the proxy on a new policy. + - CASHPILOT_PROXY_POLICY=4c547983d8a0aaa1 configs: - source: cashpilot_socket_policy target: /etc/sockguard/sockguard.yaml diff --git a/contrib/app-stores/runtipi/cashpilot/docker-compose.yml b/contrib/app-stores/runtipi/cashpilot/docker-compose.yml index f94a266b..398acb60 100644 --- a/contrib/app-stores/runtipi/cashpilot/docker-compose.yml +++ b/contrib/app-stores/runtipi/cashpilot/docker-compose.yml @@ -87,6 +87,10 @@ services: # The placeholders sit under /dev/null, where no directory can ever exist # (Docker cannot create one there), so an unset variable allows nothing. - SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOWED_BIND_MOUNTS=${CASHPILOT_COMPOSE_DIR:-/dev/null/cashpilot-compose-dir-unset},${CASHPILOT_PROXY_BIND_MOUNTS:-/dev/null/cashpilot-bind-mounts-unset} + # The policy's fingerprint. Compose does not recreate a container when + # only its config's content changes, so this line is what makes + # `docker compose up -d` restart the proxy on a new policy. + - CASHPILOT_PROXY_POLICY=4c547983d8a0aaa1 configs: - source: cashpilot_socket_policy target: /etc/sockguard/sockguard.yaml diff --git a/docker-compose.build.yml b/docker-compose.build.yml index adc53e92..30d05bc4 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -131,6 +131,10 @@ services: # The placeholders sit under /dev/null, where no directory can ever exist # (Docker cannot create one there), so an unset variable allows nothing. - SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOWED_BIND_MOUNTS=${CASHPILOT_COMPOSE_DIR:-/dev/null/cashpilot-compose-dir-unset},${CASHPILOT_PROXY_BIND_MOUNTS:-/dev/null/cashpilot-bind-mounts-unset} + # The policy's fingerprint. Compose does not recreate a container when + # only its config's content changes, so this line is what makes + # `docker compose up -d` restart the proxy on a new policy. + - CASHPILOT_PROXY_POLICY=4c547983d8a0aaa1 configs: - source: cashpilot_socket_policy target: /etc/sockguard/sockguard.yaml diff --git a/docker-compose.fleet.yml b/docker-compose.fleet.yml index 9f154a10..a19e9586 100644 --- a/docker-compose.fleet.yml +++ b/docker-compose.fleet.yml @@ -138,6 +138,10 @@ services: # The placeholders sit under /dev/null, where no directory can ever exist # (Docker cannot create one there), so an unset variable allows nothing. - SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOWED_BIND_MOUNTS=${CASHPILOT_COMPOSE_DIR:-/dev/null/cashpilot-compose-dir-unset},${CASHPILOT_PROXY_BIND_MOUNTS:-/dev/null/cashpilot-bind-mounts-unset} + # The policy's fingerprint. Compose does not recreate a container when + # only its config's content changes, so this line is what makes + # `docker compose up -d` restart the proxy on a new policy. + - CASHPILOT_PROXY_POLICY=4c547983d8a0aaa1 configs: - source: cashpilot_socket_policy target: /etc/sockguard/sockguard.yaml diff --git a/docker-compose.worker.yml b/docker-compose.worker.yml index 5773234e..d4acd067 100644 --- a/docker-compose.worker.yml +++ b/docker-compose.worker.yml @@ -90,6 +90,10 @@ services: # The placeholders sit under /dev/null, where no directory can ever exist # (Docker cannot create one there), so an unset variable allows nothing. - SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOWED_BIND_MOUNTS=${CASHPILOT_COMPOSE_DIR:-/dev/null/cashpilot-compose-dir-unset},${CASHPILOT_PROXY_BIND_MOUNTS:-/dev/null/cashpilot-bind-mounts-unset} + # The policy's fingerprint. Compose does not recreate a container when + # only its config's content changes, so this line is what makes + # `docker compose up -d` restart the proxy on a new policy. + - CASHPILOT_PROXY_POLICY=4c547983d8a0aaa1 configs: - source: cashpilot_socket_policy target: /etc/sockguard/sockguard.yaml diff --git a/docker-compose.yml b/docker-compose.yml index 650844d4..e6e4230d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -153,6 +153,10 @@ services: # The placeholders sit under /dev/null, where no directory can ever exist # (Docker cannot create one there), so an unset variable allows nothing. - SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOWED_BIND_MOUNTS=${CASHPILOT_COMPOSE_DIR:-/dev/null/cashpilot-compose-dir-unset},${CASHPILOT_PROXY_BIND_MOUNTS:-/dev/null/cashpilot-bind-mounts-unset} + # The policy's fingerprint. Compose does not recreate a container when + # only its config's content changes, so this line is what makes + # `docker compose up -d` restart the proxy on a new policy. + - CASHPILOT_PROXY_POLICY=4c547983d8a0aaa1 configs: - source: cashpilot_socket_policy target: /etc/sockguard/sockguard.yaml diff --git a/docs/configuration.md b/docs/configuration.md index 917df531..c6c57b26 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -164,7 +164,7 @@ These are read by the compose files, not by CashPilot itself. | `CASHPILOT_WORKER_BIND_ADDR` | `127.0.0.1` | Which host interface publishes the **worker** port, in the fleet and worker-only compose files. The worker's API can create containers on the host, so publish it only on an interface the UI needs, never `0.0.0.0`. | | `CASHPILOT_COMPOSE_DIR` | unset | Your compose folder. The [socket proxy](security-defaults.md#docker-socket-proxy) lets containers bind it, which the in-app update needs. Unset, the **Update** button refuses and prints the line to add. | | `CASHPILOT_PROXY_BIND_MOUNTS` | unset | Other host folders the socket proxy lets containers bind, comma-separated with no spaces: the data folder of a service you deploy with a host path, such as Storj (`/mnt/user/storj`). Each entry also counts as a `CASHPILOT_ALLOWED_VOLUME_ROOTS` entry, so this one setting opens the folder for both the worker and the proxy. A deploy outside them is refused before anything is touched, with a message naming this variable. | -| `CASHPILOT_PROXY_POLICY` | set by the compose file | The fingerprint of the socket proxy policy in the same compose file, written by `scripts/socket_proxy_policy.py`. Do not edit it. The worker compares it with the policy its own catalog needs and, when they differ, its status page says to take the compose file of the release you run. | +| `CASHPILOT_PROXY_POLICY` | set by the compose file | The fingerprint of the socket proxy policy in the same compose file, written by `scripts/socket_proxy_policy.py`; the worker and the proxy both carry it. Do not edit it. The worker compares it with the policy its own catalog needs and, when they differ, its log, its status page and the update card give the command that updates the policy in your compose file ([how](security-defaults.md#docker-socket-proxy)). | | `CASHPILOT_MIGRATE_COMPOSE_VOLUMES` | set by the compose file | Read by the one-shot `cashpilot-migrate` service: pairs `old=new` of compose volumes whose data it copies, once, into the renamed volume that carries the worker's owner label. The old volume is kept. Do not edit it. See [UPGRADING.md](https://github.com/GeiserX/CashPilot/blob/main/UPGRADING.md). | The UI's web port inside the container is fixed at `8080` (set via the container's `CMD`). `CASHPILOT_UI_PORT` and `CASHPILOT_BIND_ADDR` choose only the host side: which port and which interface it is published on. diff --git a/docs/security-defaults.md b/docs/security-defaults.md index cd1cfcea..9b2908e6 100644 --- a/docs/security-defaults.md +++ b/docs/security-defaults.md @@ -135,11 +135,23 @@ dangerous containers, not only dangerous URLs: sidecar's proxy settings), with no setuid files, device nodes or escaping links. -The policy is the `configs` block at the end of each compose file, so it moves -with the file that pins the CashPilot release. An image update does not rewrite -that file, so the worker gets the policy's fingerprint (`CASHPILOT_PROXY_POLICY`) -and compares it with the policy its own catalog needs; when they differ its -status page says to replace the compose file. `scripts/socket_proxy_policy.py` +The policy is the `configs` block at the end of each compose file, never part +of an image: the worker image is what the policy restricts, so an image update +must not be able to widen it. An image update does not rewrite that file either, +so the worker gets the policy's fingerprint (`CASHPILOT_PROXY_POLICY`) and +compares it with the policy its own catalog needs. When they differ, its log, +its status page and the dashboard's update card name the release and give the +command that brings your own compose file to that release's policy, changing +nothing else: + + docker run --rm -i --network none drumsergio/cashpilot-worker: \ + python -m app.socket_policy < docker-compose.yml > docker-compose.new.yml + +The image gets no access to the file and no network; read the diff before you +use the new file. The proxy carries the same fingerprint in its environment, +because Compose does not recreate a container when only a config's content +changes: without it, `docker compose up -d` would leave the proxy on the old +policy. `scripts/socket_proxy_policy.py` writes it from the catalog, and `tests/test_socket_proxy.py` fails when a catalog change adds a reader the policy lacks, or when anyone loosens it. `scripts/socket_proxy_live_test.sh` runs the real proxy against a real daemon: diff --git a/scripts/proxy_policy_card_check.mjs b/scripts/proxy_policy_card_check.mjs new file mode 100644 index 00000000..3dc6daae --- /dev/null +++ b/scripts/proxy_policy_card_check.mjs @@ -0,0 +1,64 @@ +// Run the real update card with a worker whose socket proxy policy is stale. +// +// The policy lives in the operator's compose file, which no image update +// changes, so the worker sends the release it needs and the commands that +// bring the compose file to it. Only running renderUpdateCard proves they reach +// the card, escaped, and that a worker cannot plant a javascript: link there. +// +// node scripts/proxy_policy_card_check.mjs +// +// Exits non-zero on any mismatch. +import {readFileSync} from 'node:fs'; + +const src = readFileSync('app/static/js/app.js', 'utf8'); +const grab = name => { + const i = src.indexOf(`function ${name}(`); + if (i < 0) { + console.error(`FAIL: ${name} is not defined in app/static/js/app.js`); + process.exit(1); + } + const rest = src.slice(i); + const end = rest.search(/\n (?:async )?function [A-Za-z_]/); + return rest.slice(0, end > 0 ? end : 4000); +}; + +const container = {innerHTML: '', querySelector: () => null}; +const document = {getElementById: id => (id === 'update-card-container' ? container : null)}; +const fns = ['escapeHtml', 'proxyPolicyLink', 'renderUpdateCard'].map(grab).join('\n'); +const render = new Function( + 'document', + `let updateCardWorkerOnly = false; let updateCardWorkerId = 1; ${fns}; return renderUpdateCard;`, +)(document); + +let bad = 0; +const check = (label, cond, detail) => { + console.log(` ${cond ? 'ok ' : 'FAIL'} ${label}${cond ? '' : ` <- ${detail}`}`); + if (!cond) bad++; +}; + +const base = {current_display: 'v2.1.9', compose_managed: true, known: true, latest: '2.1.9'}; +const fix = { + release: 'v2.1.9', + command: + 'docker run --rm -i --network none drumsergio/cashpilot-worker:2.1.9 python -m app.socket_policy < docker-compose.yml > docker-compose.new.yml', + url: 'https://github.com/GeiserX/CashPilot/blob/v2.1.9/docker-compose.worker.yml', + recreate: 'docker compose up -d --force-recreate cashpilot-socket-proxy cashpilot-worker', +}; + +render({...base, proxy_policy_fix: null}, []); +check('a current policy shows no note', !container.innerHTML.includes('socket proxy'), container.innerHTML); + +render({...base, proxy_policy_fix: fix}, []); +const html = container.innerHTML; +check('names the release', html.includes('this worker (v2.1.9) needs'), html); +check('gives the filter command, escaped', html.includes('docker-compose.yml > docker-compose.new.yml'), html); +check('gives the recreate command', html.includes(fix.recreate), html); +check('links the release compose file', html.includes(`'}}, []); +const hostile = container.innerHTML; +check('a non-repository URL is not a link', !hostile.includes('href="javascript:'), hostile); +check('a planted tag is escaped', !hostile.includes(' str: - """The policy as it sits in a compose file: indented, each `$` doubled once - per interpolation pass the file goes through (`$$` for plain Compose).""" - body = "".join(f" {line}\n" if line else "\n" for line in policy(services_dir).splitlines()) - return BEGIN + body.replace("$", "$" * 2**passes) + END - - -def render(text: str, block: str, fingerprint: str) -> str: - start, end = text.find(BEGIN), text.find(END) - if start < 0 or end < start: - raise SystemExit("compose file has no socket proxy policy markers") - if not DIGEST_RE.search(text): - raise SystemExit("compose file gives the worker no CASHPILOT_PROXY_POLICY") - text = text[:start] + block + text[end + len(END) :] - return DIGEST_RE.sub(lambda m: m.group(1) + fingerprint, text) def main(argv: list[str]) -> int: diff --git a/tests/test_socket_proxy.py b/tests/test_socket_proxy.py index d7bb18d9..d7600781 100644 --- a/tests/test_socket_proxy.py +++ b/tests/test_socket_proxy.py @@ -901,11 +901,101 @@ def test_a_fingerprint_that_differs_says_the_policy_is_stale(monkeypatch): assert worker_api._docker_access_text() == "socket proxy" monkeypatch.setenv("CASHPILOT_PROXY_POLICY", "0123456789abcdef") assert orchestrator.proxy_policy_current() is False - assert "Replace your compose file" in worker_api._docker_access_text() + assert "python -m app.socket_policy" in worker_api._docker_access_text() monkeypatch.delenv("DOCKER_HOST") assert orchestrator.proxy_policy_current() is None +@pytest.mark.parametrize("name", (*COMPOSE_FILES, *spp.TWICE_INTERPOLATED)) +def test_each_compose_file_recreates_the_proxy_when_the_policy_changes(name): + """Compose does not recreate a container when only its config's content + changes, so a new policy stayed unloaded while the worker's fingerprint + said it was current. The proxy's own environment carries the fingerprint.""" + from app import socket_policy + + env = _compose(name)["services"]["cashpilot-socket-proxy"]["environment"] + assert f"CASHPILOT_PROXY_POLICY={socket_policy.digest()}" in env + + +def _stale(text: str) -> str: + """A compose file as an older release shipped it: another policy, another fingerprint.""" + from app import socket_policy + + old = socket_policy.compose_block().replace("allow_host_cgroupns: false", "allow_host_cgroupns: true") + return socket_policy.render(text, old, "e9a30411b564dc75") + + +@pytest.mark.parametrize("name", COMPOSE_FILES) +def test_the_image_brings_an_old_compose_file_to_its_policy(name, monkeypatch, capsys): + """`python -m app.socket_policy` reads a compose file on stdin and writes the + one this release ships: the policy and fingerprints change, nothing else.""" + import io + + from app import socket_policy + + shipped = (ROOT / name).read_text() + old = _stale(shipped) + assert old != shipped and "e9a30411b564dc75" in old + # The operator's own lines outside the block survive. + old = old.replace(" restart: unless-stopped\n", " restart: unless-stopped\n # mine\n", 1) + monkeypatch.setattr("sys.stdin", io.StringIO(old)) + assert socket_policy.main() == 0 + out = capsys.readouterr().out + assert out == shipped.replace(" restart: unless-stopped\n", " restart: unless-stopped\n # mine\n", 1) + + +def test_the_image_refuses_a_compose_file_without_the_policy_markers(monkeypatch, capsys): + import io + + from app import socket_policy + + monkeypatch.setattr("sys.stdin", io.StringIO("services:\n cashpilot-worker: {}\n")) + assert socket_policy.main() == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "take the compose file of this release" in captured.err + + +def test_the_stale_policy_fix_names_this_release(monkeypatch): + monkeypatch.setattr(orchestrator, "_policy_digest", None) + _proxied_env(monkeypatch) + monkeypatch.setenv("CASHPILOT_PROXY_POLICY", "0123456789abcdef") + monkeypatch.setenv("CASHPILOT_VERSION", "2.1.9") + fix = orchestrator.proxy_policy_fix() + assert fix["release"] == "v2.1.9" + assert fix["command"] == ( + "docker run --rm -i --network none drumsergio/cashpilot-worker:2.1.9 " + "python -m app.socket_policy < docker-compose.yml > docker-compose.new.yml" + ) + assert fix["url"] == "https://github.com/GeiserX/CashPilot/blob/v2.1.9/docker-compose.worker.yml" + assert fix["recreate"] == "docker compose up -d --force-recreate cashpilot-socket-proxy cashpilot-worker" + text = orchestrator.proxy_policy_fix_text(fix) + assert fix["command"] in text and fix["url"] in text and fix["recreate"] in text + # A build with no release number points at main and the latest image. + monkeypatch.delenv("CASHPILOT_VERSION") + fix = orchestrator.proxy_policy_fix() + assert "cashpilot-worker:latest " in fix["command"] and "/blob/main/" in fix["url"] + + +def test_no_fix_when_the_policy_is_current_or_unproxied(monkeypatch): + from app import socket_policy + + monkeypatch.setattr(orchestrator, "_policy_digest", None) + _proxied_env(monkeypatch) + monkeypatch.setenv("CASHPILOT_PROXY_POLICY", socket_policy.digest()) + assert orchestrator.proxy_policy_fix() is None + monkeypatch.setenv("CASHPILOT_PROXY_POLICY", "0123456789abcdef") + monkeypatch.delenv("DOCKER_HOST") + assert orchestrator.proxy_policy_fix() is None + + +def test_the_worker_image_can_run_the_policy_filter(): + """The filter runs from the worker image, so everything it imports is copied in.""" + dockerfile = (ROOT / "Dockerfile.worker").read_text() + assert "COPY --chown=cashpilot:root app/socket_policy.py ./app/" in dockerfile + assert "COPY --chown=cashpilot:root services/ ./services/" in dockerfile + + def test_a_new_catalog_reader_changes_the_fingerprint(tmp_path): from app import socket_policy From 75a449bf2bcfd93d45ce4b35398aa3624ea81eab Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:34:58 +0100 Subject: [PATCH 2/7] fix(worker): repair old compose files fully, not just their policy block An older file has the fingerprint only on the worker, so after the filter a later policy change would again leave the proxy on the old one: the filter now adds the proxy's line. A CasaOS file keeps its two interpolation passes, and a build tagged latest links main, not vlatest. --- app/orchestrator.py | 3 +- app/socket_policy.py | 35 +++++++++++++++++++++-- tests/test_socket_proxy.py | 57 +++++++++++++++++++++++++++----------- 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/app/orchestrator.py b/app/orchestrator.py index e6e47bc9..cbb45f62 100644 --- a/app/orchestrator.py +++ b/app/orchestrator.py @@ -218,7 +218,8 @@ def proxy_policy_fix() -> dict[str, str] | None: if proxy_policy_current() is not False: return None current = version.current() - release = current != version.UNKNOWN + # A build without a release tag reports dev or latest; neither is a tag to link. + release = version.is_release(current) tag = current if release else "latest" ref = f"v{current}" if release else "main" return { diff --git a/app/socket_policy.py b/app/socket_policy.py index 23cb6a28..4b73af52 100644 --- a/app/socket_policy.py +++ b/app/socket_policy.py @@ -16,7 +16,8 @@ docker run --rm -i --network none drumsergio/cashpilot-worker: \ python -m app.socket_policy < docker-compose.yml > docker-compose.new.yml -It replaces the block between the policy markers and the fingerprint, changes +It replaces the block between the policy markers and the fingerprints, adds +the proxy's fingerprint to a file from before the proxy carried one, changes nothing else, and the operator reads the diff before using the new file. """ @@ -270,10 +271,40 @@ def render(text: str, block: str, fingerprint: str) -> str: return DIGEST_RE.sub(lambda m: m.group(1) + fingerprint, text) +#: Every shipped compose file since the proxy carries this line in the proxy's +#: environment; the proxy's fingerprint goes right after it. +_PROXY_ENV_ANCHOR = re.compile(r"^( *)- SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOWED_BIND_MOUNTS=.*\n", re.M) +_PROXY_FINGERPRINT = ( + "{indent}# The policy's fingerprint. Compose does not recreate a container when\n" + "{indent}# only its config's content changes, so this line is what makes\n" + "{indent}# `docker compose up -d` restart the proxy on a new policy.\n" + "{indent}- CASHPILOT_PROXY_POLICY=0\n" +) + + +def update(text: str) -> str: + """An operator's compose file brought to this image's policy. + + Besides the block and the fingerprints, a file from before the proxy + carried its own fingerprint gets that line, or every later policy change + would leave the proxy on the old one. A file whose block went through two + interpolation passes (CasaOS writes `$$$$`) keeps two. + """ + start, end = text.find(BEGIN), text.find(END) + passes = 2 if start >= 0 and "$$$$" in text[start:end] else 1 + if len(DIGEST_RE.findall(text)) == 1: + anchor = _PROXY_ENV_ANCHOR.search(text) + if anchor is None: + raise SystemExit("compose file has no socket proxy environment to give the policy's fingerprint") + line = _PROXY_FINGERPRINT.format(indent=anchor.group(1)) + text = text[: anchor.end()] + line + text[anchor.end() :] + return render(text, compose_block(passes=passes), digest()) + + def main() -> int: """Read a compose file on stdin, write it with this image's policy on stdout.""" try: - sys.stdout.write(render(sys.stdin.read(), compose_block(), digest())) + sys.stdout.write(update(sys.stdin.read())) except SystemExit as exc: print(f"{exc}; take the compose file of this release instead", file=sys.stderr) return 1 diff --git a/tests/test_socket_proxy.py b/tests/test_socket_proxy.py index d7600781..9d4efee7 100644 --- a/tests/test_socket_proxy.py +++ b/tests/test_socket_proxy.py @@ -917,31 +917,55 @@ def test_each_compose_file_recreates_the_proxy_when_the_policy_changes(name): assert f"CASHPILOT_PROXY_POLICY={socket_policy.digest()}" in env -def _stale(text: str) -> str: - """A compose file as an older release shipped it: another policy, another fingerprint.""" +def _stale(text: str, passes: int = 1, proxy_line: bool = True) -> str: + """A compose file as an older release shipped it: another policy, another + fingerprint and, before this release, no fingerprint on the proxy.""" from app import socket_policy - old = socket_policy.compose_block().replace("allow_host_cgroupns: false", "allow_host_cgroupns: true") + if not proxy_line: + block = socket_policy._PROXY_FINGERPRINT.format(indent=" ").replace("=0", f"={socket_policy.digest()}") + assert block in text + text = text.replace(block, "", 1) + old = socket_policy.compose_block(passes=passes).replace("allow_host_cgroupns: false", "allow_host_cgroupns: true") return socket_policy.render(text, old, "e9a30411b564dc75") -@pytest.mark.parametrize("name", COMPOSE_FILES) -def test_the_image_brings_an_old_compose_file_to_its_policy(name, monkeypatch, capsys): - """`python -m app.socket_policy` reads a compose file on stdin and writes the - one this release ships: the policy and fingerprints change, nothing else.""" +def _filter(text: str, monkeypatch, capsys) -> tuple[int, str, str]: import io from app import socket_policy + monkeypatch.setattr("sys.stdin", io.StringIO(text)) + code = socket_policy.main() + captured = capsys.readouterr() + return code, captured.out, captured.err + + +@pytest.mark.parametrize("proxy_line", (True, False), ids=("proxy-fingerprint", "worker-fingerprint-only")) +@pytest.mark.parametrize("name", (*COMPOSE_FILES, *spp.TWICE_INTERPOLATED)) +def test_the_image_brings_an_old_compose_file_to_its_policy(name, proxy_line, monkeypatch, capsys): + """`python -m app.socket_policy` reads a compose file on stdin and writes the + one this release ships: the policy, the fingerprints and, for a file from + before the proxy carried one, the proxy's fingerprint. Nothing else. A + CasaOS file keeps its two interpolation passes.""" shipped = (ROOT / name).read_text() - old = _stale(shipped) + old = _stale(shipped, passes=2 if name in spp.TWICE_INTERPOLATED else 1, proxy_line=proxy_line) assert old != shipped and "e9a30411b564dc75" in old # The operator's own lines outside the block survive. - old = old.replace(" restart: unless-stopped\n", " restart: unless-stopped\n # mine\n", 1) - monkeypatch.setattr("sys.stdin", io.StringIO(old)) - assert socket_policy.main() == 0 - out = capsys.readouterr().out - assert out == shipped.replace(" restart: unless-stopped\n", " restart: unless-stopped\n # mine\n", 1) + mine = (" restart: unless-stopped\n", " restart: unless-stopped\n # mine\n") + code, out, _ = _filter(old.replace(*mine, 1), monkeypatch, capsys) + assert code == 0 + assert out == shipped.replace(*mine, 1) + + +def test_the_image_refuses_a_file_with_no_proxy_environment(monkeypatch, capsys): + from app import socket_policy + + shipped = (ROOT / "docker-compose.worker.yml").read_text() + old = socket_policy._PROXY_ENV_ANCHOR.sub("", _stale(shipped, proxy_line=False)) + code, out, err = _filter(old, monkeypatch, capsys) + assert (code, out) == (1, "") + assert "no socket proxy environment" in err def test_the_image_refuses_a_compose_file_without_the_policy_markers(monkeypatch, capsys): @@ -972,9 +996,10 @@ def test_the_stale_policy_fix_names_this_release(monkeypatch): text = orchestrator.proxy_policy_fix_text(fix) assert fix["command"] in text and fix["url"] in text and fix["recreate"] in text # A build with no release number points at main and the latest image. - monkeypatch.delenv("CASHPILOT_VERSION") - fix = orchestrator.proxy_policy_fix() - assert "cashpilot-worker:latest " in fix["command"] and "/blob/main/" in fix["url"] + for unreleased in ("dev", "latest"): + monkeypatch.setenv("CASHPILOT_VERSION", unreleased) + fix = orchestrator.proxy_policy_fix() + assert "cashpilot-worker:latest " in fix["command"] and "/blob/main/" in fix["url"], unreleased def test_no_fix_when_the_policy_is_current_or_unproxied(monkeypatch): From f89906f94fc7efc287ca8ca02528816536f9249f Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:48:57 +0100 Subject: [PATCH 3/7] fix(worker): let the policy filter be told a file's interpolation passes A two-pass block without a $ in it cannot be told from a one-pass one, so --passes 1|2 overrides the guess. --- app/socket_policy.py | 26 +++++++++++++++++++------- tests/test_socket_proxy.py | 23 ++++++++++++++++++++--- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/app/socket_policy.py b/app/socket_policy.py index 4b73af52..27df290a 100644 --- a/app/socket_policy.py +++ b/app/socket_policy.py @@ -282,16 +282,18 @@ def render(text: str, block: str, fingerprint: str) -> str: ) -def update(text: str) -> str: +def update(text: str, passes: int | None = None) -> str: """An operator's compose file brought to this image's policy. Besides the block and the fingerprints, a file from before the proxy carried its own fingerprint gets that line, or every later policy change would leave the proxy on the old one. A file whose block went through two - interpolation passes (CasaOS writes `$$$$`) keeps two. + interpolation passes (CasaOS writes `$$$$`) keeps two. A block with no `$` + in it says nothing, so ``passes`` overrides the guess. """ - start, end = text.find(BEGIN), text.find(END) - passes = 2 if start >= 0 and "$$$$" in text[start:end] else 1 + if passes is None: + start, end = text.find(BEGIN), text.find(END) + passes = 2 if start >= 0 and "$$$$" in text[start:end] else 1 if len(DIGEST_RE.findall(text)) == 1: anchor = _PROXY_ENV_ANCHOR.search(text) if anchor is None: @@ -301,10 +303,20 @@ def update(text: str) -> str: return render(text, compose_block(passes=passes), digest()) -def main() -> int: - """Read a compose file on stdin, write it with this image's policy on stdout.""" +def main(argv: list[str] | None = None) -> int: + """Read a compose file on stdin, write it with this image's policy on stdout. + + ``--passes N`` sets how many times the file is interpolated before the + proxy reads it (1 for Compose, 2 for a CasaOS store template).""" + args = sys.argv[1:] if argv is None else argv + passes = None + if args: + if len(args) != 2 or args[0] != "--passes" or args[1] not in ("1", "2"): + print("usage: python -m app.socket_policy [--passes 1|2] < compose.yml", file=sys.stderr) + return 2 + passes = int(args[1]) try: - sys.stdout.write(update(sys.stdin.read())) + sys.stdout.write(update(sys.stdin.read(), passes)) except SystemExit as exc: print(f"{exc}; take the compose file of this release instead", file=sys.stderr) return 1 diff --git a/tests/test_socket_proxy.py b/tests/test_socket_proxy.py index 9d4efee7..1fffb469 100644 --- a/tests/test_socket_proxy.py +++ b/tests/test_socket_proxy.py @@ -930,13 +930,13 @@ def _stale(text: str, passes: int = 1, proxy_line: bool = True) -> str: return socket_policy.render(text, old, "e9a30411b564dc75") -def _filter(text: str, monkeypatch, capsys) -> tuple[int, str, str]: +def _filter(text: str, monkeypatch, capsys, argv: list[str] | None = None) -> tuple[int, str, str]: import io from app import socket_policy monkeypatch.setattr("sys.stdin", io.StringIO(text)) - code = socket_policy.main() + code = socket_policy.main(argv or []) captured = capsys.readouterr() return code, captured.out, captured.err @@ -958,6 +958,23 @@ def test_the_image_brings_an_old_compose_file_to_its_policy(name, proxy_line, mo assert out == shipped.replace(*mine, 1) +def test_passes_overrides_the_guess(monkeypatch, capsys): + """An old two-pass block with no `$` in it looks like one pass; --passes says.""" + from app import socket_policy + + shipped = (ROOT / spp.TWICE_INTERPOLATED[0]).read_text() + start, end = shipped.find(socket_policy.BEGIN), shipped.find(socket_policy.END) + no_dollar = shipped[:start] + shipped[start:end].replace("$", "") + shipped[end:] + old = socket_policy.DIGEST_RE.sub(r"\g<1>e9a30411b564dc75", no_dollar) + assert "$$$$" not in old[old.find(socket_policy.BEGIN) : old.find(socket_policy.END)] + code, out, _ = _filter(old, monkeypatch, capsys, ["--passes", "2"]) + assert (code, out) == (0, shipped) + code, out, _ = _filter(old, monkeypatch, capsys) + assert code == 0 and out != shipped + code, out, err = _filter(old, monkeypatch, capsys, ["--passes", "3"]) + assert (code, out) == (2, "") and "usage" in err + + def test_the_image_refuses_a_file_with_no_proxy_environment(monkeypatch, capsys): from app import socket_policy @@ -974,7 +991,7 @@ def test_the_image_refuses_a_compose_file_without_the_policy_markers(monkeypatch from app import socket_policy monkeypatch.setattr("sys.stdin", io.StringIO("services:\n cashpilot-worker: {}\n")) - assert socket_policy.main() == 1 + assert socket_policy.main([]) == 1 captured = capsys.readouterr() assert captured.out == "" assert "take the compose file of this release" in captured.err From 5c329c0941b6b9d63a0f685f24d24db9690c81e0 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:59:18 +0100 Subject: [PATCH 4/7] fix(worker): look for the proxy's fingerprint in the proxy's own environment A fingerprint elsewhere in the file (a comment, a second worker) made the filter skip the proxy's line, so the proxy could again stay on an old policy. --- app/socket_policy.py | 30 ++++++++++++++++++++++++++---- tests/test_socket_proxy.py | 13 +++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/app/socket_policy.py b/app/socket_policy.py index 27df290a..5ac29a30 100644 --- a/app/socket_policy.py +++ b/app/socket_policy.py @@ -282,6 +282,28 @@ def render(text: str, block: str, fingerprint: str) -> str: ) +def _env_list(text: str, at: int, indent: str) -> str: + """The environment list around offset ``at``: the run of lines at + ``indent`` that are list entries or comments.""" + lines = text.splitlines(keepends=True) + offsets, pos = [], 0 + for line in lines: + offsets.append(pos) + pos += len(line) + here = max(i for i, o in enumerate(offsets) if o <= at) + + def part_of(line: str) -> bool: + return line.startswith(indent) and line[len(indent) :].startswith(("- ", "#")) + + first = here + while first > 0 and part_of(lines[first - 1]): + first -= 1 + last = here + while last + 1 < len(lines) and part_of(lines[last + 1]): + last += 1 + return "".join(lines[first : last + 1]) + + def update(text: str, passes: int | None = None) -> str: """An operator's compose file brought to this image's policy. @@ -294,10 +316,10 @@ def update(text: str, passes: int | None = None) -> str: if passes is None: start, end = text.find(BEGIN), text.find(END) passes = 2 if start >= 0 and "$$$$" in text[start:end] else 1 - if len(DIGEST_RE.findall(text)) == 1: - anchor = _PROXY_ENV_ANCHOR.search(text) - if anchor is None: - raise SystemExit("compose file has no socket proxy environment to give the policy's fingerprint") + anchor = _PROXY_ENV_ANCHOR.search(text) + if anchor is None: + raise SystemExit("compose file has no socket proxy environment to give the policy's fingerprint") + if not DIGEST_RE.search(_env_list(text, anchor.start(), anchor.group(1))): line = _PROXY_FINGERPRINT.format(indent=anchor.group(1)) text = text[: anchor.end()] + line + text[anchor.end() :] return render(text, compose_block(passes=passes), digest()) diff --git a/tests/test_socket_proxy.py b/tests/test_socket_proxy.py index 1fffb469..e031161a 100644 --- a/tests/test_socket_proxy.py +++ b/tests/test_socket_proxy.py @@ -975,6 +975,19 @@ def test_passes_overrides_the_guess(monkeypatch, capsys): assert (code, out) == (2, "") and "usage" in err +def test_a_fingerprint_elsewhere_does_not_stand_in_for_the_proxys(monkeypatch, capsys): + """Only the proxy's own environment counts: a comment (or a second worker) + carrying the fingerprint must not stop the proxy getting its line.""" + from app import socket_policy + + shipped = (ROOT / "docker-compose.worker.yml").read_text() + note = "# was CASHPILOT_PROXY_POLICY={}\nservices:\n" + old = _stale(shipped, proxy_line=False).replace("services:\n", note.format("e9a30411b564dc75"), 1) + code, out, _ = _filter(old, monkeypatch, capsys) + assert code == 0 + assert out == shipped.replace("services:\n", note.format(socket_policy.digest()), 1) + + def test_the_image_refuses_a_file_with_no_proxy_environment(monkeypatch, capsys): from app import socket_policy From 3a1efc2313c05cb92012f3a07c47eca7e045451a Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:07:40 +0100 Subject: [PATCH 5/7] fix(worker): a commented-out proxy fingerprint is not one The filter now matches a real list entry in the proxy's environment, so an operator who commented the line out still gets it back. --- app/socket_policy.py | 4 +++- tests/test_socket_proxy.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/socket_policy.py b/app/socket_policy.py index 5ac29a30..38dbabb3 100644 --- a/app/socket_policy.py +++ b/app/socket_policy.py @@ -274,6 +274,8 @@ def render(text: str, block: str, fingerprint: str) -> str: #: Every shipped compose file since the proxy carries this line in the proxy's #: environment; the proxy's fingerprint goes right after it. _PROXY_ENV_ANCHOR = re.compile(r"^( *)- SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOWED_BIND_MOUNTS=.*\n", re.M) +#: A real entry, not a commented-out one. +_PROXY_ENTRY = re.compile(r"^ *- CASHPILOT_PROXY_POLICY=", re.M) _PROXY_FINGERPRINT = ( "{indent}# The policy's fingerprint. Compose does not recreate a container when\n" "{indent}# only its config's content changes, so this line is what makes\n" @@ -319,7 +321,7 @@ def update(text: str, passes: int | None = None) -> str: anchor = _PROXY_ENV_ANCHOR.search(text) if anchor is None: raise SystemExit("compose file has no socket proxy environment to give the policy's fingerprint") - if not DIGEST_RE.search(_env_list(text, anchor.start(), anchor.group(1))): + if not _PROXY_ENTRY.search(_env_list(text, anchor.start(), anchor.group(1))): line = _PROXY_FINGERPRINT.format(indent=anchor.group(1)) text = text[: anchor.end()] + line + text[anchor.end() :] return render(text, compose_block(passes=passes), digest()) diff --git a/tests/test_socket_proxy.py b/tests/test_socket_proxy.py index e031161a..f9f82fa2 100644 --- a/tests/test_socket_proxy.py +++ b/tests/test_socket_proxy.py @@ -988,6 +988,19 @@ def test_a_fingerprint_elsewhere_does_not_stand_in_for_the_proxys(monkeypatch, c assert out == shipped.replace("services:\n", note.format(socket_policy.digest()), 1) +def test_a_commented_out_proxy_fingerprint_does_not_count(monkeypatch, capsys): + """An operator who commented the line out still gets a real one.""" + from app import socket_policy + + old = _stale((ROOT / "docker-compose.worker.yml").read_text(), proxy_line=False) + at = socket_policy._PROXY_ENV_ANCHOR.search(old).end() + old = old[:at] + " # - CASHPILOT_PROXY_POLICY=e9a30411b564dc75\n" + old[at:] + code, out, _ = _filter(old, monkeypatch, capsys) + assert code == 0 + env = yaml.safe_load(out)["services"]["cashpilot-socket-proxy"]["environment"] + assert f"CASHPILOT_PROXY_POLICY={socket_policy.digest()}" in env + + def test_the_image_refuses_a_file_with_no_proxy_environment(monkeypatch, capsys): from app import socket_policy From 9c84dffd5bc9cf43a3616158c3351776d002d41d Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:17:25 +0100 Subject: [PATCH 6/7] fix(worker): rewrite only real fingerprint entries, and ask when passes are unclear The filter replaced CASHPILOT_PROXY_POLICY= anywhere, comments and partial values included; it now rewrites whole list entries only. The proxy's environment list runs past blank lines, a block with no $ asks for --passes, and only a plain X.Y.Z version names a release. --- app/orchestrator.py | 5 +++-- app/socket_policy.py | 39 ++++++++++++++++++++++++-------------- tests/test_socket_proxy.py | 35 +++++++++++++++++++++++++--------- 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/app/orchestrator.py b/app/orchestrator.py index cbb45f62..59f96064 100644 --- a/app/orchestrator.py +++ b/app/orchestrator.py @@ -218,8 +218,9 @@ def proxy_policy_fix() -> dict[str, str] | None: if proxy_policy_current() is not False: return None current = version.current() - # A build without a release tag reports dev or latest; neither is a tag to link. - release = version.is_release(current) + # Only a plain X.Y.Z is a published tag and image; dev, latest or a custom + # build string links main and the latest image instead. + release = re.fullmatch(r"\d+\.\d+\.\d+", current) is not None tag = current if release else "latest" ref = f"v{current}" if release else "main" return { diff --git a/app/socket_policy.py b/app/socket_policy.py index 38dbabb3..d997e026 100644 --- a/app/socket_policy.py +++ b/app/socket_policy.py @@ -250,7 +250,9 @@ def digest(services_dir: Path = SERVICES_DIR) -> str: END = " # END socket proxy policy\n" #: The worker's copy of the policy's fingerprint, so it can tell when its image #: is newer than the policy the proxy loaded. -DIGEST_RE = re.compile(r"(CASHPILOT_PROXY_POLICY=)[0-9a-f]*") +#: Only a real list entry, and its whole value: a comment or another +#: service's look-alike stays as it is. +DIGEST_RE = re.compile(r"^( *- CASHPILOT_PROXY_POLICY=)[^\s#]*", re.M) def compose_block(services_dir: Path = SERVICES_DIR, passes: int = 1) -> str: @@ -274,8 +276,6 @@ def render(text: str, block: str, fingerprint: str) -> str: #: Every shipped compose file since the proxy carries this line in the proxy's #: environment; the proxy's fingerprint goes right after it. _PROXY_ENV_ANCHOR = re.compile(r"^( *)- SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOWED_BIND_MOUNTS=.*\n", re.M) -#: A real entry, not a commented-out one. -_PROXY_ENTRY = re.compile(r"^ *- CASHPILOT_PROXY_POLICY=", re.M) _PROXY_FINGERPRINT = ( "{indent}# The policy's fingerprint. Compose does not recreate a container when\n" "{indent}# only its config's content changes, so this line is what makes\n" @@ -286,7 +286,7 @@ def render(text: str, block: str, fingerprint: str) -> str: def _env_list(text: str, at: int, indent: str) -> str: """The environment list around offset ``at``: the run of lines at - ``indent`` that are list entries or comments.""" + ``indent`` that are list entries or comments, blank lines inside it included.""" lines = text.splitlines(keepends=True) offsets, pos = [], 0 for line in lines: @@ -297,13 +297,15 @@ def _env_list(text: str, at: int, indent: str) -> str: def part_of(line: str) -> bool: return line.startswith(indent) and line[len(indent) :].startswith(("- ", "#")) - first = here - while first > 0 and part_of(lines[first - 1]): - first -= 1 - last = here - while last + 1 < len(lines) and part_of(lines[last + 1]): - last += 1 - return "".join(lines[first : last + 1]) + def edge(i: int, step: int) -> int: + j = i + step + while 0 <= j < len(lines) and (part_of(lines[j]) or not lines[j].strip()): + if part_of(lines[j]): + i = j + j += step + return i + + return "".join(lines[edge(here, -1) : edge(here, 1) + 1]) def update(text: str, passes: int | None = None) -> str: @@ -313,15 +315,24 @@ def update(text: str, passes: int | None = None) -> str: carried its own fingerprint gets that line, or every later policy change would leave the proxy on the old one. A file whose block went through two interpolation passes (CasaOS writes `$$$$`) keeps two. A block with no `$` - in it says nothing, so ``passes`` overrides the guess. + in it says nothing, so then ``passes`` must say. """ if passes is None: start, end = text.find(BEGIN), text.find(END) - passes = 2 if start >= 0 and "$$$$" in text[start:end] else 1 + block = text[start:end] if start >= 0 else "" + if "$$$$" in block: + passes = 2 + elif "$$" in block or start < 0: + passes = 1 + else: + raise SystemExit( + "the policy block has no $ to tell how often the file is interpolated: " + "run again with --passes 1 (Compose) or --passes 2 (a CasaOS store template)" + ) anchor = _PROXY_ENV_ANCHOR.search(text) if anchor is None: raise SystemExit("compose file has no socket proxy environment to give the policy's fingerprint") - if not _PROXY_ENTRY.search(_env_list(text, anchor.start(), anchor.group(1))): + if not DIGEST_RE.search(_env_list(text, anchor.start(), anchor.group(1))): line = _PROXY_FINGERPRINT.format(indent=anchor.group(1)) text = text[: anchor.end()] + line + text[anchor.end() :] return render(text, compose_block(passes=passes), digest()) diff --git a/tests/test_socket_proxy.py b/tests/test_socket_proxy.py index f9f82fa2..7dc767df 100644 --- a/tests/test_socket_proxy.py +++ b/tests/test_socket_proxy.py @@ -969,23 +969,40 @@ def test_passes_overrides_the_guess(monkeypatch, capsys): assert "$$$$" not in old[old.find(socket_policy.BEGIN) : old.find(socket_policy.END)] code, out, _ = _filter(old, monkeypatch, capsys, ["--passes", "2"]) assert (code, out) == (0, shipped) - code, out, _ = _filter(old, monkeypatch, capsys) - assert code == 0 and out != shipped + code, out, err = _filter(old, monkeypatch, capsys) + assert (code, out) == (1, "") and "--passes" in err code, out, err = _filter(old, monkeypatch, capsys, ["--passes", "3"]) assert (code, out) == (2, "") and "usage" in err def test_a_fingerprint_elsewhere_does_not_stand_in_for_the_proxys(monkeypatch, capsys): """Only the proxy's own environment counts: a comment (or a second worker) - carrying the fingerprint must not stop the proxy getting its line.""" - from app import socket_policy - + carrying the fingerprint must not stop the proxy getting its line, and + the filter leaves that comment as it was.""" shipped = (ROOT / "docker-compose.worker.yml").read_text() - note = "# was CASHPILOT_PROXY_POLICY={}\nservices:\n" - old = _stale(shipped, proxy_line=False).replace("services:\n", note.format("e9a30411b564dc75"), 1) + note = "# was CASHPILOT_PROXY_POLICY=e9a30411b564dc75\nservices:\n" + old = _stale(shipped, proxy_line=False).replace("services:\n", note, 1) code, out, _ = _filter(old, monkeypatch, capsys) assert code == 0 - assert out == shipped.replace("services:\n", note.format(socket_policy.digest()), 1) + assert out == shipped.replace("services:\n", note, 1) + + +def test_the_whole_fingerprint_value_is_replaced(monkeypatch, capsys): + shipped = (ROOT / "docker-compose.worker.yml").read_text() + old = _stale(shipped).replace("CASHPILOT_PROXY_POLICY=e9a30411b564dc75", "CASHPILOT_PROXY_POLICY=abcdefXYZ") + code, out, _ = _filter(old, monkeypatch, capsys) + assert (code, out) == (0, shipped) + + +def test_a_blank_line_does_not_hide_the_proxys_fingerprint(monkeypatch, capsys): + """An existing proxy fingerprint after a blank line is found, not duplicated.""" + from app import socket_policy + + shipped = (ROOT / "docker-compose.worker.yml").read_text() + at = socket_policy._PROXY_ENV_ANCHOR.search(shipped).end() + spaced = shipped[:at] + "\n" + shipped[at:] + code, out, _ = _filter(_stale(spaced), monkeypatch, capsys) + assert (code, out) == (0, spaced) def test_a_commented_out_proxy_fingerprint_does_not_count(monkeypatch, capsys): @@ -1039,7 +1056,7 @@ def test_the_stale_policy_fix_names_this_release(monkeypatch): text = orchestrator.proxy_policy_fix_text(fix) assert fix["command"] in text and fix["url"] in text and fix["recreate"] in text # A build with no release number points at main and the latest image. - for unreleased in ("dev", "latest"): + for unreleased in ("dev", "latest", "2.2.0-mybuild", "mybuild"): monkeypatch.setenv("CASHPILOT_VERSION", unreleased) fix = orchestrator.proxy_policy_fix() assert "cashpilot-worker:latest " in fix["command"] and "/blob/main/" in fix["url"], unreleased From aa0233a6c0094346052e2251d611da0523e4a089 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:25:25 +0100 Subject: [PATCH 7/7] fix(worker): refuse a compose file with two proxy environments With two services setting the proxy's bind mounts the filter cannot tell which one is cashpilot-socket-proxy, so it asks for the line by hand. --- app/socket_policy.py | 10 ++++++++-- tests/test_socket_proxy.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/socket_policy.py b/app/socket_policy.py index d997e026..77261db7 100644 --- a/app/socket_policy.py +++ b/app/socket_policy.py @@ -329,9 +329,15 @@ def update(text: str, passes: int | None = None) -> str: "the policy block has no $ to tell how often the file is interpolated: " "run again with --passes 1 (Compose) or --passes 2 (a CasaOS store template)" ) - anchor = _PROXY_ENV_ANCHOR.search(text) - if anchor is None: + anchors = list(_PROXY_ENV_ANCHOR.finditer(text)) + if not anchors: raise SystemExit("compose file has no socket proxy environment to give the policy's fingerprint") + if len(anchors) > 1: + raise SystemExit( + "more than one service sets the proxy's bind mounts, so the filter cannot tell which is " + "cashpilot-socket-proxy; add its CASHPILOT_PROXY_POLICY line by hand" + ) + anchor = anchors[0] if not DIGEST_RE.search(_env_list(text, anchor.start(), anchor.group(1))): line = _PROXY_FINGERPRINT.format(indent=anchor.group(1)) text = text[: anchor.end()] + line + text[anchor.end() :] diff --git a/tests/test_socket_proxy.py b/tests/test_socket_proxy.py index 7dc767df..714c4012 100644 --- a/tests/test_socket_proxy.py +++ b/tests/test_socket_proxy.py @@ -1018,6 +1018,18 @@ def test_a_commented_out_proxy_fingerprint_does_not_count(monkeypatch, capsys): assert f"CASHPILOT_PROXY_POLICY={socket_policy.digest()}" in env +def test_the_image_refuses_two_proxy_environments(monkeypatch, capsys): + """With two services setting the proxy's bind mounts it cannot tell which is + CashPilot's, so it refuses rather than give the line to the wrong one.""" + from app import socket_policy + + old = _stale((ROOT / "docker-compose.worker.yml").read_text(), proxy_line=False) + anchor = socket_policy._PROXY_ENV_ANCHOR.search(old) + old = old.replace("services:\n", "services:\n other-proxy:\n environment:\n" + anchor.group(0), 1) + code, out, err = _filter(old, monkeypatch, capsys) + assert (code, out) == (1, "") and "more than one service" in err + + def test_the_image_refuses_a_file_with_no_proxy_environment(monkeypatch, capsys): from app import socket_policy