v0.10.00-beta - Server Up draait niet meer als root
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 2m8s
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:
parent
f4b652e182
commit
fc2e80fbda
16 changed files with 998 additions and 9 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
50
CHANGELOG.md
50
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
|
# 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
|
||||||
|
|
|
||||||
15
Dockerfile
15
Dockerfile
|
|
@ -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"]
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.9.00-beta
|
0.10.00-beta
|
||||||
|
|
|
||||||
|
|
@ -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
112
docker-entrypoint.sh
Executable 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" "$@"
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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*
|
||||||
|
|
|
||||||
198
install.sh
198
install.sh
|
|
@ -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 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/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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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
169
tests/test_entrypoint.py
Normal 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
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue