install.sh
Rama main · réplica de lectura del repositorio original.
#!/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