375 lines
15 KiB
Bash
Executable File
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"
|