e23557b4fb
Add a reusable iptables baseline that hardens hosts with ICMP + SSH defaults and lets deployments register the ports they need. INPUT is deny-by-default (loopback, established, ICMP, SSH on the configured port, plus registered ports); OUTPUT stays open and FORWARD is left untouched so Docker container networking is unaffected. Persistence is native -- no boot hook. Rules are saved and restored by the distro's own package (iptables/ip6tables on Alpine, iptables-persistent on Debian, iptables-services on Alma) via the new oslib helpers install_iptables / fw_save_cmd / fw_enable_restore. The saved ruleset carries the INPUT->sshguard jump, so brute-force protection survives reboot without the old sshguard-iptables hook. A self-contained /usr/local/sbin/firewall-apply rebuilds INPUT from declarative drop-ins under /etc/firewall/ports.d and runs the native save, so deployments add a port without needing the repo present: printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/mystack.rule /usr/local/sbin/firewall-apply - SSH port read live from sshd_config (custom bastion ports just work); FW_SSH_SOURCE restricts the source CIDR; FW_ALLOW_PING gates echo - harden-ssh.sh / harden-jumphost.sh install it when ENABLE_FIREWALL=1 (default) and skip the sshguard-only hook; ENABLE_FIREWALL=0 keeps it - cloud-init base.yml / jumphost.yml forward the toggle - the four stack deploy.sh open_web_ports() register 80/443 via the firewall (ufw/firewalld kept as fallback); Docker-published ports bypass INPUT, so this is belt-and-braces and self-documenting - README + cloud-init/README document the mechanism, Docker caveat, and the `disable` recovery path
508 lines
23 KiB
Bash
508 lines
23 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# deploy.sh -- deploy the headscale stack (caddy + headscale) on Alpine.
|
|
#
|
|
# Single-node, dedicated host: runs everything as root.
|
|
#
|
|
# What this does:
|
|
# 1. Installs docker + docker-cli-compose if missing.
|
|
# 2. Lays down docker-compose.yml, Caddyfile, and a substituted
|
|
# config.yaml in $STACK_DIR.
|
|
# 3. Generates .env on first run; existing .env is never overwritten.
|
|
# 4. Prompts for required values not preset (HEADSCALE_DOMAIN,
|
|
# ACME_EMAIL, TAILNET_DOMAIN, POCKETID_DOMAIN, OIDC_CLIENT_ID,
|
|
# OIDC_CLIENT_SECRET).
|
|
# 5. Substitutes those values into config.yaml from the embedded
|
|
# template.
|
|
# 6. Opens TCP 80/443 in UFW if it's installed and active (Tailscale
|
|
# clients connect on 443; LE HTTP-01 needs 80).
|
|
# 7. Pulls images, brings the stack up, waits for healthchecks.
|
|
#
|
|
# Idempotent: re-run to apply config changes / pull new images. Re-running
|
|
# regenerates config.yaml from the current .env so edits to .env propagate
|
|
# (but secrets in .env are never touched once seeded).
|
|
#
|
|
# Self-contained: docker-compose.yml, Caddyfile, config.yaml, policy.hujson
|
|
# and .env.example are embedded as a base64-encoded tar.gz at the bottom of
|
|
# this file. Rebuild with build.sh after editing the loose source files.
|
|
#
|
|
# Usage:
|
|
# bash deploy.sh # interactive prompts
|
|
# HEADSCALE_DOMAIN=hs.example.com \
|
|
# ACME_EMAIL=admin@example.com \
|
|
# TAILNET_DOMAIN=tail.example.com \
|
|
# POCKETID_DOMAIN=auth.example.com \
|
|
# OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=... FORCE=1 bash deploy.sh
|
|
#
|
|
# STACK_DIR=/opt/headscale bash deploy.sh
|
|
# SKIP_DOCKER_INSTALL=1 bash deploy.sh
|
|
|
|
set -euo pipefail
|
|
|
|
: "${STACK_DIR:=/srv/headscale}"
|
|
: "${SKIP_DOCKER_INSTALL:=0}"
|
|
: "${FORCE:=0}"
|
|
: "${SKIP_PROMPTS:=0}" # non-interactive: require values via env, no prompts
|
|
[[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1
|
|
: "${HEADSCALE_DOMAIN:=}"
|
|
: "${ACME_EMAIL:=}"
|
|
: "${TAILNET_DOMAIN:=}"
|
|
: "${POCKETID_DOMAIN:=}"
|
|
: "${OIDC_CLIENT_ID:=}"
|
|
: "${OIDC_CLIENT_SECRET:=}"
|
|
: "${HEADPLANE_OIDC_CLIENT_ID:=}" # optional: headplane UI OIDC login
|
|
: "${HEADPLANE_OIDC_CLIENT_SECRET:=}"
|
|
|
|
log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
|
|
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
|
|
die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
|
|
|
|
[[ $EUID -eq 0 ]] || die "Run as root."
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OS detection + Docker install (Alpine / Debian / Alma). This deploy.sh is
|
|
# self-contained (scp'd standalone), so the OS logic is inlined here instead
|
|
# of sourced from scripts/oslib.sh.
|
|
# ---------------------------------------------------------------------------
|
|
osfam() {
|
|
local id="" like=""
|
|
if [[ -r /etc/os-release ]]; then
|
|
id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")"
|
|
like="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")"
|
|
fi
|
|
case " $id $like " in
|
|
*" alpine "*) echo alpine ;;
|
|
*" debian "*|*" ubuntu "*) echo debian ;;
|
|
*" rhel "*|*" fedora "*|*" centos "*) echo rhel ;;
|
|
*) echo "${id:-unknown}" ;;
|
|
esac
|
|
}
|
|
|
|
install_docker() {
|
|
if command -v docker >/dev/null 2>&1; then
|
|
log "Docker already installed: $(docker --version)"
|
|
else
|
|
log "Installing Docker (OS: $(osfam))..."
|
|
case "$(osfam)" in
|
|
alpine) apk add -q docker docker-cli-compose openrc ;;
|
|
debian|rhel) command -v curl >/dev/null 2>&1 || \
|
|
{ command -v apt-get >/dev/null 2>&1 && apt-get install -y -qq curl; } || \
|
|
{ command -v dnf >/dev/null 2>&1 && dnf install -y -q curl; }
|
|
curl -fsSL https://get.docker.com | sh ;;
|
|
*) die "Unsupported OS for auto Docker install. Set SKIP_DOCKER_INSTALL=1 and install Docker yourself." ;;
|
|
esac
|
|
fi
|
|
if command -v rc-update >/dev/null 2>&1; then
|
|
rc-update add docker default >/dev/null 2>&1 || true
|
|
rc-service docker status >/dev/null 2>&1 || rc-service docker start
|
|
elif command -v systemctl >/dev/null 2>&1; then
|
|
systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker || true
|
|
fi
|
|
}
|
|
|
|
open_web_ports() {
|
|
# Register 80/443 for this stack. Prefer the host firewall (harden-firewall.sh)
|
|
# when present: drop in a rule file and re-apply. Else fall back to ufw/
|
|
# firewalld if active (no-op when neither is).
|
|
#
|
|
# NOTE: Caddy publishes 80/443 via Docker, which reaches the host through the
|
|
# nat/FORWARD chains and BYPASSES the INPUT firewall -- so this is harmless
|
|
# belt-and-braces for any host-bound bind and self-documents the stack ports.
|
|
if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then
|
|
log "Registering 80,443/tcp with host firewall..."
|
|
printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/headscale.rule
|
|
/usr/local/sbin/firewall-apply
|
|
elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then
|
|
log "ufw active -- allowing 80,443/tcp..."
|
|
ufw allow 80/tcp >/dev/null; ufw allow 443/tcp >/dev/null
|
|
elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
|
log "firewalld active -- allowing http,https..."
|
|
firewall-cmd -q --add-service=http --permanent
|
|
firewall-cmd -q --add-service=https --permanent
|
|
firewall-cmd -q --reload
|
|
fi
|
|
}
|
|
|
|
# Write headplane's config to $STACK_DIR/headplane.yaml from the current .env
|
|
# values. OIDC is enabled only when a client id + secret are present; otherwise
|
|
# headplane falls back to API-key login. Mode is API-only (no Docker socket).
|
|
render_headplane_config() {
|
|
local oidc_enabled=false
|
|
[[ -n "${HEADPLANE_OIDC_CLIENT_ID:-}" && -n "${HEADPLANE_OIDC_CLIENT_SECRET:-}" ]] && oidc_enabled=true
|
|
( umask 077
|
|
cat > "$STACK_DIR/headplane.yaml" <<EOF
|
|
# Generated by deploy.sh -- do not edit by hand (regenerated each run from .env).
|
|
server:
|
|
host: "0.0.0.0"
|
|
port: 3000
|
|
base_url: "https://${HEADSCALE_DOMAIN}"
|
|
cookie_secret: "${HEADPLANE_COOKIE_SECRET}"
|
|
cookie_secure: true
|
|
headscale:
|
|
url: "http://headscale:8080"
|
|
api_key: "${HEADPLANE_HS_API_KEY:-}"
|
|
oidc:
|
|
enabled: ${oidc_enabled}
|
|
issuer: "https://${POCKETID_DOMAIN}"
|
|
client_id: "${HEADPLANE_OIDC_CLIENT_ID:-}"
|
|
client_secret: "${HEADPLANE_OIDC_CLIENT_SECRET:-}"
|
|
use_pkce: true
|
|
EOF
|
|
)
|
|
chmod 600 "$STACK_DIR/headplane.yaml"
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Extract embedded archive
|
|
# ----------------------------------------------------------------------------
|
|
SCRIPT_DIR=$(mktemp -d -t headscale-deploy.XXXXXX)
|
|
trap 'rm -rf "$SCRIPT_DIR"' EXIT
|
|
|
|
extract_archive() {
|
|
grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \
|
|
| tail -n +2 \
|
|
| base64 -d \
|
|
| tar -xz -C "$SCRIPT_DIR"
|
|
}
|
|
|
|
if grep -q -a '^__ARCHIVE_BELOW__$' "$0"; then
|
|
log "Extracting embedded deployment files..."
|
|
extract_archive
|
|
else
|
|
die "No embedded archive found. Run build.sh to embed deployment files."
|
|
fi
|
|
|
|
for f in docker-compose.yml Caddyfile config.yaml policy.hujson .env.example; do
|
|
[[ -f "$SCRIPT_DIR/$f" ]] || die "Embedded archive missing $f"
|
|
done
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Prompt for required vars
|
|
# ----------------------------------------------------------------------------
|
|
prompt() {
|
|
local varname="$1" message="$2"
|
|
local -n ref="$varname"
|
|
if [[ -z "${ref:-}" ]]; then
|
|
[[ "$SKIP_PROMPTS" == "1" ]] && die "$varname required (set it in the environment; running with SKIP_PROMPTS=1)."
|
|
read -r -p "$message: " ref
|
|
[[ -n "$ref" ]] || die "$varname required."
|
|
fi
|
|
}
|
|
|
|
prompt HEADSCALE_DOMAIN "Public hostname for headscale (e.g. hs.example.com)"
|
|
prompt ACME_EMAIL "Let's Encrypt email"
|
|
prompt TAILNET_DOMAIN "Tailnet base domain for MagicDNS (e.g. tail.example.com)"
|
|
prompt POCKETID_DOMAIN "OIDC issuer hostname (your pocket-id, e.g. auth.example.com)"
|
|
prompt OIDC_CLIENT_ID "OIDC client_id (from pocket-id)"
|
|
prompt OIDC_CLIENT_SECRET "OIDC client_secret (from pocket-id)"
|
|
|
|
[[ "$HEADSCALE_DOMAIN" != "$TAILNET_DOMAIN" ]] \
|
|
|| die "HEADSCALE_DOMAIN and TAILNET_DOMAIN must differ."
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Docker
|
|
# ----------------------------------------------------------------------------
|
|
if [[ "$SKIP_DOCKER_INSTALL" != "1" ]]; then
|
|
install_docker
|
|
fi
|
|
|
|
# Open 80/443 on whichever host firewall is active. (Tailscale clients connect
|
|
# on 443; LE HTTP-01 needs 80. Note the IPv6-to-docker-proxy default-deny
|
|
# gotcha on ufw -- opening the ports explicitly covers it.)
|
|
open_web_ports
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Stack directory + files
|
|
# ----------------------------------------------------------------------------
|
|
log "Setting up $STACK_DIR..."
|
|
install -d -m 0750 "$STACK_DIR"
|
|
install -m 0640 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml"
|
|
install -m 0640 "$SCRIPT_DIR/Caddyfile" "$STACK_DIR/Caddyfile"
|
|
|
|
# .env
|
|
ENV_FILE="$STACK_DIR/.env"
|
|
if [[ ! -f "$ENV_FILE" ]]; then
|
|
log "Seeding $ENV_FILE..."
|
|
install -m 0600 "$SCRIPT_DIR/.env.example" "$ENV_FILE"
|
|
sed -i \
|
|
-e "s|^HEADSCALE_DOMAIN=.*|HEADSCALE_DOMAIN=${HEADSCALE_DOMAIN}|" \
|
|
-e "s|^ACME_EMAIL=.*|ACME_EMAIL=${ACME_EMAIL}|" \
|
|
-e "s|^TAILNET_DOMAIN=.*|TAILNET_DOMAIN=${TAILNET_DOMAIN}|" \
|
|
-e "s|^POCKETID_DOMAIN=.*|POCKETID_DOMAIN=${POCKETID_DOMAIN}|" \
|
|
-e "s|^OIDC_CLIENT_ID=.*|OIDC_CLIENT_ID=${OIDC_CLIENT_ID}|" \
|
|
-e "s|^OIDC_CLIENT_SECRET=.*|OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}|" \
|
|
-e "s|^HEADPLANE_COOKIE_SECRET=.*|HEADPLANE_COOKIE_SECRET=$(openssl rand -hex 16)|" \
|
|
-e "s|^HEADPLANE_OIDC_CLIENT_ID=.*|HEADPLANE_OIDC_CLIENT_ID=${HEADPLANE_OIDC_CLIENT_ID}|" \
|
|
-e "s|^HEADPLANE_OIDC_CLIENT_SECRET=.*|HEADPLANE_OIDC_CLIENT_SECRET=${HEADPLANE_OIDC_CLIENT_SECRET}|" \
|
|
"$ENV_FILE"
|
|
else
|
|
log ".env exists; leaving it alone."
|
|
fi
|
|
|
|
# Validate
|
|
missing=()
|
|
for var in HEADSCALE_DOMAIN ACME_EMAIL TAILNET_DOMAIN POCKETID_DOMAIN \
|
|
OIDC_CLIENT_ID OIDC_CLIENT_SECRET; do
|
|
grep -E "^${var}=.+$" "$ENV_FILE" >/dev/null || missing+=("$var")
|
|
done
|
|
(( ${#missing[@]} == 0 )) || die "Missing values in $ENV_FILE: ${missing[*]}"
|
|
|
|
# Re-read final values from .env so config.yaml substitution sees what's
|
|
# actually deployed (not whatever was passed in the environment).
|
|
# shellcheck disable=SC1090
|
|
set -a; . "$ENV_FILE"; set +a
|
|
|
|
# config.yaml: regenerate every run from the template so changes to .env
|
|
# propagate. Secrets in config.yaml are derived from .env, not stored
|
|
# independently, so this is safe.
|
|
log "Generating config.yaml from template..."
|
|
install -m 0600 "$SCRIPT_DIR/config.yaml" "$STACK_DIR/config.yaml"
|
|
|
|
# ACL policy: install the default on first run only, so your edits survive
|
|
# re-deploys. headscale reads it on start (policy mode = file); after editing,
|
|
# validate with `headscale policy check` and `docker compose restart headscale`.
|
|
if [[ ! -f "$STACK_DIR/policy.hujson" ]]; then
|
|
log "Installing default ACL policy -> $STACK_DIR/policy.hujson"
|
|
install -m 0640 "$SCRIPT_DIR/policy.hujson" "$STACK_DIR/policy.hujson"
|
|
else
|
|
log "ACL policy exists; leaving $STACK_DIR/policy.hujson alone."
|
|
fi
|
|
|
|
# Render headplane.yaml now so the bind mount exists (api_key may still be
|
|
# empty on first run; it's re-rendered after the key is minted below).
|
|
render_headplane_config
|
|
|
|
# Escape replacement values for sed (handle slashes/&). Headscale URLs and
|
|
# OIDC secrets can contain those.
|
|
sed_escape() { printf '%s' "$1" | sed -e 's/[\/&]/\\&/g'; }
|
|
|
|
sed -i \
|
|
-e "s/__HEADSCALE_DOMAIN__/$(sed_escape "$HEADSCALE_DOMAIN")/g" \
|
|
-e "s/__TAILNET_DOMAIN__/$(sed_escape "$TAILNET_DOMAIN")/g" \
|
|
-e "s/__POCKETID_DOMAIN__/$(sed_escape "$POCKETID_DOMAIN")/g" \
|
|
-e "s/__OIDC_CLIENT_ID__/$(sed_escape "$OIDC_CLIENT_ID")/g" \
|
|
-e "s/__OIDC_CLIENT_SECRET__/$(sed_escape "$OIDC_CLIENT_SECRET")/g" \
|
|
"$STACK_DIR/config.yaml"
|
|
|
|
# Final sanity check (ignore comment lines so documentation tokens don't
|
|
# trip a false positive).
|
|
grep -v '^[[:space:]]*#' "$STACK_DIR/config.yaml" | grep -q '__[A-Za-z_]*__' \
|
|
&& warn "config.yaml still has un-substituted __PLACEHOLDER__ markers!" \
|
|
|| true
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Bring up the stack
|
|
# ----------------------------------------------------------------------------
|
|
if [[ "$FORCE" != "1" ]]; then
|
|
cat <<EOF
|
|
|
|
About to pull images and start the stack from $STACK_DIR.
|
|
|
|
Caddy will request a Let's Encrypt cert for ${HEADSCALE_DOMAIN}. DNS for
|
|
that name must already point at this host, and ports 80/443 must be
|
|
reachable from the internet.
|
|
|
|
In pocket-id, the OIDC client must have this redirect URI registered:
|
|
https://${HEADSCALE_DOMAIN}/oidc/callback
|
|
|
|
Continue? [y/N]
|
|
EOF
|
|
read -r ans
|
|
[[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]] || { warn "Aborted."; exit 0; }
|
|
fi
|
|
|
|
cd "$STACK_DIR"
|
|
log "Pulling images..."
|
|
docker compose pull
|
|
|
|
# Start headscale + caddy first; headplane needs a headscale API key, which we
|
|
# can only mint once headscale is up.
|
|
log "Starting headscale + caddy..."
|
|
docker compose up -d --remove-orphans headscale caddy
|
|
|
|
log "Waiting for headscale to become healthy (up to 120s)..."
|
|
deadline=$(( $(date +%s) + 120 ))
|
|
while (( $(date +%s) < deadline )); do
|
|
h=$(docker compose ps --format '{{.Service}} {{.Health}}' 2>/dev/null | awk '$1=="headscale"{print $2}')
|
|
[[ "$h" == "healthy" ]] && { log "headscale healthy."; break; }
|
|
sleep 5
|
|
done
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Headplane: mint a headscale API key on first deploy (idempotent -- stored in
|
|
# .env), render its config, then bring the UI up.
|
|
# ----------------------------------------------------------------------------
|
|
if ! grep -qE '^HEADPLANE_HS_API_KEY=.+' "$ENV_FILE"; then
|
|
log "Creating a headscale API key for headplane..."
|
|
HP_KEY=$(docker compose exec -T headscale headscale apikeys create --expiration 999d 2>/dev/null \
|
|
| tr -d '\r' | tail -n1)
|
|
if [[ -n "$HP_KEY" ]]; then
|
|
sed -i "s|^HEADPLANE_HS_API_KEY=.*|HEADPLANE_HS_API_KEY=${HP_KEY}|" "$ENV_FILE"
|
|
log "Stored headplane API key in .env."
|
|
else
|
|
warn "Could not mint a headscale API key; set HEADPLANE_HS_API_KEY in $ENV_FILE and re-run."
|
|
fi
|
|
fi
|
|
|
|
# Re-read .env (now with the API key) and render headplane's config.
|
|
set -a; . "$ENV_FILE"; set +a
|
|
render_headplane_config
|
|
|
|
log "Starting headplane + remaining services..."
|
|
docker compose up -d --remove-orphans
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Wait for health
|
|
# ----------------------------------------------------------------------------
|
|
log "Waiting for services to become healthy (up to 120s)..."
|
|
deadline=$(( $(date +%s) + 120 ))
|
|
while (( $(date +%s) < deadline )); do
|
|
status=$(docker compose ps --format '{{.Service}} {{.Health}}' 2>/dev/null || true)
|
|
unhealthy=$(echo "$status" | awk '$2 != "healthy" && $2 != "" {print $1}')
|
|
if [[ -z "$unhealthy" && -n "$status" ]]; then
|
|
log "All services healthy."
|
|
break
|
|
fi
|
|
sleep 5
|
|
done
|
|
|
|
# Convenience: a `headscale` command on the host that runs the CLI inside the
|
|
# container from ANY directory (plain `docker compose` needs to be run from
|
|
# $STACK_DIR). STACK_DIR is baked in; "$@" is passed through.
|
|
cat > /usr/local/bin/headscale <<EOF
|
|
#!/bin/sh
|
|
# Auto-generated by headscale deploy.sh -- runs the headscale CLI in-container.
|
|
exec docker compose -f "$STACK_DIR/docker-compose.yml" exec headscale headscale "\$@"
|
|
EOF
|
|
chmod +x /usr/local/bin/headscale
|
|
log "Installed /usr/local/bin/headscale (runs the CLI inside the container)."
|
|
|
|
echo
|
|
log "Stack status:"
|
|
docker compose ps
|
|
echo
|
|
cat <<EOF
|
|
================================================================
|
|
DEPLOYED
|
|
|
|
Headscale: https://${HEADSCALE_DOMAIN}
|
|
Headplane: https://${HEADSCALE_DOMAIN}/admin (web UI)
|
|
Tailnet: ${TAILNET_DOMAIN} (MagicDNS suffix)
|
|
OIDC: https://${POCKETID_DOMAIN}
|
|
Stack dir: ${STACK_DIR}
|
|
|
|
OIDC redirect/callback URIs -- register THESE in pocket-id:
|
|
headscale: https://${HEADSCALE_DOMAIN}/oidc/callback
|
|
headplane: https://${HEADSCALE_DOMAIN}/admin/oidc/callback (2nd OIDC client)
|
|
|
|
ACL policy: ${STACK_DIR}/policy.hujson
|
|
headscale policy check # validate after editing
|
|
cd ${STACK_DIR} && docker compose restart headscale # apply changes
|
|
|
|
Headscale CLI -- use the installed wrapper from anywhere:
|
|
headscale users create alice
|
|
headscale users list
|
|
headscale nodes list
|
|
headscale --help
|
|
# (equivalent: cd $STACK_DIR && docker compose exec headscale headscale ...)
|
|
|
|
Tailscale client login:
|
|
tailscale up --login-server https://${HEADSCALE_DOMAIN}
|
|
# Browser opens, OIDC flow via pocket-id, node registers.
|
|
|
|
Manage (run from $STACK_DIR):
|
|
cd $STACK_DIR
|
|
docker compose logs -f
|
|
docker compose pull && docker compose up -d # update
|
|
docker compose down # stop, keep volumes
|
|
docker compose down -v # stop, WIPE data
|
|
|
|
Or just re-run this script -- it's idempotent.
|
|
================================================================
|
|
EOF
|
|
|
|
# IMPORTANT: do not put any code below this exit. Everything after the
|
|
# __ARCHIVE_BELOW__ marker is the embedded tar.gz payload (base64).
|
|
exit 0
|
|
__ARCHIVE_BELOW__
|
|
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
|
|
dVmXdVmX/0X5H8de64YAUAAA
|