#!/usr/bin/env bash #============================================================================= # Linux-VM-Bootstrapper - Parent Node Bootstrapper (ParentNodeBootstrapper.sh) #============================================================================= # # Idempotent, silent bootstrap for a PARENT (edge) node. Safe to re-run. # # - Full system update + base tooling (openssl, ping, traceroute, tcpdump, # dig, ifconfig/net-tools, jq, cron, PowerShell) # - Docker Engine + Compose v2 plugin # - Tailscale (default) or NetBird; registers ONLY when an auth key is given # - /custom skeleton (scripts, cron, docker/stacks, backups) # - Stable docker service account (uid/gid 2000 by default) + recursive # ACLs on /custom granting docker, uid/gid 1000, and root full access # - Shared docker networks EDGE-PROXY-EXTERNAL / EDGE-PROXY-INTERNAL # - stk-edge-proxy-00001 (uozi/nginx-ui) - public 80/443, dashboard on VPN # - stk-dockhand-00001 (fnsys/dockhand) - dashboard on VPN, backup dir ready # - Management UIs on dedicated proxy TLS ports at ROOT paths (self-signed # cert, no sub-paths, no app-side config): :10443 Nginx UI, :10444 # Dockhand, :10445 Webmin. Access priority: VPN tunnel IP > private LAN # IP > these ports on the public address (opened only as last resort) # - Webmin console (tcp/10000; blocked on public interfaces by the firewall) # - iptables lockdown (NOT ufw/firewalld) when a public IP sits directly on # an interface - Docker chains are never touched, so Docker networking # keeps working across re-runs and reboots # - fail2ban sshd jail when a public IP is present # - Weekly maintenance cron (shuf-randomized once, then kept): OS patches # Saturday 20:00-21:59, reboot Sunday 22:00-23:59 # - Run log written to /custom/logs (only the 3 most recent runs are kept) # # USAGE (run from URL): # curl -fsSL https:///ParentNodeBootstrapper.sh | sudo -E bash -s -- [options] # # OPTIONS (flags win over environment variables): # --vpn=tailscale|netbird|none VPN_PROVIDER (default: tailscale) # --auth-key=KEY AUTH_KEY tailscale auth key / netbird setup key # --management-url=URL NETBIRD_MANAGEMENT_URL netbird self-hosted mgmt URL # --firewall=auto|on|off FIREWALL (default: auto = on when public IP found) # --ssh-port=N SSH_PORT (default: 22, kept open as lockout guard) # --no-ssh-allow FIREWALL_ALLOW_SSH=false do NOT hold SSH open (dangerous) # --docker-uid=N DOCKER_UID (default: 2000, stable docker service account) # --docker-gid=N DOCKER_GID (default: 2000, stable docker group id) # --timezone=TZ TZ (default: Etc/UTC) # --skip-upgrade SKIP_UPGRADE=true skip the full package upgrade pass # # SUPPORTED: Ubuntu, Debian, RHEL (and RHEL-likes: Rocky, Alma, CentOS Stream, OL) #============================================================================= set -euo pipefail umask 022 export DEBIAN_FRONTEND=noninteractive export NEEDRESTART_MODE=a #----------------------------------------------------------------------------- # Logging #----------------------------------------------------------------------------- log() { printf '[%s] [ParentNodeBootstrapper] %s\n' "$(date +%H:%M:%S)" "$*"; } warn() { printf '[%s] [ParentNodeBootstrapper] WARN: %s\n' "$(date +%H:%M:%S)" "$*" >&2; } die() { printf '[%s] [ParentNodeBootstrapper] FATAL: %s\n' "$(date +%H:%M:%S)" "$*" >&2; exit 1; } trap 'warn "command failed at line $LINENO"' ERR #----------------------------------------------------------------------------- # Defaults (environment overridable) #----------------------------------------------------------------------------- VPN_PROVIDER="${VPN_PROVIDER:-tailscale}" AUTH_KEY="${AUTH_KEY:-${TAILSCALE_AUTH_KEY:-${TS_AUTHKEY:-${NETBIRD_SETUP_KEY:-}}}}" NETBIRD_MANAGEMENT_URL="${NETBIRD_MANAGEMENT_URL:-}" FIREWALL="${FIREWALL:-auto}" SSH_PORT="${SSH_PORT:-22}" FIREWALL_ALLOW_SSH="${FIREWALL_ALLOW_SSH:-true}" TZ_VALUE="${TZ:-Etc/UTC}" SKIP_UPGRADE="${SKIP_UPGRADE:-false}" HOST_PORT_BIND_ADDRESS="${HOST_PORT_BIND_ADDRESS:-0.0.0.0}" DOCKER_UID="${DOCKER_UID:-2000}" DOCKER_GID="${DOCKER_GID:-2000}" CUSTOM_ROOT="/custom" STACKS_ROOT="${CUSTOM_ROOT}/docker/stacks" EDGE_STACK="stk-edge-proxy-00001" DOCKHAND_STACK="stk-dockhand-00001" DOCKHAND_BACKUP_DIR="${CUSTOM_ROOT}/backups/dockhand" for arg in "$@"; do case "$arg" in --vpn=*) VPN_PROVIDER="${arg#*=}" ;; --auth-key=*) AUTH_KEY="${arg#*=}" ;; --management-url=*) NETBIRD_MANAGEMENT_URL="${arg#*=}" ;; --firewall=*) FIREWALL="${arg#*=}" ;; --ssh-port=*) SSH_PORT="${arg#*=}" ;; --no-ssh-allow) FIREWALL_ALLOW_SSH="false" ;; --docker-uid=*) DOCKER_UID="${arg#*=}" ;; --docker-gid=*) DOCKER_GID="${arg#*=}" ;; --timezone=*) TZ_VALUE="${arg#*=}" ;; --skip-upgrade) SKIP_UPGRADE="true" ;; -h|--help) printf 'Usage: curl -fsSL /ParentNodeBootstrapper.sh | sudo -E bash -s -- [--vpn=tailscale|netbird|none] [--auth-key=K] [--management-url=U] [--firewall=auto|on|off] [--ssh-port=N] [--no-ssh-allow] [--docker-uid=N] [--docker-gid=N] [--timezone=TZ] [--skip-upgrade]\n'; exit 0 ;; *) die "unknown option: $arg" ;; esac done case "$VPN_PROVIDER" in tailscale|netbird|none) ;; *) die "--vpn must be tailscale|netbird|none" ;; esac case "$FIREWALL" in auto|on|off) ;; *) die "--firewall must be auto|on|off" ;; esac [ "$(id -u)" -eq 0 ] || die "must run as root (pipe into: sudo -E bash -s --)" #----------------------------------------------------------------------------- # Run log: /custom/logs, keep only the 3 most recent runs #----------------------------------------------------------------------------- LOG_DIR="${CUSTOM_ROOT}/logs" mkdir -p "$LOG_DIR" LOG_FILE="${LOG_DIR}/ParentNodeBootstrapper-$(date +%Y%m%d-%H%M%S).log" exec > >(tee -a "$LOG_FILE") 2>&1 ls -1t "${LOG_DIR}"/ParentNodeBootstrapper-*.log 2>/dev/null | tail -n +4 | xargs -r rm -f -- log "logging to ${LOG_FILE}" #----------------------------------------------------------------------------- # OS detection #----------------------------------------------------------------------------- [ -r /etc/os-release ] || die "/etc/os-release not found - unsupported system" . /etc/os-release OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}"; OS_VERSION="${VERSION_ID:-}" OS_FAMILY="" case " $OS_ID $OS_LIKE " in *" ubuntu "*|*" debian "*) OS_FAMILY="debian" ;; *" rhel "*|*" centos "*|*" rocky "*|*" almalinux "*|*" ol "*|*" fedora "*) OS_FAMILY="rhel" ;; esac [ -n "$OS_FAMILY" ] || die "unsupported distro: ${OS_ID} (need ubuntu/debian/rhel family)" PKG="apt" if [ "$OS_FAMILY" = "rhel" ]; then PKG="dnf"; command -v dnf >/dev/null 2>&1 || PKG="yum" fi log "detected ${OS_ID} ${OS_VERSION} (${OS_FAMILY} family, ${PKG})" # ubuntu|debian - used for Docker / Microsoft apt repository paths deb_flavor() { case " $OS_ID $OS_LIKE " in *" ubuntu "*) echo ubuntu ;; *) echo debian ;; esac } #----------------------------------------------------------------------------- # Package helpers (silent + idempotent) #----------------------------------------------------------------------------- pkg_refresh() { if [ "$OS_FAMILY" = "debian" ]; then apt-get update -qq else "$PKG" -y -q makecache >/dev/null 2>&1 || true; fi } pkg_install() { [ $# -gt 0 ] || return 0 if [ "$OS_FAMILY" = "debian" ]; then apt-get install -y -qq \ -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" "$@" else "$PKG" -y -q install "$@" fi } svc_enable() { command -v systemctl >/dev/null 2>&1 && systemctl enable --now "$1" >/dev/null 2>&1 || true; } update_system() { if [ "$SKIP_UPGRADE" = "true" ]; then log "skipping full package upgrade (--skip-upgrade)"; pkg_refresh; return 0; fi log "updating package index + upgrading system packages (silent)" if [ "$OS_FAMILY" = "debian" ]; then apt-get update -qq apt-get upgrade -y -qq \ -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" else "$PKG" -y -q upgrade fi } install_base_packages() { log "installing base tooling (openssl, net tools, jq, cron)" if [ "$OS_FAMILY" = "debian" ]; then pkg_install ca-certificates curl gnupg openssl iputils-ping traceroute tcpdump \ dnsutils net-tools jq tar cron acl svc_enable cron else local pkgs=(ca-certificates openssl iputils traceroute tcpdump bind-utils net-tools jq tar cronie acl) command -v curl >/dev/null 2>&1 || pkgs+=(curl) # RHEL9 ships curl-minimal; do not force-replace it pkg_install "${pkgs[@]}" svc_enable crond fi } #----------------------------------------------------------------------------- # PowerShell (Microsoft repo, GitHub tarball fallback) - non-fatal on failure #----------------------------------------------------------------------------- install_powershell_tarball() { local arch asset tmp case "$(uname -m)" in x86_64) arch="x64" ;; aarch64|arm64) arch="arm64" ;; *) warn "PowerShell: unsupported arch $(uname -m), skipping"; return 0 ;; esac log "PowerShell: installing from GitHub release tarball (${arch})" asset="$(curl -fsSL --retry 3 https://api.github.com/repos/PowerShell/PowerShell/releases/latest \ | jq -r --arg a "$arch" '.assets[].browser_download_url | select(test("linux-" + $a + "\\.tar\\.gz$"))' \ | head -n1)" || asset="" [ -n "$asset" ] || { warn "PowerShell: could not resolve latest release asset, skipping"; return 0; } tmp="$(mktemp -d)" curl -fsSL --retry 3 -o "${tmp}/pwsh.tar.gz" "$asset" mkdir -p /opt/microsoft/powershell/7 tar -xzf "${tmp}/pwsh.tar.gz" -C /opt/microsoft/powershell/7 chmod +x /opt/microsoft/powershell/7/pwsh ln -sf /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh rm -rf "$tmp" } install_powershell() { if command -v pwsh >/dev/null 2>&1; then log "PowerShell already installed ($(pwsh --version 2>/dev/null || true))"; return 0; fi log "installing PowerShell" if [ "$OS_FAMILY" = "debian" ]; then local cfg="https://packages.microsoft.com/config/$(deb_flavor)/${OS_VERSION}/packages-microsoft-prod.deb" tmp if ! dpkg -s packages-microsoft-prod >/dev/null 2>&1; then if curl -fsIL "$cfg" >/dev/null 2>&1; then tmp="$(mktemp)"; curl -fsSL --retry 3 -o "$tmp" "$cfg" dpkg -i "$tmp" >/dev/null 2>&1 || true rm -f "$tmp"; apt-get update -qq fi fi pkg_install powershell || install_powershell_tarball else local major="${OS_VERSION%%.*}" if ! rpm -q packages-microsoft-prod >/dev/null 2>&1; then "$PKG" -y -q install "https://packages.microsoft.com/config/rhel/${major}/packages-microsoft-prod.rpm" \ >/dev/null 2>&1 || true fi pkg_install powershell || install_powershell_tarball fi command -v pwsh >/dev/null 2>&1 && log "PowerShell ready: $(pwsh --version 2>/dev/null || true)" || warn "PowerShell not installed" } #----------------------------------------------------------------------------- # Stable docker service account - identical uid/gid on every node so # bind-mount ownership stays consistent across the fleet #----------------------------------------------------------------------------- ensure_docker_identity() { local owner cur owner="$(getent group "$DOCKER_GID" 2>/dev/null | cut -d: -f1 || true)" if [ -n "$owner" ] && [ "$owner" != "docker" ]; then warn "GID ${DOCKER_GID} is taken by group '${owner}' - keeping the existing docker group id" elif getent group docker >/dev/null 2>&1; then cur="$(getent group docker | cut -d: -f3)" if [ "$cur" != "$DOCKER_GID" ]; then log "re-numbering docker group gid ${cur} -> ${DOCKER_GID}" groupmod -g "$DOCKER_GID" docker # docker.sock keeps the old numeric gid until dockerd restarts if command -v systemctl >/dev/null 2>&1 && systemctl is-active docker >/dev/null 2>&1; then systemctl restart docker || warn "docker restart after gid change failed" fi fi else log "creating docker group (gid ${DOCKER_GID})" groupadd -g "$DOCKER_GID" docker fi owner="$(getent passwd "$DOCKER_UID" 2>/dev/null | cut -d: -f1 || true)" if [ -n "$owner" ] && [ "$owner" != "docker" ]; then warn "UID ${DOCKER_UID} is taken by user '${owner}' - keeping the existing docker user id" elif id -u docker >/dev/null 2>&1; then cur="$(id -u docker)" if [ "$cur" != "$DOCKER_UID" ]; then log "re-numbering docker user uid ${cur} -> ${DOCKER_UID}" usermod -u "$DOCKER_UID" docker fi else log "creating docker service user (uid ${DOCKER_UID})" useradd -u "$DOCKER_UID" -g docker -M -s /sbin/nologin -c "Docker service account" docker fi } #----------------------------------------------------------------------------- # Docker Engine + Compose v2 #----------------------------------------------------------------------------- ensure_compose_plugin() { docker compose version >/dev/null 2>&1 && return 0 pkg_install docker-compose-plugin 2>/dev/null && docker compose version >/dev/null 2>&1 && return 0 local arch case "$(uname -m)" in x86_64) arch="x86_64" ;; aarch64|arm64) arch="aarch64" ;; *) die "unsupported arch for compose" ;; esac log "installing docker compose plugin from GitHub" mkdir -p /usr/local/lib/docker/cli-plugins curl -fsSL --retry 3 -o /usr/local/lib/docker/cli-plugins/docker-compose \ "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-${arch}" chmod +x /usr/local/lib/docker/cli-plugins/docker-compose docker compose version >/dev/null 2>&1 || die "docker compose plugin install failed" } install_docker() { if command -v docker >/dev/null 2>&1; then log "Docker already installed ($(docker --version))" else log "installing Docker Engine + Compose v2" if [ "$OS_FAMILY" = "debian" ]; then local flavor codename arch flavor="$(deb_flavor)" codename="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}" [ -n "$codename" ] || die "cannot determine distro codename for Docker repository" install -m 0755 -d /etc/apt/keyrings curl -fsSL --retry 3 "https://download.docker.com/linux/${flavor}/gpg" -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc arch="$(dpkg --print-architecture)" echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${flavor} ${codename} stable" \ > /etc/apt/sources.list.d/docker.list apt-get update -qq pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin else local repo="centos" case "$OS_ID" in rhel) repo="rhel" ;; fedora) repo="fedora" ;; esac curl -fsSL --retry 3 "https://download.docker.com/linux/${repo}/docker-ce.repo" -o /etc/yum.repos.d/docker-ce.repo pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin fi fi svc_enable docker ensure_compose_plugin docker info >/dev/null 2>&1 || die "docker daemon is not running" log "Docker ready: $(docker --version | tr -d ',') / $(docker compose version)" } #----------------------------------------------------------------------------- # VPN (tailscale default | netbird) - registration ONLY when a key is present #----------------------------------------------------------------------------- install_vpn() { case "$VPN_PROVIDER" in none) log "VPN provider: none (skipping)"; return 0 ;; tailscale) if ! command -v tailscale >/dev/null 2>&1; then log "installing Tailscale" curl -fsSL --retry 3 https://tailscale.com/install.sh | sh fi svc_enable tailscaled local state state="$( (tailscale status --json 2>/dev/null || true) | jq -r '.BackendState // "unknown"' 2>/dev/null || echo unknown)" if [ "$state" = "Running" ]; then log "Tailscale already connected - reconciling flags (accept-routes, accept-dns)" tailscale set --accept-routes --accept-dns=true 2>/dev/null || warn "tailscale set failed (flags unchanged)" elif [ -n "$AUTH_KEY" ]; then log "registering with Tailscale (accept-routes, accept-dns, accept-risk=all, NO exit node)" tailscale up --authkey="$AUTH_KEY" --accept-routes --accept-dns=true --accept-risk=all else log "no auth key supplied - skipping Tailscale registration" log "register later with: tailscale up --authkey= --accept-routes --accept-dns=true --accept-risk=all" fi ;; netbird) if ! command -v netbird >/dev/null 2>&1; then log "installing NetBird" curl -fsSL --retry 3 https://pkgs.netbird.io/install.sh | sh fi svc_enable netbird if netbird status 2>/dev/null | grep -q "Management: Connected"; then log "NetBird already connected" elif [ -n "$AUTH_KEY" ]; then log "registering with NetBird" # shellcheck disable=SC2086 netbird up --setup-key "$AUTH_KEY" ${NETBIRD_MANAGEMENT_URL:+--management-url "$NETBIRD_MANAGEMENT_URL"} else log "no setup key supplied - skipping NetBird registration" log "register later with: netbird up --setup-key " fi ;; esac } get_tunnel_ip() { local ip="" if command -v tailscale >/dev/null 2>&1; then ip="$(tailscale ip -4 2>/dev/null | head -n1 || true)" fi if [ -z "$ip" ]; then ip="$(ip -4 -o addr show dev wt0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -n1 || true)" fi printf '%s' "$ip" } is_public_ipv4() { local o1 o2 IFS=. read -r o1 o2 _ _ <<<"$1" [ "$o1" = "10" ] && return 1 [ "$o1" = "127" ] && return 1 { [ "$o1" = "192" ] && [ "$o2" = "168" ]; } && return 1 { [ "$o1" = "169" ] && [ "$o2" = "254" ]; } && return 1 { [ "$o1" = "172" ] && [ "$o2" -ge 16 ] && [ "$o2" -le 31 ]; } && return 1 { [ "$o1" = "100" ] && [ "$o2" -ge 64 ] && [ "$o2" -le 127 ]; } && return 1 return 0 } has_public_ip() { local cidr while read -r _ _ _ cidr _; do is_public_ipv4 "${cidr%/*}" && return 0 done < <(ip -4 -o addr show scope global 2>/dev/null) return 1 } # First usable private IPv4 on a real interface (tunnel/docker/link-local # excluded) - the second choice in the bind priority: tunnel > private LAN get_private_lan_ip() { local ifname cidr addr while read -r _ ifname _ cidr _; do case "$ifname" in lo|docker*|br-*|veth*|tailscale*|wt*) continue ;; esac addr="${cidr%/*}" case "$addr" in 169.254.*) continue ;; esac is_public_ipv4 "$addr" || { printf '%s' "$addr"; return 0; } done < <(ip -4 -o addr show scope global 2>/dev/null) return 0 } #----------------------------------------------------------------------------- # /custom skeleton + shared edge networks #----------------------------------------------------------------------------- create_custom_tree() { log "creating ${CUSTOM_ROOT} skeleton" mkdir -p "${CUSTOM_ROOT}/scripts" "${CUSTOM_ROOT}/cron" "${CUSTOM_ROOT}/logs" "${STACKS_ROOT}" "${DOCKHAND_BACKUP_DIR}" } # Full recursive access on /custom for the docker service account (stable # ids), uid/gid 1000, and root - via POSIX ACLs so ownership of # container-managed files is never changed. Default ACLs make new content # inherit the same grants, so docker services keep working as data appears. apply_custom_permissions() { command -v setfacl >/dev/null 2>&1 || pkg_install acl >/dev/null 2>&1 || true if ! command -v setfacl >/dev/null 2>&1; then warn "setfacl unavailable - skipping ${CUSTOM_ROOT} ACLs" return 0 fi log "applying recursive ACLs on ${CUSTOM_ROOT} (docker ${DOCKER_UID}:${DOCKER_GID}, uid/gid 1000, root)" chown root:docker "${CUSTOM_ROOT}" 2>/dev/null || true chmod 0775 "${CUSTOM_ROOT}" 2>/dev/null || true setfacl -R -m "u:${DOCKER_UID}:rwX,g:${DOCKER_GID}:rwX,u:1000:rwX,g:1000:rwX" "${CUSTOM_ROOT}" \ || warn "setfacl failed (filesystem without ACL support?)" find "${CUSTOM_ROOT}" -type d -exec setfacl -m \ "d:u:${DOCKER_UID}:rwx,d:g:${DOCKER_GID}:rwx,d:u:1000:rwx,d:g:1000:rwx" {} + 2>/dev/null \ || warn "default ACLs could not be applied everywhere" } create_edge_networks() { # Host-wide shared networks. EXTERNAL is attached ONLY to the reverse proxy. # App stacks attach to EDGE-PROXY-INTERNAL (external: true) to become routable. docker network inspect EDGE-PROXY-EXTERNAL >/dev/null 2>&1 \ || { log "creating docker network EDGE-PROXY-EXTERNAL"; docker network create --driver bridge --attachable EDGE-PROXY-EXTERNAL >/dev/null; } docker network inspect EDGE-PROXY-INTERNAL >/dev/null 2>&1 \ || { log "creating docker network EDGE-PROXY-INTERNAL (internal)"; docker network create --driver bridge --attachable --internal EDGE-PROXY-INTERNAL >/dev/null; } } # Copy a directory out of an image into an empty host dir (used so the /etc/ssl # bind mount does not mask the image's CA bundle on first start). seed_dir_from_image() { local image="$1" src="$2" dest="$3" cid mkdir -p "$dest" [ -z "$(ls -A "$dest" 2>/dev/null)" ] || return 0 log "seeding ${dest} from ${image}:${src}" docker image inspect "$image" >/dev/null 2>&1 || docker pull -q "$image" >/dev/null cid="$(docker create "$image")" docker cp "${cid}:${src}/." "$dest/" >/dev/null 2>&1 || warn "could not seed ${dest} (continuing)" docker rm -f "$cid" >/dev/null } # Keep the bind address current with the priority tunnel > private LAN. # Auto-managed values (loopback/0.0.0.0 placeholders, or a LAN address now # superseded by a tunnel) are upgraded; custom values are left alone. update_env_tunnel_ip() { local envfile="$1" current [ -n "$BIND_IP" ] || return 0 current="$(grep -E '^PRIVATE_TUNNEL_BIND_ADDRESS=' "$envfile" 2>/dev/null | head -n1 | cut -d= -f2- || true)" [ -n "$current" ] || return 0 [ "$current" = "$BIND_IP" ] && return 0 if [ "$current" = "127.0.0.1" ] || [ "$current" = "0.0.0.0" ] \ || { [ -n "$TUNNEL_IP" ] && [ "$current" = "$(get_private_lan_ip)" ]; }; then log "updating PRIVATE_TUNNEL_BIND_ADDRESS ${current} -> ${BIND_IP} in ${envfile}" sed -i "s|^PRIVATE_TUNNEL_BIND_ADDRESS=.*$|PRIVATE_TUNNEL_BIND_ADDRESS=${BIND_IP}|" "$envfile" fi } #----------------------------------------------------------------------------- # Stack: stk-edge-proxy-00001 (Nginx UI) #----------------------------------------------------------------------------- write_edge_proxy_stack() { local dir="${STACKS_ROOT}/${EDGE_STACK}" mkdir -p "$dir" log "writing ${dir}/docker-compose.yml" cat > "${dir}/docker-compose.yml" <<'COMPOSE_EOF' #============================================================================= # STK-EDGE-PROXY-00001 - NginxUI Edge Reverse Proxy # https://nginxui.com | https://github.com/0xJacky/nginx-ui #============================================================================= # # CREDENTIALS: # Nginx UI admin account is created on the first dashboard visit (VPN bind). # # PRODUCTION SETUP: # 1. Browse http://:8000/ and finish the wizard. # 2. Configure ACME / Let's Encrypt. HTTP-01 challenges arrive on PUBLIC # port 80; nginx relays them internally to the Nginx UI backend # HTTPChallengePort (default 9180 - the backend cannot bind 80 because # nginx owns it). 9180 is container-internal: never publish or firewall it. # 3. Replace the image's default site (it proxies the dashboard on :80) with # real vhosts once setup is complete. # 4. Route to app containers over EDGE-PROXY-INTERNAL by container name, # e.g. proxy_pass http://DOCKHAND-APP-00001:3000; # # ENDPOINTS: # - Public HTTP : http://:80 (ACME HTTP-01 -> internal 9180 relay) # - Public HTTPS : https://:443 # - Dashboard : http://:8000 (tunnel > private LAN bind) # - Mgmt TLS : https://:10443 Nginx UI | :10444 Dockhand # | :10445 Webmin (root paths; public only as last resort) # # NETWORKS (pre-created by ParentNodeBootstrapper.sh, shared host-wide, # no stack prefix): # docker network create --driver bridge --attachable EDGE-PROXY-EXTERNAL # docker network create --driver bridge --attachable --internal EDGE-PROXY-INTERNAL # EDGE-PROXY-EXTERNAL is attached ONLY to this reverse proxy. # # NOTE: Long-form host-mode port bindings are a deliberate drift from the # stack spec, for this reverse proxy only - they keep the real client IP # while retaining internal Docker routing to upstream containers. #============================================================================= name: '${STACK_NAME:-stk-edge-proxy-00001}' networks: EXTERNAL: name: EDGE-PROXY-EXTERNAL external: true INTERNAL: name: EDGE-PROXY-INTERNAL external: true services: Proxy: image: '${NGINXUI_IMAGE:-uozi/nginx-ui}:${NGINXUI_VERSION:-latest}' container_name: EDGE-PROXY-PROXY-00001 restart: unless-stopped stop_signal: SIGTERM stop_grace_period: 30s # Must remain 0:0 - nginx master + ACME cert management require root. user: "${PUID:-0}:${PGID:-0}" logging: driver: 'local' networks: EXTERNAL: INTERNAL: extra_hosts: # Lets the proxy reach host services (Webmin on 10000 for TLS port 10445) - 'host.docker.internal:host-gateway' ports: # Public HTTP ingress + Let's Encrypt HTTP-01 challenge port. # Nginx UI answers challenges on its internal HTTPChallengePort (9180); # nginx proxies /.well-known/acme-challenge to it inside the container, # so only 80 is ever published. - target: 80 published: 80 host_ip: '${HOST_PORT_BIND_ADDRESS:-0.0.0.0}' protocol: tcp mode: host # Public HTTPS ingress - target: 443 published: 443 host_ip: '${HOST_PORT_BIND_ADDRESS:-0.0.0.0}' protocol: tcp mode: host # Nginx UI dashboard (backend listens on 9000 in-container) - VPN bind only - target: 9000 published: '${NGINXUI_DASHBOARD_PORT:-8000}' host_ip: '${PRIVATE_TUNNEL_BIND_ADDRESS:-127.0.0.1}' protocol: tcp mode: host # Management UIs at ROOT paths on dedicated TLS ports (self-signed cert, # defined in sites-available/automatic.rules.conf). The firewall opens # them on PUBLIC interfaces only when no VPN tunnel or private LAN # address exists - the last-resort access path. - target: 10443 published: '${PROXY_NGINXUI_PORT:-10443}' host_ip: '${HOST_PORT_BIND_ADDRESS:-0.0.0.0}' protocol: tcp mode: host - target: 10444 published: '${PROXY_DOCKHAND_PORT:-10444}' host_ip: '${HOST_PORT_BIND_ADDRESS:-0.0.0.0}' protocol: tcp mode: host - target: 10445 published: '${PROXY_WEBMIN_PORT:-10445}' host_ip: '${HOST_PORT_BIND_ADDRESS:-0.0.0.0}' protocol: tcp mode: host environment: TZ: '${TZ:-Etc/UTC}' volumes: - /etc/localtime:/etc/localtime:ro - '${STACK_BINDMOUNTROOT:-/custom/docker/stacks}/${STACK_NAME:-stk-edge-proxy-00001}/Proxy/etc/nginx:/etc/nginx:rw' - '${STACK_BINDMOUNTROOT:-/custom/docker/stacks}/${STACK_NAME:-stk-edge-proxy-00001}/Proxy/etc/nginx-ui:/etc/nginx-ui:rw' - '${STACK_BINDMOUNTROOT:-/custom/docker/stacks}/${STACK_NAME:-stk-edge-proxy-00001}/Proxy/var/log/nginx:/var/log/nginx:rw' - '${STACK_BINDMOUNTROOT:-/custom/docker/stacks}/${STACK_NAME:-stk-edge-proxy-00001}/Proxy/var/www:/var/www:rw' # Seeded from the image by ParentNodeBootstrapper.sh so the CA bundle is preserved - '${STACK_BINDMOUNTROOT:-/custom/docker/stacks}/${STACK_NAME:-stk-edge-proxy-00001}/Proxy/etc/ssl:/etc/ssl:rw' healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:9000/"] start_period: 30s interval: 10s retries: 5 timeout: 5s labels: # Dockhand-managed auto-updates. General services default ON; high-risk # services (databases, cache, brokers) must set their .env flag to false. dockhand.update: '${NGINXUI_ENABLEAUTOMATICUPDATES:-true}' # Optional Dockhand UI links - commented out by default # dockhand.url: 'https://:10443/' # dockhand.changelog.url: 'https://github.com/0xJacky/nginx-ui/releases' COMPOSE_EOF if [ ! -f "${dir}/.env" ]; then log "writing ${dir}/.env" cat > "${dir}/.env" < "$conf" <<'REALIP_EOF' # Restore the real client IP when requests arrive through a trusted local hop # (host gateway, private ranges, VPN/CGNAT tunnel). With host-mode published # ports the socket peer is already the true client for direct public traffic. set_real_ip_from 127.0.0.1; set_real_ip_from 10.0.0.0/8; set_real_ip_from 172.16.0.0/12; set_real_ip_from 192.168.0.0/16; set_real_ip_from 100.64.0.0/10; real_ip_header X-Forwarded-For; real_ip_recursive on; REALIP_EOF if docker exec EDGE-PROXY-PROXY-00001 nginx -t >/dev/null 2>&1; then docker exec EDGE-PROXY-PROXY-00001 nginx -s reload >/dev/null 2>&1 || true else warn "nginx -t rejected real-ip.conf - removing it" rm -f "$conf" fi } # Default HTTPS site: self-signed cert + management UIs on dedicated TLS # ports at ROOT paths (no sub-path rewriting, no app-side configuration). seed_default_sites() { local ngx="${STACKS_ROOT}/${EDGE_STACK}/Proxy/etc/nginx" local ssl="${STACKS_ROOT}/${EDGE_STACK}/Proxy/etc/ssl/selfsigned" local site="${ngx}/sites-available/automatic.rules.conf" local host_cn waited=0 while [ ! -f "${ngx}/nginx.conf" ] && [ "$waited" -lt 60 ]; do sleep 2; waited=$((waited + 2)); done [ -f "${ngx}/nginx.conf" ] || { warn "nginx config tree not materialized - re-run to seed the default site"; return 0; } # Legacy artifacts from earlier releases (sub-path era) rm -f "${ngx}/sites-enabled/vmboot-default.conf" "${ngx}/sites-available/vmboot-default.conf" rm -rf "${STACKS_ROOT}/${EDGE_STACK}/Proxy/etc/ssl/vmboot" if [ -f "$site" ] && grep -q "X-Forwarded-Prefix" "$site"; then log "replacing legacy sub-path automatic.rules.conf with port-based version" rm -f "$site" fi if [ ! -f "${ssl}/selfsigned.crt" ] || [ ! -f "${ssl}/selfsigned.key" ]; then host_cn="$(hostname -f 2>/dev/null || hostname)" log "generating self-signed certificate for ${host_cn} (${ssl})" mkdir -p "$ssl" openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \ -subj "/CN=${host_cn}" \ -addext "subjectAltName=DNS:${host_cn},DNS:localhost,IP:127.0.0.1" \ -keyout "${ssl}/selfsigned.key" -out "${ssl}/selfsigned.crt" >/dev/null 2>&1 chmod 600 "${ssl}/selfsigned.key" fi mkdir -p "${ngx}/sites-available" "${ngx}/sites-enabled" if [ ! -f "$site" ]; then log "seeding default HTTPS site (TLS ports 10443/10444/10445 at root)" cat > "$site" <<'SITE_EOF' # automatic.rules.conf - generated by ParentNodeBootstrapper.sh # Management UIs on dedicated TLS ports at ROOT paths - no sub-path # rewriting and no app-side base-path configuration - using the generated # self-signed certificate: # https://:10443/ -> Nginx UI backend (in-container 127.0.0.1:9000) # https://:10444/ -> DOCKHAND-APP-00001:3000 over EDGE-PROXY-INTERNAL # https://:10445/ -> host Webmin :10000 (host.docker.internal) # The firewall opens these on PUBLIC interfaces only when the host has # neither a VPN tunnel nor a private LAN address (last-resort access path). # Port 443 stays free for real vhosts - manage them via Nginx UI. # Baseline 443 catch-all: TLS connections without a configured vhost fail # cleanly instead of hitting a missing listener. server { listen 443 ssl default_server; server_name _; ssl_certificate /etc/ssl/selfsigned/selfsigned.crt; ssl_certificate_key /etc/ssl/selfsigned/selfsigned.key; return 404; } # Nginx UI server { listen 10443 ssl; server_name _; ssl_certificate /etc/ssl/selfsigned/selfsigned.crt; ssl_certificate_key /etc/ssl/selfsigned/selfsigned.key; location / { proxy_pass http://127.0.0.1:9000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } } # Dockhand server { listen 10444 ssl; server_name _; ssl_certificate /etc/ssl/selfsigned/selfsigned.crt; ssl_certificate_key /etc/ssl/selfsigned/selfsigned.key; # Docker's embedded DNS; deferred resolution keeps nginx serving even # while the Dockhand container restarts resolver 127.0.0.11 valid=30s ipv6=off; location / { set $dockhand_upstream DOCKHAND-APP-00001:3000; proxy_pass http://$dockhand_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } } # Webmin (host service reached via extra_hosts host-gateway mapping) server { listen 10445 ssl; server_name _; ssl_certificate /etc/ssl/selfsigned/selfsigned.crt; ssl_certificate_key /etc/ssl/selfsigned/selfsigned.key; location / { proxy_pass https://host.docker.internal:10000; proxy_ssl_verify off; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } } SITE_EOF fi ln -sf /etc/nginx/sites-available/automatic.rules.conf "${ngx}/sites-enabled/automatic.rules.conf" grep -rq "sites-enabled" "${ngx}/nginx.conf" 2>/dev/null \ || warn "nginx.conf does not include sites-enabled - the default site will not load" if docker exec EDGE-PROXY-PROXY-00001 nginx -t >/dev/null 2>&1; then docker exec EDGE-PROXY-PROXY-00001 nginx -s reload >/dev/null 2>&1 || true else warn "nginx -t failed with automatic.rules.conf - disabling it" rm -f "${ngx}/sites-enabled/automatic.rules.conf" fi } #----------------------------------------------------------------------------- # Stack: stk-dockhand-00001 (Dockhand) #----------------------------------------------------------------------------- write_dockhand_stack() { local dir="${STACKS_ROOT}/${DOCKHAND_STACK}" mkdir -p "$dir" "${dir}/App/Data" log "writing ${dir}/docker-compose.yml" cat > "${dir}/docker-compose.yml" <<'COMPOSE_EOF' #============================================================================= # STK-DOCKHAND-00001 - Dockhand Docker Management (+ Hawser fleet control) # https://dockhand.pro #============================================================================= # # CREDENTIALS: # Dockhand admin account is created on the first dashboard visit (VPN bind). # # PRODUCTION SETUP: # 1. Browse http://:13000/ and create the admin account. # 2. Enable backups: Settings -> Backups -> destination "Local" -> /backups # (bind-mounted from /custom/backups/dockhand) and set a schedule. # 3. Add branch nodes: Settings -> Environments -> Hawser Standard / Edge. # 4. Adopt the bootstrap stacks: Stacks -> Import, browse to # /custom/docker/stacks/stk-*/docker-compose.yml (visible in-container # via the same-path mount; files stay in place after import). # # ENDPOINTS: # - Dashboard: http://:13000 | https://:10444 (proxy TLS) #============================================================================= name: '${STACK_NAME:-stk-dockhand-00001}' networks: # Stack-local bridge for outbound traffic (image pulls, S3 backup targets) default: driver: bridge # Shared edge backplane - lets the reverse proxy publish this dashboard: # proxy_pass http://DOCKHAND-APP-00001:3000; INTERNAL: name: EDGE-PROXY-INTERNAL external: true services: App: image: '${DOCKHAND_IMAGE:-fnsys/dockhand}:${DOCKHAND_VERSION:-latest}' container_name: DOCKHAND-APP-00001 restart: unless-stopped stop_signal: SIGTERM stop_grace_period: 30s # Must remain 0:0 - requires /var/run/docker.sock access. user: "${PUID:-0}:${PGID:-0}" logging: driver: 'local' networks: default: INTERNAL: ports: # Dashboard - VPN bind only, never published publicly - '${PRIVATE_TUNNEL_BIND_ADDRESS:-127.0.0.1}:${DOCKHAND_PORT:-13000}:3000' environment: TZ: '${TZ:-Etc/UTC}' volumes: - /etc/localtime:/etc/localtime:ro - /var/run/docker.sock:/var/run/docker.sock - '${STACK_BINDMOUNTROOT:-/custom/docker/stacks}/${STACK_NAME:-stk-dockhand-00001}/App/Data:/app/data:rw' # Same-path stacks mount: compose paths Dockhand manages resolve # identically inside and outside the container - '${STACK_BINDMOUNTROOT:-/custom/docker/stacks}:${STACK_BINDMOUNTROOT:-/custom/docker/stacks}:rw' # Local backup destination (enable under Settings -> Backups) - '${DOCKHAND_BACKUP_DIR:-/custom/backups/dockhand}:/backups:rw' # No healthcheck: the upstream image ships no documented in-container probe. labels: # Dockhand-managed auto-updates (schedule is configured in Dockhand: # Settings -> Updates). High-risk services (databases, cache, brokers) # must set their .env flag to false. dockhand.update: '${DOCKHAND_ENABLEAUTOMATICUPDATES:-true}' # Optional Dockhand UI links - commented out by default # dockhand.url: 'https://:10444/' # dockhand.changelog.url: 'https://github.com/Finsys/dockhand/releases' COMPOSE_EOF if [ ! -f "${dir}/.env" ]; then log "writing ${dir}/.env" cat > "${dir}/.env" < Backups -> Local destination /backups + schedule (ENABLE). # 3. Settings -> Environments -> add branch nodes (Hawser Standard/Edge). # 4. Adopt the bootstrap stacks: Stacks -> Import, then browse to # /custom/docker/stacks/stk-*/docker-compose.yml (same-path mount). # #============================================================================= # Stack Identity STACK_NAME=${DOCKHAND_STACK} STACK_BINDMOUNTROOT=${STACKS_ROOT} # Container User/Group (must stay 0 - docker.sock access) PUID=0 PGID=0 # Timezone TZ=${TZ_VALUE} # Images DOCKHAND_IMAGE=fnsys/dockhand DOCKHAND_VERSION=latest # Ports / Bind Addresses PRIVATE_TUNNEL_BIND_ADDRESS=${TUNNEL_IP:-127.0.0.1} DOCKHAND_PORT=13000 # Backups (local destination bind-mounted to /backups in-container) DOCKHAND_BACKUP_DIR=${DOCKHAND_BACKUP_DIR} # Automatic Updates (Dockhand label dockhand.update; high-risk services such # as databases/cache must stay false) DOCKHAND_ENABLEAUTOMATICUPDATES=true ENV_EOF else log "${dir}/.env exists - preserving user configuration" update_env_tunnel_ip "${dir}/.env" fi } deploy_stack() { local dir="$1" log "deploying $(basename "$dir")" ( cd "$dir" \ && { docker compose pull --quiet || warn "image pull failed - using local images if present"; } \ && docker compose up -d --remove-orphans ) } #----------------------------------------------------------------------------- # Weekly maintenance: OS patches Sat 20:00-21:59, reboot Sun 22:00-23:59. # Times are shuf-randomized on first run and preserved afterwards. #----------------------------------------------------------------------------- configure_maintenance_cron() { local cron_dir="${CUSTOM_ROOT}/scripts/cron" mkdir -p "$cron_dir" rm -f "${CUSTOM_ROOT}/scripts/vmboot-patch.sh" # legacy name/location cat > "${cron_dir}/Invoke-AutomaticPatchInstallation.sh" <<'PATCH_EOF' #!/usr/bin/env bash # Invoke-AutomaticPatchInstallation.sh - generated by Linux-VM-Bootstrapper # Silent weekly OS patching; logs to /custom/logs (3 most recent kept). set -euo pipefail LOG_DIR="/custom/logs" mkdir -p "$LOG_DIR" LOG_FILE="${LOG_DIR}/Invoke-AutomaticPatchInstallation-$(date +%Y%m%d-%H%M%S).log" exec >>"$LOG_FILE" 2>&1 ls -1t "${LOG_DIR}"/Invoke-AutomaticPatchInstallation-*.log 2>/dev/null | tail -n +4 | xargs -r rm -f -- echo "=== patch run started $(date -Is) ===" if command -v apt-get >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq apt-get dist-upgrade -y -qq \ -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" apt-get autoremove -y -qq elif command -v dnf >/dev/null 2>&1; then dnf -y -q upgrade dnf -y -q autoremove || true else yum -y -q update fi echo "=== patch run finished $(date -Is) ===" PATCH_EOF chmod 0755 "${cron_dir}/Invoke-AutomaticPatchInstallation.sh" cat > "${cron_dir}/Invoke-AutomaticReboot.sh" <<'REBOOT_EOF' #!/usr/bin/env bash # Invoke-AutomaticReboot.sh - generated by Linux-VM-Bootstrapper # Weekly maintenance reboot; logs to /custom/logs (3 most recent kept). set -euo pipefail LOG_DIR="/custom/logs" mkdir -p "$LOG_DIR" LOG_FILE="${LOG_DIR}/Invoke-AutomaticReboot-$(date +%Y%m%d-%H%M%S).log" exec >>"$LOG_FILE" 2>&1 ls -1t "${LOG_DIR}"/Invoke-AutomaticReboot-*.log 2>/dev/null | tail -n +4 | xargs -r rm -f -- echo "=== maintenance reboot at $(date -Is) ===" shutdown -r now "automatic weekly maintenance reboot" REBOOT_EOF chmod 0755 "${cron_dir}/Invoke-AutomaticReboot.sh" # Randomize once; re-runs keep the existing schedule and refresh the commands local patch_min="" patch_hour="" reboot_min="" reboot_hour="" if [ -f /etc/cron.d/vmboot-maintenance ]; then patch_min="$(awk '$5=="6" && $6=="root" {print $1; exit}' /etc/cron.d/vmboot-maintenance)" patch_hour="$(awk '$5=="6" && $6=="root" {print $2; exit}' /etc/cron.d/vmboot-maintenance)" reboot_min="$(awk '$5=="0" && $6=="root" {print $1; exit}' /etc/cron.d/vmboot-maintenance)" reboot_hour="$(awk '$5=="0" && $6=="root" {print $2; exit}' /etc/cron.d/vmboot-maintenance)" fi if ! [[ "$patch_min" =~ ^[0-9]+$ && "$patch_hour" =~ ^[0-9]+$ && "$reboot_min" =~ ^[0-9]+$ && "$reboot_hour" =~ ^[0-9]+$ ]]; then patch_min="$(shuf -i 0-59 -n 1)"; patch_hour="$(shuf -i 20-21 -n 1)" reboot_min="$(shuf -i 0-59 -n 1)"; reboot_hour="$(shuf -i 22-23 -n 1)" fi log "maintenance schedule: patch Sat ${patch_hour}:$(printf '%02d' "$patch_min"), reboot Sun ${reboot_hour}:$(printf '%02d' "$reboot_min")" cat > /etc/cron.d/vmboot-maintenance </dev/null || true } #----------------------------------------------------------------------------- # Webmin console (tcp/10000; the firewall keeps it off public interfaces) #----------------------------------------------------------------------------- install_webmin() { if dpkg -s webmin >/dev/null 2>&1 || rpm -q webmin >/dev/null 2>&1; then log "Webmin already installed" else log "installing Webmin" local tmp; tmp="$(mktemp)" if curl -fsSL --retry 3 -o "$tmp" https://raw.githubusercontent.com/webmin/webmin/master/webmin-setup-repo.sh; then sh "$tmp" -f --stable >/dev/null 2>&1 || warn "webmin repo setup reported an error" rm -f "$tmp" pkg_install webmin || warn "Webmin install failed" else rm -f "$tmp" warn "could not download webmin-setup-repo.sh - skipping Webmin" fi fi svc_enable webmin # Undo the webprefix configuration from earlier releases - the proxy now # serves Webmin at root on a dedicated TLS port, no app-side config needed if [ -f /etc/webmin/config ] && grep -q '^webprefix=/webmin$' /etc/webmin/config; then log "removing legacy Webmin webprefix configuration" sed -i '/^webprefix=/d;/^webprefixnoredir=/d' /etc/webmin/config systemctl restart webmin >/dev/null 2>&1 || true fi } #----------------------------------------------------------------------------- # fail2ban: SSH brute-force protection, engaged only alongside the firewall # (public IP present, or --firewall=on). Restarted after the firewall applies # so its ban chains sit above EDGE-PROXY-INPUT. #----------------------------------------------------------------------------- install_fail2ban() { if [ "$FIREWALL" = "off" ]; then log "fail2ban: skipped (firewall off)"; return 0; fi if [ "$FIREWALL" != "on" ] && ! has_public_ip; then log "fail2ban: skipped (no public IP)"; return 0; fi if [ "$OS_FAMILY" = "debian" ]; then pkg_install fail2ban || { warn "fail2ban install failed"; return 0; } else rpm -q epel-release >/dev/null 2>&1 || pkg_install epel-release >/dev/null 2>&1 \ || "$PKG" -y -q install "https://dl.fedoraproject.org/pub/epel/epel-release-latest-${OS_VERSION%%.*}.noarch.rpm" >/dev/null 2>&1 || true pkg_install fail2ban || { warn "fail2ban install failed (EPEL unavailable?)"; return 0; } fi log "configuring fail2ban sshd jail (port ${SSH_PORT})" mkdir -p /etc/fail2ban/jail.d cat > /etc/fail2ban/jail.d/vmboot-sshd.local </dev/null 2>&1 || true } #----------------------------------------------------------------------------- # Firewall: raw iptables (never ufw/firewalld), Docker chains untouched. # Persisted via a generated boot script + systemd unit that re-detects the # public interface each boot, so rules survive reboots AND docker restarts. #----------------------------------------------------------------------------- configure_firewall() { if [ "$FIREWALL" = "off" ]; then log "firewall: disabled by request"; return 0; fi # Neutralize conflicting frontends - the user mandate is raw iptables only. if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then warn "disabling ufw (raw iptables is used instead)" ufw --force disable >/dev/null 2>&1 || true fi if command -v systemctl >/dev/null 2>&1 && systemctl is-active firewalld >/dev/null 2>&1; then warn "disabling firewalld (raw iptables is used instead)" systemctl disable --now firewalld >/dev/null 2>&1 || true fi if [ "$OS_FAMILY" = "debian" ]; then pkg_install iptables >/dev/null 2>&1 || true else pkg_install iptables-nft >/dev/null 2>&1 || pkg_install iptables >/dev/null 2>&1 || true; fi log "installing /usr/local/sbin/vmboot-firewall.sh (mode: ${FIREWALL})" cat > /usr/local/sbin/vmboot-firewall.sh <<'FW_EOF' #!/usr/bin/env bash #============================================================================= # vmboot-firewall.sh - generated by Linux-VM-Bootstrapper (idempotent) # # Locks down interfaces carrying a PUBLIC IPv4/IPv6 using raw iptables only. # - Manages ONLY the EDGE-PROXY-* firewall chains; Docker chains are never # flushed, so Docker networking keeps working regardless of run order. # - Host traffic : EDGE-PROXY-INPUT (jumped from INPUT) # - Container traffic: EDGE-PROXY-DOCKER-INPUT (jumped from DOCKER-USER; matches on # the pre-DNAT destination port via conntrack --ctorigdstport) # - Re-run any time; runs at boot via vmboot-firewall.service. #============================================================================= set -euo pipefail FW_MODE="@@FW_MODE@@" # auto | force SSH_PORT="@@SSH_PORT@@" ALLOW_SSH="@@ALLOW_SSH@@" PUBLIC_TCP_PORTS="@@PUBLIC_TCP_PORTS@@" # host+container ports kept public MGMT_TCP_PORTS="@@MGMT_TCP_PORTS@@" # mgmt TLS ports - public only as last resort VPN_UDP_PORTS="41641 51820" # tailscale / netbird direct paths is_public_ipv4() { local o1 o2 IFS=. read -r o1 o2 _ _ <<<"$1" [ "$o1" = "10" ] && return 1 [ "$o1" = "127" ] && return 1 { [ "$o1" = "192" ] && [ "$o2" = "168" ]; } && return 1 { [ "$o1" = "169" ] && [ "$o2" = "254" ]; } && return 1 { [ "$o1" = "172" ] && [ "$o2" -ge 16 ] && [ "$o2" -le 31 ]; } && return 1 { [ "$o1" = "100" ] && [ "$o2" -ge 64 ] && [ "$o2" -le 127 ]; } && return 1 return 0 } # A private access path exists when the VPN tunnel is up or a real interface # carries a private LAN address (docker bridges/veths and link-local excluded) has_private_path() { local ifname cidr addr while read -r _ ifname _ cidr _; do case "$ifname" in docker*|br-*|veth*) continue ;; esac addr="${cidr%/*}" case "$addr" in 169.254.*) continue ;; esac is_public_ipv4 "$addr" || return 0 done < <(ip -4 -o addr show scope global 2>/dev/null) return 1 } # Interfaces holding at least one public IPv4 PUB_IFS="" while read -r _ ifname _ cidr _; do ip="${cidr%/*}" if is_public_ipv4 "$ip"; then case " $PUB_IFS " in *" $ifname "*) ;; *) PUB_IFS="$PUB_IFS $ifname" ;; esac fi done < <(ip -4 -o addr show scope global 2>/dev/null) PUB_IFS="${PUB_IFS# }" if [ -z "$PUB_IFS" ] && [ "$FW_MODE" = "force" ]; then PUB_IFS="$(ip -4 route show default 2>/dev/null | awk '{print $5; exit}')" fi flush_chain() { # $1=iptables cmd $2=parent $3=chain "$1" -N "$3" 2>/dev/null || "$1" -F "$3" "$1" -C "$2" -j "$3" 2>/dev/null || "$1" -I "$2" 1 -j "$3" } # Remove chains left behind by pre-rename releases for legacy in VMBOOT-INPUT VMBOOT-DOCKER-USER; do iptables -D INPUT -j "$legacy" 2>/dev/null || true iptables -D DOCKER-USER -j "$legacy" 2>/dev/null || true iptables -F "$legacy" 2>/dev/null || true iptables -X "$legacy" 2>/dev/null || true done if command -v ip6tables >/dev/null 2>&1; then ip6tables -D INPUT -j VMBOOT-INPUT 2>/dev/null || true ip6tables -F VMBOOT-INPUT 2>/dev/null || true ip6tables -X VMBOOT-INPUT 2>/dev/null || true fi if [ -z "$PUB_IFS" ]; then echo "vmboot-firewall: no public IP on any interface - clearing EDGE-PROXY firewall chains" for c in EDGE-PROXY-INPUT EDGE-PROXY-DOCKER-INPUT; do iptables -F "$c" 2>/dev/null || true; done ip6tables -F EDGE-PROXY-INPUT 2>/dev/null || true exit 0 fi echo "vmboot-firewall: policing public interface(s): $PUB_IFS" # Bind priority: tunnel > private LAN > proxy TLS ports on the public address. # The management ports open publicly ONLY when no private path exists. MGMT_OPEN="no" if [ -n "$MGMT_TCP_PORTS" ] && ! has_private_path; then MGMT_OPEN="yes" echo "vmboot-firewall: no tunnel/private path - management TLS ports ($MGMT_TCP_PORTS) open on public interface(s)" fi #--- IPv4: host traffic ------------------------------------------------------ flush_chain iptables INPUT EDGE-PROXY-INPUT iptables -A EDGE-PROXY-INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT iptables -A EDGE-PROXY-INPUT -m conntrack --ctstate INVALID -j DROP for ifname in $PUB_IFS; do iptables -A EDGE-PROXY-INPUT -i "$ifname" -p icmp -j ACCEPT [ "$ALLOW_SSH" = "true" ] && iptables -A EDGE-PROXY-INPUT -i "$ifname" -p tcp --dport "$SSH_PORT" -j ACCEPT for p in $PUBLIC_TCP_PORTS; do iptables -A EDGE-PROXY-INPUT -i "$ifname" -p tcp --dport "$p" -j ACCEPT done if [ "$MGMT_OPEN" = "yes" ]; then for p in $MGMT_TCP_PORTS; do iptables -A EDGE-PROXY-INPUT -i "$ifname" -p tcp --dport "$p" -j ACCEPT done fi for u in $VPN_UDP_PORTS; do iptables -A EDGE-PROXY-INPUT -i "$ifname" -p udp --dport "$u" -j ACCEPT done iptables -A EDGE-PROXY-INPUT -i "$ifname" -j DROP done #--- IPv4: container (forwarded/DNAT) traffic -------------------------------- # DOCKER-USER is honored by Docker; create it if Docker has not started yet - # Docker adopts an existing chain without flushing it. iptables -N DOCKER-USER 2>/dev/null || true flush_chain iptables DOCKER-USER EDGE-PROXY-DOCKER-INPUT iptables -A EDGE-PROXY-DOCKER-INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT for ifname in $PUB_IFS; do for p in $PUBLIC_TCP_PORTS; do iptables -A EDGE-PROXY-DOCKER-INPUT -i "$ifname" -p tcp \ -m conntrack --ctdir ORIGINAL --ctorigdstport "$p" -j ACCEPT done if [ "$MGMT_OPEN" = "yes" ]; then for p in $MGMT_TCP_PORTS; do iptables -A EDGE-PROXY-DOCKER-INPUT -i "$ifname" -p tcp \ -m conntrack --ctdir ORIGINAL --ctorigdstport "$p" -j ACCEPT done fi iptables -A EDGE-PROXY-DOCKER-INPUT -i "$ifname" -j DROP done #--- IPv6: host traffic (mirror; NDP/DHCPv6 must stay open) ------------------ if command -v ip6tables >/dev/null 2>&1; then flush_chain ip6tables INPUT EDGE-PROXY-INPUT ip6tables -A EDGE-PROXY-INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT ip6tables -A EDGE-PROXY-INPUT -m conntrack --ctstate INVALID -j DROP ip6tables -A EDGE-PROXY-INPUT -p ipv6-icmp -j ACCEPT ip6tables -A EDGE-PROXY-INPUT -p udp --dport 546 -j ACCEPT for ifname in $PUB_IFS; do [ "$ALLOW_SSH" = "true" ] && ip6tables -A EDGE-PROXY-INPUT -i "$ifname" -p tcp --dport "$SSH_PORT" -j ACCEPT for p in $PUBLIC_TCP_PORTS; do ip6tables -A EDGE-PROXY-INPUT -i "$ifname" -p tcp --dport "$p" -j ACCEPT done if [ "$MGMT_OPEN" = "yes" ]; then for p in $MGMT_TCP_PORTS; do ip6tables -A EDGE-PROXY-INPUT -i "$ifname" -p tcp --dport "$p" -j ACCEPT done fi for u in $VPN_UDP_PORTS; do ip6tables -A EDGE-PROXY-INPUT -i "$ifname" -p udp --dport "$u" -j ACCEPT done ip6tables -A EDGE-PROXY-INPUT -i "$ifname" -j DROP done fi echo "vmboot-firewall: rules applied" FW_EOF sed -i \ -e "s|@@FW_MODE@@|$([ "$FIREWALL" = "on" ] && echo force || echo auto)|" \ -e "s|@@SSH_PORT@@|${SSH_PORT}|" \ -e "s|@@ALLOW_SSH@@|${FIREWALL_ALLOW_SSH}|" \ -e "s|@@PUBLIC_TCP_PORTS@@|80 443|" \ -e "s|@@MGMT_TCP_PORTS@@|10443 10444 10445|" \ /usr/local/sbin/vmboot-firewall.sh chmod 0755 /usr/local/sbin/vmboot-firewall.sh cat > /etc/systemd/system/vmboot-firewall.service <<'UNIT_EOF' [Unit] Description=Linux-VM-Bootstrapper iptables firewall (Docker-safe) After=network-online.target Wants=network-online.target # fail2ban starts after so its ban chains insert above EDGE-PROXY-INPUT Before=fail2ban.service [Service] Type=oneshot ExecStart=/usr/local/sbin/vmboot-firewall.sh RemainAfterExit=yes [Install] WantedBy=multi-user.target UNIT_EOF if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload systemctl enable vmboot-firewall.service >/dev/null 2>&1 || true fi /usr/local/sbin/vmboot-firewall.sh } #----------------------------------------------------------------------------- # Main #----------------------------------------------------------------------------- log "=== Linux-VM-Bootstrapper: PARENT (edge) node ===" update_system install_base_packages install_powershell ensure_docker_identity install_docker install_vpn TUNNEL_IP="$(get_tunnel_ip)" BIND_IP="${TUNNEL_IP:-$(get_private_lan_ip)}" if [ -n "$TUNNEL_IP" ]; then log "bind address: ${TUNNEL_IP} (VPN tunnel)" elif [ -n "$BIND_IP" ]; then log "bind address: ${BIND_IP} (private LAN - upgraded to the tunnel IP on re-run after VPN registration)" else warn "no tunnel or private address - dashboards bind to 127.0.0.1; management TLS ports 10443-10445 stay open on the public address as the fallback path" fi create_custom_tree apply_custom_permissions create_edge_networks write_edge_proxy_stack write_dockhand_stack deploy_stack "${STACKS_ROOT}/${EDGE_STACK}" seed_real_ip_conf deploy_stack "${STACKS_ROOT}/${DOCKHAND_STACK}" seed_default_sites configure_maintenance_cron install_webmin configure_firewall install_fail2ban DASH_IP="${BIND_IP:-127.0.0.1}" log "============================================================" log " Parent node bootstrap complete" log "------------------------------------------------------------" log " Bind address : ${DASH_IP} (priority: tunnel > private LAN > loopback)" log " Nginx UI : http://${DASH_IP}:8000/ or https://${DASH_IP}:10443/" log " Dockhand : http://${DASH_IP}:13000/ or https://${DASH_IP}:10444/" log " Webmin : https://${DASH_IP}:10000/ or https://${DASH_IP}:10445/" log " Public fallback : TLS ports 10443-10445 open on the public address" log " ONLY when no tunnel/private path exists" log " Public ingress : 80/tcp + 443/tcp (ACME HTTP-01 on 80, relayed" log " in-container to Nginx UI HTTPChallengePort 9180)" log " Stacks : ${STACKS_ROOT}/${EDGE_STACK}" log " ${STACKS_ROOT}/${DOCKHAND_STACK}" log " Dockhand adoption : Stacks -> Import -> /custom/docker/stacks/stk-*/" log " Dockhand backups : enable in Settings -> Backups -> Local -> /backups" log " (host path: ${DOCKHAND_BACKUP_DIR})" log " Auto-updates : dockhand.update labels (schedule: Dockhand Settings)" log " Maintenance : patch Sat $(cron_time Invoke-AutomaticPatchInstallation) reboot Sun $(cron_time Invoke-AutomaticReboot)" log " Custom skeleton : ${CUSTOM_ROOT}/{scripts,cron,logs,docker/stacks,backups}" log " Run log : ${LOG_FILE} (last 3 runs kept)" log "============================================================"