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..59f96064 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,49 @@ 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() + # 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 { + "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 +2455,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..77261db7 100644 --- a/app/socket_policy.py +++ b/app/socket_policy.py @@ -7,12 +7,26 @@ 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 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. """ from __future__ import annotations import hashlib import json +import re +import sys from pathlib import Path import yaml @@ -230,3 +244,125 @@ 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. +#: 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: + """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) + + +#: 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 _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, blank lines inside it included.""" + 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(("- ", "#")) + + 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: + """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. A block with no `$` + in it says nothing, so then ``passes`` must say. + """ + if passes is None: + start, end = text.find(BEGIN), text.find(END) + 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)" + ) + 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() :] + return render(text, compose_block(passes=passes), digest()) + + +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(), passes)) + 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 e8937872..b81e92e0 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 5a23f455..52e8845c 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 cb9d5076..773496ef 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..714c4012 100644 --- a/tests/test_socket_proxy.py +++ b/tests/test_socket_proxy.py @@ -901,11 +901,198 @@ 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, 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 + + 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") + + +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(argv or []) + 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, 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. + 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_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, 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, and + the filter leaves that comment as it was.""" + shipped = (ROOT / "docker-compose.worker.yml").read_text() + 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, 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): + """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_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 + + 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): + 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. + 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 + + +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