"""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