diff --git a/lib/common.sh b/lib/common.sh index 34aa245..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 @@ -434,6 +588,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 a2347a2..67363fd 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 @@ -733,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 "" 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 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