All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 16m58s
`+chown,dac_override,fowner` kwam door elke stringtest heen maar werd door setpriv geweigerd met "bad capability string", waarna de container niet meer opstartte. Alleen de CI-stap in een echte container zag dit. - CAPS is nu +chown,+dac_override,+fowner - de test laat de echte setpriv over de string oordelen in plaats van hem te vergelijken met een verwachte tekst; met de oude waarde faalt hij Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
213 lines
8.6 KiB
Python
213 lines
8.6 KiB
Python
"""Het entrypoint dat naar een gewone gebruiker afzakt.
|
|
|
|
Dit script draait vóór de app en heeft geen vangnet: gaat het mis, dan start
|
|
Server Up niet meer op. Het mag daarom onder geen enkele omstandigheid weigeren
|
|
— hooguit als root doorgaan en zeggen waarom.
|
|
|
|
De root-tak is hier niet echt te draaien, dus die toetsen we met neppe
|
|
hulpprogramma's op $PATH. Dat is geen simulatie van de uitkomst maar van de
|
|
omgeving: het echte script loopt er onveranderd doorheen.
|
|
"""
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import stat
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
WORTEL = Path(__file__).resolve().parent.parent
|
|
SCRIPT = WORTEL / "docker-entrypoint.sh"
|
|
|
|
# De hulpprogramma's die het script als root aanroept. Ze schrijven wat ze
|
|
# kregen naar $NEP_LOG, zodat de test kan zien wát er gevraagd is.
|
|
NEPPERS = {
|
|
"id": "echo 0",
|
|
"chown": 'echo "chown $*" >> "$NEP_LOG"',
|
|
"groupadd": 'echo "groupadd $*" >> "$NEP_LOG"',
|
|
"useradd": 'echo "useradd $*" >> "$NEP_LOG"',
|
|
"usermod": 'echo "usermod $*" >> "$NEP_LOG"',
|
|
"getent": 'echo "getent $*" >> "$NEP_LOG"; exit 2',
|
|
"stat": "echo 999",
|
|
"setpriv": 'echo "setpriv $*" >> "$NEP_LOG"; exit 0',
|
|
}
|
|
|
|
# Wat het script verder nog nodig heeft en wat we níét willen namaken.
|
|
ECHTE = ("cut", "cp", "echo")
|
|
|
|
|
|
@pytest.fixture
|
|
def nep_omgeving(tmp_path):
|
|
"""Een $PATH met alléén nepjes en het strikt noodzakelijke.
|
|
|
|
Bewust afgesloten in plaats van de nepjes ervóór te zetten: een test die een
|
|
nep weghaalt om te zien wat er dan gebeurt, vond anders gewoon het echte
|
|
programma van deze machine.
|
|
"""
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir()
|
|
for naam, body in NEPPERS.items():
|
|
p = bin_dir / naam
|
|
p.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8")
|
|
p.chmod(0o755)
|
|
for naam in ECHTE:
|
|
echt = shutil.which(naam)
|
|
if echt:
|
|
(bin_dir / naam).symlink_to(echt)
|
|
log = tmp_path / "aanroepen.log"
|
|
log.write_text("", encoding="utf-8")
|
|
return {"bin": bin_dir, "log": log, "tmp": tmp_path}
|
|
|
|
|
|
def draai(env_extra: dict, nep=None, argv=("echo", "APP-GESTART")):
|
|
env = dict(os.environ)
|
|
env.update(env_extra)
|
|
if nep:
|
|
env["PATH"] = str(nep["bin"])
|
|
env["NEP_LOG"] = str(nep["log"])
|
|
# Absoluut pad: subprocess zoekt `sh` anders in het afgeschermde $PATH.
|
|
r = subprocess.run([shutil.which("sh") or "/bin/sh", str(SCRIPT), *argv],
|
|
capture_output=True, text=True, env=env, timeout=30)
|
|
return r
|
|
|
|
|
|
# ── Het mag nooit weigeren ───────────────────────────────────────────────────
|
|
|
|
# Deze gevallen stoppen vóór het script naar zijn eigen uid kijkt, dus ze
|
|
# gedragen zich hetzelfde of je nu root bent of niet.
|
|
@pytest.mark.parametrize("env_extra, waarom", [
|
|
({}, "zonder SU_UID"),
|
|
({"SU_UID": "0"}, "expliciet root"),
|
|
({"SU_UID": ""}, "lege SU_UID"),
|
|
({"SU_UID": "abc"}, "geen getal"),
|
|
({"SU_UID": "1000", "SU_GID": "x"}, "gid geen getal"),
|
|
])
|
|
def test_de_app_start_hoe_dan_ook(env_extra, waarom):
|
|
r = draai(env_extra)
|
|
assert r.returncode == 0, f"{waarom}: {r.stderr}"
|
|
assert "APP-GESTART" in r.stdout, f"{waarom}: de app is niet gestart"
|
|
|
|
|
|
def test_wie_al_niet_root_is_zakt_niet_verder_af(nep_omgeving):
|
|
"""Zonder root valt er niets klaar te zetten en niets af te zakken.
|
|
|
|
De uid wordt hier nagebootst in plaats van aan de omgeving overgelaten: de
|
|
CI-runner draait wél als root, en dan zou deze test iets heel anders toetsen
|
|
dan waarvoor hij bedoeld is — of erger, echt een gebruiker aanmaken.
|
|
"""
|
|
(nep_omgeving["bin"] / "id").write_text("#!/bin/sh\necho 1000\n", encoding="utf-8")
|
|
(nep_omgeving["bin"] / "id").chmod(0o755)
|
|
|
|
r = draai({"SU_UID": "990", "SU_GID": "990"}, nep=nep_omgeving)
|
|
assert r.returncode == 0, r.stderr
|
|
assert "APP-GESTART" in r.stdout
|
|
assert "wordt genegeerd" in r.stderr
|
|
assert nep_omgeving["log"].read_text() == "", "er is toch iets gewijzigd"
|
|
|
|
|
|
def test_onzin_wordt_gemeld_en_niet_stil_genegeerd():
|
|
r = draai({"SU_UID": "abc"})
|
|
assert "getallen" in r.stderr
|
|
|
|
|
|
def test_als_root_blijft_root_ongemoeid(nep_omgeving):
|
|
"""SU_UID=0 mag niets klaarzetten en niets afzakken."""
|
|
r = draai({"SU_UID": "0"}, nep=nep_omgeving)
|
|
assert "APP-GESTART" in r.stdout
|
|
assert nep_omgeving["log"].read_text() == "", "er is toch iets gewijzigd"
|
|
|
|
|
|
# ── De tak die alleen als root loopt ─────────────────────────────────────────
|
|
|
|
def test_afzakken_zet_klaar_en_geeft_de_juiste_rechten(nep_omgeving, tmp_path):
|
|
basis = tmp_path / "serverup"
|
|
for sub in ("stacks", "appdata", "backups"):
|
|
(basis / sub).mkdir(parents=True)
|
|
|
|
r = draai({"SU_UID": "1000", "SU_GID": "1000", "BASE_DIR": str(basis)},
|
|
nep=nep_omgeving)
|
|
assert r.returncode == 0, r.stderr
|
|
regels = nep_omgeving["log"].read_text()
|
|
|
|
# De gebruiker krijgt een naam en een thuis, anders struikelt git erover.
|
|
assert "useradd --uid 1000 --gid 1000" in regels
|
|
assert "/data" in regels and "chown -R 1000:1000 /data" in regels
|
|
|
|
# Wat Server Up zelf aanmaakt, mag van hem zijn.
|
|
assert f"chown -R 1000:1000 {basis}/stacks" in regels
|
|
assert f"chown -R 1000:1000 {basis}/backups" in regels
|
|
|
|
# En appdata niet: die mappen zijn van de apps zelf. Een chown daaroverheen
|
|
# breekt elke container die als zijn eigen uid draait.
|
|
assert "appdata" not in regels, "appdata is van eigenaar veranderd"
|
|
|
|
setpriv = [r for r in regels.splitlines() if r.startswith("setpriv ")]
|
|
assert setpriv, "er is niet afgezakt"
|
|
assert "--reuid 1000" in setpriv[0] and "--regid 1000" in setpriv[0]
|
|
# Zonder ambient caps verliest het proces bij het afzakken precies de
|
|
# rechten die het voor verhuizen en backups nodig heeft.
|
|
assert "--ambient-caps" in setpriv[0]
|
|
assert "chown" in setpriv[0] and "dac_override" in setpriv[0]
|
|
assert "echo APP-GESTART" in setpriv[0], "de app wordt niet doorgegeven"
|
|
|
|
|
|
def _caps_uit_het_script() -> str:
|
|
for regel in SCRIPT.read_text(encoding="utf-8").splitlines():
|
|
if regel.startswith("CAPS="):
|
|
return regel.split("=", 1)[1].strip().strip('"')
|
|
raise AssertionError("geen CAPS= in het entrypoint")
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("setpriv") is None,
|
|
reason="setpriv niet aanwezig; in het image wél (Dockerfile)")
|
|
def test_setpriv_snapt_de_capability_string():
|
|
"""De echte setpriv laten oordelen in plaats van de tekst te vergelijken.
|
|
|
|
`+chown,dac_override` zag er goed uit en kwam door elke stringtest heen,
|
|
maar setpriv wil per capability een teken en weigerde het met "bad
|
|
capability string" — waarna Server Up niet meer opstartte. Dat is precies
|
|
wat een vergelijking op tekst niet ziet en dit wel.
|
|
"""
|
|
caps = _caps_uit_het_script()
|
|
r = subprocess.run([shutil.which("setpriv"), "--inh-caps", caps, "true"],
|
|
capture_output=True, text=True, timeout=30)
|
|
# Toepassen mag mislukken (dat mag alleen root); de string moet leesbaar zijn.
|
|
assert "bad capability string" not in r.stderr, \
|
|
f"setpriv weigert '{caps}': elke capability wil zijn eigen + of -"
|
|
|
|
|
|
def test_de_gid_van_de_docker_socket_wordt_overgenomen(nep_omgeving, tmp_path):
|
|
"""Die verschilt per host; hardcoderen in compose werkt daarom niet."""
|
|
sok = tmp_path / "docker.sock"
|
|
s = socket.socket(socket.AF_UNIX)
|
|
s.bind(str(sok))
|
|
assert stat.S_ISSOCK(sok.stat().st_mode)
|
|
|
|
r = draai({"SU_UID": "1000", "SU_GID": "1000", "SU_DOCKER_SOCK": str(sok)},
|
|
nep=nep_omgeving)
|
|
s.close()
|
|
assert r.returncode == 0, r.stderr
|
|
regels = nep_omgeving["log"].read_text()
|
|
# De nep-`stat` meldt gid 999; die groep bestaat niet (nep-getent faalt),
|
|
# dus hij hoort aangemaakt te worden en de gebruiker erin te belanden.
|
|
assert "groupadd --gid 999" in regels
|
|
assert "usermod -aG dockersock" in regels
|
|
|
|
|
|
def test_zonder_socket_start_hij_toch(nep_omgeving, tmp_path):
|
|
r = draai({"SU_UID": "1000", "SU_GID": "1000",
|
|
"SU_DOCKER_SOCK": str(tmp_path / "bestaat-niet.sock")},
|
|
nep=nep_omgeving)
|
|
assert r.returncode == 0
|
|
assert "docker-socket" in r.stderr
|
|
assert "setpriv " in nep_omgeving["log"].read_text()
|
|
|
|
|
|
def test_zonder_setpriv_draait_hij_als_root_verder(nep_omgeving):
|
|
"""Beter als root draaien dan helemaal niet opstarten."""
|
|
(nep_omgeving["bin"] / "setpriv").unlink()
|
|
r = draai({"SU_UID": "1000", "SU_GID": "1000"}, nep=nep_omgeving)
|
|
assert r.returncode == 0
|
|
assert "APP-GESTART" in r.stdout
|
|
assert "setpriv ontbreekt" in r.stderr
|