diff --git a/.env.example b/.env.example index 131d343..98a3bc3 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,15 @@ PORT=5000 # waar hij stond. #BASE_DIR=/opt/serverup +# Onder welk account Server Up draait. install.sh vult dit in; 0 (of leeg) is +# root, zoals vóór v0.10. De container start altijd als root — dat moet, want +# het docker-volume is van root — en zakt daarna af naar deze uid/gid. +# +# Loop je ergens tegenaan, zet ze dan allebei op 0 en draai `docker compose up -d`. +# Zie docs/beveiliging.md voor wat dit wel en niet oplevert. +#SU_UID=0 +#SU_GID=0 + # Zet op 1 zodra alles via HTTPS loopt: de sessiecookie krijgt dan de # Secure-vlag. #SU_HTTPS=1 diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index e00e877..c153fd3 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -162,6 +162,49 @@ jobs: docker compose build --pull docker tag "server-up:${VERSION}" "server-up:${CHANNEL}" || true + # Server Up draait niet meer als root. Dat hangt aan het entrypoint en aan + # `setpriv`, en beide zijn alleen in een echte container te toetsen — de + # pytest-suite kan er niet bij. Zonder deze stap merk je een regressie hier + # pas als iemands installatie niet meer opkomt. + - name: Niet-root modus controleren + run: | + set -euo pipefail + IMG="server-up:${VERSION}" + + echo "1. setpriv aanwezig in het image" + docker run --rm --entrypoint setpriv "$IMG" --help >/dev/null + echo " ok" + + echo "2. zakt af naar de opgegeven gebruiker" + uit=$(docker run --rm -e SU_UID=1000 -e SU_GID=1000 \ + -v /var/run/docker.sock:/var/run/docker.sock "$IMG" id) + echo " $uit" + case "$uit" in + uid=1000*) ;; + *) echo "FOUT: draait niet als uid 1000"; exit 1 ;; + esac + + echo "3. zit in de groep van de docker-socket" + sok_gid=$(stat -c %g /var/run/docker.sock) + case "$uit" in + *"$sok_gid"*) echo " gid $sok_gid aanwezig" ;; + *) echo "FOUT: gid $sok_gid van de socket ontbreekt"; exit 1 ;; + esac + + echo "4. kan schrijven in /data" + docker run --rm -e SU_UID=1000 -e SU_GID=1000 \ + -v "su-nietroot-proef-${VERSION}:/data" "$IMG" \ + sh -c 'touch /data/proef && echo " ok"' + docker volume rm "su-nietroot-proef-${VERSION}" >/dev/null + + echo "5. zonder SU_UID blijft het root — de ontsnappingsklep" + uit0=$(docker run --rm "$IMG" id) + echo " $uit0" + case "$uit0" in + uid=0*) ;; + *) echo "FOUT: SU_UID=0 hoort root te blijven"; exit 1 ;; + esac + - name: Deploy run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index 275caac..086a732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,53 @@ +# v0.10.00-beta — Server Up draait niet meer als root + +Er stond `user: "0:0"` in `docker-compose.yml` en nergens waarom. Dat is nu een +keuze die je bij de installatie maakt. + +**Bij het installeren wordt gevraagd onder welk account het moet draaien**: een +nieuw systeemaccount `serverup` (aanbevolen, wordt aangemaakt zonder shell en +zonder wachtwoord), het account waarmee je werkt, een bestaand account uit een +lijst, of root zoals voorheen. Met `--user NAAM` of `--yes` sla je de vraag over. +Een bestaande installatie krijgt de vraag één keer bij `--update`; daarna blijft +je keuze staan. + +**De container start nog steeds als root en zakt daarna af.** Dat moet: Docker +maakt het `su-data`-volume als root aan, en een container die meteen als een +gewone gebruiker start komt niet eens tot zijn configuratie. Het nieuwe +`docker-entrypoint.sh` zet `/data`, `stacks` en `backups` klaar en gebruikt dan +`setpriv` — niet `gosu`, want een proces dat met `setuid` afzakt verliest zijn +capabilities, en die zijn hier nodig. `appdata` blijft ongemoeid: die mappen zijn +van de apps zelf, en een `chown` daaroverheen breekt precies de containers die +als hun eigen uid draaien. + +Het afgezakte proces houdt `CHOWN`, `DAC_OVERRIDE` en `FOWNER`. Alle drie zitten +al in de standaardset van Docker, dus de container krijgt geen enkel recht bij — +er gaan er alleen af. Wat dat wél en níét oplevert staat in +`docs/beveiliging.md`; kort gezegd beschermt het tegen bugs en ongelukken, niet +tegen iemand die de interface overneemt. Die heeft de docker-socket, en dat +blijft root. Een socket-proxy helpt daar niet tegen, omdat het dóél van deze app +is om containers met willekeurige bind mounts aan te maken. + +**Twee rechten-fouten die hierdoor aan het licht kwamen, zijn ook verholpen:** + +- Het installatieformulier zette `PUID`/`PGID` op wat het sjabloon toevallig + noemde — 44 sjablonen staan blind op `1000`. Nu krijgen ze de ids waaronder + Server Up draait, zodat appdata van hem is en hij hem kan inpakken. +- Eén onleesbaar bestand liet de héle backup falen: de `tar.add` van de + appdata-map zat in dezelfde `try` als de rest. Nu gaat de rest gewoon mee en + komt wat ontbreekt in het log én in de metadata (`skipped`) te staan. Een + archief dat compleet lijkt maar het niet is, is erger dan een archief dat zegt + wat het mist. +- Terugzetten gaf alles aan wie uitpakte: `tarfile` met `filter="data"` (PEP 706) + laat uid en gid uit het archief vallen. De appdata krijgt nu de `PUID`/`PGID` + uit de metadata terug, anders die van Server Up zelf. + +De CI controleert vanaf nu in een echte container dat `setpriv` bestaat, dat er +wordt afgezakt, dat de gid van de docker-socket meekomt, dat `/data` schrijfbaar +is, en dat `SU_UID=0` root laat blijven. + +**Terug naar de oude situatie:** zet `SU_UID=0` en `SU_GID=0` in je `.env` en +draai `docker compose up -d`. + # v0.9.00-beta — Eén hoofdmap, en je data mag mee verhuizen **Server Up stelde `/opt/serverup` voor, wat je ook instelde.** Dat was geen diff --git a/Dockerfile b/Dockerfile index eed325d..812c61a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,14 @@ ARG SU_VERSION=dev LABEL org.opencontainers.image.title="Server Up" \ org.opencontainers.image.version="${SU_VERSION}" \ org.opencontainers.image.source="https://git.example.com/bes-r/server-up" +# util-linux levert `setpriv`, waarmee het entrypoint naar een gewone gebruiker +# afzakt zonder de capabilities te verliezen die Server Up nodig heeft. Het is +# een Essential-pakket en dus al aanwezig, maar expliciet noemen zorgt dat een +# uitgeklede basis-image het niet stilletjes wegbezuinigt. RUN apt-get update && apt-get install -y --no-install-recommends \ - git openssh-client curl tar gzip ca-certificates \ - && rm -rf /var/lib/apt/lists/* + git openssh-client curl tar gzip ca-certificates util-linux passwd \ + && rm -rf /var/lib/apt/lists/* \ + && setpriv --help >/dev/null # Install Docker CLI + docker-compose inside container RUN DPKG_ARCH=$(dpkg --print-architecture) \ @@ -69,6 +74,12 @@ ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \ SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \ SU_SECRET=/data/secret.key \ GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts" +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + EXPOSE 5000 HEALTHCHECK --interval=30s --timeout=8s --start-period=15s CMD curl -fs http://localhost:5000/healthz || exit 1 +# Het entrypoint start als root, zet /data klaar en zakt daarna af naar +# SU_UID:SU_GID. Zonder die variabelen verandert er niets: dan blijft het root. +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["python","app.py"] diff --git a/VERSION b/VERSION index 66e6063..35395dc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.00-beta +0.10.00-beta diff --git a/docker-compose.yml b/docker-compose.yml index be5d431..7b82ea5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,10 @@ services: image: ${SU_IMAGE:-server-up}:${SU_TAG:-latest} container_name: server-up hostname: server-up + # De container *start* als root — dat moet, want Docker maakt het su-data + # volume als root aan en daar moet het entrypoint eerst in kunnen. Het zakt + # daarna af naar SU_UID:SU_GID (zie docker-entrypoint.sh). Laat je die leeg, + # dan blijft Server Up als root draaien zoals voorheen. user: "0:0" volumes: - /var/run/docker.sock:/var/run/docker.sock @@ -36,6 +40,11 @@ services: - SU_AUDIT=/data/audit.db - SU_GIT_CACHE=/data/git - SU_CONTAINER=server-up + # Onder welk account Server Up draait. install.sh vult dit in; leeg of 0 + # betekent root. De container krijgt hiermee géén extra rechten — er gaan + # er alleen af. Zie docs/beveiliging.md. + - SU_UID=${SU_UID:-0} + - SU_GID=${SU_GID:-0} # Dezelfde waarde als de mount hierboven, zodat de app zijn standaard # paden (stacks, appdata, backups) onder de juiste map zoekt. Zonder dit # mount je /srv terwijl de app /opt/serverup blijft voorstellen. diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..c041816 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,112 @@ +#!/bin/sh +# Start Server Up onder een gewone gebruiker in plaats van als root. +# +# Waarom dit niet gewoon `user:` in compose is: Docker maakt het `su-data`-volume +# als root aan. Een container die meteen als uid 1000 start kan daar niet in +# schrijven en komt niet eens tot de configuratie. Dit script begint dus als +# root, zet klaar wat klaargezet moet worden, en zakt daarna pas af. +# +# En waarom `setpriv` en niet `gosu`: een proces dat met setuid afzakt verliest +# zijn capabilities. Server Up moet daarna nog steeds bestanden kunnen chownen +# (bij het verhuizen van mappen en het terugzetten van backups) en appdata van +# andere gebruikers kunnen lezen (bij het maken van backups). `setpriv` kan die +# rechten als *ambient* capability meegeven, zodat ze de overgang overleven. +# +# SU_UID leeg of 0 → alles blijft zoals het was: draaien als root. +set -e + +log() { printf ' %s\n' "$1" >&2; } + +SU_UID="${SU_UID:-0}" +SU_GID="${SU_GID:-$SU_UID}" + +case "$SU_UID:$SU_GID" in + *[!0-9:]*) + log "SU_UID/SU_GID moeten getallen zijn ('$SU_UID:$SU_GID'); ik negeer ze." + exec "$@" ;; +esac + +# ── Als root draaien: het gedrag van vóór deze versie ──────────────────────── +if [ "$SU_UID" = "0" ] || [ -z "$SU_UID" ]; then + exec "$@" +fi + +# Zelf al niet-root? Dan is er niets om af te zakken en missen we de rechten om +# iets klaar te zetten. Gewoon doorgaan is beter dan weigeren. +if [ "$(id -u)" != "0" ]; then + log "Draait al als uid $(id -u); SU_UID wordt genegeerd." + exec "$@" +fi + +# ── Een naam bij het nummer ────────────────────────────────────────────────── +# git en ssh willen een echte gebruiker in /etc/passwd met een $HOME, anders +# komt er "No such device or address" uit een clone. +if ! getent group "$SU_GID" >/dev/null 2>&1; then + groupadd --gid "$SU_GID" serverup 2>/dev/null || true +fi +if ! getent passwd "$SU_UID" >/dev/null 2>&1; then + useradd --uid "$SU_UID" --gid "$SU_GID" --no-create-home \ + --home-dir /data --shell /usr/sbin/nologin serverup 2>/dev/null || true +fi +GEBRUIKER="$(getent passwd "$SU_UID" | cut -d: -f1)" +GEBRUIKER="${GEBRUIKER:-$SU_UID}" + +# ── Wat van ons is, overnemen ──────────────────────────────────────────────── +# /data is het volume met de configuratie, het auditlog en de git-cache. +chown -R "$SU_UID:$SU_GID" /data 2>/dev/null || log "Kon /data niet overnemen." +cp -n /root/.gitconfig /data/.gitconfig 2>/dev/null || true +chown "$SU_UID:$SU_GID" /data/.gitconfig 2>/dev/null || true + +# De stacks en de backups maakt Server Up zelf aan, dus die mogen van hem zijn. +# +# appdata NIET. Die mappen zijn van de apps: postgres draait als 999, een +# linuxserver-image als de PUID die jij hebt ingevuld. Er een chown overheen +# halen breekt precies die containers. Server Up leest ze via CAP_DAC_OVERRIDE. +if [ -n "$BASE_DIR" ]; then + for sub in stacks backups; do + [ -d "$BASE_DIR/$sub" ] || continue + chown -R "$SU_UID:$SU_GID" "$BASE_DIR/$sub" 2>/dev/null \ + || log "Kon $BASE_DIR/$sub niet overnemen." + done +fi + +# ── Bij de docker-socket kunnen ────────────────────────────────────────────── +# De gid daarvan verschilt per host, dus die lezen we hier uit in plaats van hem +# in compose te moeten hardcoderen. De gebruiker wordt lid van die groep, want +# `setpriv --groups` en `--init-groups` sluiten elkaar uit — via /etc/group +# krijgen we allebei. +# Overschrijfbaar voor rootless Docker en Podman, die de socket elders zetten. +SOCK="${SU_DOCKER_SOCK:-/var/run/docker.sock}" +if [ -S "$SOCK" ]; then + SOCK_GID="$(stat -c %g "$SOCK" 2>/dev/null || echo '')" + if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "$SU_GID" ]; then + SOCK_GROEP="$(getent group "$SOCK_GID" | cut -d: -f1)" + if [ -z "$SOCK_GROEP" ]; then + SOCK_GROEP=dockersock + groupadd --gid "$SOCK_GID" "$SOCK_GROEP" 2>/dev/null || true + fi + usermod -aG "$SOCK_GROEP" "$GEBRUIKER" 2>/dev/null \ + || log "Kon $GEBRUIKER niet in groep $SOCK_GROEP zetten." + fi +else + log "Geen docker-socket op $SOCK; Server Up kan straks niets starten." +fi + +# ── Afzakken ───────────────────────────────────────────────────────────────── +# chown → eigenaarschap meenemen bij het verhuizen van mappen en het +# terugzetten van backups +# dac_override, fowner → appdata lezen die van een andere gebruiker is +# +# Alle drie zitten al in de standaardset van Docker; er komt hier dus geen +# enkel recht bij dat de container niet al had. Wat weggaat is de rest van root. +CAPS="chown,dac_override,fowner" + +if ! command -v setpriv >/dev/null 2>&1; then + log "setpriv ontbreekt in dit image; ik draai als root verder." + exec "$@" +fi + +log "Server Up draait als $GEBRUIKER ($SU_UID:$SU_GID)." +export HOME=/data +exec setpriv --reuid "$SU_UID" --regid "$SU_GID" --init-groups \ + --inh-caps "+$CAPS" --ambient-caps "+$CAPS" "$@" diff --git a/docs/beveiliging.md b/docs/beveiliging.md index a125f2b..adbb102 100644 --- a/docs/beveiliging.md +++ b/docs/beveiliging.md @@ -144,11 +144,79 @@ Een externe wachtwoordkluis (Vault, Infisical) raden we hier af: Server Up zou dan afhangen van een app die het zelf beheert, en als die niet draait start er niets meer. +## Onder welk account Server Up draait + +Tot v0.9 draaide Server Up als root. Vanaf v0.10 kies je bij de installatie een +account — standaard een nieuw systeemaccount `serverup` zonder shell en zonder +wachtwoord — en zakt de container daarnaartoe af. + +```bash +sh install.sh --user serverup # aanbevolen, wordt zo nodig aangemaakt +sh install.sh --user ramon # een bestaand account +sh install.sh --user root # de oude situatie +``` + +Zonder `--user` wordt ernaar gevraagd, met een lijst van bruikbare accounts. De +keuze belandt als `SU_UID`/`SU_GID` in je `.env`. Een bestaande installatie +bijwerken met `--update` vraagt het één keer; daarna blijft je keuze staan. + +### Waarom de container tóch als root start + +Docker maakt het `su-data`-volume als root aan. Een container die meteen als een +gewone gebruiker start, kan daar niet in schrijven en komt niet eens tot zijn +configuratie. `docker-entrypoint.sh` begint daarom als root, zet `/data` en de +mappen `stacks` en `backups` klaar, en zakt dan af met `setpriv`. + +`appdata` blijft daarbij bewust ongemoeid. Die mappen zijn van de apps zelf — +postgres draait als 999, een linuxserver-image als de PUID die jij hebt +ingevuld. Er een `chown` overheen halen breekt precies die containers. + +### Wat het wel en niet oplevert + +Het afgezakte proces houdt drie capabilities: `CHOWN`, `DAC_OVERRIDE` en +`FOWNER`. Die heeft het nodig om appdata van andere gebruikers in te pakken voor +een backup, en om het eigenaarschap goed te zetten bij het verhuizen van mappen +en het terugzetten van een backup. Alle drie zitten al in de standaardset van +Docker: de container krijgt dus geen enkel recht bij, er gaan er alleen af. + +**Wees niet gerust op meer dan dit.** Met `DAC_OVERRIDE` kan het proces nog +steeds elk bestand onder de gekoppelde mappen lezen en schrijven, en het heeft +nog steeds de docker-socket. Wat je wint: + +- bestanden die Server Up aanmaakt zijn van jouw account, niet van root +- een echte uid om op te filteren in systeemlogs +- geen poorten onder 1024, geen kernelmodules, geen systeemtijd, geen mount + +Wat je **niet** wint: bescherming tegen iemand die Server Up overneemt. Die heeft +de socket, en dat blijft root op de host. Zie hieronder. + +### Zet je account niet in de `docker`-groep + +Dat lijkt handig maar geeft dat account root op de machine: wie bij de socket kan, +start een container die `/` mount. Het is ook niet nodig — het entrypoint leest de +gid van de socket zelf uit en maakt de gebruiker daar in de container lid van. + +### Terug naar root + +Loop je ergens tegenaan, zet dan in je `.env`: + +``` +SU_UID=0 +SU_GID=0 +``` + +en draai `docker compose up -d`. Alles werkt dan zoals vóór v0.10. + ## Wat níét is afgedekt - **De docker-socket zelf.** Server Up heeft volledige toegang tot de daemon; dat is inherent aan wat het doet. Een socket-proxy die alleen bepaalde endpoints - toelaat werkt niet, omdat compose vrijwel alles nodig heeft. + toelaat werkt hier niet: het dóél van deze app is containers met willekeurige + bind mounts aanmaken, dus een proxy die `POST /containers/create` doorlaat laat + de ontsnapping gewoon door. Voor een monitoringtool als Traefik is zo'n proxy + wél zinvol; voor een Docker-beheerder niet. +- **Niet als root draaien lost dit niet op.** Zie hierboven: het beschermt tegen + bugs en ongelukken, niet tegen misbruik van de interface. - **Rate limiting op de API** buiten de login-lockout om. Zet er zo nodig een proxy met rate limiting voor. diff --git a/docs/installeren.md b/docs/installeren.md index ab76c47..8e2d84c 100644 --- a/docs/installeren.md +++ b/docs/installeren.md @@ -50,6 +50,8 @@ schrijven én `docker` aanroepen, dan blijft alles onder je eigen account. --bind ADRES Waarop de poort luistert (standaard: 127.0.0.1) --branch NAAM Branch om te installeren (standaard: main) --token TOKEN Toegangstoken, als je repo niet openbaar is +--user NAAM Account waaronder Server Up draait ('serverup' wordt zo nodig + aangemaakt; 'root' is de oude situatie) --admin NAAM Maak meteen een beheerdersaccount met deze naam --admin-password-file PAD Lees het wachtwoord uit een bestand @@ -84,6 +86,30 @@ sh install.sh --token jouw-forgejo-token --- +## Onder welk account het draait + +Bij de installatie wordt gevraagd onder welk account Server Up moet draaien: + +``` + Onder welk account moet Server Up draaien? + Het draait nu nog als root; dat hoeft niet voor alles. + + 1) Een nieuw account 'serverup' aanmaken — aanbevolen + Systeemaccount zonder shell en zonder wachtwoord. + 2) ramon — het account waarmee je nu werkt + 3) Een bestaand account kiezen + 4) Als root draaien — zoals voorheen +``` + +Met `--user NAAM` of `--yes` sla je de vraag over; dan wordt het `serverup`. De +keuze komt als `SU_UID`/`SU_GID` in je `.env` en blijft bij een `--update` staan. + +Zet dat account **niet** in de `docker`-groep. Dat geeft het root op je machine +en is niet nodig — de container regelt de toegang tot de socket zelf. Wat dit +wel en niet oplevert staat in [beveiliging.md](beveiliging.md). + +--- + ## Waar je gegevens staan `--dir` bepaalt waar Server Up zélf staat. Waar je *stacks, appdata en backups* diff --git a/install.sh b/install.sh index 4cc169d..01f896a 100755 --- a/install.sh +++ b/install.sh @@ -31,6 +31,13 @@ BEHEERDER_WW_BESTAND="" # blijft staan, zodat een --update je instellingen niet terugzet. BIND_EXPLICIET=0 POORT_EXPLICIET=0 +# Onder welk account Server Up draait. Leeg = nog niet gekozen; 0 = root. +GEBRUIKER="" +GEBRUIKER_EXPLICIET=0 +SU_UID="" +SU_GID="" +SU_NAAM="" +BASE_DIR="${BASE_DIR:-}" # ── Uitvoer ────────────────────────────────────────────────────────────────── # Kleuren alleen als we naar een terminal schrijven; in een pipe of logbestand @@ -86,6 +93,9 @@ Gebruik: install.sh [opties] maar zet er dan wel een reverse proxy met TLS voor. --branch NAAM Branch om te installeren (standaard: ${BRANCH}) --token TOKEN Toegangstoken, als je git-repo niet openbaar is + --user NAAM Account waaronder Server Up draait ('serverup' wordt zo + nodig aangemaakt; 'root' is de oude situatie). Zonder deze + optie wordt ernaar gevraagd. --admin NAAM Maak meteen een beheerdersaccount met deze naam --admin-password-file PAD Lees het wachtwoord uit een bestand. Kan ook via de @@ -120,6 +130,8 @@ while [ $# -gt 0 ]; do --bind) BIND="${2:?--bind vereist een adres}"; BIND_EXPLICIET=1; shift 2 ;; --branch) BRANCH="${2:?--branch vereist een naam}"; shift 2 ;; --token) TOKEN="${2:?--token vereist een waarde}"; shift 2 ;; + --user) GEBRUIKER="${2:?--user vereist een accountnaam}" + GEBRUIKER_EXPLICIET=1; shift 2 ;; --admin) BEHEERDER="${2:?--admin vereist een gebruikersnaam}"; shift 2 ;; --admin-password-file) BEHEERDER_WW_BESTAND="${2:?--admin-password-file vereist een pad}"; shift 2 ;; @@ -319,6 +331,152 @@ zet_env_regel() { $SUDO sh -c "sed -i '/^$2=/d' '$1' && printf '%s=%s\n' '$2' '$3' >> '$1'" } +# ── Onder welk account draait Server Up? ───────────────────────────────────── +# Server Up start als root — dat moet, want het docker-volume is van root — en +# zakt daarna af naar dit account. Zie docs/beveiliging.md voor wat dat wel en +# niet oplevert. + +uid_van() { getent passwd "$1" 2>/dev/null | cut -d: -f3; } +gid_van() { getent passwd "$1" 2>/dev/null | cut -d: -f4; } + +ingelogde_gebruiker() { + # Wie de installatie aanstuurt. Onder sudo is dat niet 'root' maar degene + # die het aanriep; zonder sudo valt logname terug op de terminal. + naam="${SUDO_USER:-}" + [ -n "$naam" ] || naam="$(logname 2>/dev/null || echo '')" + [ "$naam" = "root" ] && naam="" + printf '%s' "$naam" +} + +bruikbare_gebruikers() { + # Gewone accounts: uid 1000–60000 en een echte shell. Systeemaccounts + # (daemon, www-data, nobody) horen hier niet tussen — die hebben eigen + # rechten en zouden verwarrend zijn om uit te kiezen. + awk -F: '$3 >= 1000 && $3 < 60000 && + $7 !~ /(nologin|false|sync)$/ { print $1 " (uid " $3 ")" }' \ + /etc/passwd 2>/dev/null +} + +maak_serverup_account() { + # Systeemaccount: geen shell, geen wachtwoord, geen thuismap. Het bestaat + # alleen om eigenaar te zijn van bestanden. + if [ -n "$(uid_van serverup)" ]; then + log "Account 'serverup' bestaat al" + return 0 + fi + if [ "$DROOG" -eq 1 ]; then + printf ' [droog] account serverup aanmaken (systeemaccount, geen shell)\n' + return 0 + fi + if heeft useradd; then + $SUDO useradd --system --no-create-home --shell /usr/sbin/nologin serverup \ + || fout "Kon het account 'serverup' niet aanmaken." + elif heeft adduser; then + $SUDO adduser --system --no-create-home --shell /usr/sbin/nologin serverup \ + || fout "Kon het account 'serverup' niet aanmaken." + else + fout "Geen useradd of adduser gevonden. Maak zelf een account en geef het op met --user NAAM." + fi + goed "Account 'serverup' aangemaakt" +} + +zet_gebruiker() { + # zet_gebruiker NAAM — vertaal een naam naar SU_UID/SU_GID. + SU_NAAM="$1" + if [ "$1" = "root" ]; then + SU_UID=0; SU_GID=0 + waarschuw "Server Up draait als root; dat is de oude situatie." + return 0 + fi + _uid="$(uid_van "$1")" + if [ -z "$_uid" ]; then + if [ "$1" = "serverup" ]; then + maak_serverup_account + _uid="$(uid_van serverup)" + fi + if [ -z "$_uid" ] && [ "$DROOG" -eq 1 ] && [ "$1" = "serverup" ]; then + # Alleen 'serverup' zouden we zélf hebben aangemaakt; dat het nu + # nog ontbreekt is dus geen fout. Een andere naam die niet bestaat + # is een typefout, en die hoor je juist bij een proefdraai te horen. + SU_UID=""; SU_GID="" + log "Zou draaien als 'serverup' (uid wordt bij het aanmaken toegekend)" + return 0 + fi + [ -n "$_uid" ] || fout "Account '$1' bestaat niet. Maak het aan of kies een ander." + fi + SU_UID="$_uid" + SU_GID="$(gid_van "$1")" + SU_GID="${SU_GID:-$SU_UID}" + goed "Server Up draait als $1 ($SU_UID:$SU_GID)" + # Bewust géén lidmaatschap van de docker-groep: wie daarin zit kan een + # container starten die / mount, en is dus root op deze machine. De + # container leest de gid van de socket zelf uit en heeft dit niet nodig. + log "Niet toegevoegd aan de docker-groep — dat zou root-toegang geven en is niet nodig." +} + +kies_gebruiker() { + [ "$GEBRUIKER_EXPLICIET" -eq 1 ] && { zet_gebruiker "$GEBRUIKER"; return 0; } + # Wat er al staat wint: bij --update wordt kies_bind niet aangeroepen, dus + # zonder dit zou een bestaande installatie elke keer opnieuw vragen. + lees_env_terug "$DOEL/.env" + [ -n "$SU_UID" ] && return 0 + if [ "$JA" -eq 1 ] || [ ! -e /dev/tty ]; then + zet_gebruiker serverup + return 0 + fi + + _ingelogd="$(ingelogde_gebruiker)" + stap "Account" + printf ' Onder welk account moet Server Up draaien?\n' + printf ' Het draait nu nog als root; dat hoeft niet voor alles.\n\n' + printf " 1) Een nieuw account 'serverup' aanmaken — aanbevolen\n" + printf ' Systeemaccount zonder shell en zonder wachtwoord.\n' + if [ -n "$_ingelogd" ]; then + printf ' 2) %s — het account waarmee je nu werkt\n' "$_ingelogd" + else + printf ' 2) (geen ingelogd account gevonden)\n' + fi + printf ' 3) Een bestaand account kiezen\n' + printf ' 4) Als root draaien — zoals voorheen\n\n' + printf ' Keuze [1]: ' + read -r keuze /dev/null || true)" _poort="$(printf '%s\n' "$_inhoud" | sed -n 's/^PORT=//p' | tail -1)" _bind="$(printf '%s\n' "$_inhoud" | sed -n 's/^BIND=//p' | tail -1)" + # SU_UID/SU_GID en BASE_DIR overnemen, zodat --update niet opnieuw vraagt + # onder welk account het moet draaien en niet de verkeerde mappen chownt. + if [ -z "$SU_UID" ]; then + _uid="$(printf '%s\n' "$_inhoud" | sed -n 's/^SU_UID=//p' | tail -1)" + case "$_uid" in ''|*[!0-9]*) ;; *) SU_UID="$_uid" + _gid="$(printf '%s\n' "$_inhoud" | sed -n 's/^SU_GID=//p' | tail -1)" + case "$_gid" in ''|*[!0-9]*) SU_GID="$_uid" ;; *) SU_GID="$_gid" ;; esac ;; + esac + fi + if [ -z "$BASE_DIR" ]; then + _basis="$(printf '%s\n' "$_inhoud" | sed -n 's/^BASE_DIR=//p' | tail -1)" + case "$_basis" in /*) BASE_DIR="$_basis" ;; esac + fi if [ "$POORT_EXPLICIET" -eq 0 ]; then case "$_poort" in ''|*[!0-9]*) ;; *) POORT="$_poort" ;; esac fi @@ -373,6 +544,19 @@ schrijf_env() { fi gewijzigd=1 fi + # Het account is een keuze die je net gemaakt hebt; die hoort er altijd + # in te komen, ook bij een --update van een installatie die nog als root + # draaide. + if [ -n "$SU_UID" ]; then + if [ "$DROOG" -eq 1 ]; then + printf ' [droog] SU_UID=%s SU_GID=%s in bestaande .env zetten\n' \ + "$SU_UID" "$SU_GID" + else + zet_env_regel "$env_bestand" SU_UID "$SU_UID" + zet_env_regel "$env_bestand" SU_GID "$SU_GID" + fi + gewijzigd=1 + fi if [ "$gewijzigd" -eq 1 ]; then goed "Bestaande .env bijgewerkt (BIND=$BIND, PORT=$POORT)" else @@ -383,7 +567,8 @@ schrijf_env() { fi if [ "$DROOG" -eq 1 ]; then - printf ' [droog] .env aanmaken met BIND=%s PORT=%s\n' "$BIND" "$POORT" + printf ' [droog] .env aanmaken met BIND=%s PORT=%s, draaiend als %s\n' \ + "$BIND" "$POORT" "${SU_NAAM:-root}" return 0 fi @@ -394,6 +579,10 @@ schrijf_env() { fi $SUDO sh -c "sed -i '/^BIND=/d;/^PORT=/d' '$env_bestand'" $SUDO sh -c "printf 'BIND=%s\nPORT=%s\n' '$BIND' '$POORT' >> '$env_bestand'" + if [ -n "$SU_UID" ]; then + zet_env_regel "$env_bestand" SU_UID "$SU_UID" + zet_env_regel "$env_bestand" SU_GID "$SU_GID" + fi goed "Aangemaakt: $env_bestand (BIND=$BIND, PORT=$POORT)" } @@ -656,9 +845,14 @@ case "$ACTIE" in controleer_systeem controleer_docker haal_broncode + # Bestaande installaties draaien nog als root. Eén keer vragen of dat zo + # moet blijven; wie al gekozen heeft, wordt niet opnieuw lastiggevallen + # (kies_gebruiker ziet SU_UID uit .env staan). + kies_gebruiker # Ook bij bijwerken: expliciete --bind/--port doorvoeren, en anders # overnemen wat er al staat. schrijf_env + zet_mappen_klaar start wacht_op_gereed || true printf '\n' @@ -689,9 +883,11 @@ case "$ACTIE" in controleer_systeem controleer_docker kies_bind + kies_gebruiker beheerder_vooraf haal_broncode schrijf_env + zet_mappen_klaar start wacht_op_gereed || true maak_beheerder diff --git a/server-up/app.py b/server-up/app.py index 406c3b0..8b40b95 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -1471,6 +1471,14 @@ def _annotate_fields(fields: list[dict]) -> list[dict]: # data dus ergens anders terecht dan waar je hem verwacht. if naam == "appdata_dir" and f.get("type") != "int": f["default"] = appdata + # En voor PUID/PGID: 44 sjablonen zetten die blind op 1000. Draait + # Server Up onder een ander account, dan maakt de app zijn bestanden + # als een gebruiker die Server Up daarna niet meer kan inpakken voor + # een backup. Zijn eigen ids zijn het enige voorstel dat altijd klopt. + if naam == "puid": + f["default"] = os.getuid() + elif naam == "pgid": + f["default"] = os.getgid() if f.get("type") == "int" and "port" in naam: try: gewenst = int(f.get("default") or 0) diff --git a/server-up/core/backups.py b/server-up/core/backups.py index 704c8af..b81c46d 100644 --- a/server-up/core/backups.py +++ b/server-up/core/backups.py @@ -23,6 +23,7 @@ een geprepareerd archief buiten de stackmap schrijven. from __future__ import annotations import json +import os import re import shutil import tarfile @@ -91,6 +92,36 @@ def _stack_meta(stack: str) -> dict: return {} +def _voeg_toe(tar, map_: Path, arcnaam: str, log_fn=None) -> list[str]: + """Pak een appdata-map in en meld wat er niet mee kon. + + Eén onleesbaar bestand liet eerder de héle backup falen: de `tar.add` van de + map zat in dezelfde `try` als de rest. Dat is precies het scenario waarin je + een backup het hardst nodig hebt — Server Up draait niet meer als root, dus + appdata van een app met een eigen uid kan buiten bereik liggen. + + Retourneert de paden die zijn overgeslagen. Die gaan in het log én in de + metadata: een archief dat er compleet uitziet maar het niet is, is erger dan + een archief dat zegt wat het mist. + """ + gemist: list[str] = [] + # Bewust bestand voor bestand in plaats van `tar.add` te laten recursen: die + # breekt af op de eerste fout, en wat er dan al in het archief zat zou bij + # een tweede poging dubbel meegaan. + tar.add(map_, arcname=arcnaam, recursive=False) + for p in sorted(map_.rglob("*")): + rel = p.relative_to(map_) + try: + tar.add(p, arcname=f"{arcnaam}/{rel}", recursive=False) + except (OSError, tarfile.TarError) as e: + gemist.append(str(rel)) + if log_fn and len(gemist) <= 5: + log_fn(f"Overgeslagen (niet leesbaar): {rel} — {e}") + if gemist and log_fn: + log_fn(f"{len(gemist)} bestand(en) niet ingepakt; zie de metadata.") + return gemist + + def appdata_mappen(stack: str) -> list[Path]: """De mappen waar deze stack zijn gegevens in bewaart. @@ -285,6 +316,7 @@ def create(stack: str, reden: str = "handmatig", log_fn=None, if log_fn: log_fn(f"Backup maken van {stack}…") + overgeslagen: list[str] = [] try: # Python's tarfile in plaats van het tar-commando: de returncode van dat # commando werd eerder genegeerd, zodat een mislukte backup als succes @@ -299,7 +331,8 @@ def create(stack: str, reden: str = "handmatig", log_fn=None, for m in data_mappen: if log_fn: log_fn(f"Appdata: {m.name}") - tar.add(m, arcname=f"appdata/{m.name}") + overgeslagen.extend( + _voeg_toe(tar, m, f"appdata/{m.name}", log_fn=log_fn)) for dp in dumps: tar.add(dp, arcname=f"dumps/{dp.name}") except Exception as e: @@ -309,10 +342,17 @@ def create(stack: str, reden: str = "handmatig", log_fn=None, meta = {"stack": stack, "file": naam, "ts": time.time(), "size": doel.stat().st_size, "reason": reden, "appdata": [m.name for m in data_mappen], - "dumps": [dp.name for dp in dumps]} + "dumps": [dp.name for dp in dumps], + # Leeg is het normale geval. Staat hier iets in, dan is dit archief + # niet compleet en moet je dat kunnen zien vóór je het terugzet. + "skipped": overgeslagen} _schrijf_meta(doel, meta) if log_fn: - log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])})") + if overgeslagen: + log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])}) — " + f"{len(overgeslagen)} bestand(en) ontbreken") + else: + log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])})") if cfg.load().get("BACKUP_VERIFY", True): if log_fn: @@ -622,11 +662,44 @@ def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]: return True, f"'{stack}' teruggezet uit {bestandsnaam}." +def _verwachte_eigenaar(waarden: dict) -> tuple[int, int]: + """Van wie hoort de teruggezette appdata te zijn? + + `tarfile` met `filter="data"` (PEP 706) laat uid en gid uit het archief + vallen — alles wordt van het proces dat uitpakt. Draait Server Up onder een + eigen account, dan krijgt de app zijn gegevens terug als een gebruiker die + hij niet is, en start hij niet meer. De PUID/PGID uit de metadata is wat de + app verwacht; staat die er niet, dan is onze eigen uid het beste antwoord. + """ + def _getal(sleutel, terugval): + try: + return int(str(waarden.get(sleutel)).strip()) + except (TypeError, ValueError): + return terugval + return _getal("puid", os.getuid()), _getal("pgid", os.getgid()) + + +def _zet_eigenaar(wortel: Path, uid: int, gid: int, log_fn=None) -> bool: + """Zet map en inhoud op deze eigenaar. False als dat niet mocht.""" + try: + os.chown(wortel, uid, gid, follow_symlinks=False) + for p in wortel.rglob("*"): + os.chown(p, uid, gid, follow_symlinks=False) + return True + except (OSError, NotImplementedError) as e: + if log_fn: + log_fn(f"Let op: eigenaar van {wortel.name} kon niet op {uid}:{gid} " + f"gezet worden ({e}). De app kan mogelijk niet bij zijn " + f"gegevens.") + return False + + def _zet_appdata_terug(stack: str, bron: Path, log_fn=None): """De appdata-tak uit het archief terug naar de appdata-map.""" if not bron.is_dir(): return waarden = (_stack_meta(stack).get("values") or {}) + uid, gid = _verwachte_eigenaar(waarden) basis = Path(str(waarden.get("appdata_dir") or cfg.load().get("DATA_DIR") or "")).resolve() if basis == Path("/") or not basis.parent.exists(): @@ -641,8 +714,9 @@ def _zet_appdata_terug(stack: str, bron: Path, log_fn=None): if doel.exists(): shutil.rmtree(doel, ignore_errors=True) shutil.move(str(m), str(doel)) + _zet_eigenaar(doel, uid, gid, log_fn=log_fn) if log_fn: - log_fn(f"Appdata teruggezet: {m.name}") + log_fn(f"Appdata teruggezet: {m.name} ({uid}:{gid})") def _speel_dumps_in(stack: str, stackmap: Path, log_fn=None) -> list[str]: diff --git a/tests/test_backups.py b/tests/test_backups.py index 41e3ba3..f012d34 100644 --- a/tests/test_backups.py +++ b/tests/test_backups.py @@ -316,3 +316,91 @@ def _mislukt(stderr=b"fout"): def _gelukt(stdout=b""): import subprocess return subprocess.CompletedProcess([], 0, stdout, b"") + + +# ── Rechten: wie is de eigenaar van wat er teruggezet wordt? ───────────────── +# Server Up draait vanaf v0.10 niet meer als root. Twee gevolgen die eerder +# geen rol speelden: appdata van een app met een eigen uid kan buiten bereik +# liggen, en `tarfile` met filter="data" geeft alles aan wie uitpakt. + +def test_een_onleesbaar_bestand_laat_de_rest_van_de_backup_staan(env, monkeypatch): + """Eén bestand waar we niet bij kunnen mocht niet de héle backup slopen — + juist dan heb je hem nodig.""" + from core import backups, docker + monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt()) + _d, appdata = _stack_met_appdata(env) + (appdata / "umami" / "tweede.txt").write_bytes(b"deze wel") + + # Geen namaak: een bestand waar de eigenaar zelf niet in kan kijken. Precies + # wat je krijgt als een container zijn data als een andere uid wegschrijft. + onleesbaar = appdata / "umami" / "bestand.txt" + onleesbaar.chmod(0o000) + try: + meta = backups.create("umami") + finally: + onleesbaar.chmod(0o644) + + assert meta["skipped"] == ["bestand.txt"], meta + with backups.tarfile.open(backups.backup_dir() / meta["file"]) as tar: + namen = tar.getnames() + assert "appdata/umami/tweede.txt" in namen, "de rest is ook weggevallen" + assert "appdata/umami/bestand.txt" not in namen + + +def test_wat_ontbreekt_staat_in_de_metadata(env, monkeypatch): + """Een archief dat compleet lijkt maar het niet is, is erger dan een + archief dat zegt wat het mist.""" + from core import backups, docker + monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt()) + _stack_met_appdata(env) + meta = backups.create("umami") + assert meta["skipped"] == [], "een geslaagde backup meldt niets ontbrekends" + assert "skipped" in backups.list_for("umami")[0] + + +def test_teruggezette_appdata_krijgt_de_puid_van_de_stack(env, monkeypatch): + """filter="data" laat uid/gid vallen, dus zonder chown wordt alles van wie + uitpakt — en start de app niet meer.""" + import json as _json + from core import backups, docker + monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt()) + monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0) + monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0) + d, appdata = _stack_met_appdata(env) + meta_pad = d / ".serverup.json" + inhoud = _json.loads(meta_pad.read_text()) + inhoud["values"].update({"puid": "1005", "pgid": "1006"}) + meta_pad.write_text(_json.dumps(inhoud)) + + meta = backups.create("umami") + gevraagd = [] + monkeypatch.setattr(backups.os, "chown", + lambda p, u, g, **kw: gevraagd.append((str(p), u, g))) + ok, msg = backups.restore("umami", meta["file"]) + + assert ok, msg + assert gevraagd, "er is helemaal niet gechownd" + assert all(u == 1005 and g == 1006 for _p, u, g in gevraagd), gevraagd + + +def test_zonder_puid_valt_hij_terug_op_ons_eigen_account(env): + from core import backups + import os as _os + assert backups._verwachte_eigenaar({}) == (_os.getuid(), _os.getgid()) + assert backups._verwachte_eigenaar({"puid": "1005", "pgid": "1006"}) == (1005, 1006) + # Een sjabloon met onzin erin mag geen TypeError geven. + assert backups._verwachte_eigenaar({"puid": "abc"}) == (_os.getuid(), _os.getgid()) + + +def test_chown_die_niet_mag_stopt_het_terugzetten_niet(env, monkeypatch, tmp_path): + """Draai je zonder de rechten om te chownen, dan is een waarschuwing beter + dan een mislukt herstel.""" + from core import backups + monkeypatch.setattr(backups.os, "chown", + lambda *a, **k: (_ for _ in ()).throw(PermissionError("mag niet"))) + map_ = tmp_path / "appdata-umami" + map_.mkdir() + (map_ / "a.txt").write_text("x") + regels = [] + assert backups._zet_eigenaar(map_, 1005, 1006, log_fn=regels.append) is False + assert any("eigenaar" in r for r in regels), regels diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py new file mode 100644 index 0000000..398a734 --- /dev/null +++ b/tests/test_entrypoint.py @@ -0,0 +1,169 @@ +"""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 diff --git a/tests/test_install_script.py b/tests/test_install_script.py index 5cf88d9..9d22fa9 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -520,3 +520,97 @@ def test_readme_en_docs_noemen_dezelfde_installatieregel(): "raw/branch/main/install.sh") assert regel in (WORTEL / "README.md").read_text(encoding="utf-8") assert regel in (WORTEL / "docs" / "installeren.md").read_text(encoding="utf-8") + + +# ── Onder welk account draait Server Up? ───────────────────────────────────── +# Vanaf v0.10 draait Server Up niet meer als root. Welke gebruiker het wordt is +# een keuze bij de installatie, en die keuze mag niet stilzwijgend verkeerd +# uitpakken: een typefout in de naam hoort te stoppen, en 'root' moet blijven +# kunnen voor wie problemen krijgt. + +GEBRUIKER_FRAGMENT = ( + 'DROOG=0; JA=0; GEBRUIKER_EXPLICIET=0; SU_UID=""; DOEL="$MAP"\n' + 'uid_van() { case "$1" in ramon) echo 1000 ;; serverup) echo 990 ;; esac; }\n' + 'gid_van() { case "$1" in ramon) echo 1000 ;; serverup) echo 990 ;; esac; }\n' + 'ingelogde_gebruiker() { printf "%s" "${NEP_INGELOGD-ramon}"; }\n' + 'bruikbare_gebruikers() { echo "ramon (uid 1000)"; }\n' + 'kies_gebruiker\n' + 'printf "UITKOMST uid=%s gid=%s naam=%s\\n" "$SU_UID" "$SU_GID" "$SU_NAAM"\n' +) + + +@pytest.mark.parametrize("invoer,verwacht", [ + ("\n", "uid=990 gid=990 naam=serverup"), # enter = aanbevolen + ("1\n", "uid=990 gid=990 naam=serverup"), + ("2\n", "uid=1000 gid=1000 naam=ramon"), # de ingelogde gebruiker + ("3\nramon\n", "uid=1000 gid=1000 naam=ramon"), # bestaand account + ("4\n", "uid=0 gid=0 naam=root"), # de oude situatie +]) +def test_keuzemenu_voor_het_account(invoer, verwacht, tmp_path): + leeg = tmp_path / "leeg" + leeg.mkdir() + uit = _in_terminal(f'MAP="{leeg}"\n' + GEBRUIKER_FRAGMENT, invoer, tmp_path) + assert f"UITKOMST {verwacht}" in uit, uit + + +def test_de_ingelogde_gebruiker_staat_erbij(tmp_path): + leeg = tmp_path / "leeg" + leeg.mkdir() + uit = _in_terminal(f'MAP="{leeg}"\n' + GEBRUIKER_FRAGMENT, "1\n", tmp_path) + assert "ramon — het account waarmee je nu werkt" in uit, uit + + +def test_zonder_ingelogde_gebruiker_geen_loze_keuze(tmp_path): + """Draai je als root zonder sudo, dan is er niemand om voor te stellen.""" + leeg = tmp_path / "leeg" + leeg.mkdir() + uit = _in_terminal(f'MAP="{leeg}"\nNEP_INGELOGD=""\n' + GEBRUIKER_FRAGMENT, + "2\n", tmp_path) + assert "geen ingelogd account gevonden" in uit + assert "UITKOMST" not in uit, "keuze 2 werd geaccepteerd zonder account" + + +def test_een_naam_die_niet_bestaat_stopt(tmp_path): + leeg = tmp_path / "leeg" + leeg.mkdir() + uit = _in_terminal(f'MAP="{leeg}"\n' + GEBRUIKER_FRAGMENT, "3\ntypfout\n", tmp_path) + assert "bestaat niet" in uit + assert "UITKOMST" not in uit + + +def test_een_eerdere_keuze_wordt_niet_opnieuw_gevraagd(tmp_path): + """Bij --update mag het script je niet elke keer opnieuw lastigvallen.""" + bestaand = tmp_path / "bestaand" + bestaand.mkdir() + # Bewust een uid die geen enkele menukeuze oplevert: met 990 (serverup) zou + # deze test ook slagen als het menu tóch verscheen en enter werd gekozen. + (bestaand / ".env").write_text("BIND=127.0.0.1\nPORT=5000\nSU_UID=1234\nSU_GID=1234\n") + uit = _in_terminal(f'MAP="{bestaand}"\n' + GEBRUIKER_FRAGMENT, "", tmp_path) + assert "UITKOMST uid=1234 gid=1234" in uit, uit + assert "Onder welk account" not in uit, "er werd toch gevraagd" + + +def test_de_docker_groep_wordt_bewust_overgeslagen(): + """Wie in de docker-groep zit kan een container starten die / mount, en is + daarmee root op de host. De container heeft het niet nodig: die leest de gid + van de socket zelf uit.""" + tekst = SCRIPT.read_text(encoding="utf-8") + for regel in tekst.splitlines(): + kaal = regel.strip() + if kaal.startswith("#") or kaal.startswith("log ") or kaal.startswith("printf"): + continue + assert "usermod" not in kaal or "docker" not in kaal, \ + f"gebruiker wordt in de docker-groep gezet: {kaal}" + assert "gpasswd" not in kaal, f"groepswijziging: {kaal}" + + +def test_appdata_wordt_niet_van_eigenaar_veranderd(): + """De appdata-mappen zijn van de apps zelf — postgres draait als 999, een + linuxserver-image als jouw PUID. Een chown daaroverheen breekt ze.""" + tekst = SCRIPT.read_text(encoding="utf-8") + for regel in tekst.splitlines(): + kaal = regel.strip() + if kaal.startswith("#") or kaal.startswith("printf"): + continue + if "chown" in kaal: + assert "appdata" not in kaal, f"appdata wordt gechownd: {kaal}" diff --git a/tests/test_paden.py b/tests/test_paden.py index e8aed3d..2cfa6b7 100644 --- a/tests/test_paden.py +++ b/tests/test_paden.py @@ -335,3 +335,35 @@ def test_installatieformulier_gebruikt_de_ingestelde_appdata(client, env, monkey headers={"X-CSRF-Token": csrf}).get_json() veld = next(f for f in d["fields"] if f["name"] == "appdata_dir") assert veld["default"] == "/srv/eigen/appdata" + + +def test_puid_volgt_het_account_van_server_up(client, env): + """44 sjablonen zetten PUID/PGID blind op 1000. Draait Server Up onder een + ander account, dan schrijft de app zijn data weg als een gebruiker die + Server Up daarna niet kan inpakken voor een backup.""" + from core import git + csrf = login(client) + + bron = git.cache_dir("server-up") / "apps" / "lsioapp" + (bron / "files").mkdir(parents=True) + (bron / "template.json").write_text(json.dumps({ + "kind": "compose", + "metadata": {"name": "Lsio", "description": "d", "tags": ["t"]}, + "variables": [{"title": "Algemeen", "items": [ + {"name": "service_name", "type": "str", "title": "Naam", + "default": "lsioapp", "required": True}, + {"name": "puid", "type": "int", "title": "PUID", "default": 1000}, + {"name": "pgid", "type": "int", "title": "PGID", "default": 1000}, + ]}], + }), encoding="utf-8") + (bron / "files" / "compose.yaml").write_text( + "services:\n << service_name >>:\n image: x\n" + " environment:\n - PUID=<< puid >>\n - PGID=<< pgid >>\n", + encoding="utf-8") + + d = client.post("/api/store/preview", + json={"stack": "lsioapp", "repo_id": "server-up"}, + headers={"X-CSRF-Token": csrf}).get_json() + velden = {f["name"]: f for f in d["fields"]} + assert velden["puid"]["default"] == os.getuid() + assert velden["pgid"]["default"] == os.getgid()