server-up/tests/test_entrypoint.py
Ramon fc2e80fbda
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 2m8s
v0.10.00-beta - Server Up draait niet meer als root
- bij de installatie wordt gevraagd onder welk account Server Up draait: een
  nieuw systeemaccount 'serverup', het account waarmee je werkt, een bestaand
  account, of root zoals voorheen (--user NAAM slaat de vraag over)
- nieuw docker-entrypoint.sh: start als root, zet /data, stacks en backups
  klaar, en zakt met setpriv af naar SU_UID:SU_GID met de capabilities die
  nodig blijven (chown, dac_override, fowner - alle drie al in Docker's
  standaardset)
- appdata blijft daarbij bewust ongemoeid; die mappen zijn van de apps zelf
- de gid van de docker-socket wordt uit de socket zelf gelezen, zodat het
  account niet in de docker-groep hoeft (dat zou root op de host geven)
- installatieformulier vult PUID/PGID met de ids waaronder Server Up draait in
  plaats van de 1000 die 44 sjablonen blind noemen
- een onleesbaar bestand laat de rest van de backup niet meer sneuvelen; wat
  ontbreekt komt in het log en in de metadata (skipped)
- terugzetten herstelt het eigenaarschap van appdata, dat tarfile met
  filter="data" laat vallen
- CI controleert in een echte container dat setpriv bestaat, dat er wordt
  afgezakt, dat de socket-gid meekomt, dat /data schrijfbaar is en dat SU_UID=0
  root laat blijven
- SU_UID=0 in .env is de ontsnappingsklep naar het oude gedrag

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-02 22:02:36 +02:00

169 lines
6.5 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 ───────────────────────────────────────────────────
@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"),
({"SU_UID": "1000"}, "al niet-root"),
])
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_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 +chown,dac_override,fowner" in setpriv[0]
assert "echo APP-GESTART" in setpriv[0], "de app wordt niet doorgegeven"
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