diff --git a/app/isolation_check.py b/app/isolation_check.py
index 029684dc..dcf59f65 100644
--- a/app/isolation_check.py
+++ b/app/isolation_check.py
@@ -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 "
diff --git a/app/lan_fence.py b/app/lan_fence.py
index c7f8b31b..c5c4e2a5 100644
--- a/app/lan_fence.py
+++ b/app/lan_fence.py
@@ -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.
@@ -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
@@ -627,6 +640,23 @@ 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
@@ -634,6 +664,8 @@ def resolve_mode(client: Any) -> str:
if chosen == "off":
_remember(False)
return MODE_OFF
+ if is_podman(client):
+ return MODE_UNSUPPORTED
if (
chosen == "on"
or _remembered_on()
@@ -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:
@@ -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)
@@ -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
diff --git a/app/templates/fleet.html b/app/templates/fleet.html
index e54ce7fd..94d12513 100644
--- a/app/templates/fleet.html
+++ b/app/templates/fleet.html
@@ -480,7 +480,9 @@
Proxies
? ` `
: '';
let line = '';
- if (iso.mode === 'legacy') {
+ if (iso.mode === 'unsupported') {
+ line = '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.';
+ } else if (iso.mode === 'legacy') {
line = `Earners on this worker can reach your LAN and this host: ${esc(outside.join(', ') || 'none running')}.${button('Turn on isolation')}${stoppedNote}`;
} else if (iso.mode === 'on' && fence.state === 'failed') {
line = `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.`;
diff --git a/docs/isolation-check.md b/docs/isolation-check.md
index 76566393..90caec58 100644
--- a/docs/isolation-check.md
+++ b/docs/isolation-check.md
@@ -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
diff --git a/docs/isolation.md b/docs/isolation.md
index fdd9e9ae..aaa3945e 100644
--- a/docs/isolation.md
+++ b/docs/isolation.md
@@ -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 |
@@ -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.
diff --git a/tests/test_lan_fence.py b/tests/test_lan_fence.py
index 9008b934..6590919d 100644
--- a/tests/test_lan_fence.py
+++ b/tests/test_lan_fence.py
@@ -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)
@@ -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