mirror of
https://github.com/pi-hole/FTL.git
synced 2026-10-08 16:54:45 +01:00
All of Pi-hole's configuration and state currently lives under a hardcoded
/etc/pihole. Packagers that install FTL into a different prefix (snaps, /opt
installs, read-only-root images, ...) have to patch the sources.
Introduce a single compile-time base directory, PIHOLE_INSTALL_DIR, defaulting
to /etc/pihole so behaviour is unchanged, and route the /etc/pihole paths
through it: the hardcoded config/state defines (pihole.toml, the legacy config,
dnsmasq.conf, hosts, custom.list, dhcp.leases, backups, cli_pw, versions, the
inotify watch dir), the embedded dnsmasq CONFFILE, and the /etc/pihole defaults
of the runtime-settable config options (files.database, files.tmp_db,
files.gravity, files.macvendor, webserver.tls.cert). Packagers can now relocate
the tree with
cmake -DPIHOLE_INSTALL_DIR=/opt/pihole/etc ...
This is a build-time knob only and deliberately does not add a runtime override
(files.pid stays fixed per GHSA-6w8x-p785-6pm4). The /var/log/pihole log
defaults are left untouched as they are a different base directory.
Teleporter archives stay portable across installs, so the archive entry names
are pinned to their canonical etc/pihole/... values regardless of where the
files live on disk - only the on-disk source path is relocated. Export
previously derived every name from the absolute path minus its leading slash,
which would have made a relocated build write opt/pihole/etc/... while the
import side matched fixed names, silently skipping the affected files. That
applied to pihole.toml and dhcp.leases via PIHOLE_INSTALL_DIR, and to the
gravity and FTL databases via the runtime files.gravity/files.database values -
the latter reachable even without relocation, for anyone who moves gravity.db.
All four now go through ZIPNAME_TOML/ZIPNAME_DHCPLEASES/ZIPNAME_GRAVITY/
ZIPNAME_FTLDB, shared by export and import, and a test exports an archive and
asserts the names. Import targets are unchanged: gravity is still imported
table-wise into whatever files.gravity points at.
The embedded OpenAPI specs are run through configure_file() so the five
relocatable defaults they document (cert, database, tmp_db, gravity, macvendor)
and the two adlists.list mentions in lists.yaml follow the configured value
instead of documenting paths a relocated install does not use. The two help
strings naming the directory (webserver.api.cli_pw, debug.inotify) follow it as
well, as they are rendered into pihole.toml and served in the API docs.
CMake rejects a relative or trailing-slash PIHOLE_INSTALL_DIR, as the teleporter
strips the leading slash by offset, and one containing a percent sign, as the
value reaches printf format strings by concatenation in existing log messages
(e.g. main.c "Parsed config file "GLOBALTOMLPATH" successfully"). The check
carries a comment naming those call sites so it is not relaxed without
converting them first. The --gen-x509 and --read-x509 usage examples pass the
directory as an argument rather than concatenating it, matching
write_config_header().
Signed-off-by: Boris Rybalkin <ribalkin@gmail.com>
167 lines
6.7 KiB
Python
167 lines
6.7 KiB
Python
"""
|
|
Pi-hole FTL OpenAPI specification validation tests.
|
|
|
|
Verifies that FTL's API implementation matches the OpenAPI specs:
|
|
- Endpoint coverage (OpenAPI ↔ FTL cross-check)
|
|
- Response schema validation (types, formats, examples)
|
|
- Teleporter export/import round-trip
|
|
|
|
Ported from checkAPI.py. Reuses the existing libs/ utilities.
|
|
|
|
Usage:
|
|
pytest test/api/test_openapi.py -v
|
|
"""
|
|
|
|
import pytest
|
|
from libs.FTLAPI import FTLAPI, AuthenticationMethods
|
|
from libs.openAPI import openApi
|
|
from libs.responseVerifyer import ResponseVerifyer
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers for parametrize — collect endpoint lists at import time is not
|
|
# possible (needs fixtures). Instead, tests iterate inside the body and
|
|
# use subtests-style assertions, or we use indirect fixtures.
|
|
# We use a hybrid: fixtures provide the data, tests iterate with clear
|
|
# error messages per endpoint.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEndpointCoverage:
|
|
"""Cross-check that OpenAPI specs and FTL agree on available endpoints."""
|
|
|
|
def test_openapi_get_endpoints_exist_in_ftl(self, openapi, ftl):
|
|
"""Every GET endpoint in the OpenAPI specs is implemented in FTL."""
|
|
missing = []
|
|
for path in openapi.endpoints["get"]:
|
|
if path not in ftl.endpoints["get"]:
|
|
missing.append(path)
|
|
assert missing == [], \
|
|
"GET endpoints in OpenAPI specs but not in FTL:\n" + \
|
|
"\n".join(f" {p}" for p in missing)
|
|
|
|
def test_ftl_get_endpoints_exist_in_openapi(self, openapi, ftl):
|
|
"""Every GET endpoint in FTL is documented in the OpenAPI specs."""
|
|
# /api/docs is intentionally undocumented
|
|
skip = {"/api/docs"}
|
|
missing = []
|
|
for path in ftl.endpoints["get"]:
|
|
if path in skip:
|
|
continue
|
|
if path not in openapi.endpoints["get"]:
|
|
missing.append(path)
|
|
assert missing == [], \
|
|
"GET endpoints in FTL but not in OpenAPI specs:\n" + \
|
|
"\n".join(f" {p}" for p in missing)
|
|
|
|
def test_all_endpoints_cross_check(self, openapi, ftl):
|
|
"""Full bidirectional check across all HTTP methods."""
|
|
with ResponseVerifyer(ftl, openapi) as verifyer:
|
|
errors, checked = verifyer.verify_endpoints()
|
|
assert errors == [], \
|
|
f"Endpoint cross-check errors ({checked} checked):\n" + \
|
|
"\n".join(f" {e}" for e in errors)
|
|
|
|
|
|
class TestEndpointResponses:
|
|
"""Validate each GET endpoint's response against its OpenAPI schema."""
|
|
|
|
def test_get_endpoint_responses(self, openapi, ftl):
|
|
"""Each GET endpoint's response matches its OpenAPI spec.
|
|
|
|
Skips /api/action/* endpoints (would trigger unwanted actions).
|
|
Reports all failures with the endpoint path for easy identification.
|
|
"""
|
|
all_errors = {}
|
|
teleporter_archive = None
|
|
|
|
for path in openapi.endpoints["get"]:
|
|
if path.startswith("/api/action"):
|
|
continue
|
|
with ResponseVerifyer(ftl, openapi) as verifyer:
|
|
errors = verifyer.verify_endpoint(path)
|
|
if verifyer.teleporter_archive is not None:
|
|
teleporter_archive = verifyer.teleporter_archive
|
|
if len(errors) > 0:
|
|
all_errors[path] = (verifyer.auth_method, errors)
|
|
|
|
# Store teleporter archive for the teleporter tests
|
|
TestEndpointResponses._teleporter_archive = teleporter_archive
|
|
|
|
assert all_errors == {}, \
|
|
"Endpoint response validation errors:\n" + \
|
|
"\n".join(
|
|
f" GET {path} ({auth} auth):\n" +
|
|
"\n".join(f" - {e}" for e in errs)
|
|
for path, (auth, errs) in all_errors.items()
|
|
)
|
|
|
|
# Store across test instances
|
|
_teleporter_archive = None
|
|
|
|
|
|
class TestTeleporter:
|
|
"""Teleporter export/import round-trip via API."""
|
|
|
|
def test_teleporter_export_entry_names(self, ftl):
|
|
"""Exported archives must use the canonical etc/pihole/... entry names.
|
|
|
|
The names are independent of PIHOLE_INSTALL_DIR and of the runtime
|
|
files.gravity / files.database values, so that archives stay portable
|
|
between differently configured installations and the import side
|
|
(which matches fixed names) keeps accepting them.
|
|
"""
|
|
import io
|
|
import zipfile
|
|
|
|
archive = ftl.GET("/api/teleporter", [], "application/zip",
|
|
AuthenticationMethods.HEADER)
|
|
assert archive is not None, \
|
|
"Teleporter export failed:\n" + \
|
|
"\n".join(f" - {e}" for e in ftl.errors)
|
|
|
|
names = zipfile.ZipFile(io.BytesIO(archive)).namelist()
|
|
for expected in ("etc/pihole/pihole.toml", "etc/pihole/gravity.db",
|
|
"etc/pihole/pihole-FTL.db"):
|
|
assert expected in names, \
|
|
f"Teleporter archive does not contain {expected}:\n" + \
|
|
"\n".join(f" - {n}" for n in names)
|
|
leases = [n for n in names if n.endswith("dhcp.leases")]
|
|
assert leases in ([], ["etc/pihole/dhcp.leases"]), \
|
|
f"Teleporter archive stores DHCP leases under {leases}, " \
|
|
"expected etc/pihole/dhcp.leases"
|
|
|
|
def test_teleporter_import(self, openapi, ftl):
|
|
"""Re-import the teleporter ZIP archive exported during response tests.
|
|
|
|
Teleporter import triggers an internal FTL restart (gravity
|
|
database reload, exit code 22). We wait for FTL to come back
|
|
afterwards so subsequent tests (auth, rate limiting) have a
|
|
working API. Note: this is the only API call that causes an
|
|
FTL restart — password hashing (BALLOON-SHA256) and all other
|
|
config changes are fully synchronous and do not restart FTL.
|
|
"""
|
|
import time
|
|
import requests
|
|
|
|
archive = TestEndpointResponses._teleporter_archive
|
|
if archive is None:
|
|
pytest.skip("No teleporter archive captured during response tests")
|
|
|
|
with ResponseVerifyer(ftl, openapi) as verifyer:
|
|
errors = verifyer.verify_teleporter_zip(archive)
|
|
assert errors == [], \
|
|
"Teleporter import errors:\n" + \
|
|
"\n".join(f" - {e}" for e in errors)
|
|
|
|
# Wait for FTL to complete its internal restart after teleporter import
|
|
for _ in range(30):
|
|
time.sleep(0.5)
|
|
try:
|
|
r = requests.get("http://127.0.0.1/api/auth", timeout=2)
|
|
if r.status_code in (200, 401):
|
|
return
|
|
except requests.ConnectionError:
|
|
continue
|
|
pytest.fail("FTL did not come back after teleporter import")
|