Skip to content

Instantly share code, notes, and snippets.

@supermarsx
Last active August 27, 2025 17:07
Show Gist options
  • Save supermarsx/d44516c6d386720c4b24ff4d54c74d83 to your computer and use it in GitHub Desktop.
Save supermarsx/d44516c6d386720c4b24ff4d54c74d83 to your computer and use it in GitHub Desktop.
Docker Bridges cleaner, cleanup all configured docker bridges on network manager
#!/usr/bin/env bash
#
# docker-nmcli-bridge-cleaner.sh
# ------------------------------------------------------------
# Delete NetworkManager connections whose NAME matches a regex
# (defaults to "^br-" for Docker-style bridges). Shows a pretty
# preview table, detects Docker/Podman/Libvirt/LXD/Incus, warns
# when a connection is active or likely managed by those stacks,
# supports dry-run, backups, and safety skips.
#
# Usage:
# ./nmcli-bridge-cleaner.sh # preview + prompt
# ./nmcli-bridge-cleaner.sh -y # no prompt (force)
# ./nmcli-bridge-cleaner.sh -n # dry-run (what-if)
# ./nmcli-bridge-cleaner.sh -p '^br-|virbr' # custom regex
# ./nmcli-bridge-cleaner.sh -b backups/ # export/backup first
# ./nmcli-bridge-cleaner.sh --detect-only # just detect/preview
# ./nmcli-bridge-cleaner.sh --skip-origins docker,libvirt \
# --ignore-active # advanced safety
#
# Notes:
# - Requires: nmcli, awk. (column is optional for nicer tables.)
# - We detect services and bridge origins by heuristics & commands:
# * Docker: br-<12 chars of network ID>; verified via `docker network ls`.
# * Podman: cni-podman0/podman[0-9]? heuristics.
# * Libvirt: virbr* heuristic (virsh optional).
# * LXD/Incus: lxdbr*/incusbr* (lxc/incus optional).
# - Deleting an ACTIVE connection can disrupt networking. By default
# we SKIP active ones unless --ignore-active is set.
# ------------------------------------------------------------
set -euo pipefail
IFS=$'
'
shopt -s extglob
# --------------------------- Styling ---------------------------
BOLD=""; DIM=""; RESET=""
GREEN=""; YELLOW=""; RED=""; CYAN=""; GRAY=""
CHECK="✔"; CROSS="✖"; INFO="ℹ"; WARN="⚠"; DOT="•"
# -----------------------------------------------------------------------------
# Function: banner
# Purpose : Print a decorative header to stdout.
# Globals : CYAN, BOLD, RESET
# Returns : 0
# -----------------------------------------------------------------------------
banner() {
printf "${CYAN}%s${RESET}
" "┌───────────────────────────────────────────────────────────┐"
printf "${CYAN}%s${RESET}
" "│ ${BOLD}NMCLI BRIDGE CLEANER${RESET} — smart, safe, stylish │"
printf "${CYAN}%s${RESET}
" "└───────────────────────────────────────────────────────────┘"
}
# -----------------------------------------------------------------------------
# Logging helpers
# Purpose : Uniform, colorized logs for info/warn/ok/error/dim traces.
# Usage : log_info "message"; log_err "fatal"; etc.
# Globals : color codes above
# Returns : 0
# -----------------------------------------------------------------------------
log_info() { printf "${CYAN}${INFO}${RESET} %s
" "$*"; }
log_warn() { printf "${YELLOW}${WARN}${RESET} %s
" "$*"; }
log_ok() { printf "${GREEN}${CHECK}${RESET} %s
" "$*"; }
log_err() { printf "${RED}${CROSS}${RESET} %s
" "$*" >&2; }
log_dim() { printf "${GRAY}${DOT}${RESET} %s
" "$*"; }
# -----------------------------------------------------------------------------
# Function: die
# Purpose : Print an error via log_err and exit non‑zero.
# Args : $* Error message
# Returns : Exits 1
# -----------------------------------------------------------------------------
die() { log_err "$*"; exit 1; }
# --------------------------- Args ------------------------------
DEFAULT_REGEX='^br-'
REGEX="$DEFAULT_REGEX"
DRY_RUN=false
FORCE=false
BACKUP_DIR=""
VERBOSE=false
DETECT_ONLY=false
IGNORE_ACTIVE=false
SKIP_ORIGINS="" # csv: docker,libvirt,lxd,incus,podman,k8s,other
# -----------------------------------------------------------------------------
# Function: usage
# Purpose : Print CLI help/usage text.
# Returns : 0
# -----------------------------------------------------------------------------
usage() {
cat <<USAGE
${BOLD}nmcli-bridge-cleaner.sh${RESET}
Delete NetworkManager connections whose NAME matches a regex (default: ${BOLD}$DEFAULT_REGEX${RESET}).
${BOLD}Options${RESET}
-p, --pattern <regex> Regex against connection NAME (default: ^br-)
-n, --dry-run Show what would be deleted; do not delete
-y, --yes, --force Do not prompt for confirmation
-b, --backup-dir <dir> Export each connection to <dir> before deletion
-v, --verbose Chatty output
--detect-only Do not delete; just detect/preview
--ignore-active Allow deletion of ACTIVE connections (danger!)
--skip-origins <csv> Skip deleting origins (e.g. docker,libvirt,lxd)
-h, --help Show this help
${BOLD}Examples${RESET}
./nmcli-bridge-cleaner.sh
./nmcli-bridge-cleaner.sh -y -b ./backups
./nmcli-bridge-cleaner.sh -p '^br-|virbr' --skip-origins libvirt -n
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
-p|--pattern) REGEX="${2-}"; shift ;;
-n|--dry-run|--whatif) DRY_RUN=true ;;
-y|--yes|--force) FORCE=true ;;
-b|--backup-dir) BACKUP_DIR="${2-}"; shift ;;
-v|--verbose) VERBOSE=true ;;
--detect-only) DETECT_ONLY=true ;;
--ignore-active) IGNORE_ACTIVE=true ;;
--skip-origins) SKIP_ORIGINS="${2-}"; shift ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown argument: $1 (use -h for help)" ;;
esac
shift
done
# --------------------- Preflight checks ------------------------
command -v nmcli >/dev/null 2>&1 || die "nmcli not found. Install NetworkManager tools."
command -v awk >/dev/null 2>&1 || die "awk not found."
HAS_COLUMN=true
command -v column >/dev/null 2>&1 || HAS_COLUMN=false
if [[ -n "$BACKUP_DIR" ]]; then
mkdir -p -- "$BACKUP_DIR" || die "Cannot create backup dir: $BACKUP_DIR"
fi
banner
log_info "Pattern: ${BOLD}/$REGEX/${RESET}"
$DRY_RUN && log_warn "Dry-run mode: no changes will be made."
$DETECT_ONLY && log_warn "Detect-only mode: preview without deletion."
[[ -n "$BACKUP_DIR" ]] && log_info "Backup directory: ${BOLD}$BACKUP_DIR${RESET}"
[[ -n "$SKIP_ORIGINS" ]] && log_info "Skip origins: ${BOLD}$SKIP_ORIGINS${RESET}"
$IGNORE_ACTIVE || log_info "Active connections will be ${BOLD}skipped${RESET} (use --ignore-active to override)."
# ---------------------- Environment probe ---------------------
# -----------------------------------------------------------------------------
# Function: svc_active
# Purpose : Check if a systemd service (or process fallback) is active.
# Args : $1 service name (e.g., docker)
# Returns : 0 if active, 1 otherwise
# -----------------------------------------------------------------------------
svc_active() {
local s="$1"
if command -v systemctl >/dev/null 2>&1; then
systemctl is-active --quiet "$s" 2>/dev/null && return 0 || return 1
fi
pgrep -x "$s" >/dev/null 2>&1 && return 0 || return 1
}
# -----------------------------------------------------------------------------
# Function: have
# Purpose : Test if a command exists in PATH.
# Args : $1 binary name
# Returns : 0 if found, 1 otherwise
# -----------------------------------------------------------------------------
have() { command -v "$1" >/dev/null 2>&1; }
# -----------------------------------------------------------------------------
# Function: print_env_status
# Purpose : Detect and summarize container/virtualization stacks present.
# Globals : uses have, svc_active; prints human-friendly status lines.
# Returns : 0
# -----------------------------------------------------------------------------
print_env_status() {
local label status
declare -A MAP=(
[docker]=Docker
[podman]=Podman
[containerd]=containerd
[crio]=CRI-O
[virsh]=Libvirt
[lxc]=LXD
[incus]=Incus
[multipass]=Multipass
[kubelet]=Kubernetes
[k3s]=K3s
)
log_dim "Detecting container/virt stacks…"
for bin in "${!MAP[@]}"; do
label="${MAP[$bin]}"
if have "$bin"; then
case "$bin" in
docker) status=$(svc_active docker && echo active || echo present) ;;
podman) status=$(svc_active podman && echo active || echo present) ;;
containerd) status=$(svc_active containerd && echo active || echo present) ;;
crio) status=$(svc_active crio && echo active || echo present) ;;
virsh) status=$(svc_active libvirtd || svc_active virtqemud && echo active || echo present) ;;
lxc) status=$(svc_active lxd && echo active || echo present) ;;
incus) status=$(svc_active incus && echo active || echo present) ;;
multipass) status=$(svc_active multipassd && echo active || echo present) ;;
kubelet) status=$(svc_active kubelet && echo active || echo present) ;;
k3s) status=$(svc_active k3s && echo active || echo present) ;;
esac
log_info "${label}: ${BOLD}$status${RESET}"
fi
done
}
print_env_status
# Build Docker br-<id> → name map if possible
declare -A DOCKER_BR2NAME
if have docker; then
if docker info >/dev/null 2>&1; then
while read -r id name; do
[[ -z "$id" || -z "$name" ]] && continue
DOCKER_BR2NAME["br-${id:0:12}"]="$name"
done < <(docker network ls --format '{{.ID}} {{.Name}}' 2>/dev/null || true)
fi
fi
# Podman heuristics: interfaces usually cni-podman0 / podman0
PODMAN_BRIDGES=(cni-podman0 podman0)
# Libvirt/LXD/Incus names are conventional
# - virbr*, lxdbr*, incusbr*
# ----------------------- Collection ----------------------------
log_dim "Querying NetworkManager connections…"
# Fields: NAME,UUID,TYPE,DEVICE (tab-separated for easy column formatting)
mapfile -t ROWS < <(nmcli -t -f NAME,UUID,TYPE,DEVICE connection show \
| awk -F: -v re="$REGEX" '$1 ~ re { printf "%s %s %s %s
", $1,$2,$3,$4 }')
COUNT=${#ROWS[@]}
if (( COUNT == 0 )); then
log_ok "No connections match /$REGEX/. Nothing to do."
exit 0
fi
# ---------------------- Classification ------------------------
# -----------------------------------------------------------------------------
# Function: classify_origin
# Purpose : Heuristically classify a connection NAME to an origin stack.
# Args : $1 NAME (e.g., br-1a2b3c4d5e6f, virbr0, lxdbr0)
# Returns : Echoes one of: docker:<net-name> | podman | libvirt | lxd | incus | k8s | other
# Notes : Docker mapping uses a prebuilt br-<ID> → network name map.
# -----------------------------------------------------------------------------
classify_origin() {
local n="$1"
if [[ "${DOCKER_BR2NAME[$n]-}" != "" ]]; then
echo "docker:${DOCKER_BR2NAME[$n]}"; return
fi
for p in "${PODMAN_BRIDGES[@]}"; do
[[ $n == "$p" ]] && { echo "podman"; return; }
done
[[ $n =~ ^virbr[0-9]*$ ]] && { echo "libvirt"; return; }
[[ $n =~ ^lxdbr[0-9]*$ ]] && { echo "lxd"; return; }
[[ $n =~ ^incusbr[0-9]*$ ]] && { echo "incus"; return; }
[[ $n =~ ^cni0$|^flannel\.[0-9]+$|^weave$|^cali.*$ ]] && { echo "k8s"; return; }
echo "other"
}
# -----------------------------------------------------------------------------
# Function: is_active_device
# Purpose : Determine if a network DEVICE is operational (state UP).
# Args : $1 DEVICE (e.g., br-xxxxx)
# Returns : 0 if link is UP, 1 otherwise (also returns 1 for empty/"--")
# -----------------------------------------------------------------------------
is_active_device() {
local d="$1"
[[ -z "$d" || "$d" == "--" ]] && return 1
ip -o link show "$d" 2>/dev/null | grep -q 'state UP' && return 0 || return 1
}
# -----------------------------------------------------------------------------
# Function: should_skip_origin
# Purpose : Check whether an origin type is in the user‑provided skip list.
# Args : $1 origin token possibly with suffix after ':' (e.g., docker:bridge)
# Returns : 0 if should skip, 1 if not
# -----------------------------------------------------------------------------
should_skip_origin() {
local tok=${1%%:*}
[[ -z "$SKIP_ORIGINS" ]] && return 1
IFS=',' read -r -a arr <<<"$SKIP_ORIGINS"
for x in "${arr[@]}"; do [[ "$tok" == "$x" ]] && return 0; done
return 1
}
# Augment rows with ORIGIN and STATE
AUG=()
for r in "${ROWS[@]}"; do
NAME="${r%%$' '*}"
REST="${r#*$' '}"; UUID="${REST%%$' '*}"
REST2="${REST#*$' '}"; TYPE="${REST2%%$' '*}"
DEVICE="${REST2#*$' '}"
ORIGIN=$(classify_origin "$NAME")
STATE="inactive"
if is_active_device "$DEVICE"; then STATE="active"; fi
AUG+=("$NAME $UUID $TYPE $DEVICE $ORIGIN $STATE")
done
# ------------------------ Preview table ------------------------
# -----------------------------------------------------------------------------
# Function: print_table
# Purpose : Pretty‑print the augmented connection list as a table.
# Globals : AUG, HAS_COLUMN
# Returns : 0
# -----------------------------------------------------------------------------
print_table() {
printf "NAME UUID TYPE DEVICE ORIGIN STATE
"
for r in "${AUG[@]}"; do
printf "%s
" "$r"
done | { if $HAS_COLUMN; then column -t -s $' '; else cat; fi; }
}
log_info "Found ${BOLD}$COUNT${RESET} matching connection(s):"
print_table
$DETECT_ONLY && { log_info "Detect-only finished."; exit 0; }
# ----------------------- Confirmation --------------------------
if ! $FORCE && ! $DRY_RUN; then
printf "
${BOLD}Type 'delete' to remove the above %d connection(s) (respecting skips), or anything else to cancel:${RESET} " "$COUNT"
read -r ANSWER || true
if [[ "$ANSWER" != "delete" ]]; then
log_warn "Cancelled by user."
exit 0
fi
fi
# ------------------------- Deletion ----------------------------
DELETED=0; SKIPPED=0; FAILED=0
for r in "${AUG[@]}"; do
NAME="${r%%$' '*}"
REST="${r#*$' '}"; UUID="${REST%%$' '*}"
REST2="${REST#*$' '}"; TYPE="${REST2%%$' '*}"
REST3="${REST2#*$' '}"; DEVICE="${REST3%%$' '*}"
REST4="${REST3#*$' '}"; ORIGIN="${REST4%%$' '*}"
STATE="${REST4#*$' '}"
if should_skip_origin "$ORIGIN"; then
((SKIPPED++)); log_warn "Skipping $NAME due to origin: ${BOLD}${ORIGIN}${RESET}"; continue
fi
if [[ "$STATE" == "active" && $IGNORE_ACTIVE == false ]]; then
((SKIPPED++)); log_warn "Skipping ACTIVE connection ${BOLD}$NAME${RESET} (${DEVICE}); use --ignore-active to force."
continue
fi
if [[ -n "$BACKUP_DIR" ]]; then
OUT="$BACKUP_DIR/${NAME//\//_}_${UUID}.keyfile"
$VERBOSE && log_dim "Exporting $NAME → $OUT"
if ! nmcli connection show "$UUID" >"$OUT" 2>/dev/null; then
sudo nmcli connection show "$UUID" >"$OUT" || { log_warn "Backup failed for $NAME ($UUID)"; }
fi
fi
if $DRY_RUN; then
log_info "Would delete ${BOLD}$NAME${RESET} (${DIM}$UUID${RESET}) [${ORIGIN}/${STATE}]"
((SKIPPED++))
continue
fi
$VERBOSE && log_dim "Deleting $NAME ($UUID)…"
if nmcli connection delete uuid "$UUID" >/dev/null 2>&1 \
|| sudo nmcli connection delete uuid "$UUID" >/dev/null 2>&1; then
((DELETED++))
log_ok "Deleted ${BOLD}$NAME${RESET} (${DIM}$UUID${RESET}) [${ORIGIN}]"
else
((FAILED++))
log_err "Failed to delete $NAME ($UUID)"
fi
done
# -------------------------- Summary ----------------------------
printf "
"
log_info "${BOLD}Summary${RESET}: deleted=$DELETED, skipped=$SKIPPED, failed=$FAILED"
if (( FAILED > 0 )); then
log_warn "Some deletions failed. Try running again with sudo or check polkit rules."
fi
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment