diff --git a/README.md b/README.md index 08a481d..c4c078d 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | | `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) | -| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | +| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | diff --git a/services/base.sh b/services/base.sh index 1d14d62..1876513 100644 --- a/services/base.sh +++ b/services/base.sh @@ -21,6 +21,7 @@ install_base() { echo "[DRY-RUN] Would offer to mount SMB data from a NetBird-connected home box (if NetBird is present)" echo "[DRY-RUN] Would offer Caddy reverse proxy install (full repo only)" echo "[DRY-RUN] Would offer CrowdSec intrusion prevention install (full repo only)" + echo "[DRY-RUN] Would offer Samba (SMB/CIFS) file sharing install (full repo only)" echo "[DRY-RUN] Would offer to add SSH Host aliases to ~/.ssh/config" return 0 fi @@ -80,6 +81,14 @@ install_base() { _base_setup_crowdsec cd "$_BASE_PWD" 2>/dev/null || true + # ── Samba ──────────────────────────────────────────────────────────────── + # Same nudge-not-mandatory shape as Caddy/CrowdSec above: fully optional, + # independently re-runnable later via `sudo ./setup.sh samba`. Defaults to + # n (unlike Caddy/CrowdSec) because it needs real input to be useful — a + # share path and at least one user — not just "yes, with sane defaults". + _base_setup_samba + cd "$_BASE_PWD" 2>/dev/null || true + # ── SSH Host aliases ───────────────────────────────────────────────────── _base_setup_ssh_aliases @@ -329,6 +338,22 @@ _base_setup_crowdsec() { install_crowdsec } +_base_setup_samba() { + if command -v smbd &>/dev/null; then + log_info "Samba already installed." + return 0 + fi + # Only available when the full repo is sourced (setup.sh loads every + # services/*.sh up front) — a standalone copy of base.sh doesn't have + # install_samba, so skip silently rather than error. + declare -F install_samba &>/dev/null || return 0 + + local INSTALL_SAMBA="" + prompt_yn "Install Samba (SMB/CIFS) file sharing now — shares, users, passwords? (y/n):" "n" INSTALL_SAMBA + [[ "$INSTALL_SAMBA" =~ ^[Yy]$ ]] || return 0 + install_samba +} + _base_setup_ssh_aliases() { local ADD_ALIAS="" prompt_yn "Add an SSH Host alias now ('ssh myserver' instead of 'ssh user@1.2.3.4')? (y/n):" "n" ADD_ALIAS diff --git a/services/samba.sh b/services/samba.sh new file mode 100644 index 0000000..f311435 --- /dev/null +++ b/services/samba.sh @@ -0,0 +1,365 @@ +#!/bin/bash +# services/samba.sh — Samba (SMB/CIFS) file sharing: shares, users, passwords. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash samba.sh +# +# Samba is a SYSTEM install (apt package + native smbd/nmbd services), NOT a +# docker-compose service — same shape as services/crowdsec.sh. There is no +# ~/docker/samba compose stack; we only create a docs-only folder there with +# a README pointing at the real config under /etc/samba/smb.conf. This is +# the SERVER side — for mounting an existing remote Samba share instead, see +# services/vpn-data-mount.sh (deliberately the opposite: reads an existing +# smb.conf over SSH, never installs Samba, never creates or resets a share +# password). + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +# Detected when the script is executed directly rather than sourced by setup.sh. +# Sets up helpers and globals, then defers execution until after the function +# definition at the bottom of this file. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } + + _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _COMMON="$_SELF_DIR/../lib/common.sh" + + if [[ -f "$_COMMON" ]]; then + # Full repo present — use the real helpers (picks up ~/docker/.config too) + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + # One-off copy — inline minimal stubs so the script works without the repo + log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } + log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } + log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } + log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } + + ensure_docker_dir_ownership() { + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true + } + + prompt_text() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_yn() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + ensure_ufw_enabled() { + command -v ufw &>/dev/null || return 0 + ufw status 2>/dev/null | grep -q "Status: active" && return 0 + local _ssh_port + _ssh_port="$(grep -iE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null \ + | tail -1 | awk '{print $2}')" + _ssh_port="${_ssh_port:-22}" + ufw allow "${_ssh_port}/tcp" comment 'SSH' >/dev/null 2>&1 + ufw --force enable >/dev/null 2>&1 + log_success "UFW enabled (SSH on port ${_ssh_port} allowed first, so this won't lock you out)." + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + backup_if_exists() { + local _file="$1" + [ -f "$_file" ] || return 0 + cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null + } + fi + + # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR + # ($HOME under sudo is /root, not the real user's home) + ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" + ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" + DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + DRY_RUN="${DRY_RUN:-false}" + UNATTENDED="${UNATTENDED:-false}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service samba utilities "Samba file sharing (SMB/CIFS) — shares, users, passwords" + +install_samba() { + local SMB_CONF="/etc/samba/smb.conf" + local DOCS_DIR="$DOCKER_DIR/samba" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would install samba (smbd/nmbd) if not already present" + echo "[DRY-RUN] Would show any shares this installer already manages" + echo "[DRY-RUN] Would prompt to add one or more shares (path, guest-or-authenticated, users)" + echo "[DRY-RUN] Would create a system Linux account + Samba password for any new user" + echo "[DRY-RUN] Would append share stanzas to $SMB_CONF, validate with testparm, restart smbd/nmbd" + echo "[DRY-RUN] Would open UFW for SMB (137/138 udp, 139/445 tcp) — scoped to the LAN by default" + echo "[DRY-RUN] Would write $DOCS_DIR/README.md (docs only — Samba itself runs natively, not in Docker)" + return 0 + fi + + if ! command -v smbd &>/dev/null; then + log_info "Installing Samba..." + apt-get update -y + apt-get install -y samba || { log_error "Samba install failed"; return 1; } + log_success "Samba installed" + else + log_success "Samba already installed" + fi + + backup_if_exists "$SMB_CONF" + + if grep -q '^# ubuntu-post-install:share:' "$SMB_CONF" 2>/dev/null; then + echo "" + log_info "Shares already managed by this installer:" + grep '^# ubuntu-post-install:share:' "$SMB_CONF" | sed 's/^# ubuntu-post-install:share:/ - /' + fi + + echo "" + local _added_any=false + while true; do + local ADD_SHARE="" + prompt_yn "Add a Samba share now? (y/n):" "y" ADD_SHARE + [[ "$ADD_SHARE" =~ ^[Yy]$ ]] || break + _samba_add_share "$SMB_CONF" && _added_any=true + echo "" + done + + if [ "$_added_any" = true ]; then + log_info "Validating smb.conf..." + if testparm -s "$SMB_CONF" &>/dev/null; then + systemctl restart smbd 2>/dev/null + systemctl restart nmbd 2>/dev/null # NetBIOS name resolution — some Samba packages split this out + log_success "smbd/nmbd restarted with the new configuration" + else + log_error "testparm reports smb.conf is invalid — NOT restarting smbd/nmbd." + log_error "Check manually: sudo testparm -s $SMB_CONF" + return 1 + fi + else + log_info "No shares added this run." + fi + + _samba_configure_firewall + + mkdir -p "$DOCS_DIR" + ensure_docker_dir_ownership "$DOCS_DIR" + write_readme "$DOCS_DIR" << MD +# Samba + +Samba runs natively on this box (not in Docker) — the real config is +\`/etc/samba/smb.conf\`, managed by \`systemctl\`. This folder just holds this +README; there's no compose stack here. + +## Manage + +\`\`\`bash +sudo testparm -s # validate smb.conf before restarting +sudo systemctl restart smbd nmbd +sudo systemctl status smbd +\`\`\` + +## Shares + +Re-run \`sudo ./setup.sh samba\` (or \`sudo bash services/samba.sh\` standalone) +to add another share or another user — existing shares/users are left alone. + +Each share this installer wrote is marked in smb.conf with a +\`# ubuntu-post-install:share:\` comment right above its \`[]\` +stanza, so you can find (or hand-edit / remove) them later. + +## Users + +Samba users need BOTH a Linux account and a separate Samba password +(\`smbpasswd\`) — they are not the same credential. This installer creates a +system account (\`useradd --system --no-create-home\`, no shell login) for +any username that doesn't already exist as a Linux user, adds it to the +\`sambashare\` group, and sets its Samba password with \`smbpasswd\`. + +\`\`\`bash +sudo smbpasswd # change an existing user's Samba password +sudo pdbedit -L # list all Samba users +sudo smbpasswd -x # remove a user from Samba (leaves the Linux account alone) +\`\`\` + +## Connecting + +- Windows: \`\\\\\\\` +- macOS Finder: Go -> Connect to Server -> \`smb:///\` +- Linux: \`smbclient /// -U \` or mount with + \`mount.cifs\` / \`cifs-utils\` (already installed by \`services/base.sh\`). + +## Firewall + +SMB (137/138 UDP, 139/445 TCP) should almost never be exposed to the public +internet — this installer scopes the UFW rule to your LAN subnet by default. +Check what's currently allowed with \`sudo ufw status | grep -E '13[7-9]|445'\`. +MD + + log_success "Samba configured. Re-run 'sudo ./setup.sh samba' any time to add another share or user." +} + +# Appends one [share] stanza to smb.conf. Returns non-zero (and adds nothing) +# on a blank/duplicate name so the caller's "did we actually add one" tracking +# stays accurate. +_samba_add_share() { + local _conf="$1" + local NAME="" SHARE_PATH="" GUEST="" + + prompt_text " Share name (letters/numbers/hyphens/underscores, e.g. media):" "" NAME + NAME="$(echo "$NAME" | tr -cd 'A-Za-z0-9_-')" + if [[ -z "$NAME" ]]; then + log_warning "Share name required — skipping." + return 1 + fi + if grep -q "^\[$NAME\]\$" "$_conf" 2>/dev/null; then + log_warning "A share named [$NAME] already exists in smb.conf — skipping." + log_warning "Edit $_conf by hand to change it, or pick a different name." + return 1 + fi + + local DEFAULT_PATH="/srv/samba/$NAME" + prompt_text " Path to share [$DEFAULT_PATH]:" "$DEFAULT_PATH" SHARE_PATH + SHARE_PATH="${SHARE_PATH:-$DEFAULT_PATH}" + SHARE_PATH="${SHARE_PATH/#\~/$ACTUAL_HOME}" + mkdir -p "$SHARE_PATH" + + prompt_yn " Allow guest (no password) access to '$NAME'? (y/n):" "n" GUEST + + local VALID_USERS="" + if [[ ! "$GUEST" =~ ^[Yy]$ ]]; then + echo " Enter Samba usernames to grant access to '$NAME' (blank to stop):" + while true; do + local SUSER="" + prompt_text " Username:" "" SUSER + [[ -z "$SUSER" ]] && break + _samba_ensure_user "$SUSER" + VALID_USERS="${VALID_USERS:+$VALID_USERS }$SUSER" + done + if [[ -z "$VALID_USERS" ]]; then + log_warning "No users added and guest access declined — '$NAME' will be inaccessible until you add a user (re-run this installer, or edit smb.conf by hand)." + fi + fi + + getent group sambashare >/dev/null 2>&1 || groupadd sambashare + if [[ "$GUEST" =~ ^[Yy]$ ]]; then + chmod 0777 "$SHARE_PATH" + else + chgrp sambashare "$SHARE_PATH" 2>/dev/null || true + chmod 0770 "$SHARE_PATH" + fi + + { + echo "" + echo "# ubuntu-post-install:share:$NAME" + echo "[$NAME]" + echo " path = $SHARE_PATH" + echo " browseable = yes" + echo " read only = no" + if [[ "$GUEST" =~ ^[Yy]$ ]]; then + echo " guest ok = yes" + else + echo " guest ok = no" + [[ -n "$VALID_USERS" ]] && echo " valid users = $VALID_USERS" + fi + } >> "$_conf" + + log_success "Share '$NAME' -> $SHARE_PATH added to smb.conf" +} + +# Creates the Linux system account (if missing) and sets a Samba password for +# it. Samba users need BOTH — a Linux account and a separate smbpasswd entry +# — they are not the same credential, and smbpasswd -a fails outright against +# a username with no matching Linux account at all. +_samba_ensure_user() { + local _user="$1" + + if ! id "$_user" &>/dev/null; then + log_info "Linux account '$_user' doesn't exist — creating a system account (no shell login, no home dir)." + useradd --system --no-create-home --shell /usr/sbin/nologin "$_user" + fi + + getent group sambashare >/dev/null 2>&1 || groupadd sambashare + usermod -aG sambashare "$_user" + + if pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qx "$_user"; then + log_info "Samba password already set for '$_user' — leaving as-is (change it later with: sudo smbpasswd $_user)." + return 0 + fi + + local _pass _entered="" + _pass="$(generate_password 16)" + prompt_text " Samba password for '$_user' [$_pass]:" "$_pass" _entered + _pass="${_entered:-$_pass}" + + if printf '%s\n%s\n' "$_pass" "$_pass" | smbpasswd -s -a "$_user" >/dev/null 2>&1 \ + && smbpasswd -e "$_user" >/dev/null 2>&1; then + log_success "Samba user '$_user' set — password: $_pass (write this down, it isn't stored anywhere else)" + else + log_warning "Failed to set Samba password for '$_user' — set it manually: sudo smbpasswd $_user" + fi +} + +# SMB should almost never face the public internet — scope the UFW rule to +# the LAN by default (LAN-subnet detection borrowed from the same pattern +# services/asterisk.sh uses for its VLAN/local-network prompt). +_samba_configure_firewall() { + command -v ufw &>/dev/null || { + log_warning "ufw not installed — if you use a firewall, open TCP 139/445 and UDP 137/138 for SMB (LAN only, never the internet)." + return 0 + } + + echo "" + local DETECTED_NETS DEFAULT_SUBNET="" + DETECTED_NETS="$(ip -o -f inet addr show scope global 2>/dev/null \ + | awk '{print $2, $4}' \ + | grep -Ev '^(docker|br-|veth|tun|tap|wg)' \ + | awk '{ split($2,a,"/"); split(a[1],o,"."); print o[1]"."o[2]"."o[3]".0/"a[2] }' \ + | sort -u)" + DEFAULT_SUBNET="$(echo "$DETECTED_NETS" | head -1)" + + local RESTRICT_LAN="" + prompt_yn "Restrict Samba access to your local network only (recommended — SMB should never face the internet)? (y/n):" "y" RESTRICT_LAN + + if [[ "$RESTRICT_LAN" =~ ^[Yy]$ ]]; then + local SUBNET="" + prompt_text " LAN subnet to allow (CIDR)${DEFAULT_SUBNET:+ [$DEFAULT_SUBNET]}:" "$DEFAULT_SUBNET" SUBNET + SUBNET="${SUBNET:-$DEFAULT_SUBNET}" + if [[ -z "$SUBNET" ]]; then + log_warning "No subnet given — skipping UFW rules. Open them manually if needed." + return 0 + fi + local p + for p in 137 138; do + ufw allow from "$SUBNET" to any port "$p" proto udp comment "Samba (LAN)" >/dev/null 2>&1 + done + for p in 139 445; do + ufw allow from "$SUBNET" to any port "$p" proto tcp comment "Samba (LAN)" >/dev/null 2>&1 + done + log_success "UFW: Samba opened to $SUBNET only" + else + log_warning "Opening Samba to ALL sources — not recommended, SMB has a long history of remote exploits." + ufw allow 137/udp comment "Samba" >/dev/null 2>&1 + ufw allow 138/udp comment "Samba" >/dev/null 2>&1 + ufw allow 139/tcp comment "Samba" >/dev/null 2>&1 + ufw allow 445/tcp comment "Samba" >/dev/null 2>&1 + log_success "UFW: Samba opened (unrestricted)" + fi + ensure_ufw_enabled +} + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_samba diff --git a/setup.sh b/setup.sh index eebb264..7caeb7e 100755 --- a/setup.sh +++ b/setup.sh @@ -121,6 +121,7 @@ is_installed() { base) command -v ncdu >/dev/null 2>&1 ;; glow) command -v glow >/dev/null 2>&1 ;; crowdsec) command -v cscli >/dev/null 2>&1 ;; + samba) command -v smbd >/dev/null 2>&1 ;; security-dashboard) [ -f /opt/security-dashboard/app.py ] ;; kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;; silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;; @@ -156,7 +157,7 @@ is_installed() { # is_installed() as 0 or 1. install_count() { case "$1" in - base|glow|crowdsec|security-dashboard|kdeconnect|silent-send|sync-cc|claude-cli|sky-cam|sky-cam-frigate|asterisk|pstn-trunk|sms-inbound|ssh-config|ssh-key-import) + base|glow|crowdsec|samba|security-dashboard|kdeconnect|silent-send|sync-cc|claude-cli|sky-cam|sky-cam-frigate|asterisk|pstn-trunk|sms-inbound|ssh-config|ssh-key-import) is_installed "$1" && echo 1 || echo 0 ;; wordpress) find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name 'wordpress-*' -type d 2>/dev/null | wc -l ;;