Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
2f6bf31
fix(agent): adopt, update and roll back stacks that Portainer runs
ConnorHaggerty Oct 7, 2026
4e6f2d9
fix(agent): a managed agent left on the monitor-mode service says so …
ConnorHaggerty Oct 7, 2026
3c4179b
feat(apps): the Apps page explains itself; "By host" opens rows in place
ConnorHaggerty Oct 7, 2026
10783ce
fix(apps): a host's expanded rows show Latest only when outdated, and…
ConnorHaggerty Oct 7, 2026
360a63d
feat(vulns): split By fix and By host into tabs, and skeleton the fix…
ConnorHaggerty Oct 7, 2026
43bed4a
fix(firewall): the page opens on a host instead of "Choose a host…"
ConnorHaggerty Oct 7, 2026
28c3bd2
fix(firewall): let a rule that blocks SSH or RDP be removed again
ConnorHaggerty Oct 7, 2026
0793555
feat(accounts): sign-in sessions expire on idleness and on a hard cap
ConnorHaggerty Oct 7, 2026
a3898a2
feat(tasks): validate community content with Vigil's own parsers
ConnorHaggerty Oct 7, 2026
9b99b4d
feat(accounts): warn two minutes before a session times out, and keep…
ConnorHaggerty Oct 7, 2026
012aeb6
fix(firewall): offer Remove on a protected port only for a deny or re…
ConnorHaggerty Oct 7, 2026
d4235d4
fix(security): only a user allowed to run tasks on a host can deploy …
ConnorHaggerty Oct 8, 2026
d178991
fix(security): rollouts, stacks and the enrolment check ask who may r…
ConnorHaggerty Oct 8, 2026
f207ff3
fix(security): task history and run detail show only what the caller'…
ConnorHaggerty Oct 8, 2026
8fa0c6f
fix(security): a standard Windows user can no longer plant a script t…
ConnorHaggerty Oct 8, 2026
7abf87c
fix(security): the Windows installer takes the whole config tree back…
ConnorHaggerty Oct 8, 2026
fcfe77c
fix(security): agents trust the key they were installed with, and ins…
ConnorHaggerty Oct 8, 2026
56eb3c1
fix(security): installers distrust a config a non-root account could …
ConnorHaggerty Oct 8, 2026
888cdbd
fix(security): tasks are signed over when they were sent and to which…
ConnorHaggerty Oct 8, 2026
1e0105f
feat(tasks): a results-ring group sorts its hosts to the top; a host …
ConnorHaggerty Oct 8, 2026
73d4423
fix(security): launching a vulnerability scan or adding a device need…
ConnorHaggerty Oct 8, 2026
bded09d
fix(install): a reinstall restarts the agent it replaces
ConnorHaggerty Oct 8, 2026
000a276
fix(security): the Windows installer quarantines scripts a non-admin …
ConnorHaggerty Oct 8, 2026
0f7619d
fix(security): scripts are judged after their folder is locked, by ow…
ConnorHaggerty Oct 8, 2026
ffe6715
docs(security): say why the agent's run-time script check, not the in…
ConnorHaggerty Oct 8, 2026
9162177
feat(ui): one styled TOTP dialog, a labelled host control, aligned st…
ConnorHaggerty Oct 8, 2026
8a5a022
fix(security): agent endpoints answer only approved hosts
ConnorHaggerty Oct 8, 2026
978d28e
fix(security): TOTP-gated actions stop after five wrong codes
ConnorHaggerty Oct 8, 2026
29bf48b
fix(security): the TOTP limit has its own table and counts an attempt…
ConnorHaggerty Oct 8, 2026
3597878
fix(security): turning TOTP off goes through the same attempt limit a…
ConnorHaggerty Oct 8, 2026
c69f76d
fix(security): agent tokens are stored as a SHA-256, not in the clear
ConnorHaggerty Oct 8, 2026
48665b5
fix(security): the public status page keys its cards by an opaque id,…
ConnorHaggerty Oct 8, 2026
c93fc39
fix(security): the installer file tree serves only files that really …
ConnorHaggerty Oct 8, 2026
cbcfa0d
fix(ui): line up rollout wave columns, and let the keyboard open a card
ConnorHaggerty Oct 8, 2026
4eccbce
fix(security): a compose file cannot reach the host through capabilit…
ConnorHaggerty Oct 9, 2026
6806b21
fix(security): the compose sandbox allows only keys it knows, and no …
ConnorHaggerty Oct 9, 2026
885cbfc
fix(security): no lifecycle hooks, build options or device paths arou…
ConnorHaggerty Oct 9, 2026
198f47f
fix(security): a stack cannot bind a directory that contains the engi…
ConnorHaggerty Oct 9, 2026
d670916
fix(security): //run is /run, and pid: Host is host
ConnorHaggerty Oct 9, 2026
e39912c
fix(security): the agent checks compose's resolved configuration befo…
ConnorHaggerty Oct 9, 2026
ec0809f
fix(security): the agent starts the compose file it checked, in every…
ConnorHaggerty Oct 9, 2026
3385408
fix(security): pin compose paths to the stack folder, and refuse bind…
ConnorHaggerty Oct 9, 2026
2034599
fix(security): no stack path a non-root user could re-point, no write…
ConnorHaggerty Oct 9, 2026
ba37f73
fix(security): adopting a stack reads its files without following a s…
ConnorHaggerty Oct 9, 2026
ff48083
fix(security): adopt hashes private copies, keeps compose's errors to…
ConnorHaggerty Oct 9, 2026
f50d5cf
feat(stacks): an endpoint that tells the editor what a compose file says
ConnorHaggerty Oct 9, 2026
bce6a2c
fix(security): a deploy's compose config error stays in the host's log
ConnorHaggerty Oct 9, 2026
8c78a2a
feat: make the stack editor a full-screen page (QA-20)
ConnorHaggerty Oct 9, 2026
38ad300
feat(rollouts): start several wave groups in parallel as one batch
ConnorHaggerty Oct 9, 2026
c38c0f3
feat(rollouts): pick several wave groups, and see a parallel batch as…
ConnorHaggerty Oct 9, 2026
5de2549
test: six tests no longer depend on which tests ran before them in th…
ConnorHaggerty Oct 9, 2026
fed266a
fix(containers): a stopped or crashed stack shows as stopped, with it…
ConnorHaggerty Oct 10, 2026
2f6b00a
feat(stacks): create a stack from a Git repository (server)
ConnorHaggerty Oct 10, 2026
1da23f5
feat(agent): place a Git stack's source folder, checked and without f…
ConnorHaggerty Oct 10, 2026
628270c
feat(stacks): a stack revision lists the Git commit it came from
ConnorHaggerty Oct 10, 2026
b9a1035
fix(security): a Git credential goes to its own server only, git keep…
ConnorHaggerty Oct 10, 2026
3926672
fix(security): a Git fetch keeps one pack file, and fetches run one a…
ConnorHaggerty Oct 10, 2026
6a28235
fix(security): a slow Git server cannot hold every Git fetch up
ConnorHaggerty Oct 10, 2026
4956962
feat(stacks): pick a stack's source in the editor — write it here, or…
ConnorHaggerty Oct 10, 2026
d0ab36e
docs(stacks): say where the credential select gets its options
ConnorHaggerty Oct 10, 2026
5efe494
fix(stacks): no autofill on a pasted deploy key either
ConnorHaggerty Oct 10, 2026
d350401
fix(stacks): new Git settings stick when they land on the same commit…
ConnorHaggerty Oct 10, 2026
5d10934
fix(stacks): the Git editor names a credential's server, asks TOTP to…
ConnorHaggerty Oct 10, 2026
57c90b6
fix(rollouts): a ladder claims hosts from its own waves only, and its…
ConnorHaggerty Oct 10, 2026
b23ca82
fix(stacks): a new stack starts with blank Git fields, and the editor…
ConnorHaggerty Oct 10, 2026
f99e091
docs(wiki): 2026.14.1 — stacks from Git, the stack editor, parallel w…
ConnorHaggerty Oct 10, 2026
5e60b03
docs(wiki): style the PUT method badge the API table now uses
ConnorHaggerty Oct 10, 2026
463618d
docs: no emoji in the wiki, the app, the README or the editions table
ConnorHaggerty Oct 10, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ python3 -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
VIGIL_SIGNING_KEY_SEED=Hk3PtP...P0c=
```

> ⚠️ **If you're pasting the compose stack into Portainer** (or any other UI that round-trips through YAML), set the value in the **Environment variables** tab — never inline it into the YAML. If you must inline it, **wrap it in double quotes** (`"Hk3...P0c="`) — the trailing `=` is base64 padding and unquoted YAML can strip or mangle it. A malformed seed produces `binascii.Error: Incorrect padding` and breaks every agent checkin in the fleet.
> **If you're pasting the compose stack into Portainer** (or any other UI that round-trips through YAML), set the value in the **Environment variables** tab — never inline it into the YAML. If you must inline it, **wrap it in double quotes** (`"Hk3...P0c="`) — the trailing `=` is base64 padding and unquoted YAML can strip or mangle it. A malformed seed produces `binascii.Error: Incorrect padding` and breaks every agent checkin in the fleet.

**3. Verify the seed is good before bringing the stack up:**

Expand Down
4 changes: 2 additions & 2 deletions agent/tests/test_branches_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,15 +313,15 @@ def setUp(self):

def test_features_include_branches(self):
self.assertIn("branches", features.FEATURES)
self.assertEqual(features.FEATURES, ("relevant", "branches", "boost"))
self.assertEqual(features.FEATURES, ("relevant", "branches", "boost", "signed_v2", "stack_source"))

def test_checkin_sends_branches(self):
resp = MagicMock()
resp.json.return_value = {"status": "online", "tasks": []}
with patch.object(client.requests, "post", return_value=resp) as post:
client.checkin(self.config, {})
payload = post.call_args.kwargs["json"]
self.assertEqual(payload["features"], ["relevant", "branches", "boost"])
self.assertEqual(payload["features"], ["relevant", "branches", "boost", "signed_v2", "stack_source"])


if __name__ == "__main__":
Expand Down
13 changes: 13 additions & 0 deletions agent/tests/test_checkin_allowlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ def test_empty_allowlist_is_sent(self):
client.checkin(cfg, metrics=[])
self.assertEqual(self.sent["allowlist"], [])

def test_payload_says_whether_it_runs_as_root(self):
"""QA-08: a managed agent on the monitor-mode unit runs unprivileged,
and the server needs that fact to warn before a deploy."""
with tempfile.TemporaryDirectory() as tmp:
cfg = AgentConfig(server_url="http://127.0.0.1:1", agent_token="t" * 40,
data_dir=Path(tmp), mode="managed")
with patch.object(client.os, "geteuid", return_value=0):
client.checkin(cfg, metrics=[])
self.assertIs(self.sent["runs_as_root"], True)
with patch.object(client.os, "geteuid", return_value=1000):
client.checkin(cfg, metrics=[])
self.assertIs(self.sent["runs_as_root"], False)


if __name__ == "__main__":
unittest.main()
Expand Down
274 changes: 274 additions & 0 deletions agent/tests/test_compose_check.py

Large diffs are not rendered by default.

210 changes: 210 additions & 0 deletions agent/tests/test_compose_host_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"""QA-07: compose paths written by a manager that runs compose in its own
container (Portainer) are the container's, not the host's — map them through
the mount that holds them before compose or Vigil reads them."""
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

import tests._safety_net # noqa: F401 — the guard, even when this file is run or imported on its own

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

# isort: split
from vigil_agent import engine, executor
from vigil_agent.actions import containers
from vigil_agent.config import AgentConfig

from tests.fake_engine import FakeEngine

_CFG = AgentConfig(server_url="https://v", agent_token="t", mode="full_control")
PORTAINER_COMPOSE = "/data/compose/49/docker-compose.yml"
PORTAINER_ENV = "/data/compose/49/stack.env"
PORTAINER_WD = "/data/compose/49"


def _mount(source: str, destination: str) -> dict:
return {"Type": "bind", "Source": source, "Destination": destination}


class HostPathTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
p = patch.object(executor, "_validate_path", side_effect=lambda v, label: Path(v))
p.start()
self.addCleanup(p.stop)

def _engine(self, containers_):
fake = FakeEngine(routes={("GET", "/containers/json"): (200, containers_)})
self.addCleanup(fake.close)
p = patch.object(executor, "_engine", return_value=engine.EngineClient(fake.path))
p.start()
self.addCleanup(p.stop)
return fake

def test_existing_path_is_unchanged(self):
existing = self.root / "docker-compose.yml"
existing.write_text("services: {}\n")
fake = self._engine([{"Id": "p", "Mounts": [_mount(str(self.root / "host"), "/data")]}])
self.assertEqual(containers._host_path(str(existing)), str(existing))
self.assertFalse([r for r in fake.requests if "/containers/json" in r[1]])

def test_maps_through_the_containers_mount(self):
host = self.root / "host" / "compose" / "49"
host.mkdir(parents=True)
compose = host / "docker-compose.yml"
compose.write_text("services:\n searxng:\n image: searxng/searxng\n")
self._engine([{"Id": "p", "Mounts": [_mount(str(self.root / "host"), "/data"),
_mount("/var/run/docker.sock", "/var/run/docker.sock")]}])
self.assertEqual(containers._host_path(PORTAINER_COMPOSE), str(compose))

def test_longest_destination_wins(self):
a = self.root / "a" / "compose" / "49"
b = self.root / "b" / "49"
a.mkdir(parents=True)
b.mkdir(parents=True)
(b / "docker-compose.yml").write_text("services: {}\n")
self._engine([{"Id": "p", "Mounts": [_mount(str(self.root / "b"), "/data/compose"),
_mount(str(self.root / "a"), "/data")]}])
self.assertEqual(containers._host_path(PORTAINER_COMPOSE), str(b / "docker-compose.yml"))

def test_unexplained_path_is_unchanged(self):
self._engine([{"Id": "p", "Mounts": [_mount(str(self.root / "elsewhere"), "/srv")]}])
self.assertEqual(containers._host_path(PORTAINER_COMPOSE), PORTAINER_COMPOSE)

def test_a_mount_that_maps_to_nothing_keeps_the_label_path(self):
self._engine([{"Id": "p", "Mounts": [_mount(str(self.root / "host"), "/data")]}])
self.assertEqual(containers._host_path(PORTAINER_COMPOSE), PORTAINER_COMPOSE)

def test_engine_error_is_unchanged(self):
"""An agent with no answering engine keeps the label path — the
caller's error then names it. _mount_lookup swallows nothing else."""
from vigil_agent.engine import EngineError
for boom in (RuntimeError("No container engine found (Docker or Podman socket)"),
EngineError("engine refused", status=500),
OSError("socket gone"),
ValueError("not json")):
p = patch.object(executor, "_engine", side_effect=boom)
p.start()
self.addCleanup(p.stop)
self.assertEqual(containers._host_path(PORTAINER_COMPOSE), PORTAINER_COMPOSE)

def test_the_labelled_path_is_what_an_unexplained_failure_names(self):
"""The fallback of last resort is the label itself, so an admin who
hits it sees the path Portainer's own stack entry shows."""
self._engine([{"Id": "p", "Mounts": []}])
self.assertEqual(containers._host_path("/data/compose/49/.env"),
"/data/compose/49/.env")
self.assertEqual(containers._label_env_files(
{"com.docker.compose.project.environment_file": "/data/compose/49/stack.env"}), [])


class PortainerActionTests(unittest.TestCase):
"""The actions the cphmserv report hit, end to end through the seams: the
argv that reaches compose carries host paths, never a container's."""

def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.host = Path(self.tmp.name) / "portainer_data"
stack = self.host / "compose" / "49"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text("services:\n searxng:\n image: searxng/searxng\n")
(stack / "stack.env").write_text("SEARXNG_SECRET=abc\n")
self.compose = stack / "docker-compose.yml"
self.env = stack / "stack.env"
self.fake = FakeEngine(routes={("GET", "/containers/json"): (200, [
{"Id": "p", "Mounts": [_mount(str(self.host), "/data"),
_mount("/var/run/docker.sock", "/var/run/docker.sock")]}])})
self.addCleanup(self.fake.close)
self.calls = []
for name, value in (("_engine", engine.EngineClient(self.fake.path)),
("_compose_cmd", ["docker", "compose"]),
("_compose_env", {})):
p = patch.object(executor, name, return_value=value)
p.start()
self.addCleanup(p.stop)
p = patch.object(executor, "_run", side_effect=lambda cmd, timeout=60, extra_env=None:
self.calls.append(cmd) or "ok")
p.start()
self.addCleanup(p.stop)
p = patch.object(executor, "_validate_path", side_effect=lambda v, label: Path(v))
p.start()
self.addCleanup(p.stop)
p = patch.object(executor.collector, "request_docker_recheck")
p.start()
self.addCleanup(p.stop)

def test_portainer_container_rollback(self):
spec = {"Config": {
"Image": "searxng/searxng:latest",
"Labels": {"com.docker.compose.project": "searxng",
"com.docker.compose.service": "searxng",
"com.docker.compose.project.config_files": PORTAINER_COMPOSE,
"com.docker.compose.project.working_dir": PORTAINER_WD,
"com.docker.compose.project.environment_file": PORTAINER_ENV}}}
with patch.object(executor, "_docker_inspect", return_value=spec):
executor._container_rollback({"container_name": "searxng",
"image": "searxng/searxng:2025-09-17"}, _CFG)

cmd = self.calls[0]
override = str(self.host / "compose" / "49" / containers.ROLLBACK_OVERRIDE)
self.assertEqual(
cmd,
["docker", "compose", "-p", "searxng", "--project-directory",
str(self.host / "compose" / "49"), "--env-file", str(self.env),
"-f", str(self.compose), "-f", override, "up", "-d", "--no-deps", "searxng"])
self.assertFalse([a for a in cmd if a.startswith("/data/")],
f"a container path leaked into the host's argv: {cmd!r}")
self.assertEqual((self.host / "compose" / "49" / containers.ROLLBACK_OVERRIDE).read_text(),
"# Written by Vigil: searxng rolled back. The next update removes it.\n"
"services:\n searxng:\n image: \"searxng/searxng:2025-09-17\"\n")

def test_portainer_stack_compose_args(self):
self.fake.routes[("GET", "/containers/json")] = (200, [{
"Id": "s",
"Labels": {"com.docker.compose.project": "searxng",
"com.docker.compose.project.config_files": PORTAINER_COMPOSE,
"com.docker.compose.project.working_dir": PORTAINER_WD,
"com.docker.compose.project.environment_file": PORTAINER_ENV},
"Mounts": [_mount(str(self.host), "/data")]}])
self.assertEqual(
containers._stack_compose_args("searxng"),
["-p", "searxng", "--project-directory", str(self.host / "compose" / "49"),
"--env-file", str(self.env), "-f", str(self.compose)])

def test_portainer_update_container(self):
spec = {"Image": "sha256:old", "Config": {
"Image": "searxng/searxng:latest",
"Labels": {"com.docker.compose.project": "searxng",
"com.docker.compose.service": "searxng",
"com.docker.compose.project.config_files": PORTAINER_COMPOSE,
"com.docker.compose.project.working_dir": PORTAINER_WD,
"com.docker.compose.project.environment_file": PORTAINER_ENV}}}
with patch.object(executor, "_docker_inspect", return_value=spec), \
patch.object(executor, "_pull", return_value=""), \
patch.object(executor, "_image_id", return_value="sha256:new"), \
patch.object(executor, "_container_image_id", return_value="sha256:new"):
executor._update_container({"container_name": "searxng"}, _CFG)

self.assertEqual(len(self.calls), 2)
tail = ["--project-directory", str(self.host / "compose" / "49"),
"--env-file", str(self.env), "-f", str(self.compose)]
self.assertEqual(self.calls[0], ["docker", "compose", *tail, "pull", "searxng"])
self.assertEqual(self.calls[1], ["docker", "compose", *tail,
"up", "-d", "--no-deps", "searxng"])
for cmd in self.calls: # both compose runs carry the host's argv
self.assertFalse([a for a in cmd if a.startswith("/data/")],
f"a container path leaked into the host's argv: {cmd!r}")
# compose writes the labels from the paths it is handed, so the same
# re-up has to carry the env file — otherwise it interpolates blanks
# and recreates every service of the stack.
self.assertEqual(tail.count("--env-file"), 1)
self.assertEqual(tail[tail.index("--env-file") + 1], str(self.env))


if __name__ == "__main__":
unittest.main()
71 changes: 71 additions & 0 deletions agent/tests/test_configured_server_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""SEC-3: the installer writes the server's public key into agent.yml, and then it
is the only key the agent trusts. No trust on first use, and a pin file in
data_dir (owned by the service account in monitor mode) cannot override it."""
import tests._safety_net # noqa: F401 — the guard, even when this file is run or imported on its own

import base64
import sys
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

# isort: split
from nacl.signing import SigningKey

from vigil_agent import config as agent_config
from vigil_agent import verify

REAL = base64.b64encode(bytes(SigningKey.generate().verify_key)).decode()
OTHER = base64.b64encode(bytes(SigningKey.generate().verify_key)).decode()


class ConfiguredKeyTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.data = Path(self.tmp.name)

def test_the_configured_key_is_accepted(self):
key = verify.pin_public_key(self.data, REAL, configured=REAL)
self.assertEqual(base64.b64encode(bytes(key)).decode(), REAL)

def test_a_different_key_from_the_server_is_refused(self):
with self.assertRaises(verify.KeyMismatchError):
verify.pin_public_key(self.data, OTHER, configured=REAL)

def test_a_planted_pin_file_cannot_override_the_configured_key(self):
(self.data / "server_public_key.pin").write_text(OTHER)
key = verify.get_pinned_key(self.data, configured=REAL)
self.assertEqual(base64.b64encode(bytes(key)).decode(), REAL)
with self.assertRaises(verify.KeyMismatchError):
verify.pin_public_key(self.data, OTHER, configured=REAL)

def test_without_a_configured_key_first_use_still_pins(self):
verify.pin_public_key(self.data, REAL)
with self.assertRaises(verify.KeyMismatchError):
verify.pin_public_key(self.data, OTHER)


class ConfigFieldTests(unittest.TestCase):
def _load(self, extra):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "agent.yml"
path.write_text(f'server_url: "https://v"\nagent_token: "t"\nmode: monitor\n{extra}')
path.chmod(0o600)
return agent_config.load_config(path)

def test_key_is_read_from_agent_yml(self):
self.assertEqual(self._load(f'server_public_key: "{REAL}"\n').server_public_key, REAL)

def test_a_malformed_key_is_refused(self):
with self.assertRaises(ValueError):
self._load('server_public_key: "not-a-key"\n')

def test_absent_means_empty(self):
self.assertEqual(self._load("").server_public_key, "")


if __name__ == "__main__":
unittest.main()
55 changes: 55 additions & 0 deletions agent/tests/test_firewall_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,61 @@ def snapshot(self):
return SNAPSHOT


def _fw_cmd(params):
"""The ufw command the removal action runs, with the tool calls faked."""
seen = {}

def fake_run(cmd, timeout=None):
seen["cmd"] = cmd
return "ok"

with patch.object(firewall, "_run", fake_run), \
patch.object(firewall.shutil, "which", lambda t: "/usr/sbin/ufw"
if t == "ufw" else None):
executor._remove_firewall_rule(params, _config())
return seen["cmd"]


def _firewalld_cmds(params):
"""Every firewall-cmd call the removal action runs."""
calls = []

def fake_run(cmd, timeout=None):
calls.append(cmd)
return "ok"

with patch.object(firewall, "_run", fake_run), \
patch.object(firewall.shutil, "which",
lambda t: "/usr/bin/firewall-cmd"
if t == "firewall-cmd" else None):
executor._remove_firewall_rule(params, _config())
return calls


class RemoveRejectRuleTests(unittest.TestCase):
"""A reject rule (ufw reports REJECT) was removable by nobody: the agent
refused the action outright, so the only way back in was by hand."""

def test_a_reject_rule_is_removed_as_reject(self):
self.assertEqual(
_fw_cmd({"port": 22, "protocol": "tcp", "action": "reject"}),
["ufw", "delete", "reject", "22/tcp"])

def test_firewalld_removes_a_reject_with_the_rich_rule(self):
calls = _firewalld_cmds({"port": 8080, "protocol": "tcp",
"action": "reject"})
joined = [" ".join(c) for c in calls]
self.assertTrue(
any("--remove-rich-rule=" in c and "reject" in c for c in joined),
"a reject was written as a rich rule, so it must be removed as one")
self.assertFalse(any("--remove-port" in c for c in joined),
"a reject is not a port grant")

def test_a_bogus_action_is_still_refused(self):
with self.assertRaises(ValueError):
_fw_cmd({"port": 22, "protocol": "tcp", "action": "drop"})


class ListRulesTests(unittest.TestCase):
def test_the_snapshot_is_returned_as_json(self):
with patch.object(firewall, "detect", lambda: FakeBackend()):
Expand Down
Loading
Loading