4e10e58d58
URL-executable, idempotent bootstrap for Docker VMs (Ubuntu/Debian/RHEL): - ParentNodeBootstrapper.sh: base tooling, Docker+Compose v2, Tailscale or NetBird (key-gated registration), EDGE-PROXY-EXTERNAL/INTERNAL networks, Nginx UI edge proxy (host-mode 80/443, VPN-bound dashboard, ACME relay to HTTPChallengePort 9180), Dockhand with provisioned backup destination, default HTTPS routes (/nginxui/, /dockhand/) behind a generated self-signed cert, Webmin, iptables VMBOOT-* firewall, fail2ban on public hosts, weekly shuf-randomized patch/reboot cron, /custom skeleton with run logs (keep 3) - ChildNodeBootstrapper.sh: same baseline plus Hawser agent (standard/edge mode) and Webmin agent role; no public service ports - Auto-updates via Dockhand labels (dockhand.update; URL labels commented) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
903 lines
38 KiB
Bash
903 lines
38 KiB
Bash
#!/usr/bin/env bash
|
|
#=============================================================================
|
|
# Linux-VM-Bootstrapper - Child Node Bootstrapper (ChildNodeBootstrapper.sh)
|
|
#=============================================================================
|
|
#
|
|
# Idempotent, silent bootstrap for a CHILD (branch) node managed by a parent
|
|
# node's Dockhand instance via the Hawser agent. Safe to re-run at any time.
|
|
#
|
|
# - 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)
|
|
# - stk-hawser-00001 (ghcr.io/finsys/hawser) in Standard or Edge mode
|
|
# - Webmin (tcp/10000; blocked on public interfaces by the firewall) as the
|
|
# managed agent - register it from the parent's Webmin Servers Index
|
|
# - iptables lockdown (NOT ufw/firewalld) when a public IP sits directly on
|
|
# an interface - no public service ports are opened on a child node
|
|
# - 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>/ChildNodeBootstrapper.sh | sudo -E bash -s -- [options]
|
|
#
|
|
# HAWSER MODES:
|
|
# standard (default) - agent listens on ${HAWSER_PORT} (VPN/LAN bind); add it
|
|
# in Dockhand as "Hawser Standard" with host + token
|
|
# edge - agent dials OUT to Dockhand over WebSocket; no inbound
|
|
# port; requires --dockhand-url and the token generated
|
|
# by Dockhand (Settings -> Environments -> Hawser Edge)
|
|
#
|
|
# OPTIONS (flags win over environment variables):
|
|
# --mode=standard|edge HAWSER_MODE (default: auto - edge when a dockhand URL is set)
|
|
# --token=TOKEN HAWSER_TOKEN (standard: generated if omitted; edge: REQUIRED)
|
|
# --dockhand-url=URL DOCKHAND_SERVER_URL https://... is auto-converted to wss://.../api/hawser/connect
|
|
# --hawser-port=N HAWSER_PORT (default: 12376, standard mode only)
|
|
# --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)
|
|
# --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] [ChildNodeBootstrapper] %s\n' "$(date +%H:%M:%S)" "$*"; }
|
|
warn() { printf '[%s] [ChildNodeBootstrapper] WARN: %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
|
|
die() { printf '[%s] [ChildNodeBootstrapper] 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}"
|
|
|
|
HAWSER_MODE="${HAWSER_MODE:-}" # standard|edge|"" (auto)
|
|
HAWSER_TOKEN="${HAWSER_TOKEN:-}"
|
|
DOCKHAND_SERVER_URL="${DOCKHAND_SERVER_URL:-}"
|
|
HAWSER_PORT="${HAWSER_PORT:-12376}"
|
|
|
|
CUSTOM_ROOT="/custom"
|
|
STACKS_ROOT="${CUSTOM_ROOT}/docker/stacks"
|
|
HAWSER_STACK="stk-hawser-00001"
|
|
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--mode=*) HAWSER_MODE="${arg#*=}" ;;
|
|
--token=*) HAWSER_TOKEN="${arg#*=}" ;;
|
|
--dockhand-url=*) DOCKHAND_SERVER_URL="${arg#*=}" ;;
|
|
--hawser-port=*) HAWSER_PORT="${arg#*=}" ;;
|
|
--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" ;;
|
|
--timezone=*) TZ_VALUE="${arg#*=}" ;;
|
|
--skip-upgrade) SKIP_UPGRADE="true" ;;
|
|
-h|--help) printf 'Usage: curl -fsSL <raw-url>/ChildNodeBootstrapper.sh | sudo -E bash -s -- [--mode=standard|edge] [--token=T] [--dockhand-url=U] [--vpn=tailscale|netbird|none] [--auth-key=K] [--firewall=auto|on|off] [--ssh-port=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
|
|
case "$HAWSER_MODE" in standard|edge|"") ;; *) die "--mode must be standard|edge" ;; 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}/ChildNodeBootstrapper-$(date +%Y%m%d-%H%M%S).log"
|
|
exec > >(tee -a "$LOG_FILE") 2>&1
|
|
ls -1t "${LOG_DIR}"/ChildNodeBootstrapper-*.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})"
|
|
|
|
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
|
|
svc_enable cron
|
|
else
|
|
local pkgs=(ca-certificates openssl iputils traceroute tcpdump bind-utils net-tools jq tar cronie)
|
|
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"
|
|
}
|
|
|
|
#-----------------------------------------------------------------------------
|
|
# 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
|
|
}
|
|
|
|
#-----------------------------------------------------------------------------
|
|
# Hawser mode resolution
|
|
#-----------------------------------------------------------------------------
|
|
env_get() { grep -E "^$2=" "$1" 2>/dev/null | head -n1 | cut -d= -f2- || true; }
|
|
|
|
normalize_dockhand_url() {
|
|
local u="$1"
|
|
case "$u" in
|
|
wss://*|ws://*) ;;
|
|
https://*) u="wss://${u#https://}" ;;
|
|
http://*) u="ws://${u#http://}" ;;
|
|
*) u="wss://${u}" ;;
|
|
esac
|
|
case "$u" in
|
|
*/api/hawser/connect) ;;
|
|
*) u="${u%/}/api/hawser/connect" ;;
|
|
esac
|
|
printf '%s' "$u"
|
|
}
|
|
|
|
resolve_hawser_config() {
|
|
local envfile="${STACKS_ROOT}/${HAWSER_STACK}/.env"
|
|
# Fall back to values from a previous run so re-runs keep the same identity
|
|
[ -n "$DOCKHAND_SERVER_URL" ] || DOCKHAND_SERVER_URL="$(env_get "$envfile" DOCKHAND_SERVER_URL)"
|
|
[ -n "$HAWSER_TOKEN" ] || HAWSER_TOKEN="$(env_get "$envfile" HAWSER_TOKEN)"
|
|
|
|
if [ -z "$HAWSER_MODE" ]; then
|
|
[ -n "$DOCKHAND_SERVER_URL" ] && HAWSER_MODE="edge" || HAWSER_MODE="standard"
|
|
fi
|
|
|
|
if [ "$HAWSER_MODE" = "edge" ]; then
|
|
[ -n "$DOCKHAND_SERVER_URL" ] || die "edge mode requires --dockhand-url=<dockhand-base-or-wss-url>"
|
|
[ -n "$HAWSER_TOKEN" ] || die "edge mode requires --token=<agent token from Dockhand: Settings -> Environments -> Hawser Edge>"
|
|
DOCKHAND_SERVER_URL="$(normalize_dockhand_url "$DOCKHAND_SERVER_URL")"
|
|
else
|
|
DOCKHAND_SERVER_URL="" # empty = standard mode (hawser auto-detects by env presence)
|
|
if [ -z "$HAWSER_TOKEN" ]; then
|
|
HAWSER_TOKEN="$(openssl rand -hex 32)"
|
|
log "generated Hawser standard-mode token (stored in ${envfile})"
|
|
fi
|
|
fi
|
|
log "Hawser mode: ${HAWSER_MODE}"
|
|
}
|
|
|
|
#-----------------------------------------------------------------------------
|
|
# /custom skeleton
|
|
#-----------------------------------------------------------------------------
|
|
create_custom_tree() {
|
|
log "creating ${CUSTOM_ROOT} skeleton"
|
|
mkdir -p "${CUSTOM_ROOT}/scripts" "${CUSTOM_ROOT}/cron" "${CUSTOM_ROOT}/logs" "${STACKS_ROOT}"
|
|
}
|
|
|
|
update_env_tunnel_ip() {
|
|
local envfile="$1"
|
|
[ -n "$TUNNEL_IP" ] || return 0
|
|
if grep -qE '^PRIVATE_TUNNEL_BIND_ADDRESS=(127\.0\.0\.1|0\.0\.0\.0)$' "$envfile" 2>/dev/null; then
|
|
log "updating PRIVATE_TUNNEL_BIND_ADDRESS -> ${TUNNEL_IP} in ${envfile}"
|
|
sed -i "s|^PRIVATE_TUNNEL_BIND_ADDRESS=.*$|PRIVATE_TUNNEL_BIND_ADDRESS=${TUNNEL_IP}|" "$envfile"
|
|
fi
|
|
}
|
|
|
|
upsert_env_var() { # $1=file $2=key $3=value
|
|
if grep -qE "^$2=" "$1" 2>/dev/null; then
|
|
sed -i "s|^$2=.*$|$2=$3|" "$1"
|
|
else
|
|
printf '%s=%s\n' "$2" "$3" >> "$1"
|
|
fi
|
|
}
|
|
|
|
#-----------------------------------------------------------------------------
|
|
# Stack: stk-hawser-00001 (Hawser agent)
|
|
#-----------------------------------------------------------------------------
|
|
write_hawser_stack() {
|
|
local dir="${STACKS_ROOT}/${HAWSER_STACK}"
|
|
local decoy_token
|
|
decoy_token="$(openssl rand -hex 32)" # compose default must differ from the .env secret
|
|
mkdir -p "$dir"
|
|
log "writing ${dir}/docker-compose.yml (${HAWSER_MODE} mode)"
|
|
|
|
cat > "${dir}/docker-compose.yml" <<'COMPOSE_EOF'
|
|
#=============================================================================
|
|
# STK-HAWSER-00001 - Hawser Agent (Dockhand branch node)
|
|
# https://dockhand.pro | https://github.com/Finsys/hawser
|
|
#=============================================================================
|
|
#
|
|
# CREDENTIALS:
|
|
# HAWSER_TOKEN in .env authenticates this agent.
|
|
# - standard mode: enter host:port + this token in Dockhand
|
|
# (Settings -> Environments -> Hawser Standard)
|
|
# - edge mode: the token comes FROM Dockhand
|
|
# (Settings -> Environments -> Hawser Edge)
|
|
#
|
|
# PRODUCTION SETUP:
|
|
# 1. Pair this node in the edge Dockhand dashboard (see CREDENTIALS).
|
|
# 2. Deploy stacks to this node from Dockhand; compose files land under
|
|
# /custom/docker/stacks (same-path mount, STACKS_DIR).
|
|
#
|
|
# ENDPOINTS:
|
|
# - standard mode: tcp://<tunnel-ip>:12376 (agent API, VPN/LAN bind only)
|
|
# - edge mode : none (outbound WebSocket to Dockhand)
|
|
#=============================================================================
|
|
|
|
name: '${STACK_NAME:-stk-hawser-00001}'
|
|
|
|
networks:
|
|
default:
|
|
driver: bridge
|
|
|
|
services:
|
|
App:
|
|
image: '${HAWSER_IMAGE:-ghcr.io/finsys/hawser}:${HAWSER_VERSION:-latest}'
|
|
container_name: HAWSER-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:
|
|
COMPOSE_EOF
|
|
|
|
if [ "$HAWSER_MODE" = "standard" ]; then
|
|
cat >> "${dir}/docker-compose.yml" <<'COMPOSE_EOF'
|
|
ports:
|
|
# Agent API - VPN/LAN bind only; never opened on the public firewall
|
|
- '${PRIVATE_TUNNEL_BIND_ADDRESS:-0.0.0.0}:${HAWSER_PORT:-12376}:2376'
|
|
COMPOSE_EOF
|
|
fi
|
|
|
|
cat >> "${dir}/docker-compose.yml" <<COMPOSE_EOF
|
|
environment:
|
|
TZ: '\${TZ:-Etc/UTC}'
|
|
TOKEN: '\${HAWSER_TOKEN:-${decoy_token}}'
|
|
STACKS_DIR: '\${STACK_BINDMOUNTROOT:-/custom/docker/stacks}'
|
|
COMPOSE_EOF
|
|
|
|
if [ "$HAWSER_MODE" = "edge" ]; then
|
|
cat >> "${dir}/docker-compose.yml" <<'COMPOSE_EOF'
|
|
# Presence of DOCKHAND_SERVER_URL switches the agent into Edge mode
|
|
DOCKHAND_SERVER_URL: '${DOCKHAND_SERVER_URL:?edge mode requires DOCKHAND_SERVER_URL in .env}'
|
|
COMPOSE_EOF
|
|
fi
|
|
|
|
cat >> "${dir}/docker-compose.yml" <<'COMPOSE_EOF'
|
|
volumes:
|
|
- /etc/localtime:/etc/localtime:ro
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
# Same-path stacks mount: compose files Dockhand deploys here resolve
|
|
# identically inside and outside the container
|
|
- '${STACK_BINDMOUNTROOT:-/custom/docker/stacks}:${STACK_BINDMOUNTROOT:-/custom/docker/stacks}:rw'
|
|
# No compose healthcheck: the image ships its own HEALTHCHECK probing
|
|
# /_hawser/health with TLS auto-detection.
|
|
labels:
|
|
# Dockhand-managed auto-updates. General services default ON; high-risk
|
|
# services (databases, cache, brokers) must set their .env flag to false.
|
|
dockhand.update: '${HAWSER_ENABLEAUTOMATICUPDATES:-true}'
|
|
# Optional Dockhand UI links - commented out by default
|
|
# dockhand.url: 'tcp://<tunnel-ip>:12376'
|
|
# dockhand.changelog.url: 'https://github.com/Finsys/hawser/releases'
|
|
COMPOSE_EOF
|
|
|
|
if [ ! -f "${dir}/.env" ]; then
|
|
log "writing ${dir}/.env"
|
|
cat > "${dir}/.env" <<ENV_EOF
|
|
#=============================================================================
|
|
# STK-HAWSER-00001 - Hawser Agent (Dockhand branch node)
|
|
#=============================================================================
|
|
#
|
|
# CREDENTIALS:
|
|
# HAWSER_TOKEN authenticates this agent against Dockhand.
|
|
#
|
|
# ACCESS URL: tcp://${TUNNEL_IP:-0.0.0.0}:${HAWSER_PORT} (standard mode agent API)
|
|
#
|
|
# PRODUCTION SETUP:
|
|
# 1. Pair in Dockhand: Settings -> Environments -> Hawser Standard/Edge.
|
|
# 2. Standard mode: host = this node's tunnel IP, port = ${HAWSER_PORT},
|
|
# token = HAWSER_TOKEN below.
|
|
#
|
|
#=============================================================================
|
|
|
|
# Stack Identity
|
|
STACK_NAME=${HAWSER_STACK}
|
|
STACK_BINDMOUNTROOT=${STACKS_ROOT}
|
|
|
|
# Container User/Group (must stay 0 - docker.sock access)
|
|
PUID=0
|
|
PGID=0
|
|
|
|
# Timezone
|
|
TZ=${TZ_VALUE}
|
|
|
|
# Images
|
|
HAWSER_IMAGE=ghcr.io/finsys/hawser
|
|
HAWSER_VERSION=latest
|
|
|
|
# Ports / Bind Addresses (standard mode only)
|
|
PRIVATE_TUNNEL_BIND_ADDRESS=${TUNNEL_IP:-0.0.0.0}
|
|
HAWSER_PORT=${HAWSER_PORT}
|
|
|
|
# Secrets
|
|
HAWSER_TOKEN=${HAWSER_TOKEN}
|
|
|
|
# Edge mode (empty = standard mode)
|
|
DOCKHAND_SERVER_URL=${DOCKHAND_SERVER_URL}
|
|
|
|
# Automatic Updates (Dockhand label dockhand.update; high-risk services such
|
|
# as databases/cache must stay false)
|
|
HAWSER_ENABLEAUTOMATICUPDATES=true
|
|
ENV_EOF
|
|
else
|
|
log "${dir}/.env exists - preserving user configuration"
|
|
update_env_tunnel_ip "${dir}/.env"
|
|
upsert_env_var "${dir}/.env" HAWSER_TOKEN "${HAWSER_TOKEN}"
|
|
upsert_env_var "${dir}/.env" DOCKHAND_SERVER_URL "${DOCKHAND_SERVER_URL}"
|
|
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() {
|
|
cat > "${CUSTOM_ROOT}/scripts/vmboot-patch.sh" <<'PATCH_EOF'
|
|
#!/usr/bin/env bash
|
|
# vmboot-patch.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}/vmboot-patch-$(date +%Y%m%d-%H%M%S).log"
|
|
exec >>"$LOG_FILE" 2>&1
|
|
ls -1t "${LOG_DIR}"/vmboot-patch-*.log 2>/dev/null | tail -n +4 | xargs -r rm -f --
|
|
echo "=== vmboot-patch 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 "=== vmboot-patch finished $(date -Is) ==="
|
|
PATCH_EOF
|
|
chmod 0755 "${CUSTOM_ROOT}/scripts/vmboot-patch.sh"
|
|
|
|
if [ ! -f /etc/cron.d/vmboot-maintenance ]; then
|
|
local patch_min patch_hour reboot_min reboot_hour
|
|
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)"
|
|
log "installing /etc/cron.d/vmboot-maintenance (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 ${CUSTOM_ROOT}/scripts/vmboot-patch.sh
|
|
${reboot_min} ${reboot_hour} * * 0 root shutdown -r now "vmboot weekly maintenance reboot"
|
|
CRON_EOF
|
|
chmod 0644 /etc/cron.d/vmboot-maintenance
|
|
else
|
|
log "/etc/cron.d/vmboot-maintenance exists - keeping the randomized schedule"
|
|
fi
|
|
}
|
|
|
|
# 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 (tcp/10000; the firewall keeps it off public interfaces).
|
|
# Acts as the managed agent for the parent: register this node in the
|
|
# parent's Webmin Servers Index / Cluster modules over the VPN.
|
|
#-----------------------------------------------------------------------------
|
|
install_webmin() {
|
|
if dpkg -s webmin >/dev/null 2>&1 || rpm -q webmin >/dev/null 2>&1; then
|
|
log "Webmin already installed"; svc_enable webmin; return 0
|
|
fi
|
|
log "installing Webmin (agent role)"
|
|
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
|
|
svc_enable webmin
|
|
}
|
|
|
|
#-----------------------------------------------------------------------------
|
|
# 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 VMBOOT-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.
|
|
# Branch nodes expose NO public service ports - only SSH (guard) + VPN UDP.
|
|
#-----------------------------------------------------------------------------
|
|
configure_firewall() {
|
|
if [ "$FIREWALL" = "off" ]; then log "firewall: disabled by request"; return 0; fi
|
|
|
|
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 VMBOOT-* chains; Docker chains are never flushed, so
|
|
# Docker networking keeps working regardless of run order.
|
|
# - Host traffic : VMBOOT-INPUT (jumped from INPUT)
|
|
# - Container traffic: VMBOOT-DOCKER-USER (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
|
|
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
|
|
}
|
|
|
|
# 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"
|
|
}
|
|
|
|
if [ -z "$PUB_IFS" ]; then
|
|
echo "vmboot-firewall: no public IP on any interface - clearing VMBOOT chains"
|
|
for c in VMBOOT-INPUT VMBOOT-DOCKER-USER; do iptables -F "$c" 2>/dev/null || true; done
|
|
ip6tables -F VMBOOT-INPUT 2>/dev/null || true
|
|
exit 0
|
|
fi
|
|
echo "vmboot-firewall: policing public interface(s): $PUB_IFS"
|
|
|
|
#--- IPv4: host traffic ------------------------------------------------------
|
|
flush_chain iptables INPUT VMBOOT-INPUT
|
|
iptables -A VMBOOT-INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
iptables -A VMBOOT-INPUT -m conntrack --ctstate INVALID -j DROP
|
|
for ifname in $PUB_IFS; do
|
|
iptables -A VMBOOT-INPUT -i "$ifname" -p icmp -j ACCEPT
|
|
[ "$ALLOW_SSH" = "true" ] && iptables -A VMBOOT-INPUT -i "$ifname" -p tcp --dport "$SSH_PORT" -j ACCEPT
|
|
for p in $PUBLIC_TCP_PORTS; do
|
|
iptables -A VMBOOT-INPUT -i "$ifname" -p tcp --dport "$p" -j ACCEPT
|
|
done
|
|
for u in $VPN_UDP_PORTS; do
|
|
iptables -A VMBOOT-INPUT -i "$ifname" -p udp --dport "$u" -j ACCEPT
|
|
done
|
|
iptables -A VMBOOT-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 VMBOOT-DOCKER-USER
|
|
iptables -A VMBOOT-DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
for ifname in $PUB_IFS; do
|
|
for p in $PUBLIC_TCP_PORTS; do
|
|
iptables -A VMBOOT-DOCKER-USER -i "$ifname" -p tcp \
|
|
-m conntrack --ctdir ORIGINAL --ctorigdstport "$p" -j ACCEPT
|
|
done
|
|
iptables -A VMBOOT-DOCKER-USER -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 VMBOOT-INPUT
|
|
ip6tables -A VMBOOT-INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
ip6tables -A VMBOOT-INPUT -m conntrack --ctstate INVALID -j DROP
|
|
ip6tables -A VMBOOT-INPUT -p ipv6-icmp -j ACCEPT
|
|
ip6tables -A VMBOOT-INPUT -p udp --dport 546 -j ACCEPT
|
|
for ifname in $PUB_IFS; do
|
|
[ "$ALLOW_SSH" = "true" ] && ip6tables -A VMBOOT-INPUT -i "$ifname" -p tcp --dport "$SSH_PORT" -j ACCEPT
|
|
for p in $PUBLIC_TCP_PORTS; do
|
|
ip6tables -A VMBOOT-INPUT -i "$ifname" -p tcp --dport "$p" -j ACCEPT
|
|
done
|
|
for u in $VPN_UDP_PORTS; do
|
|
ip6tables -A VMBOOT-INPUT -i "$ifname" -p udp --dport "$u" -j ACCEPT
|
|
done
|
|
ip6tables -A VMBOOT-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@@||" \
|
|
/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 VMBOOT-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: CHILD (branch) node (Hawser agent) ==="
|
|
update_system
|
|
install_base_packages
|
|
install_powershell
|
|
install_docker
|
|
install_vpn
|
|
|
|
TUNNEL_IP="$(get_tunnel_ip)"
|
|
[ -n "$TUNNEL_IP" ] && log "tunnel IP detected: ${TUNNEL_IP}" \
|
|
|| warn "no tunnel IP yet - standard-mode agent binds 0.0.0.0 (public side stays blocked by the firewall; re-run after VPN registration to narrow the bind)"
|
|
|
|
create_custom_tree
|
|
resolve_hawser_config
|
|
write_hawser_stack
|
|
deploy_stack "${STACKS_ROOT}/${HAWSER_STACK}"
|
|
configure_maintenance_cron
|
|
install_webmin
|
|
configure_firewall
|
|
install_fail2ban
|
|
|
|
log "============================================================"
|
|
log " Child node bootstrap complete (Hawser ${HAWSER_MODE} mode)"
|
|
log "------------------------------------------------------------"
|
|
if [ "$HAWSER_MODE" = "standard" ]; then
|
|
log " Agent API : tcp://${TUNNEL_IP:-<this-host>}:$(env_get "${STACKS_ROOT}/${HAWSER_STACK}/.env" HAWSER_PORT) (VPN/LAN only)"
|
|
log " Pair it : Dockhand -> Settings -> Environments -> Hawser Standard"
|
|
log " host = ${TUNNEL_IP:-<this-host>}, port = $(env_get "${STACKS_ROOT}/${HAWSER_STACK}/.env" HAWSER_PORT)"
|
|
log " token = HAWSER_TOKEN in ${STACKS_ROOT}/${HAWSER_STACK}/.env"
|
|
else
|
|
log " Agent dials : ${DOCKHAND_SERVER_URL}"
|
|
log " Pair it : Dockhand -> Settings -> Environments -> Hawser Edge"
|
|
log " (this agent authenticates with the token you supplied)"
|
|
fi
|
|
log " Webmin : https://${TUNNEL_IP:-<this-host>}:10000/ (VPN/LAN only)"
|
|
log " register in the parent's Webmin Servers Index for RPC"
|
|
log " Maintenance : patch Sat $(cron_time vmboot-patch) reboot Sun $(cron_time 'shutdown -r')"
|
|
log " Stacks dir : ${STACKS_ROOT} (Dockhand deployments land here)"
|
|
log " Custom tree : ${CUSTOM_ROOT}/{scripts,cron,logs,docker/stacks}"
|
|
log " Run log : ${LOG_FILE} (last 3 runs kept)"
|
|
log "============================================================"
|