Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 45 additions & 1 deletion app/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
Comment thread
GeiserX marked this conversation as resolved.
Resolved


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.

Expand Down Expand Up @@ -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)
Expand Down
136 changes: 136 additions & 0 deletions app/socket_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version> \
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
Expand Down Expand Up @@ -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())
18 changes: 18 additions & 0 deletions app/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/')
? `<a href="${text}" target="_blank" rel="noopener">${text}</a>`
: text;
}

function renderUpdateCard(info, onlineWorkers) {
updateCardWorkerOnly = !!info.ui_by_hand;
const container = document.getElementById('update-card-container');
Expand All @@ -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 += `
<p class="form-hint text-warn">${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):`)}</p>
<pre class="mono">${escapeHtml(fix.command)}\n${escapeHtml(fix.recreate)}</pre>
<p class="form-hint">${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)}</p>`;
}

let action;
if (info.updater_running) {
Expand Down
14 changes: 4 additions & 10 deletions app/worker_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"


Expand Down
4 changes: 4 additions & 0 deletions contrib/app-stores/casaos/CashPilot/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions contrib/app-stores/runtipi/cashpilot/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.fleet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.worker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading