gsadmin 4e10e58d58 Initial release: parent/child node bootstrappers
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>
2026-08-13 14:03:16 -04:00

Linux-VM-Bootstrapper

Idempotent, URL-executable bootstrap scripts for Docker VMs. Two node roles:

Script Role What it deploys
ParentNodeBootstrapper.sh Parent (edge) node Nginx UI reverse proxy (public 80/443, default HTTPS routes to the UIs) + Dockhand (fleet manager, backups, auto-updates) + Webmin
ChildNodeBootstrapper.sh Child (branch) node Hawser agent (managed remotely by the parent node's Dockhand) + Webmin (agent)

Both roles also get fail2ban (public hosts only) and a weekly patch/reboot maintenance window.

Both scripts are safe to re-run at any time — packages update, existing .env files are preserved, and running stacks are only recreated when their compose definition actually changed. Every run is logged to /custom/logs (only the 3 most recent run logs are kept per script).

Supported: Ubuntu, Debian, RHEL (plus RHEL-likes: Rocky, Alma, CentOS Stream, Oracle Linux).


Quick start

Replace <raw-url> with the raw-file base URL of wherever you host these scripts. Pin production machines to a release tag, not a branch — with Gitea-style raw paths that means /raw/tag/v1.0.0/ParentNodeBootstrapper.sh instead of /raw/branch/main/ParentNodeBootstrapper.sh, so a fleet always bootstraps from a known revision.

Parent node

# Tailscale (default), register with an auth key, auto firewall:
curl -fsSL https://<raw-url>/ParentNodeBootstrapper.sh \
  | sudo -E bash -s -- --auth-key=tskey-auth-XXXXXXXX

# No auth key = VPN is installed but registration is skipped:
curl -fsSL https://<raw-url>/ParentNodeBootstrapper.sh | sudo -E bash

# NetBird instead of Tailscale:
curl -fsSL https://<raw-url>/ParentNodeBootstrapper.sh \
  | sudo -E bash -s -- --vpn=netbird --auth-key=<setup-key>

Child node

# Standard mode (agent listens; Dockhand connects to it over the VPN):
curl -fsSL https://<raw-url>/ChildNodeBootstrapper.sh \
  | sudo -E bash -s -- --auth-key=tskey-auth-XXXXXXXX

# Edge mode (agent dials out to Dockhand; nothing listens):
curl -fsSL https://<raw-url>/ChildNodeBootstrapper.sh \
  | sudo -E bash -s -- \
  --auth-key=tskey-auth-XXXXXXXX \
  --dockhand-url=https://dockhand.example.com \
  --token=<agent token from Dockhand>

Every flag also has an environment-variable form (VPN_PROVIDER, AUTH_KEY, FIREWALL, SSH_PORT, TZ, HAWSER_MODE, HAWSER_TOKEN, DOCKHAND_SERVER_URL, …) — use sudo -E so exported variables survive. Flags win over environment variables.


What gets installed

Both scripts, silently and idempotently:

  • Full package update/upgrade (--skip-upgrade to skip the upgrade pass)
  • Base tooling: openssl, ping (iputils), traceroute, tcpdump, dig (dnsutils/bind-utils), ifconfig (net-tools), jq, tar, cron
  • PowerShell (pwsh) — Microsoft repo, with a GitHub-tarball fallback
  • Docker Engine + Compose v2 from Docker's official repositories
  • Tailscale (default) or NetBird (--vpn=netbird)
    • Registration happens only when an auth/setup key is given; otherwise installation completes and registration is skipped
    • Tailscale registration uses --accept-routes --accept-dns=true --accept-risk=all and does not advertise or use an exit node
  • Webmin (https://<host>:10000) — full console on the parent; on children it acts as the managed agent: register each child in the parent's Webmin → Webmin Servers Index / Cluster modules over the VPN. The firewall keeps 10000 off public interfaces.
  • fail2ban — installed and enabled only when the firewall engages (public IP present): sshd jail, systemd backend, 5 tries / 10 min window / 1 h ban.
  • Weekly maintenance window (/etc/cron.d/vmboot-maintenance) — schedule randomized once with shuf and preserved on re-runs:
    • OS patching: Saturday between 20:0021:59 (/custom/scripts/vmboot-patch.sh, logs to /custom/logs, 3 kept)
    • Reboot: Sunday between 22:0023:59
  • /custom skeleton (see layout below)

Directory layout

/custom
├── scripts/                       # your operational scripts
│   └── vmboot-patch.sh            # weekly silent OS patching (generated)
├── cron/                          # your cron payloads
├── logs/                          # bootstrapper + patch run logs (last 3 kept per kind)
├── backups/
│   └── dockhand/                  # Dockhand local backup destination (parent)
└── docker/
    └── stacks/                    # STACK_BINDMOUNTROOT
        ├── stk-edge-proxy-00001/  # parent: Nginx UI (compose + .env + data)
        │   └── Proxy/{etc/nginx,etc/nginx-ui,etc/ssl,var/log/nginx,var/www}
        ├── stk-dockhand-00001/    # parent: Dockhand (compose + .env + App/Data)
        └── stk-hawser-00001/      # child: Hawser agent (compose + .env)

Stack conventions follow this repo's compose/env stack specs: stack names stk-<name>-00001, static container names {STACKNAME}-{TIER}-00001 (no hostname:), network names never contain the stack name, .env + compose defaults, logging: local, dockhand.update labels (apps on, high-risk off).

Network architecture (parent node)

Two host-wide docker networks are pre-created by the script (fixed names, no stack prefixes):

docker network create --driver bridge --attachable            EDGE-PROXY-EXTERNAL
docker network create --driver bridge --attachable --internal EDGE-PROXY-INTERNAL
                    Internet                    VPN (Tailscale/NetBird)
                       │                                  │
              0.0.0.0:80 / 0.0.0.0:443          <tunnel-ip>:8000 / :13000
                       │  (host-mode binds)               │
                ┌──────▼──────────────────────────────────▼─────┐
EDGE-PROXY-     │            EDGE-PROXY-PROXY-00001              │
EXTERNAL ──────▶│                (uozi/nginx-ui)                 │
                └──────┬─────────────────────────────────────────┘
                       │ EDGE-PROXY-INTERNAL (internal: true backplane)
        ┌──────────────┼──────────────────┐
        ▼              ▼                  ▼
 DOCKHAND-APP-00001  MYAPP-APP-00001   (your stacks…)
  • EDGE-PROXY-EXTERNAL is attached only to the reverse proxy.
  • EDGE-PROXY-INTERNAL is the private backplane (internal: true — no direct internet egress). App stacks attach to it to become routable.
  • The reverse proxy publishes 80/443 with long-form, host-mode port bindings — a deliberate drift from the stack spec for this one service. It delivers the true client IP to Nginx while upstream routing still uses Docker's internal DNS, so the container does not need full host networking.

Attaching an app stack to the proxy

networks:
  INTERNAL:
    name: EDGE-PROXY-INTERNAL
    external: true

services:
  App:
    container_name: MYAPP-APP-00001
    networks:
      INTERNAL:
    expose:
      - '8080'        # INTERNAL-only services use expose, not host ports

Then in Nginx UI, route by container name (cross-stack DNS on a shared network resolves container names):

proxy_pass http://MYAPP-APP-00001:8080;

Real client IP

Host-mode published ports mean direct public traffic already arrives with the real source IP. Additionally, ParentNodeBootstrapper.sh seeds Proxy/etc/nginx/conf.d/real-ip.conf so requests forwarded by a trusted local hop (host gateway, private ranges, VPN/CGNAT) are un-masked via X-Forwarded-For:

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;

Deployed stacks

stk-edge-proxy-00001 — Nginx UI (parent)

Binding Address Purpose
:80 → 80 HOST_PORT_BIND_ADDRESS (default 0.0.0.0) Public HTTP + ACME HTTP-01 challenges
:443 → 443 HOST_PORT_BIND_ADDRESS Public HTTPS
:8000 → 9000 PRIVATE_TUNNEL_BIND_ADDRESS (tunnel IP) Nginx UI dashboard (backend port 9000)

First visit to http://<tunnel-ip>:8000/ creates the admin account. The image's default site proxies the dashboard on port 80 as well — replace it with your real vhosts once setup is done. /etc/ssl is bind-mounted for cert persistence; the script pre-seeds it from the image so the CA bundle isn't masked.

Let's Encrypt / ACME HTTP-01 flow: challenges always arrive on public port 80. Because nginx itself owns port 80 inside the container, the Nginx UI backend answers challenges on a separate internal listener, the HTTPChallengePort (default 9180) — nginx proxies /.well-known/acme-challenge to it in-container. Port 9180 is container-internal only: it is never published in the compose file and never opened in the firewall. The only ports that must stay reachable from the internet for issuance/renewal are 80 (HTTP-01) and 443 (TLS-ALPN).

Default HTTPS routes: the script generates a self-signed certificate (Proxy/etc/ssl/vmboot/) and seeds sites-available/vmboot-default.conf (symlinked into sites-enabled/) as the default_server on 443, so the management UIs are reachable through the proxy immediately:

Route Upstream
https://<host>/nginxui/ Nginx UI backend (in-container 127.0.0.1:9000)
https://<host>/dockhand/ DOCKHAND-APP-00001:3000 over EDGE-PROXY-INTERNAL

Expect the browser's self-signed-cert warning until you replace it. The Dockhand upstream resolves through Docker's embedded DNS with deferred resolution, so nginx keeps serving while containers restart. If either UI misbehaves under its sub-path (SPA asset paths), the direct VPN-bound ports above remain the canonical access method. The site file is created once and then left alone — manage it from Nginx UI afterwards.

stk-dockhand-00001 — Dockhand (parent)

  • Dashboard: http://<tunnel-ip>:13000/ (VPN bind only, never public).
  • Manages local Docker via /var/run/docker.sock; /custom/docker/stacks is mounted at the same path inside the container so managed compose files resolve identically.
  • Backups: /custom/backups/dockhand is mounted at /backups. Dockhand configures backups in the UI (no env switch exists): Settings → Backups → destination Local/backups → set a schedule → enable. This is the one post-install click the script cannot do for you.
  • Child nodes are added under Settings → Environments as Hawser Standard or Hawser Edge.

stk-hawser-00001 — Hawser agent (child)

Mode Direction Needs
standard (default) Dockhand → agent, tcp://<tunnel-ip>:12376 token (auto-generated into .env if omitted)
edge (--dockhand-url=…) agent → Dockhand (outbound WebSocket) --token from Dockhand's Hawser Edge dialog

--dockhand-url accepts a plain base URL — https://dockhand.example.com is normalized to wss://dockhand.example.com/api/hawser/connect automatically. STACKS_DIR points at /custom/docker/stacks (same-path mount), so stacks deployed from Dockhand land in the standard location.

Auto-updates (Dockhand labels)

Container updates are handled by Dockhand's built-in updater — no Watchtower. Every deployed service carries the dockhand.update label wired to its .env flag:

labels:
  dockhand.update: '${MYAPP_ENABLEAUTOMATICUPDATES:-true}'
  # dockhand.url: 'https://<host>/myapp/'
  # dockhand.changelog.url: 'https://.../releases'
  • General/app services default true; set the update schedule in Dockhand under Settings → Updates (per-container or environment-wide, with optional vulnerability-gating).
  • High-risk services are always false: databases, Valkey/Redis-style caches, message brokers, search/index stores, object stores — anything SHA256-pinned.
  • URL labels (dockhand.url, dockhand.port.<hostPort>.url, dockhand.changelog.url) are included commented out — uncomment per-host if you want clickable links in the Dockhand UI. Other supported labels: dockhand.hidden, dockhand.notify, dockhand.order, dockhand.adopt.

Weekly maintenance window

/etc/cron.d/vmboot-maintenance is generated on first run with shuf-randomized times, then preserved on re-runs (delete the file to re-randomize):

  • Patch: Saturday at a random minute between 20:00 and 21:59 — /custom/scripts/vmboot-patch.sh runs a silent full upgrade (dist-upgrade/dnf upgrade + autoremove), logging to /custom/logs/vmboot-patch-*.log (3 most recent kept).
  • Reboot: Sunday at a random minute between 22:00 and 23:59.

The exact times for a host are printed in the bootstrap summary and in the cron file's header comment.

Firewall (iptables, Docker-safe)

When any interface carries a public IP directly (--firewall=auto, the default), the script locks the box down with raw iptables — never ufw or firewalld (both are disabled if found active, since they'd fight the rules):

  • Host traffic (VMBOOT-INPUT, jumped from INPUT): on public interfaces only — allow established/related, ICMP, SSH (lockout guard, see below), parent: TCP 80/443 (80 covers ACME HTTP-01, relayed in-container to the Nginx UI HTTPChallengePort 9180; 443 covers TLS-ALPN), VPN direct-connection UDP (41641 Tailscale / 51820 NetBird), drop the rest.
  • Container traffic (VMBOOT-DOCKER-USER, jumped from DOCKER-USER): matches the pre-DNAT port via conntrack --ctorigdstport, so on public interfaces only 80/443 reach containers (parent) or nothing does (child). VPN-side and LAN-side traffic is untouched.
  • Docker chains are never flushed or reordered — only the VMBOOT-* chains are managed, so ordering relative to Docker (before/after, restarts, reboots) can't break Docker networking.
  • Persistence: /usr/local/sbin/vmboot-firewall.sh + vmboot-firewall.service re-detect the public interface and re-apply on every boot. Hosts with only private/CGNAT addresses get no rules.
  • IPv6 is mirrored (ICMPv6/NDP and DHCPv6-client stay open).

SSH stays allowed by default on SSH_PORT (22) as a lockout guard — locking a public VPS to literally 80/443 before the VPN is registered would cut off access. Once your VPN access is verified, re-run with --no-ssh-allow to close it.

fail2ban rides along whenever the firewall engages: an sshd jail (systemd backend, maxretry 5, findtime 10m, bantime 1h) via /etc/fail2ban/jail.d/vmboot-sshd.local. The firewall unit orders itself Before=fail2ban.service and the bootstrap restarts fail2ban after applying rules, so ban chains always sit above VMBOOT-INPUT in the INPUT chain.

Logging

Each run writes a full transcript to /custom/logs/<ScriptName>-YYYYMMDD-HHMMSS.log (console output is unchanged — the log is a tee). Retention is automatic: only the 3 most recent logs per script are kept; older ones are deleted at the start of each run.

Idempotency & re-runs

  • docker-compose.yml files are rewritten each run (the script is their source of truth); .env files are preserved once created.
  • If no VPN key was given on the first run, dashboards bind to 127.0.0.1 (parent) / 0.0.0.0 (child standard agent). After registering the VPN, re-run the script — it detects the tunnel IP and rewrites the placeholder bind address in .env (custom values are left alone).
  • Firewall chains are flushed and rebuilt atomically per run; disabling is --firewall=off (existing VMBOOT-* chains are then left as-is; run iptables -F VMBOOT-INPUT etc. to clear manually).

Notes & caveats

  • Tunnel-IP port binds after reboot: Docker may start before the VPN interface has its IP; the containers simply restart until the bind succeeds (restart: unless-stopped).
  • RHEL: firewalld is disabled when the firewall engages — that is intentional per the raw-iptables mandate.
  • Dockhand backups must be toggled once in the UI (path /backups); the destination directory, mount, and instructions are provisioned for you.
  • curl | bash runs third-party installers (Docker repo setup, Tailscale install.sh, NetBird install.sh, Microsoft packages) — vet them if your environment requires it.
  • Scripts must keep LF line endings (enforced via .gitattributes) or curl | bash will fail with \r errors.
S
Description
Executes the tasks necessary to get a Linux virtual machine configured.
Readme 182 KiB
Languages
Shell 100%