From 74628f373631d2f57158bdb001f46cfa6f84a8db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 03:22:53 +0000 Subject: [PATCH 1/3] Fix vaultwarden SMTP false-positive and mattermost DB password mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vaultwarden: SMTP_PORT defaulted to "587" and SMTP_SECURITY was a hardcoded "starttls" literal in the .env template, written unconditionally regardless of whether SMTP_HOST was ever provided. Confirmed live: skipping SMTP entirely (blank SMTP_HOST) still wrote real values for those two, and Vaultwarden reads that as "some SMTP config is present," refusing to start ("Both SMTP_HOST and SMTP_FROM need to be set") even with host/from genuinely blank. Both now stay empty unless SMTP_HOST is actually set. mattermost: DB_PASS/MM_SECRET were only reused from the existing .env when MODE=update — a "fresh" reinstall always generated a new POSTGRES_PASSWORD. Confirmed live: choosing fresh after removing only the mattermost app container (not the whole directory) regenerates the password in .env while db/'s existing Postgres data still enforces the OLD one from its first init (the entrypoint skips re-init on existing data), causing "password authentication failed for user mattermost" on every start. Whether db/ already has real data is what actually determines whether the old password is still live, not which reinstall mode was chosen — reuse the existing secrets whenever db/ is non-empty, regardless of MODE. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- services/mattermost.sh | 21 +++++++++++++++------ services/vaultwarden.sh | 16 +++++++++++++--- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/services/mattermost.sh b/services/mattermost.sh index a2347a2..224e887 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -322,13 +322,22 @@ install_mattermost() { ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 - # Reuse existing secrets on update — Postgres's volume keeps the password - # from its first init, so overwriting .env with a fresh one locks - # Mattermost out of its own database. Confirmed this was previously - # unconditional (regenerated every single rerun, silently breaking the DB - # connection) — fixed here as part of adding proper update detection. + # Reuse existing secrets whenever the Postgres data volume already has + # real data in it — not just when MODE=update. Confirmed live: picking + # "fresh" after removing only the mattermost APP container (docker rm, + # not the whole ~/docker/mattermost directory) regenerates + # POSTGRES_PASSWORD in a new .env while db/ still holds the OLD + # password baked in from its first init (postgres:15-alpine's + # entrypoint skips re-initializing an existing data directory, so the + # old credential is still the one actually enforced) — "password + # authentication failed for user mattermost" on every start + # afterward. Whether the data volume already has real data in it is + # what actually determines whether the old password is still live, + # not which reinstall mode was chosen. local DB_PASS="" MM_SECRET="" - if [ "$MODE" = "update" ]; then + local _db_has_data=false + [ -d db ] && [ -n "$(ls -A db 2>/dev/null)" ] && _db_has_data=true + if [ "$MODE" = "update" ] || [ "$_db_has_data" = true ]; then DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)" [ "$_HAD_EMBEDDED_COTURN" = true ] && MM_SECRET="$(grep '^COTURN_SECRET=' .env 2>/dev/null | cut -d= -f2-)" fi diff --git a/services/vaultwarden.sh b/services/vaultwarden.sh index 1cb635e..d7734c3 100644 --- a/services/vaultwarden.sh +++ b/services/vaultwarden.sh @@ -317,13 +317,23 @@ install_vaultwarden() { echo " SMTP (optional) — for password-reset and invite emails." echo " Press Enter to skip each field and configure SMTP later in .env." echo "" - local SMTP_HOST="" SMTP_FROM="" SMTP_USER="" SMTP_PASS="" SMTP_PORT="587" + # Every SMTP_* value (including PORT/SECURITY) stays genuinely empty + # unless SMTP_HOST is actually provided — confirmed live, this used to + # default SMTP_PORT to "587" and hardcode SMTP_SECURITY=starttls in the + # .env template unconditionally, so even a fully-skipped SMTP setup + # (SMTP_HOST left blank) still wrote real, non-empty values for those + # two. Vaultwarden reads that as "some SMTP config is present" and + # refuses to start ("Both SMTP_HOST and SMTP_FROM need to be set"), + # crash-looping even though the actual host/from fields were blank — + # the "skip SMTP" path was never actually clean. + local SMTP_HOST="" SMTP_FROM="" SMTP_USER="" SMTP_PASS="" SMTP_PORT="" SMTP_SECURITY="" prompt_text "SMTP host (e.g. smtp.gmail.com) [skip]:" "" SMTP_HOST if [ -n "$SMTP_HOST" ]; then prompt_text "SMTP port [587]:" "587" SMTP_PORT prompt_text "SMTP from address:" "" SMTP_FROM prompt_text "SMTP username:" "" SMTP_USER prompt_text "SMTP password:" "" SMTP_PASS + SMTP_SECURITY=starttls # Vaultwarden refuses to start at all if SMTP_HOST is set without # SMTP_FROM ("Both SMTP_HOST and SMTP_FROM need to be set") — # confirmed live, crash-loops on every start, not just a warning at @@ -333,7 +343,7 @@ install_vaultwarden() { # container — better than guessing a from-address on your behalf. if [ -z "$SMTP_FROM" ]; then log_warning "No SMTP from address entered — disabling SMTP entirely (Vaultwarden requires both or neither). Re-run this installer to set it up later." - SMTP_HOST=""; SMTP_PORT="587"; SMTP_USER=""; SMTP_PASS="" + SMTP_HOST=""; SMTP_PORT=""; SMTP_SECURITY=""; SMTP_USER=""; SMTP_PASS="" fi fi @@ -398,7 +408,7 @@ SIGNUPS_VERIFY=false # ── SMTP (optional — for password-reset and invite emails) ──────────────────── SMTP_HOST=$SMTP_HOST SMTP_PORT=$SMTP_PORT -SMTP_SECURITY=starttls +SMTP_SECURITY=$SMTP_SECURITY SMTP_FROM=$SMTP_FROM SMTP_USERNAME=$SMTP_USER SMTP_PASSWORD=$SMTP_PASS From ad011b2db80b250c3ba4668b3aefcdf84c9e4aa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 03:24:50 +0000 Subject: [PATCH 2/3] Add check_container_health helper, wired into mattermost.sh as reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A service's "Started" message after docker compose up -d doesn't mean the app is actually working — it can still crash-loop (bad DB password, missing required env var, etc.) with no visible sign until someone separately runs docker ps -a much later, exactly what happened repeatedly this session (mattermost, koha-db, homebox, vaultwarden, filebrowser all showed a clean "Started" message while crash-looping). check_container_health (lib/common.sh) waits briefly, checks the container's actual status and restart count via docker inspect, and prints recent logs automatically if it's not running or has already restarted — instead of a misleading one-line success message. Wired into mattermost.sh's own start step as the reference implementation, guarded by declare -F so standalone runs (no lib/common.sh sourced) degrade gracefully. Not retrofitted across every other service in one pass — this establishes the shared helper so other services can adopt it incrementally. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- lib/common.sh | 43 ++++++++++++++++++++++++++++++++++++++++++ services/mattermost.sh | 16 +++++++++++++--- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index 34aa245..b8e54d4 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -434,6 +434,49 @@ ensure_docker_dir_ownership() { done } +# Waits briefly after a container starts, then reports whether it's +# actually running or stuck restarting/crash-looping — printing recent +# logs on failure instead of leaving a silent "started" message that +# doesn't reflect whether it's actually working. Confirmed live: +# several services' own "Started"-looking `docker compose up -d` +# success message meant nothing — the container was already +# crash-looping by the time that message printed, with no indication +# anything was wrong until someone separately ran `docker ps -a` much +# later and had to go dig through logs by hand. +# +# Usage: check_container_health CONTAINER_NAME [WAIT_SECONDS] +# Returns 0 if the container is up and hasn't restarted, 1 otherwise. +check_container_health() { + local container="$1" wait_seconds="${2:-8}" + [ "$DRY_RUN" = true ] && return 0 + + sleep "$wait_seconds" + + local status + status="$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null)" + if [ -z "$status" ]; then + log_warning "Container '$container' doesn't exist — something failed before it could even be created." + return 1 + fi + + local restart_count + restart_count="$(docker inspect -f '{{.RestartCount}}' "$container" 2>/dev/null || echo 0)" + + if [ "$status" = "running" ] && [ "$restart_count" -eq 0 ]; then + return 0 + fi + + if [ "$status" = "running" ]; then + log_warning "Container '$container' is running now but already restarted $restart_count time(s) — check the logs below." + else + log_warning "Container '$container' is not running (status: $status) — recent logs:" + fi + echo "" + docker logs "$container" --tail 20 2>&1 | sed 's/^/ /' + echo "" + return 1 +} + # Generate a secure alphanumeric password (no special characters) generate_password() { local length="${1:-32}" diff --git a/services/mattermost.sh b/services/mattermost.sh index 224e887..67363fd 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -742,9 +742,19 @@ MIGRATE_BODY local START="" prompt_yn "Start Mattermost now? (y/n):" "y" START if [ "$START" = "y" ] || [ "$START" = "Y" ]; then - docker compose up -d \ - && log_success "Mattermost started" \ - || log_warning "Start failed — check: docker compose logs" + if docker compose up -d; then + log_success "Mattermost started" + # Reference implementation of the shared health check — a + # "Started" message alone doesn't mean the app is actually up; + # it can still crash-loop (bad DB password, missing required + # env var, etc.) with no visible sign until someone separately + # runs `docker ps -a` much later. Mattermost's own first DB + # connection attempt can take a few seconds, hence the longer + # wait than check_container_health's 8s default. + declare -F check_container_health >/dev/null 2>&1 && check_container_health "$MM_CONTAINER" 12 + else + log_warning "Start failed — check: docker compose logs" + fi fi echo "" From efcad48755435a8b3830bfcf8926cc62f237d29a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 03:29:10 +0000 Subject: [PATCH 3/3] Add a generic service removal command: ./setup.sh --remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No removal path existed anywhere in this repo — manually removing a service meant hand-editing docker-compose.yml, the Caddyfile, and UFW rules yourself, or just leaving orphaned config behind. remove_service (lib/common.sh) handles the common case: stop/remove the service's containers (with an explicit y/n on whether to also wipe data volumes, default no), find and remove its Caddy site block if one exists, remove any UFW rule tagged with its name, and optionally delete its ~/docker/ directory (default no — keep data as a safety net unless explicitly confirmed). The Caddy site block removal (_remove_caddy_site_block) tracks actual brace depth rather than scanning to the next blank line or EOF — the same class of bug this repo already hit once with a naive Samba config edit. Verified against a multi-block test Caddyfile with nested log{}/header{} blocks: removes exactly the targeted block, leaves every other block (including ones with their own nested braces) byte-for-byte intact, and is a safe no-op when nothing matches. Also fixes the UFW rule-number extraction: ufw status numbered pads single-digit rule numbers with a leading space ("[ 3]" vs "[10]") to align columns, which the regex didn't account for — every single-digit rule would have silently never matched and never gotten deleted. Wired into setup.sh as a new --remove flag, resolving SERVICE_ALIAS and validating the name the same way run_service already does. Scoped to the common case (a Docker service at $DOCKER_DIR/ with a standard configure_caddy_for_service site block); a hand-built Caddy block or non-standard layout may need manual cleanup for the parts this can't find. Non-Docker services (base, ssh-key-import, etc.) report cleanly that they're not handled rather than erroring confusingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- lib/common.sh | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++ setup.sh | 30 +++++++++- 2 files changed, 183 insertions(+), 1 deletion(-) diff --git a/lib/common.sh b/lib/common.sh index b8e54d4..01e5687 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -334,6 +334,160 @@ ufw_allow_from_caddy_net() { fi } +# ── Remove a service ────────────────────────────────────────────────────────── +# Removes a specific site block from a Caddyfile, keyed on the block whose +# body reverse_proxy's to the given container name. Bounded by tracking +# actual brace depth (handles nested log{}/header{}/forward_auth{} blocks +# correctly), not a "delete to next blank line" scan — see this repo's own +# history for why an unbounded range delete on a live Caddy/Samba config is +# exactly the kind of thing that silently destroys unrelated content. +_remove_caddy_site_block() { + local caddy_file="$1" container="$2" + awk -v container="$container" ' + BEGIN { depth = 0; buf = ""; skip = 0; pending_comment = "" } + { + line = $0 + opens = gsub(/\{/, "{", line) + closes = gsub(/\}/, "}", line) + + if (depth == 0 && opens == 0) { + if ($0 ~ /^#/) { + if (pending_comment != "") print pending_comment + pending_comment = $0 + next + } else { + if (pending_comment != "") { print pending_comment; pending_comment = "" } + print $0 + next + } + } + if (depth == 0 && opens > 0) { + buf = $0 "\n" + depth += opens - closes + if (index($0, "reverse_proxy " container ":") > 0) skip = 1 + next + } + if (depth > 0) { + buf = buf $0 "\n" + if (index($0, "reverse_proxy " container ":") > 0) skip = 1 + depth += opens - closes + if (depth <= 0) { + depth = 0 + if (!skip) { + if (pending_comment != "") print pending_comment + printf "%s", buf + } + pending_comment = "" + buf = "" + skip = 0 + next + } + next + } + } + END { if (pending_comment != "") print pending_comment } + ' "$caddy_file" +} + +# Generic per-service removal: stops/removes its containers, its Caddy site +# block (if any), any UFW rule tagged with its name, and optionally its +# ~/docker/ directory. Scoped to the common case (a Docker service +# living at $DOCKER_DIR/ with a standard configure_caddy_for_service +# site block) — a service with a hand-built Caddy block or non-standard +# layout may need manual cleanup for the parts this can't find. +remove_service() { + local name="$1" + local dir="$DOCKER_DIR/$name" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would stop/remove $name's containers, Caddy site block, and any tagged UFW rule" + return 0 + fi + + if [ ! -d "$dir" ]; then + log_error "No $dir found — nothing to remove. (Non-Docker services, e.g. base/ssh-key-import, aren't handled by this — remove those manually.)" + return 1 + fi + + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " Remove $name" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo " This will, as applicable:" + [ -f "$dir/docker-compose.yml" ] && echo " - Stop and remove its Docker container(s)" + echo " - Remove its Caddy site block, if any (Caddyfile backed up first)" + echo " - Remove any UFW rule tagged with '$name'" + echo "" + + local CONFIRM="" + prompt_yn " Continue? (y/n):" "n" CONFIRM + if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + log_info "Cancelled." + return 0 + fi + + # ── Docker teardown ──────────────────────────────────────────────────── + local container="" + if [ -f "$dir/docker-compose.yml" ]; then + container="$(grep -m1 '^\s*container_name:' "$dir/docker-compose.yml" 2>/dev/null | awk '{print $2}')" + local WIPE_DATA="" + prompt_yn " Also delete its data volumes (database, uploaded files, etc. — irreversible)? (y/n):" "n" WIPE_DATA + if [[ "$WIPE_DATA" =~ ^[Yy]$ ]]; then + ( cd "$dir" && docker compose down -v ) \ + && log_success "Containers and volumes removed" \ + || log_warning "docker compose down -v failed — check manually" + else + ( cd "$dir" && docker compose down ) \ + && log_success "Containers stopped and removed (data left on disk)" \ + || log_warning "docker compose down failed — check manually" + fi + fi + + # ── Caddy site block ──────────────────────────────────────────────────── + local caddy_file="$DOCKER_DIR/caddy/Caddyfile" + if [ -n "$container" ] && [ -f "$caddy_file" ] && grep -q "reverse_proxy ${container}:" "$caddy_file"; then + local bk="$caddy_file.backup.$(date +%Y%m%d-%H%M%S)" + cp "$caddy_file" "$bk" + _remove_caddy_site_block "$caddy_file" "$container" > "$caddy_file.tmp" \ + && mv "$caddy_file.tmp" "$caddy_file" + log_success "Removed $name's Caddy site block (backup: $(basename "$bk"))" + if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx caddy; then + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null \ + || docker restart caddy &>/dev/null \ + || log_warning "Reload/restart Caddy manually to apply this." + fi + fi + + # ── UFW rules ─────────────────────────────────────────────────────────── + if command -v ufw &>/dev/null; then + local rule_nums + # [[:space:]]* after the opening bracket — ufw pads single-digit + # rule numbers with a leading space to align with double-digit + # ones ("[ 3]" vs "[10]"); without it, every single-digit rule + # silently fails to match and never gets deleted. + rule_nums="$(ufw status numbered 2>/dev/null | grep -i "# .*\b${name}\b" | grep -oE '^\[[[:space:]]*[0-9]+\]' | tr -d '[] ' | sort -rn)" + if [ -n "$rule_nums" ]; then + local n + for n in $rule_nums; do + ufw --force delete "$n" >/dev/null 2>&1 + done + log_success "Removed UFW rule(s) tagged for $name" + fi + fi + + # ── Directory itself ──────────────────────────────────────────────────── + local DELETE_DIR="" + prompt_yn " Also delete $dir itself (its README, configs, and any data left on disk)? (y/n):" "n" DELETE_DIR + if [[ "$DELETE_DIR" =~ ^[Yy]$ ]]; then + rm -rf "$dir" + log_success "Removed $dir" + else + log_info "Left $dir in place." + fi +} + # ── SSH client config (~/.ssh/config) Host aliases ──────────────────────────── # Lets "ssh " connect directly to user@host without typing it out each # time — handy for VPN/NetBird peers with unmemorable IPs. Operates on the diff --git a/setup.sh b/setup.sh index 66e3e26..e0c5eae 100755 --- a/setup.sh +++ b/setup.sh @@ -12,6 +12,10 @@ # Flags: # --dry-run preview actions without making changes # --unattended use defaults, no prompts (pair with explicit service names) +# --remove remove instead of install (pair with a service name, e.g. +# ./setup.sh filebrowser --remove) — stops/removes its +# containers, its Caddy site block (if any), any UFW rule +# tagged for it, and optionally its ~/docker/ directory # # Every service lives in services/.sh, registers itself with # register_service, and defines install_. Adding a service = adding one @@ -34,7 +38,7 @@ declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 ) declare -A SERVICE_ALIAS=( [asterisk-digital-ocean]=asterisk ) # ── Parse flags / collect service names ────────────────────────────────────── -DRY_RUN=false; UNATTENDED=false; DO_LIST=false; DO_STATUS=false +DRY_RUN=false; UNATTENDED=false; DO_LIST=false; DO_STATUS=false; DO_REMOVE=false REQUESTED=() for arg in "$@"; do case "$arg" in @@ -42,6 +46,7 @@ for arg in "$@"; do --unattended) UNATTENDED=true ;; --list|-l) DO_LIST=true ;; --status) DO_STATUS=true ;; + --remove) DO_REMOVE=true ;; --version|-V) cat "$HERE/VERSION" 2>/dev/null || echo "unknown"; exit 0 ;; -h|--help) sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; -*) echo "Unknown flag: $arg" >&2; exit 1 ;; @@ -267,6 +272,29 @@ if [ "${REQUESTED[*]:-}" = "configure" ]; then exit 0 fi +# ── --remove: ./setup.sh filebrowser --remove ──────────────────────────────── +if [ "$DO_REMOVE" = true ]; then + require_root + if [ "${#REQUESTED[@]}" -eq 0 ]; then + log_error "--remove needs a service name, e.g. ./setup.sh filebrowser --remove" + exit 1 + fi + rc=0 + for name in "${REQUESTED[@]}"; do + if [ -n "${SERVICE_ALIAS[$name]:-}" ]; then + log_info "'$name' is now part of '${SERVICE_ALIAS[$name]}' — removing that instead." + name="${SERVICE_ALIAS[$name]}" + fi + if [ -z "${SERVICE_GROUP[$name]:-}" ]; then + log_error "Unknown service: $name (try --list)" + rc=1 + continue + fi + remove_service "$name" || rc=1 + done + exit "$rc" +fi + # ── Direct install: ./setup.sh caddy homeassistant ────────────────────────── if [ "${#REQUESTED[@]}" -gt 0 ]; then require_root