Ctrl K
ComunidadGuías…

install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
# RootGuard one-line installer - for people who just want a working DNS
# filter, not a Docker Compose tutorial. Detects and installs Docker if
# needed (via Foxly's dockerinstall.sh, Debian/Ubuntu only), downloads the
# current release's compose.release.yaml/.env.release.example, generates
# the two secret tokens, asks for a WebGUI username/password, and starts
# the stack.
#
# This is *in addition to* the manual quick start in docs.html, not a
# replacement - anyone who wants to see/edit every step by hand still can.
#
# Usage:
#   curl -fsSL https://raw.githubusercontent.com/foxly-it/rootguard/main/install.sh | bash
#
# Non-interactive / scripted use: set ROOTGUARD_ADMIN_USER and
# ROOTGUARD_ADMIN_PASSWORD in the environment first, or pass
# --non-interactive (generates a random password and prints it once).

set -Eeuo pipefail

RG_REPO="foxly-it/rootguard"
# Pinned to a specific commit, not the moving main branch - found in
# review: this script downloads and runs dockerinstall.sh as root
# (install_docker below), and a plain .../main/dockerinstall.sh URL meant
# a compromised commit to that separate repository would be live on every
# fresh RootGuard install within minutes, no review or release process in
# between (foxly-it/dockerinstall has no tags or releases to pin to
# instead). The SHA-256 check below is the actual integrity guarantee -
# the commit pin alone only fixes *which* content is expected, it doesn't
# verify what was actually received still matches it. To intentionally
# update dockerinstall.sh: pin the new commit's SHA below, then set
# DOCKERINSTALL_SHA256 to `curl -fsSL "$DOCKERINSTALL_URL" | sha256sum`.
DOCKERINSTALL_URL="https://raw.githubusercontent.com/foxly-it/dockerinstall/f359cbdaef0f09ab04b7151f21fb649850e22d42/dockerinstall.sh"
DOCKERINSTALL_SHA256="1c2cc0df617d94bb8728ac3690139e66782e1c5888840b469922f4fb5795554c"
NON_INTERACTIVE=0
TARGET_DIR="rootguard"

for arg in "$@"; do
  case "$arg" in
    --non-interactive) NON_INTERACTIVE=1 ;;
    --dir=*) TARGET_DIR="${arg#--dir=}" ;;
    -h|--help)
      echo "Usage: install.sh [--non-interactive] [--dir=PATH]"
      exit 0
      ;;
    *) echo "Unbekannte Option: $arg" >&2; exit 1 ;;
  esac
done

log() { printf '>> %s\n' "$*"; }
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }

# Checked before anything else. The documented "curl -fsSL ... | bash"
# invocation already requires curl to exist just to fetch this script, but
# anyone who obtained it a different way (wget, git clone, copy-paste)
# reaches this point with curl still missing - every later download here
# (dockerinstall.sh, the GitHub releases API, compose.release.yaml/.env)
# depends on it, so a raw "curl: command not found" deep into the script
# is a worse failure mode than catching it here with something actionable.
#
# sudo is only actually needed to install/run Docker as a non-root user -
# found live on a fresh, minimal Debian 13 container logged in as root by
# default, with neither curl nor sudo preinstalled: requiring sudo
# unconditionally would force installing a package that user structurally
# doesn't need just to satisfy this check. as_root() below picks sudo only
# when actually running as a non-root user.
check_prerequisites() {
  command -v curl >/dev/null 2>&1 \
    || die "curl ist nicht installiert. Bitte zuerst installieren (z. B. 'apt update && apt install -y curl' unter Debian/Ubuntu) und install.sh erneut ausführen."
  if [ "$(id -u)" != "0" ] && ! command -v sudo >/dev/null 2>&1; then
    die "sudo ist nicht installiert und dieses Skript läuft nicht als root. Bitte entweder 'apt update && apt install -y sudo' ausführen oder install.sh direkt als root starten."
  fi
}

as_root() {
  if [ "$(id -u)" = "0" ]; then
    "$@"
  else
    # env PWD="$PWD", not a bare `sudo "$@"` - found in review: default
    # sudoers on Debian/Ubuntu (env_reset) strips PWD, so `docker compose`
    # calls made through this on the primary install path (docker_cmd's
    # own fallback, hit right after install_docker() adds the invoking
    # user to the docker group - group membership needs a fresh login to
    # take effect, so this fallback is the common case there, not an edge
    # case) resolved compose.release.yaml's ${PWD}-based bind mounts to
    # an empty/invalid path, failing every container that mounts one.
    sudo env PWD="$PWD" "$@"
  fi
}

# curl | bash means this script's own stdin is the download pipe, not the
# terminal - a plain `read` here would read from that (already-exhausted)
# pipe and either hang or silently get empty input. /dev/tty is the actual
# keyboard for whoever is running this interactively.
prompt() {
  local __var="$1" __message="$2" __default="${3:-}"
  if [ "$NON_INTERACTIVE" = "1" ] || [ ! -r /dev/tty ]; then
    printf -v "$__var" '%s' "$__default"
    return
  fi
  local __reply
  read -r -p "$__message" __reply < /dev/tty
  printf -v "$__var" '%s' "${__reply:-$__default}"
}

prompt_secret() {
  local __var="$1" __message="$2"
  if [ "$NON_INTERACTIVE" = "1" ] || [ ! -r /dev/tty ]; then
    printf -v "$__var" ''
    return
  fi
  local __reply
  read -r -s -p "$__message" __reply < /dev/tty
  echo >&2
  printf -v "$__var" '%s' "$__reply"
}

random_secret() {
  if command -v openssl >/dev/null 2>&1; then
    openssl rand -hex 32
  else
    head -c 48 /dev/urandom | od -An -tx1 | tr -d ' \n'
  fi
}

# version_core_parts/version_prerelease/is_numeric_identifier/
# compare_identifier/version_gt implement real SemVer 2.0 precedence
# comparison in pure bash - no jq/semver library, still dependency-free
# for a script that has to run on a fresh machine before anything but
# curl is guaranteed. Replaced an earlier `sort -V` + "-" -> "~"
# workaround (GNU coreutils' version-sort treats "~" as sorting before
# everything, dpkg-style, which fixed the common "1.0.0-rc.1 ranks above
# 1.0.0" case) once review found it still mis-sorts once a prerelease
# identifier contains a literal hyphen itself: "1.0.0-rc-one.1" sorted
# *below* "1.0.0-rc.1" under that trick, backwards from real SemVer
# precedence (comparing "rc-one" against "rc" identifier-by-identifier,
# "rc" - the shorter, equal-prefix string - has lower precedence). A
# one-off pairwise comparator sidesteps relying on sort -V's exact,
# undocumented multi-key behavior entirely; verified against SemVer.org's
# own canonical precedence example chain plus this specific bug case.
version_core_parts() {
  local core="${1%%-*}"
  IFS='.' read -r -a __rg_core_parts <<< "$core"
}

version_prerelease() {
  case "$1" in
    *-*) printf '%s' "${1#*-}" ;;
    *) printf '' ;;
  esac
}

is_numeric_identifier() {
  [[ "$1" =~ ^[0-9]+$ ]]
}

# numeric_compare a b: echoes -1/0/1 for a<b / a==b / a>b, treating a and
# b as arbitrary-precision unsigned decimal strings (no numeric identifier
# has been rejected for size so far, and SemVer itself doesn't cap one at
# 64 bits) - a plain `((10#$a < 10#$b))` would silently miscompare once a
# value overflows bash's native integer type. Leading zeros stripped
# first (kept possible here even though a real RootGuard tag never has
# one - release-alpha.yml's own gate already refuses to mint one), then
# the longer digit string wins (no leading zeros left means length alone
# decides), and only a tie in length falls through to a lexical compare,
# which is numerically correct once the lengths already match.
numeric_compare() {
  local a="$1" b="$2"
  while [[ ${#a} -gt 1 && "${a:0:1}" == 0 ]]; do a="${a:1}"; done
  while [[ ${#b} -gt 1 && "${b:0:1}" == 0 ]]; do b="${b:1}"; done
  if ((${#a} != ${#b})); then
    if ((${#a} < ${#b})); then echo -1; else echo 1; fi
    return
  fi
  if [[ "$a" == "$b" ]]; then echo 0;
  elif [[ "$a" < "$b" ]]; then echo -1;
  else echo 1; fi
}

# compare_identifier a b: echoes -1/0/1 for a<b / a==b / a>b, per SemVer's
# prerelease-identifier precedence rules (numeric identifiers compare
# numerically and always rank below alphanumeric ones; alphanumeric
# identifiers compare byte-for-byte).
compare_identifier() {
  local a="$1" b="$2"
  if is_numeric_identifier "$a" && is_numeric_identifier "$b"; then
    numeric_compare "$a" "$b"
    return
  fi
  if is_numeric_identifier "$a"; then echo -1; return; fi
  if is_numeric_identifier "$b"; then echo 1; return; fi
  if [[ "$a" == "$b" ]]; then echo 0;
  elif [[ "$a" < "$b" ]]; then echo -1;
  else echo 1; fi
}

# version_gt a b: succeeds (exit 0) if a has strictly higher SemVer
# precedence than b. Both a and b are "X.Y.Z" or "X.Y.Z-prerelease",
# without the leading "v".
version_gt() {
  local a="$1" b="$2"
  local -a __rg_core_parts
  version_core_parts "$a"; local -a acore=("${__rg_core_parts[@]}")
  version_core_parts "$b"; local -a bcore=("${__rg_core_parts[@]}")
  local i c
  for i in 0 1 2; do
    c="$(numeric_compare "${acore[$i]}" "${bcore[$i]}")"
    ((c > 0)) && return 0
    ((c < 0)) && return 1
  done
  local apre bpre
  apre="$(version_prerelease "$a")"
  bpre="$(version_prerelease "$b")"
  [[ -z "$apre" && -z "$bpre" ]] && return 1
  [[ -z "$apre" ]] && return 0
  [[ -z "$bpre" ]] && return 1
  local -a aids bids
  IFS='.' read -r -a aids <<< "$apre"
  IFS='.' read -r -a bids <<< "$bpre"
  local n=${#aids[@]} idx
  ((${#bids[@]} < n)) && n=${#bids[@]}
  for ((idx = 0; idx < n; idx++)); do
    c="$(compare_identifier "${aids[$idx]}" "${bids[$idx]}")"
    ((c > 0)) && return 0
    ((c < 0)) && return 1
  done
  ((${#aids[@]} > ${#bids[@]})) && return 0
  return 1
}

# Same picking logic as rootguard-core's own updater
# (internal/updater/github_release.go): GitHub's own /releases/latest
# excludes prereleases, and every RootGuard release is published as one -
# so the full list is queried and ranked locally instead of trusting the
# API's own order. That distinction matters live, not just in theory: a
# direct query once returned v0.1.0-beta.9 *ahead of* v0.1.0-beta.12 on
# this exact repository, so "the newest-first list" isn't actually
# guaranteed - taking the first matching entry would have silently
# resolved to the older release. The matching pattern accepts any real
# semantic version, not just RootGuard's current "0.1.0-(alpha|beta).N"
# scheme, so a future base-version change (0.2.0, 1.0.0-rc.1, a bare
# 1.0.0, ...) keeps resolving correctly without another change here. This
# script can't source scripts/version-pattern.sh (no repo checkout exists
# yet on a fresh machine), so keep this pattern in sync with that file's
# rootguard_version_pattern by hand if it ever changes.
resolve_latest_tag() {
  local body
  body="$(curl -fsSL -H 'Accept: application/vnd.github+json' \
    "https://api.github.com/repos/${RG_REPO}/releases")" \
    || die "Konnte die GitHub-Releases-API nicht erreichen."
  local best="" candidate
  while IFS= read -r candidate; do
    [[ -z "$candidate" ]] && continue
    if [[ -z "$best" ]] || version_gt "${candidate#v}" "${best#v}"; then
      best="$candidate"
    fi
  done < <(printf '%s' "$body" \
    | grep -o '"tag_name": *"[^"]*"' \
    | sed -E 's/.*"([^"]*)"$/\1/' \
    | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$')
  printf '%s' "$best"
}

# Best-effort: only checks what ss/netstat can currently see (a container
# that starts publishing a port a moment later still slips through - the
# same limitation the real preflight check in Setup itself accepts, see
# docs.html's "zweistufig erkannt" note). Returns 1 (not listening) rather
# than failing outright when neither tool is available, since a missing
# tool isn't evidence either way.
port_listening() {
  local family="$1" port="$2"
  if command -v ss >/dev/null 2>&1; then
    ss -Hln "$family" 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p { found=1 } END { exit !found }'
  elif command -v netstat >/dev/null 2>&1; then
    # "$family", not a bare -ln - found in review: unlike the ss branch
    # above, this used to ignore the TCP/UDP distinction entirely, so a
    # UDP check and a TCP check both saw the exact same combined listing.
    # netstat accepts the same -t/-u flags ss does.
    netstat -ln "$family" 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p { found=1 } END { exit !found }'
  else
    return 1
  fi
}

# Checks the port this script's own `docker compose up` binds (WebGUI) plus
# the two RootGuard eventually needs once guided Setup creates the DNS
# containers (53 for DNS itself, 80 for the optional blockpage) - not
# because install.sh binds those two itself, but so a conflict surfaces now
# instead of confusing someone mid-wizard. Setup's own preflight (see
# docs.html) re-checks port 53 properly (published Docker ports plus a real
# bind attempt, catching non-Docker processes like systemd-resolved) - this
# is just an early heads-up, not a replacement for that.
valid_port() {
  case "$1" in
    ''|*[!0-9]*) return 1 ;;
  esac
  [ "$1" -ge 1 ] && [ "$1" -le 65535 ]
}

check_ports() {
  WEB_PORT="${ROOTGUARD_WEB_PORT:-8080}"
  valid_port "$WEB_PORT" \
    || die "ROOTGUARD_WEB_PORT muss eine Zahl zwischen 1 und 65535 sein (war: '${WEB_PORT}')."

  if port_listening -t "$WEB_PORT"; then
    log "Port ${WEB_PORT} ist bereits belegt."
    if [ "$NON_INTERACTIVE" = "1" ]; then
      die "Setze ROOTGUARD_WEB_PORT auf einen freien Port und starte install.sh erneut."
    fi
    prompt WEB_PORT "Alternativen Port für die WebGUI verwenden [8081]: " "8081"
    valid_port "$WEB_PORT" \
      || die "Ungültiger Port: '${WEB_PORT}'. Muss eine Zahl zwischen 1 und 65535 sein."
    port_listening -t "$WEB_PORT" \
      && die "Port ${WEB_PORT} ist ebenfalls belegt. Bitte manuell einen freien Port wählen und erneut starten."
  fi

  if port_listening -t 53 || port_listening -u 53; then
    log "Hinweis: Port 53 scheint bereits belegt zu sein (häufig systemd-resolved oder dnsmasq). Wird erst später für die DNS-Dienste gebraucht - der geführte Setup-Assistent in der WebGUI prüft das erneut und erklärt, wie man ihn freigibt."
  fi
  if port_listening -t 80; then
    log "Hinweis: Port 80 scheint bereits belegt zu sein. Wird nur für die optionale Blockseite verwendet - im geführten Setup lässt sie sich bei Bedarf auch deaktivieren."
  fi
}

docker_present() {
  command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1
}

# Detects whether the invoking user's *default* Docker context is rootless
# and, if so, resolves the real host socket path - written into .env as
# ROOTGUARD_DOCKER_SOCKET_PATH so compose.release.yaml bind-mounts the
# right socket into docker-proxy instead of the rootful default (see that
# file's own comment on the volume line). Deliberately never installs,
# configures, or recommends rootless Docker - only adapts to whichever
# daemon is already there - and never aborts the install either way.
#
# Checked with a plain, unelevated `docker info`/`docker context inspect`
# (not docker_cmd's sudo-fallback wrapper): rootless Docker is reachable by
# its own unprivileged user without any group membership, so an
# unelevated call already gives the right answer for a rootless daemon;
# for a rootful daemon that actually needs sudo, the same unelevated call
# simply fails and this function correctly does nothing, which is exactly
# the right outcome for a normal rootful install.
#
# `docker info`'s SecurityOptions field is Docker's own documented way to
# learn this (includes "name=rootless" only when actually connected to a
# rootless daemon) - inferring it from a $DOCKER_HOST override or a
# `/run/user/` path pattern instead would miss a rootless daemon reached
# through its default context, and false-positive on an unrelated
# $DOCKER_HOST pointed at a perfectly normal rootful remote daemon.
detect_docker_context() {
  local __var="$1" security_options
  security_options="$(docker info --format '{{json .SecurityOptions}}' 2>/dev/null || true)"
  case "$security_options" in
    *name=rootless*) ;;
    *) printf -v "$__var" ''; return ;;
  esac

  local endpoint socket_path
  endpoint="$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true)"
  socket_path="${endpoint#unix://}"
  printf -v "$__var" '%s' "$socket_path"

  log "Rootless Docker erkannt (Socket: ${socket_path:-unbekannt})."
  # Not a RootGuard limitation - a real, documented restriction of
  # *unprivileged* LXC containers specifically (Proxmox's default),
  # confirmed live: see docs/rootless-docker.md for the full writeup.
  # Purely informational - install.sh still continues either way, the
  # operator's own environment and risk tolerance decide what to do
  # with this, not this script.
  if [ "$(systemd-detect-virt 2>/dev/null || true)" = "lxc" ]; then
    log "Hinweis: Diese Umgebung läuft in einer LXC. Eine unprivilegierte LXC (Proxmox-Standardeinstellung) kann rootless Docker aus bekannten Gründen oft nicht vollständig unterstützen - siehe docs/rootless-docker.md im Repository, falls der Stack gleich nicht startet."
  fi
  log "Hinweis: Für rootless Docker muss der RootlessKit-Netzwerktreiber 'pasta' aktiv sein (Docker 25 oder neuer) - mit der Standardeinstellung geht sonst bei DNS-Anfragen die echte Client-IP verloren und AdGuards Filterung pro Gerät funktioniert nicht richtig. Details: docs/rootless-docker.md"
}

# Runs the actual `docker` calls below - falls back to as_root automatically:
# either Docker was just installed (group membership needs a fresh login
# to take effect, so the current shell isn't in the docker group yet even
# right after --add-user), or it was already present but this user was
# simply never added to the group.
docker_cmd() {
  if docker info >/dev/null 2>&1; then
    docker "$@"
  else
    as_root docker "$@"
  fi
}

install_docker() {
  log "Docker wurde nicht gefunden - Installation über Foxly dockerinstall wird gestartet."
  command -v sha256sum >/dev/null 2>&1 || die "sha256sum wird benötigt, um den Docker-Installer zu verifizieren."
  local installer actual_sha256
  installer="$(mktemp)"
  # EXIT, not RETURN - found in review: a RETURN trap only fires on this
  # function's own normal return, never on a die() call (die always
  # exits the whole process), which is exactly the failure path this
  # cleanup exists for - e.g. the checksum mismatch below used to leave
  # the failed-integrity-check installer script sitting on disk forever.
  # Explicitly cleared again below once no longer needed, so it doesn't
  # linger past this function and collide with the staging directory's
  # own EXIT trap later in main().
  trap 'rm -f "$installer"' EXIT
  curl -fsSL "$DOCKERINSTALL_URL" -o "$installer" \
    || die "Docker-Installer konnte nicht heruntergeladen werden."
  # The commit pin above fixes *which* content is expected; this is what
  # actually verifies the download matches it, closing the gap a pinned
  # URL alone leaves open (a compromised CDN edge, DNS spoofing, or a
  # tampered mirror could still serve something else at the same URL).
  actual_sha256="$(sha256sum "$installer" | cut -d' ' -f1)"
  if [ "$actual_sha256" != "$DOCKERINSTALL_SHA256" ]; then
    die "Docker-Installer hat eine unerwartete Prüfsumme (erwartet ${DOCKERINSTALL_SHA256}, erhalten ${actual_sha256}) - Installation abgebrochen."
  fi
  chmod +x "$installer"
  as_root "$installer" install --non-interactive --no-hello --add-user="$(id -un)" \
    || die "Docker-Installation fehlgeschlagen. Manuelle Anleitung: https://docs.docker.com/engine/install/"
  rm -f "$installer"
  trap - EXIT
  log "Docker wurde installiert."
}

main() {
  check_prerequisites

  # Checked next, before anything else runs - so a pre-existing target
  # directory aborts immediately instead of after check_ports and a
  # potentially real Docker installation, which would otherwise leave a
  # system with Docker just installed for nothing on a retry.
  if [ -e "$TARGET_DIR" ]; then
    die "'$TARGET_DIR' existiert bereits - vermutlich läuft hier schon eine Installation. Abgebrochen, um nichts zu überschreiben."
  fi

  check_ports
  echo

  if docker_present; then
    log "Docker ist bereits vorhanden."
  else
    install_docker
  fi
  echo

  local docker_socket_path
  detect_docker_context docker_socket_path

  # Every prompt/validation happens up front, before anything below creates
  # $TARGET_DIR or downloads a single byte - so a rejected (too short)
  # password can't leave a half-set-up directory behind, the same principle
  # as the existence check at the very top of this function.
  local api_token recovery_token admin_user admin_password
  api_token="$(random_secret)"
  recovery_token="$(random_secret)"

  admin_user="${ROOTGUARD_ADMIN_USER:-}"
  if [ -z "$admin_user" ]; then
    prompt admin_user "Benutzername für die RootGuard-WebGUI [admin]: " "admin"
  fi

  local generated_password=0
  admin_password="${ROOTGUARD_ADMIN_PASSWORD:-}"
  if [ -z "$admin_password" ]; then
    prompt_secret admin_password "Passwort für die RootGuard-WebGUI (leer = zufällig generieren): "
    echo
  fi
  if [ -z "$admin_password" ]; then
    admin_password="$(random_secret | head -c 20)"
    generated_password=1
  fi
  # Only reachable for a typed or ROOTGUARD_ADMIN_PASSWORD-supplied value -
  # the generated fallback above is always 20 characters. Matches the
  # backend's own minimum (see requireSecretStrength in
  # rootguard-webapp/backend/cmd/rootguard-webapp/main.go), which would
  # otherwise reject this password at container startup anyway, just with
  # a far less helpful error buried in `docker compose logs`.
  if [ "${#admin_password}" -lt 12 ]; then
    die "Das WebGUI-Passwort muss mindestens 12 Zeichen lang sein. Setze ROOTGUARD_ADMIN_PASSWORD auf ein längeres Passwort oder lasse das Feld leer, um eines automatisch generieren zu lassen."
  fi
  # A typed value can never contain a newline or carriage return (read -r
  # stops at the first newline, and a real terminal doesn't deliver a bare
  # \r either) - only reachable via ROOTGUARD_ADMIN_USER/
  # ROOTGUARD_ADMIN_PASSWORD supplied directly in the environment for a
  # non-interactive install. Rejected outright rather than relying on the
  # .env quoting below to contain either: a value spanning multiple
  # physical lines in .env is a real ambiguity across dotenv-style parsers
  # (some treat a quoted value as continuing past the newline, some
  # don't), not something worth trusting for something that's supposed to
  # also become this account's actual password.
  #
  # A literal single quote is rejected too, found in review: the .env
  # writer below single-quotes every value specifically so Docker Compose
  # never expands a literal $ in it (see the awk block's own comment) -
  # dotenv single-quoted values are fully literal with no escape
  # mechanism at all, so there's no way to represent an embedded ' inside
  # one. Simpler and more certain to reject it here than to attempt a
  # quoting trick (ending the quote, escaping one ', resuming it) whose
  # correctness would depend on parser behavior this script has no way to
  # verify against every dotenv-style implementation Compose might use.
  case "$admin_user$admin_password" in
    *$'\n'*|*$'\r'*) die "Benutzername und Passwort dürfen keinen Zeilenumbruch enthalten." ;;
    *"'"*) die "Benutzername und Passwort dürfen kein Apostroph (') enthalten." ;;
  esac

  log "Ermittle die aktuelle RootGuard-Version…"
  local tag
  tag="$(resolve_latest_tag)"
  [ -n "$tag" ] || die "Keine RootGuard-Version über die GitHub-Releases-API gefunden."
  log "Aktuelle Version: $tag"

  # Downloaded to a scratch dir first and only moved into place once both
  # files are down - so a failed download never leaves an empty $TARGET_DIR
  # behind that would make a retry hit the "existiert bereits" abort above.
  local staging
  staging="$(mktemp -d)"
  # EXIT, not RETURN - see install_docker's identical fix above: a
  # RETURN trap never fires on a die() call, which is exactly the path
  # that needs this cleanup (e.g. either download below failing left the
  # scratch directory behind in /tmp forever). It only works while main()
  # is still running, though, since $staging is local - see the cleanup
  # right after the move below for why it is dropped there.
  trap 'rm -rf "$staging"' EXIT

  local raw_base="https://raw.githubusercontent.com/${RG_REPO}/${tag}"
  curl -fsSL -o "$staging/compose.release.yaml" "${raw_base}/compose.release.yaml" \
    || die "compose.release.yaml konnte nicht heruntergeladen werden."
  curl -fsSL -o "$staging/.env" "${raw_base}/.env.release.example" \
    || die "Beispielkonfiguration konnte nicht heruntergeladen werden."

  # mkdir -p only for the parent (so --dir=nested/path still works), then
  # a plain, non-"-p" mkdir for $TARGET_DIR itself - found in review: the
  # early "existiert bereits" check above and this creation are otherwise
  # two separate steps with a real gap between them (Docker install,
  # prompts, the GitHub API call, both downloads above), during which a
  # second concurrent run of this same script against the same --dir
  # would pass the same early check and then race this one to create it.
  # Plain mkdir fails if the directory already exists, making this the
  # actual atomic check-and-create - the loser dies with the same message
  # instead of silently mixing its own downloaded files/generated secrets
  # with the winner's.
  mkdir -p "$(dirname "$TARGET_DIR")"
  mkdir "$TARGET_DIR" \
    || die "'$TARGET_DIR' existiert bereits - vermutlich läuft hier schon eine Installation. Abgebrochen, um nichts zu überschreiben."
  mv "$staging/compose.release.yaml" "$staging/.env" "$TARGET_DIR/"
  # Both files are in place, so the scratch dir is done. Found on a real
  # install (2026-11-10): leaving the trap registered meant it ran after
  # main() had returned, when the local $staging no longer existed, and
  # under set -u every successful install ended with "staging: unbound
  # variable" and exit status 1.
  rm -rf "$staging"
  trap - EXIT
  cd "$TARGET_DIR"

  # Values are passed through the environment, never interpolated into a
  # sed/awk program's own text - a password containing '&', '#', or a
  # backslash would otherwise corrupt the substitution (or worse) with any
  # editor-style in-place replacement.
  #
  # Each value is also single-quoted in the .env output itself (q(),
  # below) - found in review: an unquoted value is re-read by Docker
  # Compose later, which treats '#' as starting a comment and a literal
  # newline as a new KEY=VALUE line outside of quotes; a password or
  # token containing either would otherwise get silently truncated at the
  # '#', or inject an extra variable into .env entirely.
  #
  # Single quotes, not double: found in review - Compose's own dotenv
  # parser still expands $VAR/${VAR} references *inside* a double-quoted
  # value (reproduced live with `docker compose config`: a password of
  # abc$HOME${MISSING} came back out with $HOME expanded to the
  # invoking user's real home directory), so double-quoting alone doesn't
  # actually stop an admin-supplied password from being silently
  # rewritten. Single-quoted dotenv values are fully literal - no
  # expansion, no escape processing at all - which is also exactly why a
  # literal ' can't appear in one; admin_user/admin_password are already
  # rejected above if they contain one, so q() itself never needs to
  # handle that case. The three internally-generated values below
  # (tokens, port) can't contain a ' either - random_secret() only ever
  # produces hex/base64url output, and WEB_PORT is numeric.
  # Created with the final restrictive mode *before* anything is written
  # to it, not after via chmod below - found in review: `awk ... > .env.tmp`
  # alone creates the file at the process's default umask (typically
  # 644) while it already holds the real admin password/API token/
  # recovery token, and only chmod 600 further down closed that window -
  # briefly readable by another local user on this host, however narrow
  # the timing. Redirecting into an already-existing file only truncates
  # it, it doesn't reset the mode, so pre-creating it here keeps it 600
  # for its entire existence, including through the mv and the append
  # below.
  : > .env.tmp
  chmod 600 .env.tmp
  ROOTGUARD_API_TOKEN="$api_token" \
  ROOTGUARD_RECOVERY_TOKEN="$recovery_token" \
  ROOTGUARD_ADMIN_USER="$admin_user" \
  ROOTGUARD_ADMIN_PASSWORD="$admin_password" \
  ROOTGUARD_WEB_PORT="$WEB_PORT" \
  awk '
    function q(v) { return "\047" v "\047" }
    /^ROOTGUARD_API_TOKEN=/      { print "ROOTGUARD_API_TOKEN=" q(ENVIRON["ROOTGUARD_API_TOKEN"]); next }
    /^ROOTGUARD_RECOVERY_TOKEN=/ { print "ROOTGUARD_RECOVERY_TOKEN=" q(ENVIRON["ROOTGUARD_RECOVERY_TOKEN"]); next }
    /^ROOTGUARD_ADMIN_USER=/     { print "ROOTGUARD_ADMIN_USER=" q(ENVIRON["ROOTGUARD_ADMIN_USER"]); next }
    /^ROOTGUARD_ADMIN_PASSWORD=/ { print "ROOTGUARD_ADMIN_PASSWORD=" q(ENVIRON["ROOTGUARD_ADMIN_PASSWORD"]); next }
    /^ROOTGUARD_WEB_PORT=/       { print "ROOTGUARD_WEB_PORT=" q(ENVIRON["ROOTGUARD_WEB_PORT"]); next }
    { print }
  ' .env > .env.tmp
  mv .env.tmp .env
  # Appended, not folded into the awk rewrite above: .env.release.example
  # only ever ships this commented out (most installs are rootful and
  # never need it), so there's no existing "ROOTGUARD_DOCKER_SOCKET_PATH="
  # line for that script's pattern-matching rewrite to find.
  if [ -n "$docker_socket_path" ]; then
    printf "ROOTGUARD_DOCKER_SOCKET_PATH='%s'\n" "$docker_socket_path" >> .env
  fi
  chmod 600 .env
  echo

  log "Starte den RootGuard-Stack…"
  docker_cmd compose -f compose.release.yaml up -d \
    || die "RootGuard konnte nicht gestartet werden - läuft der Docker-Dienst? ('systemctl status docker' prüfen)."

  echo
  log "Fertig! Öffne http://localhost:${WEB_PORT}/login"
  log "Benutzername: ${admin_user}"
  if [ "$generated_password" = "1" ]; then
    log "Generiertes Passwort (jetzt notieren, wird nicht erneut angezeigt): ${admin_password}"
  fi
  log "Danach dem geführten Setup in der WebGUI folgen."
}

main