Add filter write/create/delete and jail-filter assign endpoints (Task 2.2)

- PUT /api/config/filters/{name}: updates failregex/ignoreregex/datepattern/
  journalmatch in filter.d/{name}.local; validates regex via re.compile();
  supports ?reload=true
- POST /api/config/filters: creates filter.d/{name}.local from FilterCreateRequest;
  returns 409 if file already exists
- DELETE /api/config/filters/{name}: deletes .local only; returns 409 for
  conf-only (readonly) filters
- POST /api/config/jails/{name}/filter: assigns filter to jail by writing
  'filter = {name}' to jail.d/{jail}.local; supports ?reload=true
- New models: FilterUpdateRequest, FilterCreateRequest, AssignFilterRequest
- New service helpers: _safe_filter_name, _validate_regex_patterns,
  _write_filter_local_sync, _set_jail_local_key_sync
- Fixed .local-only filter discovery in _parse_filters_sync (5-tuple return)
- Fixed get_filter extension stripping (.local is 6 chars not 5)
- Renamed file_config.py raw-write routes to /raw suffix
  (PUT /filters/{name}/raw, POST /filters/raw) to avoid routing conflicts
- Full service + router tests; all 930 tests pass
This commit is contained in:
2026-03-13 18:13:03 +01:00
parent 4c138424a5
commit e15ad8fb62
8 changed files with 1885 additions and 64 deletions

View File

@@ -660,11 +660,12 @@ class TestParseFiltersSync:
result = _parse_filters_sync(filter_d)
assert len(result) == 1
name, filename, content, has_local = result[0]
name, filename, content, has_local, source_path = result[0]
assert name == "nginx"
assert filename == "nginx.conf"
assert "failregex" in content
assert has_local is False
assert source_path.endswith("nginx.conf")
def test_local_override_detected(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
@@ -675,7 +676,7 @@ class TestParseFiltersSync:
result = _parse_filters_sync(filter_d)
_, _, _, has_local = result[0]
_, _, _, has_local, _ = result[0]
assert has_local is True
def test_local_content_appended_to_content(self, tmp_path: Path) -> None:
@@ -687,7 +688,7 @@ class TestParseFiltersSync:
result = _parse_filters_sync(filter_d)
_, _, content, _ = result[0]
_, _, content, _, _ = result[0]
assert "local tweak" in content
def test_sorted_alphabetically(self, tmp_path: Path) -> None:
@@ -852,3 +853,637 @@ class TestGetFilter:
result = await get_filter(str(tmp_path), "/fake.sock", "sshd")
assert result.has_local_override is True
# ---------------------------------------------------------------------------
# _parse_filters_sync — .local-only filters (Task 2.2)
# ---------------------------------------------------------------------------
class TestParseFiltersSyncLocalOnly:
"""Verify that .local-only user-created filters appear in results."""
def test_local_only_included(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\nfailregex = ^fail\n")
result = _parse_filters_sync(filter_d)
assert len(result) == 1
name, filename, content, has_local, source_path = result[0]
assert name == "custom"
assert filename == "custom.local"
assert has_local is False # .local-only: no conf to override
assert source_path.endswith("custom.local")
def test_local_only_not_duplicated_when_conf_exists(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
_write(filter_d / "sshd.local", "[Definition]\n")
result = _parse_filters_sync(filter_d)
# sshd should appear exactly once (conf + local, not as separate entry)
names = [r[0] for r in result]
assert names.count("sshd") == 1
_, _, _, has_local, _ = result[0]
assert has_local is True # conf + local → has_local_override
def test_local_only_sorted_with_conf_filters(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "zzz.conf", _FILTER_CONF)
_write(filter_d / "aaa.local", "[Definition]\nfailregex = x\n")
result = _parse_filters_sync(filter_d)
names = [r[0] for r in result]
assert names == ["aaa", "zzz"]
# ---------------------------------------------------------------------------
# get_filter — .local-only filter (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestGetFilterLocalOnly:
"""Verify that get_filter handles .local-only user-created filters."""
async def test_returns_local_only_filter(self, tmp_path: Path) -> None:
from app.services.config_file_service import get_filter
filter_d = tmp_path / "filter.d"
_write(
filter_d / "custom.local",
"[Definition]\nfailregex = ^fail from <HOST>\n",
)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await get_filter(str(tmp_path), "/fake.sock", "custom")
assert result.name == "custom"
assert result.has_local_override is False
assert result.source_file.endswith("custom.local")
assert len(result.failregex) == 1
async def test_raises_when_neither_conf_nor_local(self, tmp_path: Path) -> None:
from app.services.config_file_service import FilterNotFoundError, get_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterNotFoundError):
await get_filter(str(tmp_path), "/fake.sock", "nonexistent")
async def test_accepts_local_extension(self, tmp_path: Path) -> None:
from app.services.config_file_service import get_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\nfailregex = x\n")
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await get_filter(str(tmp_path), "/fake.sock", "custom.local")
assert result.name == "custom"
# ---------------------------------------------------------------------------
# _validate_regex_patterns (Task 2.2)
# ---------------------------------------------------------------------------
class TestValidateRegexPatterns:
def test_valid_patterns_pass(self) -> None:
from app.services.config_file_service import _validate_regex_patterns
_validate_regex_patterns([r"^fail from \S+", r"\d+\.\d+"])
def test_empty_list_passes(self) -> None:
from app.services.config_file_service import _validate_regex_patterns
_validate_regex_patterns([])
def test_invalid_pattern_raises(self) -> None:
from app.services.config_file_service import (
FilterInvalidRegexError,
_validate_regex_patterns,
)
with pytest.raises(FilterInvalidRegexError) as exc_info:
_validate_regex_patterns([r"[unclosed"])
assert "[unclosed" in exc_info.value.pattern
def test_mixed_valid_invalid_raises_on_first_invalid(self) -> None:
from app.services.config_file_service import (
FilterInvalidRegexError,
_validate_regex_patterns,
)
with pytest.raises(FilterInvalidRegexError) as exc_info:
_validate_regex_patterns([r"\d+", r"[bad", r"\w+"])
assert "[bad" in exc_info.value.pattern
# ---------------------------------------------------------------------------
# _write_filter_local_sync (Task 2.2)
# ---------------------------------------------------------------------------
class TestWriteFilterLocalSync:
def test_writes_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _write_filter_local_sync
filter_d = tmp_path / "filter.d"
filter_d.mkdir()
_write_filter_local_sync(filter_d, "myfilter", "[Definition]\n")
local = filter_d / "myfilter.local"
assert local.is_file()
assert "[Definition]" in local.read_text()
def test_creates_filter_d_if_missing(self, tmp_path: Path) -> None:
from app.services.config_file_service import _write_filter_local_sync
filter_d = tmp_path / "filter.d"
_write_filter_local_sync(filter_d, "test", "[Definition]\n")
assert (filter_d / "test.local").is_file()
def test_overwrites_existing_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _write_filter_local_sync
filter_d = tmp_path / "filter.d"
filter_d.mkdir()
(filter_d / "myfilter.local").write_text("old content")
_write_filter_local_sync(filter_d, "myfilter", "[Definition]\nnew=1\n")
assert "new=1" in (filter_d / "myfilter.local").read_text()
assert "old content" not in (filter_d / "myfilter.local").read_text()
# ---------------------------------------------------------------------------
# _set_jail_local_key_sync (Task 2.2)
# ---------------------------------------------------------------------------
class TestSetJailLocalKeySync:
def test_creates_new_local_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _set_jail_local_key_sync
_set_jail_local_key_sync(tmp_path, "sshd", "filter", "myfilter")
local = tmp_path / "jail.d" / "sshd.local"
assert local.is_file()
content = local.read_text()
assert "filter" in content
assert "myfilter" in content
def test_updates_existing_local_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _set_jail_local_key_sync
jail_d = tmp_path / "jail.d"
jail_d.mkdir()
(jail_d / "sshd.local").write_text(
"[sshd]\nenabled = true\n"
)
_set_jail_local_key_sync(tmp_path, "sshd", "filter", "newfilter")
content = (jail_d / "sshd.local").read_text()
assert "newfilter" in content
# Existing key is preserved
assert "enabled" in content
def test_overwrites_existing_key(self, tmp_path: Path) -> None:
from app.services.config_file_service import _set_jail_local_key_sync
jail_d = tmp_path / "jail.d"
jail_d.mkdir()
(jail_d / "sshd.local").write_text("[sshd]\nfilter = old\n")
_set_jail_local_key_sync(tmp_path, "sshd", "filter", "newfilter")
content = (jail_d / "sshd.local").read_text()
assert "newfilter" in content
# ---------------------------------------------------------------------------
# update_filter (Task 2.2)
# ---------------------------------------------------------------------------
_FILTER_CONF_WITH_REGEX = """\
[Definition]
failregex = ^fail from <HOST>
^error from <HOST>
ignoreregex =
"""
@pytest.mark.asyncio
class TestUpdateFilter:
async def test_writes_local_override(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import update_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await update_filter(
str(tmp_path),
"/fake.sock",
"sshd",
FilterUpdateRequest(failregex=[r"^new pattern <HOST>"]),
)
local = filter_d / "sshd.local"
assert local.is_file()
assert result.name == "sshd"
assert any("new pattern" in p for p in result.failregex)
async def test_accepts_conf_extension(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import update_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await update_filter(
str(tmp_path),
"/fake.sock",
"sshd.conf",
FilterUpdateRequest(datepattern="%Y-%m-%d"),
)
assert result.name == "sshd"
async def test_raises_filter_not_found(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import FilterNotFoundError, update_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterNotFoundError):
await update_filter(
str(tmp_path),
"/fake.sock",
"missing",
FilterUpdateRequest(),
)
async def test_raises_on_invalid_regex(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import (
FilterInvalidRegexError,
update_filter,
)
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterInvalidRegexError):
await update_filter(
str(tmp_path),
"/fake.sock",
"sshd",
FilterUpdateRequest(failregex=[r"[unclosed"]),
)
async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import FilterNameError, update_filter
with pytest.raises(FilterNameError):
await update_filter(
str(tmp_path),
"/fake.sock",
"../etc/passwd",
FilterUpdateRequest(),
)
async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import update_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), patch(
"app.services.config_file_service.jail_service.reload_all",
new=AsyncMock(),
) as mock_reload:
await update_filter(
str(tmp_path),
"/fake.sock",
"sshd",
FilterUpdateRequest(journalmatch="_SYSTEMD_UNIT=sshd.service"),
do_reload=True,
)
mock_reload.assert_awaited_once()
# ---------------------------------------------------------------------------
# create_filter (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestCreateFilter:
async def test_creates_local_file(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import create_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(
name="my-custom",
failregex=[r"^fail from <HOST>"],
),
)
local = tmp_path / "filter.d" / "my-custom.local"
assert local.is_file()
assert result.name == "my-custom"
assert result.source_file.endswith("my-custom.local")
async def test_raises_already_exists_when_conf_exists(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterAlreadyExistsError, create_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterAlreadyExistsError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="sshd"),
)
async def test_raises_already_exists_when_local_exists(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterAlreadyExistsError, create_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\n")
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterAlreadyExistsError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="custom"),
)
async def test_raises_invalid_regex(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterInvalidRegexError, create_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterInvalidRegexError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="bad", failregex=[r"[unclosed"]),
)
async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterNameError, create_filter
with pytest.raises(FilterNameError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="../etc/evil"),
)
async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import create_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), patch(
"app.services.config_file_service.jail_service.reload_all",
new=AsyncMock(),
) as mock_reload:
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="newfilter"),
do_reload=True,
)
mock_reload.assert_awaited_once()
# ---------------------------------------------------------------------------
# delete_filter (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestDeleteFilter:
async def test_deletes_local_file_when_conf_and_local_exist(
self, tmp_path: Path
) -> None:
from app.services.config_file_service import delete_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
_write(filter_d / "sshd.local", "[Definition]\n")
await delete_filter(str(tmp_path), "sshd")
assert not (filter_d / "sshd.local").exists()
assert (filter_d / "sshd.conf").exists()
async def test_deletes_local_only_filter(self, tmp_path: Path) -> None:
from app.services.config_file_service import delete_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\n")
await delete_filter(str(tmp_path), "custom")
assert not (filter_d / "custom.local").exists()
async def test_raises_readonly_for_conf_only(self, tmp_path: Path) -> None:
from app.services.config_file_service import FilterReadonlyError, delete_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
with pytest.raises(FilterReadonlyError):
await delete_filter(str(tmp_path), "sshd")
async def test_raises_not_found_for_missing_filter(self, tmp_path: Path) -> None:
from app.services.config_file_service import FilterNotFoundError, delete_filter
with pytest.raises(FilterNotFoundError):
await delete_filter(str(tmp_path), "nonexistent")
async def test_accepts_filter_name_error_for_invalid_name(
self, tmp_path: Path
) -> None:
from app.services.config_file_service import FilterNameError, delete_filter
with pytest.raises(FilterNameError):
await delete_filter(str(tmp_path), "../evil")
# ---------------------------------------------------------------------------
# assign_filter_to_jail (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestAssignFilterToJail:
async def test_writes_filter_key_to_jail_local(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import assign_filter_to_jail
# Setup: jail.conf with sshd jail, filter.conf for "myfilter"
_write(tmp_path / "jail.conf", JAIL_CONF)
_write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="myfilter"),
)
local = tmp_path / "jail.d" / "sshd.local"
assert local.is_file()
content = local.read_text()
assert "myfilter" in content
async def test_raises_jail_not_found(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import (
JailNotFoundInConfigError,
assign_filter_to_jail,
)
_write(tmp_path / "filter.d" / "sshd.conf", _FILTER_CONF)
with pytest.raises(JailNotFoundInConfigError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"nonexistent-jail",
AssignFilterRequest(filter_name="sshd"),
)
async def test_raises_filter_not_found(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import FilterNotFoundError, assign_filter_to_jail
_write(tmp_path / "jail.conf", JAIL_CONF)
with pytest.raises(FilterNotFoundError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="nonexistent-filter"),
)
async def test_raises_jail_name_error_for_invalid_name(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import JailNameError, assign_filter_to_jail
with pytest.raises(JailNameError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"../etc/evil",
AssignFilterRequest(filter_name="sshd"),
)
async def test_raises_filter_name_error_for_invalid_filter(
self, tmp_path: Path
) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import FilterNameError, assign_filter_to_jail
with pytest.raises(FilterNameError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="../etc/evil"),
)
async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import assign_filter_to_jail
_write(tmp_path / "jail.conf", JAIL_CONF)
_write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF)
with patch(
"app.services.config_file_service.jail_service.reload_all",
new=AsyncMock(),
) as mock_reload:
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="myfilter"),
do_reload=True,
)
mock_reload.assert_awaited_once()