Skip to content
Closed
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/isolation_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,12 @@ def placement_findings(earners: list[Any], network: str, mode: str | None = None
"and can reach your LAN and this host. Turn it on with the button on this worker's row",
ISOLATION_PAGE,
)
elif mode == lan_fence.MODE_UNSUPPORTED:
why, docs = (
"This worker runs on Podman, which CashPilot cannot fence yet, so these earners sit on Podman's "
"default network and can reach your LAN and this host",
ISOLATION_PAGE,
)
elif mode == lan_fence.MODE_OFF:
why, docs = (
"Isolation is turned off on this worker with CASHPILOT_ISOLATION=off, so these earners sit on "
Expand Down
42 changes: 42 additions & 0 deletions app/lan_fence.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@
``off`` deletes the file.
* ``legacy``: an upgraded install whose earners are on the default bridge.
Nothing changes until the owner turns isolation on from the fleet page.
* ``unsupported``: a Podman engine, with the setting not ``off``. Podman's
Docker-compatible API keeps the interface name out of the network's options
and drops the masquerade and inter-container options, and netavark adds its
own masquerade, so none of what the fence relies on can be set or checked
there. Earners deploy on the default network and the fleet page says they
are not fenced; with CASHPILOT_ISOLATION=on, deploys are refused, since on
was asked for and cannot be given.

The top of this module is stdlib only: the helper container imports it to
render the rules, and the UI image imports its constants.
Expand Down Expand Up @@ -76,6 +83,12 @@
MODE_OFF = "off"
MODE_ON = "on"
MODE_LEGACY = "legacy"
MODE_UNSUPPORTED = "unsupported"

PODMAN_UNSUPPORTED = (
"CashPilot cannot fence earners off the LAN on Podman yet: its Docker-compatible API does not keep "
"the network settings the fence relies on"
)

#: Seconds between checks that the table is still in place.
RECHECK_SECONDS = 300
Expand Down Expand Up @@ -627,13 +640,32 @@ def _remember(on: bool) -> None:
)


def is_podman(client: Any) -> bool:
"""A Podman engine behind the Docker-compatible API.

Podman names itself in /version ("Podman Engine"). An engine that does not
answer is taken for Docker: the fence's own checks still refuse a network
it cannot hold.
"""
try:
version = client.version() or {}
except Exception as exc: # noqa: BLE001 - taken for Docker, as above
logger.debug("Could not read the engine's /version, so it is taken for Docker: %s", exc)
return False
names = [str((version.get("Platform") or {}).get("Name") or "")]
names += [str((c or {}).get("Name") or "") for c in version.get("Components") or []]
return any("podman" in n.lower() for n in names)


def resolve_mode(client: Any) -> str:
if _orchestrator()._CONTAINER_NETWORK:
return MODE_OPERATOR
chosen = setting()
if chosen == "off":
_remember(False)
return MODE_OFF
if is_podman(client):
return MODE_UNSUPPORTED
if (
chosen == "on"
or _remembered_on()
Expand Down Expand Up @@ -906,6 +938,12 @@ def network_for_deploy() -> str | None:
client = orch._get_client()
mode = resolve_mode(client)
_state["mode"] = mode
if mode == MODE_UNSUPPORTED and setting() == "on":
raise orch.ContainerNetworkError(
f"CASHPILOT_ISOLATION=on, but {PODMAN_UNSUPPORTED}. The running container was not touched. "
"Set CASHPILOT_ISOLATION=auto or off on this worker to deploy on Podman's default network, "
"without the fence (docs/isolation.md)."
)
if mode != MODE_ON:
return None
try:
Expand Down Expand Up @@ -971,6 +1009,8 @@ def reconcile() -> dict[str, Any]:
if mode == MODE_ON:
with contextlib.suppress(RuntimeError, IsolationConflict):
apply_fence(client)
elif mode == MODE_UNSUPPORTED:
_set_fence("unsupported", error=PODMAN_UNSUPPORTED + ".")
elif _needs_reduced(client, mode, orch._CONTAINER_NETWORK):
try:
reduce_fence(client)
Expand Down Expand Up @@ -1045,6 +1085,8 @@ def enable() -> dict[str, Any]:
"This worker has CASHPILOT_ISOLATION=off. Remove that setting and restart the worker to turn isolation on."
)
client = orch._get_client()
if is_podman(client):
raise IsolationConflict(PODMAN_UNSUPPORTED + ".")
apply_fence(client)
_remember(True)
_state["mode"] = MODE_ON
Expand Down
4 changes: 3 additions & 1 deletion app/templates/fleet.html
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,9 @@ <h2 class="block-title" id="proxies-title">Proxies</h2>
? ` <button class="btn btn-primary btn-sm btn-fence-on" data-worker-id="${w.id}" data-worker-name="${esc(w.name)}">${text}</button>`
: '';
let line = '';
if (iso.mode === 'legacy') {
if (iso.mode === 'unsupported') {
line = '<span class="text-warn">This worker runs on Podman, which CashPilot cannot fence yet, so its earners sit on Podman\'s default network and can reach your LAN and this host.</span>';
} else if (iso.mode === 'legacy') {
line = `<span class="text-warn">Earners on this worker can reach your LAN and this host: ${esc(outside.join(', ') || 'none running')}.</span>${button('Turn on isolation')}${stoppedNote}`;
} else if (iso.mode === 'on' && fence.state === 'failed') {
line = `<span class="text-fail">The LAN fence is not in place on this worker: ${esc(fence.error || 'unknown error')}. While it is missing, earners on its network can reach this host's own services, and they may still reach the internet if another masquerade rule on the host covers them. Deploys are refused until it is back. To run without the fence, set CASHPILOT_ISOLATION=off on the worker.</span>`;
Expand Down
3 changes: 2 additions & 1 deletion docs/isolation-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ What CashPilot's own fence has been tested on:
| Linux Docker with nf_tables (tested on Ubuntu 24.04, Docker 29.5, inside colima) | tested: LAN, host, tailnet and metadata blocked, internet works, fails closed without the table |
| The same host forced onto `iptables-legacy` (older NAS kernels) | tested, same results |
| Docker Desktop on Windows and macOS | not tested. It uses the same VM model as colima, so it is expected to work, unless Enhanced Container Isolation blocks the helper |
| Rootless Docker, Podman | not tested |
| Rootless Docker | not tested |
| Podman | not fenced: detected, earners stay on its default network, and the fleet page says so |
| Unraid, Synology, QNAP | not tested |

When the fence cannot be written on a host, the worker refuses deploys and says
Expand Down
6 changes: 5 additions & 1 deletion docs/isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ otherwise (older NAS kernels). Both write the same rules.
|---|---|---|
| on | a new install, `CASHPILOT_ISOLATION=on`, the `cashpilot-isolated` network CashPilot created exists, an earner is still bound to it after Unraid removed it, or the worker recorded it in `.isolation_on` in its data folder (`CASHPILOT_DATA_DIR`, `/data` by default) | earners join the fenced network |
| legacy | an upgraded install whose earners already sit on Docker's default bridge | nothing changes on upgrade; the fleet page offers **Turn on isolation** |
| unsupported | a Podman engine, with `CASHPILOT_ISOLATION` not `off` | earners deploy on Podman's default network, not fenced, and the fleet page says so; with `CASHPILOT_ISOLATION=on`, deploys are refused, since the fence was asked for and cannot be given |
| off | `CASHPILOT_ISOLATION=off` | Docker's default bridge, as before; earners left on the fenced network keep their internet until you redeploy them |
| your own network | `CASHPILOT_CONTAINER_NETWORK` is set | that network, with your own rules, as before; CashPilot writes no table, except the NAT alone while earners are still on `cashpilot-isolated`, so they keep their internet, without the fence's drops, until you redeploy them |

Expand Down Expand Up @@ -169,7 +170,10 @@ off button on purpose: one click should not take the fence down.
host) is not dropped. Use a VLAN or your own rules.
- Tested on Linux Docker 29 with nf_tables (colima on macOS, the same VM model
as Docker Desktop), including the `iptables-legacy` fallback. Docker Desktop
itself, rootless Docker, Podman and Unraid are not tested yet. On Docker
itself, rootless Docker and Unraid are not tested yet. Podman is not fenced:
its Docker-compatible API drops the network settings the fence relies on
(the interface name, masquerade off, inter-container traffic off), so the
worker detects it and leaves earners on its default network. On Docker
Desktop, the Enhanced Container Isolation setting may block the helper; the
deploy is then refused with the opt-out in the message.

Expand Down
86 changes: 86 additions & 0 deletions tests/test_lan_fence.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,10 +443,24 @@ def _get(name):
client.networks.get.side_effect = _get
client.networks.create.side_effect = lambda name, **kw: nets.setdefault(name, _network(name))
client.containers.list.return_value = list(earners)
client.version.return_value = {"Platform": {"Name": "Docker Engine - Community"}, "Components": []}
client._nets = nets
return client


#: /version as Podman 5 answers it on its Docker-compatible socket.
PODMAN_VERSION = {
"Platform": {"Name": "linux/arm64/fedora-42"},
"Components": [{"Name": "Podman Engine", "Version": "5.6.1"}, {"Name": "Conmon", "Version": "2.1.13"}],
}


def _podman(managed=None, earners=()):
client = _docker(managed, earners)
client.version.return_value = PODMAN_VERSION
return client


def _mode(client, setting="auto", operator=""):
with patch.object(lan_fence, "_SETTING", setting), patch.object(orchestrator, "_CONTAINER_NETWORK", operator):
return lan_fence.resolve_mode(client)
Expand Down Expand Up @@ -966,6 +980,78 @@ def test_it_returns_the_earners_to_move(self):
assert lan_fence.NETWORK_NAME in client._nets


class TestPodman:
"""Podman's Docker-compatible API cannot hold the fence's network, so it is never fenced."""

@pytest.mark.parametrize(
("version", "podman"),
[
(PODMAN_VERSION, True),
({"Platform": {"Name": "Podman Engine"}}, True),
({"Platform": {"Name": "Docker Engine - Community"}, "Components": [{"Name": "Engine"}]}, False),
({}, False),
],
)
def test_it_is_detected_from_the_version(self, version, podman):
client = MagicMock()
client.version.return_value = version
assert lan_fence.is_podman(client) is podman

def test_an_engine_that_does_not_answer_is_taken_for_docker(self):
client = MagicMock()
client.version.side_effect = OSError("no answer")
assert lan_fence.is_podman(client) is False

@pytest.mark.parametrize("setting", ["auto", "on"])
def test_it_resolves_to_unsupported_and_records_nothing(self, setting):
assert _mode(_podman(), setting) == lan_fence.MODE_UNSUPPORTED
assert not lan_fence.ON_MARKER.exists()

def test_off_and_the_operators_network_still_win(self):
assert _mode(_podman(), "off") == lan_fence.MODE_OFF
assert _mode(_podman(), operator="mine") == lan_fence.MODE_OPERATOR

def test_a_deploy_goes_to_the_default_network_without_the_fence(self):
client = _podman()
kwargs, helper = _deploy(client, "auto")
assert kwargs["network"] is None
helper.assert_not_called()
client.networks.create.assert_not_called()

def test_with_isolation_on_a_deploy_is_refused_before_the_old_container_is_touched(self):
old = MagicMock()
old.labels = {"cashpilot.service": "honeygain"}
client = _podman()
with pytest.raises(orchestrator.ContainerNetworkError, match="Podman"):
_deploy(client, "on", old=old)
old.stop.assert_not_called()
old.remove.assert_not_called()
client.containers.run.assert_not_called()

def test_the_check_reports_it_and_runs_no_helper(self):
state, helper = _reconcile(_podman(), "auto")
helper.assert_not_called()
assert state["mode"] == "unsupported" and state["network"] is None
assert state["fence"]["state"] == "unsupported" and "Podman" in state["fence"]["error"]

def test_turning_it_on_is_refused(self):
client = _podman(earners=[_container("cashpilot-earnapp")])
with (
patch.object(lan_fence, "_SETTING", "auto"),
patch.object(orchestrator, "_CONTAINER_NETWORK", ""),
patch.object(orchestrator, "_get_client", return_value=client),
patch.object(lan_fence, "run_helper") as helper,
pytest.raises(lan_fence.IsolationConflict, match="Podman"),
):
lan_fence.enable()
helper.assert_not_called()
assert not lan_fence.ON_MARKER.exists()

def test_the_isolation_check_says_why_the_earners_are_exposed(self):
found = isolation_check.placement_findings([_container("cashpilot-earnapp")], "", lan_fence.MODE_UNSUPPORTED)
assert found[0]["kind"] == "no_isolated_network" and "Podman" in found[0]["detail"]


class TestTheUiRoute:
def test_owner_only(self):
ui_app.router.lifespan_context = _noop_lifespan
Expand Down
Loading