#!/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 ab, 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 ab, 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