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 app/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@
CASHPILOT_DEPLOYED = frozenset({"worker", "direct"})


#: Linux's default ephemeral range (net.ipv4.ip_local_port_range): any program on
#: the host can hold a port in it for a moment, and Docker publishing a port 1:1
#: then fails the whole start.
EPHEMERAL_PORTS = (32768, 60999)


def drift_warnings_enabled() -> bool:
"""CASHPILOT_DRIFT_WARNINGS: off stops the worker reporting container settings
and the UI judging them (app/drift.py). On unless set to 0, off, false or no."""
Expand Down
58 changes: 50 additions & 8 deletions app/drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
import shlex
from typing import Any

from app import arch, catalog
from app import arch, catalog, lan_fence
from app.constants import EPHEMERAL_PORTS

#: Repositories CashPilot builds itself (images/<slug>/). The catalog pins their tag.
OWN_IMAGE_PREFIX = "drumsergio/cashpilot-"
Expand Down Expand Up @@ -127,6 +128,53 @@ def _names(values: set[str]) -> str:
return ", ".join(sorted(values))


def move_advice(
svc: dict[str, Any] | None,
shape: dict[str, Any] | None,
recorded: dict[str, Any] | None,
isolation_mode: str | None,
) -> dict[str, Any] | None:
"""For a fence-gated container still on the host's network: the one action, and what to fix first.

``action`` depends on the worker's isolation mode (lan_fence.move_advice);
``redeploy`` says whether the worker reports a mode where a redeploy alone does it; ``warning`` is set
when the recorded UDP range, which the redeploy keeps, overlaps Linux's
ephemeral range, where one port a host program holds fails the start.
None for any other container.
"""
if not svc or not isinstance(shape, dict) or not catalog.fence_gated(svc):
return None
if shape.get("network_mode") != "host":
return None
docker_conf = svc.get("docker") or {}
name = svc.get("name") or "it"
advice: dict[str, Any] = {
"action": lan_fence.move_advice(isolation_mode, name),
# Only where the worker says a redeploy alone works: one that reports no
# mode yet may be in legacy, off or on Podman, where it is refused.
"redeploy": isolation_mode in (lan_fence.MODE_ON, lan_fence.MODE_OPERATOR),
"warning": None,
}
rng = docker_conf.get("udp_port_range")
key = str(rng.get("env") or "") if isinstance(rng, dict) else ""
value = str(((recorded or {}).get("env") or {}).get(key) or "").strip() if key else ""
m = re.fullmatch(r"(\d{1,5}):(\d{1,5})", value)
if m and int(m[1]) <= EPHEMERAL_PORTS[1] and int(m[2]) >= EPHEMERAL_PORTS[0]:
low = EPHEMERAL_PORTS[0]
default = next((str(v.get("default") or "") for v in docker_conf.get("env") or [] if v.get("key") == key), "")
example = ""
d = re.fullmatch(r"(\d{1,5}):(\d{1,5})", default)
if d and int(d[2]) + 100 < low:
example = f", such as {int(d[1]) + 100}:{int(d[2]) + 100}"
advice["warning"] = (
f"Its recorded {key}, {value}, overlaps Linux's ephemeral range ({low}-{EPHEMERAL_PORTS[1]}), which the "
"redeploy keeps: on its own network every port in it is published, and one a host program holds fails "
f"the start. Before you redeploy, set {key} to a free range below {low} other than the default{example} "
"(a value equal to the default keeps the recorded one), and change the router forward to match."
)
return advice


def container_drift(
svc: dict[str, Any] | None,
shape: dict[str, Any] | None,
Expand Down Expand Up @@ -192,13 +240,7 @@ def container_drift(
found.append(
"uses its own network, but should share the host's"
if want_host
else "shares the host's network"
+ (
f", where {reach}. Switch isolation on in Fleet, then redeploy {svc.get('name') or 'it'} once to "
"move it onto the fenced bridge"
if gated
else ""
)
else "shares the host's network" + (f", where {reach}" if gated else "")
)
elif not want_host:
recorded_ports = None if gated else rec.get("ports")
Expand Down
7 changes: 3 additions & 4 deletions app/isolation_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ def _name(container: Any) -> str:
NETNS_CAPABILITIES = frozenset({"NET_ADMIN", "SYS_ADMIN"})


def host_netns_findings(containers: list[Any]) -> list[dict[str, Any]]:
def host_netns_findings(containers: list[Any], mode: str | None = None) -> list[dict[str, Any]]:
"""Managed containers holding NET_ADMIN or SYS_ADMIN in the host's network namespace.

Such a container can rewrite the host's firewall, the LAN fence included,
Expand All @@ -431,8 +431,7 @@ def host_netns_findings(containers: list[Any]) -> list[dict[str, Any]]:
"detail": (
"These containers hold firewall capabilities in this host's own network namespace, so they can "
"rewrite the host's firewall, the LAN fence included, and reach everything the host reaches: "
f"{', '.join(sorted(held))}. Switch isolation on in Fleet, then redeploy them once from the dashboard to "
"move them onto the fenced bridge."
f"{', '.join(sorted(held))}. " + lan_fence.move_advice(mode, "them", "them")
),
"docs": ISOLATION_PAGE,
}
Expand Down Expand Up @@ -770,7 +769,7 @@ def _run(client: Any) -> dict[str, Any]:
"not_answering_from_host": [],
"addresses_left_out": [],
"findings": (
host_netns_findings(every)
host_netns_findings(every, mode)
Comment thread
GeiserX marked this conversation as resolved.
Resolved
+ placement_findings(every, network, mode)
+ ipv6_findings(client, every, network)
),
Expand Down
37 changes: 31 additions & 6 deletions app/lan_fence.py
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,36 @@ def network_for_deploy() -> str | None:
return NETWORK_NAME


def move_advice(mode: str | None, service: str = "it", it: str = "it") -> str:
"""The one thing to do to move a fence-gated node off the host's network, in this isolation mode.

``on`` and ``operator`` take a redeploy alone (switching isolation on is
refused in ``operator`` mode); ``legacy`` needs isolation on first, ``off``
the setting changed, and Podman cannot be fenced at all. ``None`` is a
worker that reports no mode.
"""
if mode == MODE_ON:
return f"Redeploy {service} once to move {it} onto the fenced bridge."
if mode == MODE_OPERATOR:
return f"Redeploy {service} once to move {it} onto your own network (CASHPILOT_CONTAINER_NETWORK)."
if mode == MODE_LEGACY:
return f"Switch isolation on in Fleet first, then redeploy {service} once to move {it} onto the fenced bridge."
if mode == MODE_OFF:
return (
f"Set CASHPILOT_ISOLATION=auto on this worker and restart it, then redeploy {service} once to move "
f"{it} onto the fenced bridge."
)
if mode == MODE_UNSUPPORTED:
return (
f"{PODMAN_UNSUPPORTED}, so {service} cannot be fenced on this worker. Run {it} on Docker, or on your "
"own network (CASHPILOT_CONTAINER_NETWORK)."
)
return (
f"Redeploy {service} once to move {it} onto the fenced bridge; if isolation is not on for this worker, "
"switch it on in Fleet first."
)


def mode_problem(mode: str, service: str = "this service") -> str | None:
"""Why a fence-gated service cannot run in this isolation mode at all, or None.

Expand All @@ -1086,12 +1116,7 @@ def mode_problem(mode: str, service: str = "this service") -> str | None:
"""
if mode in (MODE_ON, MODE_OPERATOR):
return None
what = {
MODE_LEGACY: f"Switch isolation on in Fleet, then redeploy {service}.",
MODE_OFF: f"Set CASHPILOT_ISOLATION=auto on this worker and restart it, then redeploy {service}.",
MODE_UNSUPPORTED: "Run it on Docker with isolation on, or on your own network (CASHPILOT_CONTAINER_NETWORK).",
}.get(mode, f"Switch isolation on in Fleet, then redeploy {service}.")
return f"isolation is {mode} on this worker, and {service} runs only behind the LAN fence. {what}"
return f"isolation is {mode} on this worker, and {service} runs only behind the LAN fence. {move_advice(mode, service)}"


def fence_problem(client: Any, service: str = "this service") -> str | None:
Expand Down
18 changes: 13 additions & 5 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,11 @@ async def _get_all_worker_containers(workers: list[dict[str, Any]] | None = None
# check (app/drift.py). None from a worker too old to send it.
"shape": c.get("shape"),
"_arch": sys_info.get("arch"),
# The worker's isolation mode, for the drift advice on
# a fence-gated node still on the host's network.
"_isolation_mode": (
(sys_info.get("isolation") or {}) if isinstance(sys_info.get("isolation"), dict) else {}
).get("mode"),
"_node": worker_name,
"_worker_id": w.get("id"),
"_has_docker": worker_has_docker,
Expand Down Expand Up @@ -2618,8 +2623,8 @@ async def _instance_drift(
inst: dict[str, Any],
custom_image: str | None,
specs: dict[tuple[str, int], dict[str, Any] | None],
) -> tuple[list[str], bool]:
"""How this running copy differs from what a redeploy would build, and whether it has a record.
) -> tuple[list[str], bool, dict[str, Any] | None]:
"""How this running copy differs from what a redeploy would build, whether it has a record, and the move advice.

Never raises: a record that cannot be read leaves the copy judged on the
catalog alone, and a check that fails says nothing rather than blanking
Expand All @@ -2637,10 +2642,11 @@ async def _instance_drift(
found = drift.container_drift(
svc, inst.get("shape"), recorded, custom_image=custom_image, worker_arch=inst.get("_arch")
)
move = drift.move_advice(svc, inst.get("shape"), recorded, inst.get("_isolation_mode")) if found else None
except Exception as exc:
logger.warning("Drift check failed for %s on worker %s: %s", slug, wid, exc)
return [], False
return found, recorded is not None
return [], False, None
return found, recorded is not None, move


@app.get("/api/services/deployed")
Expand Down Expand Up @@ -2786,12 +2792,14 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]:
and inst.get("deployed_by") in constants.CASHPILOT_DEPLOYED
and drift_ignored.get(f"{slug}|{inst.get('_worker_id')}") != drift.catalog_fingerprint(svc)
):
drift_found, has_record = await _instance_drift(
drift_found, has_record, move = await _instance_drift(
svc, slug, inst, custom_images.get(inst.get("_worker_id")), specs
)
if drift_found:
detail["drift"] = drift_found
detail["drift_has_record"] = has_record
if move:
detail["drift_move"] = move
if inst.get("_is_android"):
detail["net_tx_24h"] = inst.get("_net_tx_24h", 0)
detail["net_rx_24h"] = inst.get("_net_rx_24h", 0)
Expand Down
3 changes: 1 addition & 2 deletions app/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1244,8 +1244,7 @@ def _deploy_failure(exc: BaseException, restored: str, slug: str = "") -> Contai
return ContainerRuntimeError(f"The container could not be created: {exc}. {restored}", 500)


#: Linux's default ephemeral range (net.ipv4.ip_local_port_range).
EPHEMERAL_PORTS = (32768, 60999)
from app.constants import EPHEMERAL_PORTS # noqa: E402 - Linux's default ephemeral range


def _ephemeral_hint(reason: str, slug: str = "") -> str:
Expand Down
11 changes: 9 additions & 2 deletions app/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1585,16 +1585,23 @@ const CP = (() => {
const fix = inst.drift_has_record
? 'Redeploy it here: CashPilot keeps the settings it was deployed with.'
: 'CashPilot has no record of how it was deployed, so it cannot rebuild it without losing its settings. Recreate it with the setting above, or remove it and deploy it again from the service page.';
// A fence-gated node still on the host's network (app/drift.py move_advice):
// the one action for this worker's isolation mode first, then what to fix
// before it, then the reasons.
const move = inst.drift_move;
const why = move
? `${escapeHtml(move.action)}${move.warning ? ` ${escapeHtml(move.warning)}` : ''}${inst.drift_has_record ? '' : ` ${fix}`}${inst.drift.map(d => `<br>· ${escapeHtml(d)}`).join('')}`
: `container does not match the catalog: ${what}. ${fix}`;
let actions = '';
if (_isOwner && !inst.unmanaged) {
actions = inst.drift_has_record && inst.worker_id != null
actions = inst.drift_has_record && inst.worker_id != null && !(move && move.redeploy === false)
? `<button class="btn btn-primary btn-sm" data-action="redeployOnWorker" data-a1="${escapeHtml(svc.slug)}" data-a2="${escapeHtml(String(inst.worker_id))}">Redeploy</button>`
: `<button class="btn btn-ghost btn-sm" data-action="openServiceDetail" data-a1="${escapeHtml(svc.slug)}">Open</button>`;
if (inst.worker_id != null) {
actions += `<button class="btn btn-ghost btn-sm" data-action="ignoreDrift" data-a1="${escapeHtml(svc.slug)}" data-a2="${escapeHtml(String(inst.worker_id))}">Ignore</button>`;
}
}
items.push({ level: 'fail', tag: 'Needs fixing', what: svc.name, where, why: `container does not match the catalog: ${what}. ${fix}`, actions });
items.push({ level: 'fail', tag: 'Needs fixing', what: svc.name, where, why, actions });
}
}

Expand Down
8 changes: 5 additions & 3 deletions docs/isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,11 @@ rules, and keeping other containers off its WebUI is up to those rules too. In
`off` and `legacy` mode, and on Podman, the worker does not run the node at
all: on Docker's default bridge every other earner could reach its WebUI and
change its payout wallet, and nothing would keep it off your LAN. A deploy is
refused before the running node is touched, with the order to follow: switch
isolation on in Fleet, then redeploy Mysterium. A fenced node already running
in such a mode is stopped.
refused before the running node is touched, with what to do in that mode:
in `legacy`, switch isolation on in Fleet first, then redeploy Mysterium; in
`off`, set `CASHPILOT_ISOLATION=auto` and restart the worker first; on Podman
it cannot be fenced. In `on` and operator mode a redeploy alone moves it. A
fenced node already running in such a mode is stopped.

**A node from 2.0.6 or earlier keeps running as it is.** A Mysterium container
on the host's network, which is what every node deployed through 2.0.6 is, is
Expand Down
12 changes: 9 additions & 3 deletions tests/test_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ def test_a_device_the_catalog_dropped_is_drift(self):
def test_an_unreadable_record_still_judges_the_catalog(self):
"""A failed record read must not hide what the catalog alone shows."""
with patch("app.main.database.get_deployment_spec", AsyncMock(side_effect=RuntimeError("db"))):
found, has_record = asyncio.run(
found, has_record, _move = asyncio.run(
main._instance_drift(
BITPING, "bitping", {"_worker_id": 1, "shape": _shape(_bitping_spec(cap_add=None))}, None, {}
)
Expand Down Expand Up @@ -256,7 +256,7 @@ def test_the_row_has_a_redeploy_handler(self):
assert "tag: 'Needs fixing'" in js


def _deployed_row(svc, shape, recorded, deployed_by="worker", config=None):
def _deployed_row(svc, shape, recorded, deployed_by="worker", config=None, isolation_mode=None):
from fastapi.testclient import TestClient

container = {
Expand All @@ -272,7 +272,13 @@ def _deployed_row(svc, shape, recorded, deployed_by="worker", config=None):
"id": 1,
"name": "w1",
"status": "online",
"system_info": json.dumps({"docker_available": True, "arch": "x86_64"}),
"system_info": json.dumps(
{
"docker_available": True,
"arch": "x86_64",
**({"isolation": {"mode": isolation_mode}} if isolation_mode else {}),
}
),
"containers": json.dumps([container]),
"apps": "[]",
}
Expand Down
Loading
Loading