Add a generic service removal command: ./setup.sh <name> --remove
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/<name> 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/<name> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
This commit is contained in:
+154
@@ -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/<name> directory. Scoped to the common case (a Docker service
|
||||
# living at $DOCKER_DIR/<name> 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 <alias>" connect directly to user@host without typing it out each
|
||||
# time — handy for VPN/NetBird peers with unmemorable IPs. Operates on the
|
||||
|
||||
@@ -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/<name> directory
|
||||
#
|
||||
# Every service lives in services/<name>.sh, registers itself with
|
||||
# register_service, and defines install_<name>. 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
|
||||
|
||||
Reference in New Issue
Block a user