v0.10.00-beta - Server Up draait niet meer als root
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 2m8s

- 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
This commit is contained in:
Ramon 2026-08-02 22:02:36 +02:00
parent f4b652e182
commit fc2e80fbda
16 changed files with 998 additions and 9 deletions

View file

@ -42,6 +42,15 @@ PORT=5000
# waar hij stond. # waar hij stond.
#BASE_DIR=/opt/serverup #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 # Zet op 1 zodra alles via HTTPS loopt: de sessiecookie krijgt dan de
# Secure-vlag. # Secure-vlag.
#SU_HTTPS=1 #SU_HTTPS=1

View file

@ -162,6 +162,49 @@ jobs:
docker compose build --pull docker compose build --pull
docker tag "server-up:${VERSION}" "server-up:${CHANNEL}" || true 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 - name: Deploy
run: | run: |
set -euo pipefail set -euo pipefail

View file

@ -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 # 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 **Server Up stelde `/opt/serverup` voor, wat je ook instelde.** Dat was geen

View file

@ -11,9 +11,14 @@ ARG SU_VERSION=dev
LABEL org.opencontainers.image.title="Server Up" \ LABEL org.opencontainers.image.title="Server Up" \
org.opencontainers.image.version="${SU_VERSION}" \ org.opencontainers.image.version="${SU_VERSION}" \
org.opencontainers.image.source="https://git.example.com/bes-r/server-up" 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
git openssh-client curl tar gzip ca-certificates \ git openssh-client curl tar gzip ca-certificates util-linux passwd \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/* \
&& setpriv --help >/dev/null
# Install Docker CLI + docker-compose inside container # Install Docker CLI + docker-compose inside container
RUN DPKG_ARCH=$(dpkg --print-architecture) \ 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_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \
SU_SECRET=/data/secret.key \ SU_SECRET=/data/secret.key \
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts" 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 EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=8s --start-period=15s CMD curl -fs http://localhost:5000/healthz || exit 1 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"] CMD ["python","app.py"]

View file

@ -1 +1 @@
0.9.00-beta 0.10.00-beta

View file

@ -13,6 +13,10 @@ services:
image: ${SU_IMAGE:-server-up}:${SU_TAG:-latest} image: ${SU_IMAGE:-server-up}:${SU_TAG:-latest}
container_name: server-up container_name: server-up
hostname: 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" user: "0:0"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
@ -36,6 +40,11 @@ services:
- SU_AUDIT=/data/audit.db - SU_AUDIT=/data/audit.db
- SU_GIT_CACHE=/data/git - SU_GIT_CACHE=/data/git
- SU_CONTAINER=server-up - 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 # Dezelfde waarde als de mount hierboven, zodat de app zijn standaard
# paden (stacks, appdata, backups) onder de juiste map zoekt. Zonder dit # paden (stacks, appdata, backups) onder de juiste map zoekt. Zonder dit
# mount je /srv terwijl de app /opt/serverup blijft voorstellen. # mount je /srv terwijl de app /opt/serverup blijft voorstellen.

112
docker-entrypoint.sh Executable file
View file

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

View file

@ -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 dan afhangen van een app die het zelf beheert, en als die niet draait start er
niets meer. 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 ## Wat níét is afgedekt
- **De docker-socket zelf.** Server Up heeft volledige toegang tot de daemon; dat - **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 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 - **Rate limiting op de API** buiten de login-lockout om. Zet er zo nodig een
proxy met rate limiting voor. proxy met rate limiting voor.

View file

@ -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) --bind ADRES Waarop de poort luistert (standaard: 127.0.0.1)
--branch NAAM Branch om te installeren (standaard: main) --branch NAAM Branch om te installeren (standaard: main)
--token TOKEN Toegangstoken, als je repo niet openbaar is --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 NAAM Maak meteen een beheerdersaccount met deze naam
--admin-password-file PAD --admin-password-file PAD
Lees het wachtwoord uit een bestand 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 ## Waar je gegevens staan
`--dir` bepaalt waar Server Up zélf staat. Waar je *stacks, appdata en backups* `--dir` bepaalt waar Server Up zélf staat. Waar je *stacks, appdata en backups*

View file

@ -31,6 +31,13 @@ BEHEERDER_WW_BESTAND=""
# blijft staan, zodat een --update je instellingen niet terugzet. # blijft staan, zodat een --update je instellingen niet terugzet.
BIND_EXPLICIET=0 BIND_EXPLICIET=0
POORT_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 ────────────────────────────────────────────────────────────────── # ── Uitvoer ──────────────────────────────────────────────────────────────────
# Kleuren alleen als we naar een terminal schrijven; in een pipe of logbestand # 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. maar zet er dan wel een reverse proxy met TLS voor.
--branch NAAM Branch om te installeren (standaard: ${BRANCH}) --branch NAAM Branch om te installeren (standaard: ${BRANCH})
--token TOKEN Toegangstoken, als je git-repo niet openbaar is --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 NAAM Maak meteen een beheerdersaccount met deze naam
--admin-password-file PAD --admin-password-file PAD
Lees het wachtwoord uit een bestand. Kan ook via de 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 ;; --bind) BIND="${2:?--bind vereist een adres}"; BIND_EXPLICIET=1; shift 2 ;;
--branch) BRANCH="${2:?--branch vereist een naam}"; shift 2 ;; --branch) BRANCH="${2:?--branch vereist een naam}"; shift 2 ;;
--token) TOKEN="${2:?--token vereist een waarde}"; 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) BEHEERDER="${2:?--admin vereist een gebruikersnaam}"; shift 2 ;;
--admin-password-file) --admin-password-file)
BEHEERDER_WW_BESTAND="${2:?--admin-password-file vereist een pad}"; shift 2 ;; 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'" $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 100060000 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/tty || keuze=""
case "$keuze" in
''|1) zet_gebruiker serverup ;;
2) [ -n "$_ingelogd" ] || fout "Er is geen ingelogd account gevonden; kies 1, 3 of 4."
zet_gebruiker "$_ingelogd" ;;
3) printf '\n Bruikbare accounts:\n'
bruikbare_gebruikers | sed 's/^/ /'
printf '\n Naam: '
read -r naam </dev/tty || naam=""
naam="$(printf '%s' "$naam" | tr -d '[:space:]')"
[ -n "$naam" ] || fout "Geen naam opgegeven."
zet_gebruiker "$naam" ;;
4) zet_gebruiker root ;;
*) fout "Onbekende keuze: $keuze" ;;
esac
}
zet_mappen_klaar() {
# De hoofdmap en de twee mappen die Server Up zélf beheert alvast op de
# juiste eigenaar zetten, zodat de eerste start niet op rechten struikelt.
#
# appdata blijft er bewust buiten: die mappen zijn van de apps (postgres
# draait als 999, een linuxserver-image als jouw PUID). Er een chown
# overheen halen breekt precies die containers.
[ "${SU_UID:-0}" = "0" ] && [ "$DROOG" -eq 0 ] && return 0
[ "${SU_NAAM:-root}" = "root" ] && return 0
_basis="${BASE_DIR:-/opt/serverup}"
if [ "$DROOG" -eq 1 ]; then
printf ' [droog] %s, %s/stacks en %s/backups op %s zetten (appdata blijft ongemoeid)\n' \
"$_basis" "$_basis" "$_basis" "$SU_NAAM"
return 0
fi
$SUDO mkdir -p "$_basis/stacks" "$_basis/backups" "$_basis/appdata"
$SUDO chown "$SU_UID:$SU_GID" "$_basis"
$SUDO chown -R "$SU_UID:$SU_GID" "$_basis/stacks" "$_basis/backups"
goed "Mappen klaargezet onder $_basis"
}
lees_env_terug() { lees_env_terug() {
# Wat er in .env staat is de waarheid, niet wat dit script als standaard # Wat er in .env staat is de waarheid, niet wat dit script als standaard
# heeft. Zonder dit klopt bij --update de wachtlus en het slotadres niet # heeft. Zonder dit klopt bij --update de wachtlus en het slotadres niet
@ -338,6 +496,19 @@ lees_env_terug() {
_inhoud="$($_lees "$1" 2>/dev/null || true)" _inhoud="$($_lees "$1" 2>/dev/null || true)"
_poort="$(printf '%s\n' "$_inhoud" | sed -n 's/^PORT=//p' | tail -1)" _poort="$(printf '%s\n' "$_inhoud" | sed -n 's/^PORT=//p' | tail -1)"
_bind="$(printf '%s\n' "$_inhoud" | sed -n 's/^BIND=//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 if [ "$POORT_EXPLICIET" -eq 0 ]; then
case "$_poort" in ''|*[!0-9]*) ;; *) POORT="$_poort" ;; esac case "$_poort" in ''|*[!0-9]*) ;; *) POORT="$_poort" ;; esac
fi fi
@ -373,6 +544,19 @@ schrijf_env() {
fi fi
gewijzigd=1 gewijzigd=1
fi 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 if [ "$gewijzigd" -eq 1 ]; then
goed "Bestaande .env bijgewerkt (BIND=$BIND, PORT=$POORT)" goed "Bestaande .env bijgewerkt (BIND=$BIND, PORT=$POORT)"
else else
@ -383,7 +567,8 @@ schrijf_env() {
fi fi
if [ "$DROOG" -eq 1 ]; then 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 return 0
fi fi
@ -394,6 +579,10 @@ schrijf_env() {
fi fi
$SUDO sh -c "sed -i '/^BIND=/d;/^PORT=/d' '$env_bestand'" $SUDO sh -c "sed -i '/^BIND=/d;/^PORT=/d' '$env_bestand'"
$SUDO sh -c "printf 'BIND=%s\nPORT=%s\n' '$BIND' '$POORT' >> '$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)" goed "Aangemaakt: $env_bestand (BIND=$BIND, PORT=$POORT)"
} }
@ -656,9 +845,14 @@ case "$ACTIE" in
controleer_systeem controleer_systeem
controleer_docker controleer_docker
haal_broncode 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 # Ook bij bijwerken: expliciete --bind/--port doorvoeren, en anders
# overnemen wat er al staat. # overnemen wat er al staat.
schrijf_env schrijf_env
zet_mappen_klaar
start start
wacht_op_gereed || true wacht_op_gereed || true
printf '\n' printf '\n'
@ -689,9 +883,11 @@ case "$ACTIE" in
controleer_systeem controleer_systeem
controleer_docker controleer_docker
kies_bind kies_bind
kies_gebruiker
beheerder_vooraf beheerder_vooraf
haal_broncode haal_broncode
schrijf_env schrijf_env
zet_mappen_klaar
start start
wacht_op_gereed || true wacht_op_gereed || true
maak_beheerder maak_beheerder

View file

@ -1471,6 +1471,14 @@ def _annotate_fields(fields: list[dict]) -> list[dict]:
# data dus ergens anders terecht dan waar je hem verwacht. # data dus ergens anders terecht dan waar je hem verwacht.
if naam == "appdata_dir" and f.get("type") != "int": if naam == "appdata_dir" and f.get("type") != "int":
f["default"] = appdata 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: if f.get("type") == "int" and "port" in naam:
try: try:
gewenst = int(f.get("default") or 0) gewenst = int(f.get("default") or 0)

View file

@ -23,6 +23,7 @@ een geprepareerd archief buiten de stackmap schrijven.
from __future__ import annotations from __future__ import annotations
import json import json
import os
import re import re
import shutil import shutil
import tarfile import tarfile
@ -91,6 +92,36 @@ def _stack_meta(stack: str) -> dict:
return {} 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]: def appdata_mappen(stack: str) -> list[Path]:
"""De mappen waar deze stack zijn gegevens in bewaart. """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: if log_fn:
log_fn(f"Backup maken van {stack}") log_fn(f"Backup maken van {stack}")
overgeslagen: list[str] = []
try: try:
# Python's tarfile in plaats van het tar-commando: de returncode van dat # Python's tarfile in plaats van het tar-commando: de returncode van dat
# commando werd eerder genegeerd, zodat een mislukte backup als succes # 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: for m in data_mappen:
if log_fn: if log_fn:
log_fn(f"Appdata: {m.name}") 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: for dp in dumps:
tar.add(dp, arcname=f"dumps/{dp.name}") tar.add(dp, arcname=f"dumps/{dp.name}")
except Exception as e: except Exception as e:
@ -309,9 +342,16 @@ def create(stack: str, reden: str = "handmatig", log_fn=None,
meta = {"stack": stack, "file": naam, "ts": time.time(), meta = {"stack": stack, "file": naam, "ts": time.time(),
"size": doel.stat().st_size, "reason": reden, "size": doel.stat().st_size, "reason": reden,
"appdata": [m.name for m in data_mappen], "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) _schrijf_meta(doel, meta)
if log_fn: if log_fn:
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'])})") log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])})")
if cfg.load().get("BACKUP_VERIFY", True): if cfg.load().get("BACKUP_VERIFY", True):
@ -622,11 +662,44 @@ def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]:
return True, f"'{stack}' teruggezet uit {bestandsnaam}." 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): def _zet_appdata_terug(stack: str, bron: Path, log_fn=None):
"""De appdata-tak uit het archief terug naar de appdata-map.""" """De appdata-tak uit het archief terug naar de appdata-map."""
if not bron.is_dir(): if not bron.is_dir():
return return
waarden = (_stack_meta(stack).get("values") or {}) waarden = (_stack_meta(stack).get("values") or {})
uid, gid = _verwachte_eigenaar(waarden)
basis = Path(str(waarden.get("appdata_dir") basis = Path(str(waarden.get("appdata_dir")
or cfg.load().get("DATA_DIR") or "")).resolve() or cfg.load().get("DATA_DIR") or "")).resolve()
if basis == Path("/") or not basis.parent.exists(): 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(): if doel.exists():
shutil.rmtree(doel, ignore_errors=True) shutil.rmtree(doel, ignore_errors=True)
shutil.move(str(m), str(doel)) shutil.move(str(m), str(doel))
_zet_eigenaar(doel, uid, gid, log_fn=log_fn)
if 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]: def _speel_dumps_in(stack: str, stackmap: Path, log_fn=None) -> list[str]:

View file

@ -316,3 +316,91 @@ def _mislukt(stderr=b"fout"):
def _gelukt(stdout=b""): def _gelukt(stdout=b""):
import subprocess import subprocess
return subprocess.CompletedProcess([], 0, stdout, b"") 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

169
tests/test_entrypoint.py Normal file
View file

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

View file

@ -520,3 +520,97 @@ def test_readme_en_docs_noemen_dezelfde_installatieregel():
"raw/branch/main/install.sh") "raw/branch/main/install.sh")
assert regel in (WORTEL / "README.md").read_text(encoding="utf-8") assert regel in (WORTEL / "README.md").read_text(encoding="utf-8")
assert regel in (WORTEL / "docs" / "installeren.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}"

View file

@ -335,3 +335,35 @@ def test_installatieformulier_gebruikt_de_ingestelde_appdata(client, env, monkey
headers={"X-CSRF-Token": csrf}).get_json() headers={"X-CSRF-Token": csrf}).get_json()
veld = next(f for f in d["fields"] if f["name"] == "appdata_dir") veld = next(f for f in d["fields"] if f["name"] == "appdata_dir")
assert veld["default"] == "/srv/eigen/appdata" 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()