Files
Linux-VM-Bootstrapper/ParentNodeBootstrapper.sh
gsadmin 0873197b43 Add gitignore, stable docker service account, and recursive /custom ACLs
- .gitignore for IDE/OS files; Linux-VM-Bootstrapper.code-workspace untracked
  (stays local only)
- docker user + docker group with stable ids (default 2000:2000, overridable
  via --docker-uid/--docker-gid or DOCKER_UID/DOCKER_GID). Group is ensured
  before Docker installs so the package adopts it; an existing group is
  renumbered with a docker restart. Collisions with foreign uid/gid owners
  are detected and left alone with a warning.
- /custom gets recursive POSIX ACLs (u/g docker, u/g 1000, root implicit)
  plus default ACLs on directories so new stack data inherits the grants;
  ownership of container-managed files is never changed. acl package added
  to the base toolset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:19:53 -04:00

1392 lines
60 KiB
Bash

#!/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://<raw-url>/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 <raw-url>/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=<key> --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 <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://<PRIVATE_TUNNEL_BIND_ADDRESS>: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://<host>:80 (ACME HTTP-01 -> internal 9180 relay)
# - Public HTTPS : https://<host>:443
# - Dashboard : http://<bind-ip>:8000 (tunnel > private LAN bind)
# - Mgmt TLS : https://<host>: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://<host>: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" <<ENV_EOF
#=============================================================================
# STK-EDGE-PROXY-00001 - NginxUI Edge Reverse Proxy
#=============================================================================
#
# CREDENTIALS:
# Nginx UI admin is created on the first dashboard visit (VPN-bound port).
#
# ACCESS URL: http://${BIND_IP:-127.0.0.1}:8000/ | https://${BIND_IP:-127.0.0.1}:10443/
#
# PRODUCTION SETUP:
# 1. Finish the Nginx UI wizard, then configure ACME / Let's Encrypt.
# 2. Point vhosts at app containers on EDGE-PROXY-INTERNAL by container name.
#
#=============================================================================
# Stack Identity
STACK_NAME=${EDGE_STACK}
STACK_BINDMOUNTROOT=${STACKS_ROOT}
# Container User/Group (must stay 0 - nginx + ACME need root)
PUID=0
PGID=0
# Timezone
TZ=${TZ_VALUE}
# Images
NGINXUI_IMAGE=uozi/nginx-ui
NGINXUI_VERSION=latest
# Ports / Bind Addresses
HOST_PORT_BIND_ADDRESS=${HOST_PORT_BIND_ADDRESS}
PRIVATE_TUNNEL_BIND_ADDRESS=${BIND_IP:-127.0.0.1}
NGINXUI_DASHBOARD_PORT=8000
# Management TLS ports (root-path proxy access; automatic.rules.conf)
PROXY_NGINXUI_PORT=10443
PROXY_DOCKHAND_PORT=10444
PROXY_WEBMIN_PORT=10445
# Automatic Updates (Dockhand label dockhand.update; high-risk services such
# as databases/cache must stay false)
NGINXUI_ENABLEAUTOMATICUPDATES=true
ENV_EOF
else
log "${dir}/.env exists - preserving user configuration"
update_env_tunnel_ip "${dir}/.env"
fi
# Prevent the /etc/ssl bind mount from masking the image's CA certificates.
local image
image="$(grep -E '^NGINXUI_IMAGE=' "${dir}/.env" | cut -d= -f2- || true)"
local version
version="$(grep -E '^NGINXUI_VERSION=' "${dir}/.env" | cut -d= -f2- || true)"
seed_dir_from_image "${image:-uozi/nginx-ui}:${version:-latest}" /etc/ssl "${dir}/Proxy/etc/ssl"
}
# Drop the real-IP restoration snippet into conf.d once nginx has materialized
# its config tree on the host (best effort - never fatal).
seed_real_ip_conf() {
local dir="${STACKS_ROOT}/${EDGE_STACK}/Proxy/etc/nginx"
local conf="${dir}/conf.d/real-ip.conf"
local waited=0
while [ ! -f "${dir}/nginx.conf" ] && [ "$waited" -lt 60 ]; do sleep 2; waited=$((waited + 2)); done
[ -f "${dir}/nginx.conf" ] || { warn "nginx config tree not materialized yet - re-run to seed real-ip.conf"; return 0; }
[ -f "$conf" ] && return 0
log "seeding ${conf}"
mkdir -p "${dir}/conf.d"
cat > "$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://<host>:10443/ -> Nginx UI backend (in-container 127.0.0.1:9000)
# https://<host>:10444/ -> DOCKHAND-APP-00001:3000 over EDGE-PROXY-INTERNAL
# https://<host>: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://<bind-ip>: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://<bind-ip>:13000 | https://<host>: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://<host>:10444/'
# dockhand.changelog.url: 'https://github.com/Finsys/dockhand/releases'
COMPOSE_EOF
if [ ! -f "${dir}/.env" ]; then
log "writing ${dir}/.env"
cat > "${dir}/.env" <<ENV_EOF
#=============================================================================
# STK-DOCKHAND-00001 - Dockhand Docker Management
#=============================================================================
#
# CREDENTIALS:
# Dockhand admin is created on the first dashboard visit (VPN-bound port).
#
# ACCESS URL: http://${BIND_IP:-127.0.0.1}:13000/ | https://${BIND_IP:-127.0.0.1}:10444/
#
# PRODUCTION SETUP:
# 1. Create the admin account.
# 2. Settings -> 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 <<CRON_EOF
# generated by Linux-VM-Bootstrapper - weekly maintenance window
# Patch: Saturday ${patch_hour}:$(printf '%02d' "$patch_min") | Reboot: Sunday ${reboot_hour}:$(printf '%02d' "$reboot_min")
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
${patch_min} ${patch_hour} * * 6 root ${cron_dir}/Invoke-AutomaticPatchInstallation.sh
${reboot_min} ${reboot_hour} * * 0 root ${cron_dir}/Invoke-AutomaticReboot.sh
CRON_EOF
chmod 0644 /etc/cron.d/vmboot-maintenance
}
# Reads a scheduled time back out of the cron file for the summary
cron_time() {
awk -v pat="$1" '$0 ~ pat && $1 ~ /^[0-9]+$/ {printf "%02d:%02d", $2, $1; exit}' \
/etc/cron.d/vmboot-maintenance 2>/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 <<JAIL_EOF
# generated by Linux-VM-Bootstrapper - SSH brute-force protection
[sshd]
enabled = true
port = ${SSH_PORT}
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
JAIL_EOF
svc_enable fail2ban
systemctl restart fail2ban >/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 "============================================================"