Files
Docker-Install/patchmon-enroll.sh

375 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_NAME="$(basename "$0")"
PATCHMON_SERVER="${PATCHMON_SERVER:-https://patchmon.wheelz.lab}"
CADDY_CA_INSTALL_URL="${CADDY_CA_INSTALL_URL:-https://gitea.wheelytho.com/Wheelz/Docker-Install/raw/branch/main/install-caddy-local-ca.sh}"
CADDY_CA_CERT_PATH="${CADDY_CA_CERT_PATH:-/usr/local/share/ca-certificates/wheelz-caddy-local-root.crt}"
ENROLL_TYPE="${PATCHMON_ENROLL_TYPE:-direct-host}"
TOKEN_KEY="${PATCHMON_AUTO_ENROLL_KEY:-}"
TOKEN_SECRET="${PATCHMON_AUTO_ENROLL_SECRET:-}"
FRIENDLY_NAME_VALUE="${FRIENDLY_NAME:-}"
FORCE_INSTALL="false"
INSTALL_CADDY_CA="true"
PRINT_ONLY="false"
YES="false"
usage() {
cat <<'USAGE'
Enroll this Linux host into PatchMon using PatchMon's auto-enrollment script.
Usage:
./patchmon-enroll.sh [options]
curl -fsSL https://gitea.wheelytho.com/Wheelz/Docker-Install/raw/branch/main/patchmon-enroll.sh | sudo bash -s -- [options]
Options:
--server URL PatchMon base URL. Default: https://patchmon.wheelz.lab
--type TYPE Enrollment type: direct-host or proxmox-lxc. Default: direct-host
--token-key KEY PatchMon auto-enrollment token key. Can also use PATCHMON_AUTO_ENROLL_KEY.
--token-secret SECRET PatchMon auto-enrollment token secret. Can also use PATCHMON_AUTO_ENROLL_SECRET.
--friendly-name NAME Friendly name to report to PatchMon.
--force Ask PatchMon's generated installer to force/reinstall when supported.
--no-caddy-ca Do not install the Wheelz/Caddy internal CA before enrollment.
--print-script Download and print the generated PatchMon script instead of running it.
-y, --yes Non-interactive. Fail if token values are missing instead of prompting.
-h, --help Show this help.
Recommended one-liner, prompts for token if env vars are not set:
curl -fsSL https://gitea.wheelytho.com/Wheelz/Docker-Install/raw/branch/main/patchmon-enroll.sh | sudo bash
Non-interactive one-liner:
PATCHMON_AUTO_ENROLL_KEY='<key>' PATCHMON_AUTO_ENROLL_SECRET='<secret>' curl -fsSL https://gitea.wheelytho.com/Wheelz/Docker-Install/raw/branch/main/patchmon-enroll.sh | sudo -E bash -s -- --type direct-host -y
If a Proxmox host does not trust patchmon.wheelz.lab during bootstrap, pass the raw-IP server as a script argument, not as an env var before curl:
curl -fsSL https://gitea.wheelytho.com/Wheelz/Docker-Install/raw/branch/main/patchmon-enroll.sh | bash -s -- --server http://192.168.20.232:3000 --token-key '<key>' --token-secret '<secret>' --type direct-host --force -y
USAGE
}
log() { printf '\n[PatchMon-Enroll] %s\n' "$*"; }
warn() { printf '\n[PatchMon-Enroll WARNING] %s\n' "$*" >&2; }
fail() { printf '\n[PatchMon-Enroll ERROR] %s\n' "$*" >&2; exit 1; }
need_cmd() {
command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"
}
urlencode() {
# POSIX-ish URL encoding via Python, which is present on the supported Ubuntu/Debian hosts.
python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1"
}
prompt_secret() {
local prompt="$1"
local value=""
if [[ "$YES" == "true" ]]; then
fail "$prompt is required. Provide it with --token-key/--token-secret or PATCHMON_AUTO_ENROLL_KEY/PATCHMON_AUTO_ENROLL_SECRET."
fi
if [[ -t 0 ]]; then
read -r -s -p "$prompt: " value
printf '\n' >&2
else
if [[ -r /dev/tty ]]; then
read -r -s -p "$prompt: " value </dev/tty
printf '\n' >&2
else
fail "$prompt is required, and no TTY is available for prompting."
fi
fi
printf '%s' "$value"
}
install_caddy_ca_if_needed() {
if [[ "$INSTALL_CADDY_CA" != "true" ]]; then
return 0
fi
case "$PATCHMON_SERVER" in
https://patchmon.wheelz.lab|https://patchmon.wheelz.lab/*)
log "Installing/updating Wheelz Caddy local root CA before contacting PatchMon"
curl -fsSL "$CADDY_CA_INSTALL_URL" | bash
;;
*)
log "Skipping Caddy CA install because PatchMon server is not patchmon.wheelz.lab"
;;
esac
}
curl_patchmon() {
local -a trust_args=()
if [[ -f "$CADDY_CA_CERT_PATH" ]]; then
# Use the exact Wheelz/Caddy root CA for PatchMon calls. Some minimal
# Proxmox/Debian installs update the system store but curl still fails in
# the same one-liner; this makes enrollment deterministic.
trust_args=(--cacert "$CADDY_CA_CERT_PATH")
fi
curl "${trust_args[@]}" "$@"
}
normalize_agent_config() {
local cfg="/etc/patchmon/config.yml"
[[ -f "$cfg" ]] || return 0
log "Normalizing local PatchMon agent config to canonical FQDN"
python3 - "$cfg" "$PATCHMON_SERVER" <<'PY'
from pathlib import Path
import re
import sys
p = Path(sys.argv[1])
server = sys.argv[2].rstrip('/')
s = p.read_text()
s = re.sub(r'^patchmon_server:.*$', f'patchmon_server: {server}', s, flags=re.M)
if 'skip_ssl_verify:' in s:
s = re.sub(r'^skip_ssl_verify:.*$', 'skip_ssl_verify: false', s, flags=re.M)
else:
s += '\nskip_ssl_verify: false\n'
p.write_text(s)
PY
}
normalize_generated_script() {
local generated_script="$1"
log "Normalizing generated PatchMon installer to use: $PATCHMON_SERVER"
python3 - "$generated_script" "$PATCHMON_SERVER" <<'PY'
from pathlib import Path
import re
import sys
p = Path(sys.argv[1])
server = sys.argv[2].rstrip('/')
s = p.read_text()
# PatchMon may generate installers using its internal/raw-IP origin even when the
# wrapper contacted the canonical FQDN. Force the generated installer itself to
# enroll/configure the agent against the FQDN.
s = re.sub(r'export PATCHMON_URL="[^"]+"', f'export PATCHMON_URL="{server}"', s)
s = s.replace('http://192.168.20.232:3000', server)
# Make curl inside PatchMon's generated script trust the Wheelz/Caddy root even
# on minimal hosts where the CA bundle refresh is not picked up immediately.
if 'CURL_CA_BUNDLE=' not in s:
s = s.replace(
f'export PATCHMON_URL="{server}"',
f'export PATCHMON_URL="{server}"\nexport CURL_CA_BUNDLE="${{CURL_CA_BUNDLE:-/usr/local/share/ca-certificates/wheelz-caddy-local-root.crt}}"\nexport SSL_CERT_FILE="${{SSL_CERT_FILE:-/usr/local/share/ca-certificates/wheelz-caddy-local-root.crt}}"',
1,
)
# PatchMon's generated auto-enroll script captures the real agent-install
# output, but by default only prints a generic "Failed to install agent" line.
# Make failures actionable for one-liner installs.
s = s.replace(
' error "Failed to install agent (exit: $install_exit_code)"',
' printf "%s\\n" "$install_output" >&2\n error "Failed to install agent (exit: $install_exit_code)"',
)
# Direct-host generated installers and proxmox-lxc generated installers use
# different nested install shapes. Normalize both.
old_nested = ''' # Download and execute installation script
install_exit_code=0
install_output=$(curl $CURL_FLAGS \\
-H "X-API-ID: $api_id" \\
-H "X-API-KEY: $api_key" \\
"$install_url" | sh 2>&1) || install_exit_code=$?
'''
new_nested = ''' # Download, normalize, and execute installation script
install_exit_code=0
nested_install_script=$(mktemp /tmp/patchmon-agent-install.XXXXXX.sh)
if curl $CURL_FLAGS \\
-H "X-API-ID: $api_id" \\
-H "X-API-KEY: $api_key" \\
"$install_url" > "$nested_install_script"; then
sed -i "s#http://192.168.20.232:3000#$PATCHMON_URL#g" "$nested_install_script"
install_output=$(bash "$nested_install_script" 2>&1) || install_exit_code=$?
else
install_exit_code=1
install_output="Failed to download nested PatchMon agent installer from $install_url"
fi
rm -f "$nested_install_script"
'''
s = s.replace(old_nested, new_nested)
# PatchMon's proxmox-lxc generated installer downloads a nested installer inside
# each container with `pct exec ... sh -c`. The containers also need the local
# Caddy CA before HTTPS calls to patchmon.wheelz.lab work. Inject a host-side CA
# push/update step and normalize the nested script inside the container before
# running it.
old_lxc_install = ''' # Download and execute in separate steps to avoid stdin issues with piping
# Pass CURL_FLAGS as environment variable to container
# Use sh -c for POSIX compatibility (Alpine uses ash, not bash)
install_output=$(timeout 180 pct exec "$vmid" -- sh -c "
export CURL_FLAGS='$CURL_FLAGS'
cd /tmp
curl \\$CURL_FLAGS \\
-H \\"X-API-ID: $api_id\\" \\
-H \\"X-API-KEY: $api_key\\" \\
-o patchmon-install.sh \\
'$install_url' && \\
sh patchmon-install.sh && \\
rm -f patchmon-install.sh
" 2>&1 </dev/null) || install_exit_code=$?
'''
new_lxc_install = ''' # Install/trust the Wheelz Caddy root CA inside the LXC before the nested
# installer contacts https://patchmon.wheelz.lab. Minimal containers may
# not trust the host's CA bundle.
ca_push_output=""
if [[ -f "/usr/local/share/ca-certificates/wheelz-caddy-local-root.crt" ]]; then
ca_push_output=$(timeout 30 pct exec "$vmid" -- sh -c "mkdir -p /usr/local/share/ca-certificates" 2>&1 </dev/null || true)
ca_push_output="$ca_push_output
$(timeout 30 pct push "$vmid" /usr/local/share/ca-certificates/wheelz-caddy-local-root.crt /usr/local/share/ca-certificates/wheelz-caddy-local-root.crt 2>&1 || true)"
ca_push_output="$ca_push_output
$(timeout 60 pct exec "$vmid" -- sh -c "command -v update-ca-certificates >/dev/null 2>&1 && update-ca-certificates || true" 2>&1 </dev/null || true)"
fi
# Download and execute in separate steps to avoid stdin issues with piping.
# Normalize the nested installer inside the container before running it.
install_output=$(timeout 180 pct exec "$vmid" -- sh -c "
export CURL_FLAGS='$CURL_FLAGS'
export CURL_CA_BUNDLE=\"/usr/local/share/ca-certificates/wheelz-caddy-local-root.crt\"
export SSL_CERT_FILE=\"/usr/local/share/ca-certificates/wheelz-caddy-local-root.crt\"
cd /tmp
curl \\$CURL_FLAGS --cacert /usr/local/share/ca-certificates/wheelz-caddy-local-root.crt \\
-H \\"X-API-ID: $api_id\\" \\
-H \\"X-API-KEY: $api_key\\" \\
-o patchmon-install.sh \\
'$install_url' && \\
sed -i 's#http://192.168.20.232:3000#$PATCHMON_URL#g' patchmon-install.sh && \\
sh patchmon-install.sh && \\
rm -f patchmon-install.sh
" 2>&1 </dev/null) || install_exit_code=$?
install_output="$ca_push_output
$install_output"
'''
s = s.replace(old_lxc_install, new_lxc_install)
p.write_text(s)
PY
}
while [[ $# -gt 0 ]]; do
case "$1" in
--server)
[[ $# -ge 2 ]] || fail "--server requires a URL"
PATCHMON_SERVER="$2"
shift 2
;;
--type)
[[ $# -ge 2 ]] || fail "--type requires direct-host or proxmox-lxc"
ENROLL_TYPE="$2"
shift 2
;;
--token-key)
[[ $# -ge 2 ]] || fail "--token-key requires a value"
TOKEN_KEY="$2"
shift 2
;;
--token-secret)
[[ $# -ge 2 ]] || fail "--token-secret requires a value"
TOKEN_SECRET="$2"
shift 2
;;
--friendly-name)
[[ $# -ge 2 ]] || fail "--friendly-name requires a value"
FRIENDLY_NAME_VALUE="$2"
shift 2
;;
--force)
FORCE_INSTALL="true"
shift
;;
--no-caddy-ca)
INSTALL_CADDY_CA="false"
shift
;;
--print-script)
PRINT_ONLY="true"
shift
;;
-y|--yes)
YES="true"
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown option: $1"
;;
esac
done
case "$ENROLL_TYPE" in
direct-host|proxmox-lxc) ;;
*) fail "--type must be direct-host or proxmox-lxc. Got: $ENROLL_TYPE" ;;
esac
PATCHMON_SERVER="${PATCHMON_SERVER%/}"
[[ "$PATCHMON_SERVER" =~ ^https?:// ]] || fail "--server must start with http:// or https://"
need_cmd curl
need_cmd python3
need_cmd mktemp
if [[ -z "$TOKEN_KEY" ]]; then
TOKEN_KEY="$(prompt_secret 'PatchMon auto-enrollment token key')"
fi
if [[ -z "$TOKEN_SECRET" ]]; then
TOKEN_SECRET="$(prompt_secret 'PatchMon auto-enrollment token secret')"
fi
[[ -n "$TOKEN_KEY" ]] || fail "Token key cannot be empty"
[[ -n "$TOKEN_SECRET" ]] || fail "Token secret cannot be empty"
if [[ "$PRINT_ONLY" != "true" && "$(id -u)" -ne 0 ]]; then
fail "This script must be run as root because PatchMon's generated installer installs a system agent. Use sudo."
fi
if [[ "$PRINT_ONLY" != "true" ]]; then
install_caddy_ca_if_needed
fi
query="type=$(urlencode "$ENROLL_TYPE")&force=$(urlencode "$FORCE_INSTALL")&token_key=$(urlencode "$TOKEN_KEY")&token_secret=$(urlencode "$TOKEN_SECRET")"
script_url="${PATCHMON_SERVER}/api/v1/auto-enrollment/script?${query}"
tmp_script="$(mktemp /tmp/patchmon-enroll.XXXXXX.sh)"
cleanup() { rm -f "$tmp_script"; }
trap cleanup EXIT
log "Downloading PatchMon generated enrollment script"
if ! curl_patchmon -fsSL "$script_url" -o "$tmp_script"; then
fail "Failed to download PatchMon enrollment script. Check server URL, token, allowed IP range, and network access."
fi
if ! head -n 1 "$tmp_script" | grep -q '^#!'; then
warn "PatchMon did not return a shell script. First lines follow:"
sed -n '1,10p' "$tmp_script" >&2
fail "Unexpected response from PatchMon enrollment endpoint"
fi
normalize_generated_script "$tmp_script"
chmod 700 "$tmp_script"
if [[ "$PRINT_ONLY" == "true" ]]; then
sed -e "s/${TOKEN_SECRET//\//\\/}/<redacted>/g" -e "s/${TOKEN_KEY//\//\\/}/<redacted>/g" "$tmp_script"
exit 0
fi
log "Running PatchMon enrollment script as type: $ENROLL_TYPE"
# PatchMon's generated scripts use bash-specific features. The proxmox-lxc
# installer in particular contains `trap ... ERR`, which fails under Debian's
# /bin/sh (dash) with `trap: ERR: bad trap`. Always execute the generated
# installer with bash.
need_cmd bash
if [[ -n "$FRIENDLY_NAME_VALUE" ]]; then
FRIENDLY_NAME="$FRIENDLY_NAME_VALUE" bash "$tmp_script"
else
bash "$tmp_script"
fi
normalize_agent_config
if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files 'patchmon-agent.service' --no-legend 2>/dev/null | grep -q .; then
log "Restarting PatchMon agent after config normalization"
systemctl restart patchmon-agent || warn "patchmon-agent restart failed; check service logs"
fi
log "Verifying local PatchMon agent service"
if command -v systemctl >/dev/null 2>&1; then
if systemctl list-unit-files 'patchmon*' --no-legend 2>/dev/null | grep -q .; then
systemctl --no-pager --full status 'patchmon*' || true
else
warn "No patchmon systemd unit was found. Check the installer output above."
fi
else
warn "systemctl not available; skipping service verification."
fi
log "Enrollment wrapper completed"