feat(primitives): 3 shell provisioning + hardening
- provision-hetzner.sh — idempotent hcloud wrapper; create/destroy/status/list * HCLOUD_TOKEN from ~/.claude/secrets/.env (RULE 0.8) - provision-vultr.sh — idempotent vultr-cli wrapper; Vultr resolves IP async * VULTR_API_KEY from ~/.claude/secrets/.env (RULE 0.8) - harden-base.sh — Debian/Ubuntu baseline; apt → ssh → ufw → fail2ban → auditd → unattended-upgrades; idempotent; ports generic patterns from vortex/control/setup/setup.sh:13-53 (no Xray/sing-box/WG steps) All three reject unsupported platforms early; harden-base.sh never auto-reboots (surfaces needrestart hints only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
19cbdbd689
commit
969e24c6c4
3 changed files with 600 additions and 0 deletions
240
_primitives/harden-base.sh
Executable file
240
_primitives/harden-base.sh
Executable file
|
|
@ -0,0 +1,240 @@
|
|||
#!/usr/bin/env bash
|
||||
# harden-base.sh — idempotent Debian/Ubuntu baseline hardening.
|
||||
# Runs ON THE TARGET VPS (not the local workstation). Ports generic
|
||||
# patterns from ~/Projects/vortex/control/setup/setup.sh:13-53 — strips
|
||||
# Vortex-specific Xray/sing-box/Wireguard steps.
|
||||
#
|
||||
# USAGE
|
||||
# curl -fsSL <your-raw-url>/harden-base.sh | sudo bash -s -- [options]
|
||||
# OR
|
||||
# scp harden-base.sh keiadmin@host:/tmp/
|
||||
# ssh keiadmin@host "sudo bash /tmp/harden-base.sh"
|
||||
#
|
||||
# OPTIONS
|
||||
# --admin-user <name> default: keiadmin
|
||||
# --ssh-port <n> default: 22 (opens in ufw + enforces in sshd drop-in)
|
||||
# --allow-port <n/proto> repeatable. e.g. --allow-port 443/tcp --allow-port 80/tcp
|
||||
# --no-caddy skip Caddy install (default: skip — install via its own block)
|
||||
# --no-reboot default (never reboots; surfaces needrestart hints only)
|
||||
# --skip <step> repeatable; known steps: apt, ssh, ufw, fail2ban, auditd, unattended
|
||||
#
|
||||
# IDEMPOTENCY
|
||||
# Every step is `test → configure → reload`. Re-run = diff-and-apply.
|
||||
# Detects the sshd_config.d/99-kei.conf + audit rules files; overwrites with
|
||||
# known-good content; never destroys /etc/ssh/sshd_config itself.
|
||||
#
|
||||
# ENV
|
||||
# No secrets read. SECRETS SINGLE SOURCE (RULE 0.8) → harden-base does not
|
||||
# touch tokens/keys.
|
||||
#
|
||||
# EXIT
|
||||
# 0 ok
|
||||
# 1 usage / platform not supported
|
||||
# 2 hardening step failed (stderr)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
log() { printf '[%s] [harden-base] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
|
||||
die() { log "ERROR: $*"; exit "${2:-2}"; }
|
||||
|
||||
# ------------------------------------------------------------------ args
|
||||
ADMIN_USER="keiadmin"
|
||||
SSH_PORT="22"
|
||||
ALLOW_PORTS=()
|
||||
SKIPS=()
|
||||
NO_CADDY=1 # default on; Caddy install is its own block
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--admin-user) ADMIN_USER="$2"; shift 2 ;;
|
||||
--ssh-port) SSH_PORT="$2"; shift 2 ;;
|
||||
--allow-port) ALLOW_PORTS+=("$2"); shift 2 ;;
|
||||
--no-caddy) NO_CADDY=1; shift ;;
|
||||
--no-reboot) shift ;; # accepted for clarity; we never auto-reboot
|
||||
--skip) SKIPS+=("$2"); shift 2 ;;
|
||||
-h|--help) cat <<EOF >&2
|
||||
harden-base.sh — Debian/Ubuntu baseline hardening.
|
||||
OPTIONS
|
||||
--admin-user <name> default: keiadmin
|
||||
--ssh-port <n> default: 22
|
||||
--allow-port <n/proto> repeatable (e.g. 443/tcp, 80/tcp)
|
||||
--no-caddy (default) skip Caddy install
|
||||
--skip <step> apt|ssh|ufw|fail2ban|auditd|unattended
|
||||
EOF
|
||||
exit 0 ;;
|
||||
*) die "unknown flag '$1'" 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------------ guards
|
||||
[ "$(id -u)" -eq 0 ] || die "must run as root (sudo)." 1
|
||||
. /etc/os-release 2>/dev/null || die "cannot read /etc/os-release" 1
|
||||
case "${ID:-}" in
|
||||
debian|ubuntu) : ;;
|
||||
*) die "only Debian/Ubuntu supported (got ID=${ID:-unknown})" 1 ;;
|
||||
esac
|
||||
|
||||
skipped() {
|
||||
local step="$1"
|
||||
for s in "${SKIPS[@]}"; do [ "$s" = "$step" ] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ step: apt
|
||||
step_apt() {
|
||||
skipped apt && { log "skip apt"; return; }
|
||||
log "apt update + base packages…"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq \
|
||||
ufw fail2ban unattended-upgrades needrestart auditd audispd-plugins \
|
||||
curl wget jq ca-certificates openssh-server
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ step: admin user
|
||||
step_admin_user() {
|
||||
if id "$ADMIN_USER" >/dev/null 2>&1; then
|
||||
log "user '$ADMIN_USER' exists"
|
||||
else
|
||||
log "creating '$ADMIN_USER' (sudo, bash, NOPASSWD)"
|
||||
useradd -m -s /bin/bash -G sudo "$ADMIN_USER"
|
||||
install -d -m 0700 -o "$ADMIN_USER" -g "$ADMIN_USER" "/home/$ADMIN_USER/.ssh"
|
||||
fi
|
||||
install -d -m 0755 /etc/sudoers.d
|
||||
cat >/etc/sudoers.d/90-keiadmin <<EOF
|
||||
$ADMIN_USER ALL=(ALL) NOPASSWD:ALL
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/90-keiadmin
|
||||
visudo -cf /etc/sudoers.d/90-keiadmin >/dev/null
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ step: ssh
|
||||
step_ssh() {
|
||||
skipped ssh && { log "skip ssh"; return; }
|
||||
log "ssh: writing /etc/ssh/sshd_config.d/99-kei.conf (port=$SSH_PORT, user=$ADMIN_USER)…"
|
||||
install -d -m 0755 /etc/ssh/sshd_config.d
|
||||
cat >/etc/ssh/sshd_config.d/99-kei.conf <<EOF
|
||||
# GENERATED by harden-base.sh — idempotent. Edit intent, re-run the script.
|
||||
Port $SSH_PORT
|
||||
Protocol 2
|
||||
PasswordAuthentication no
|
||||
ChallengeResponseAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PermitRootLogin prohibit-password
|
||||
PermitEmptyPasswords no
|
||||
UsePAM yes
|
||||
MaxAuthTries 3
|
||||
MaxSessions 4
|
||||
LoginGraceTime 20
|
||||
AllowUsers $ADMIN_USER
|
||||
AllowTcpForwarding no
|
||||
X11Forwarding no
|
||||
PermitTunnel no
|
||||
ClientAliveInterval 120
|
||||
ClientAliveCountMax 2
|
||||
LogLevel VERBOSE
|
||||
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,sntrup761x25519-sha512@openssh.com
|
||||
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
|
||||
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
|
||||
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
|
||||
EOF
|
||||
sshd -t
|
||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ step: ufw
|
||||
step_ufw() {
|
||||
skipped ufw && { log "skip ufw"; return; }
|
||||
log "ufw: default-deny-in + allow-out + ssh rate-limit…"
|
||||
ufw --force reset >/dev/null
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw default deny routed
|
||||
ufw limit "$SSH_PORT/tcp" comment "ssh (rate-limited)"
|
||||
for p in "${ALLOW_PORTS[@]}"; do
|
||||
log " allow $p"
|
||||
ufw allow "$p"
|
||||
done
|
||||
ufw logging medium
|
||||
ufw --force enable
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ step: fail2ban
|
||||
step_fail2ban() {
|
||||
skipped fail2ban && { log "skip fail2ban"; return; }
|
||||
log "fail2ban: writing /etc/fail2ban/jail.local (sshd jail)…"
|
||||
cat >/etc/fail2ban/jail.local <<EOF
|
||||
[DEFAULT]
|
||||
bantime = 3600
|
||||
findtime = 600
|
||||
maxretry = 5
|
||||
backend = systemd
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = $SSH_PORT
|
||||
EOF
|
||||
systemctl enable --now fail2ban
|
||||
systemctl restart fail2ban
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ step: auditd
|
||||
step_auditd() {
|
||||
skipped auditd && { log "skip auditd"; return; }
|
||||
log "auditd: writing /etc/audit/rules.d/99-kei.rules…"
|
||||
install -d -m 0750 /etc/audit/rules.d
|
||||
cat >/etc/audit/rules.d/99-kei.rules <<'EOF'
|
||||
# GENERATED by harden-base.sh — idempotent baseline.
|
||||
-w /etc/ssh/sshd_config -p wa -k sshd_config
|
||||
-w /etc/ssh/sshd_config.d/ -p wa -k sshd_config
|
||||
-w /root/.ssh/ -p wa -k ssh_keys_root
|
||||
-w /etc/sudoers -p wa -k sudoers
|
||||
-w /etc/sudoers.d/ -p wa -k sudoers
|
||||
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=unset -k sudo_root
|
||||
-w /etc/passwd -p wa -k identity
|
||||
-w /etc/group -p wa -k identity
|
||||
-w /etc/shadow -p wa -k identity
|
||||
-w /etc/gshadow -p wa -k identity
|
||||
-a always,exit -F arch=b64 -S init_module -S finit_module -S delete_module -k module
|
||||
-a always,exit -F arch=b64 -S adjtimex -S settimeofday -S clock_settime -k time
|
||||
-w /etc/localtime -p wa -k time
|
||||
-e 2
|
||||
EOF
|
||||
augenrules --load >/dev/null
|
||||
systemctl enable --now auditd
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ step: unattended-upgrades + needrestart
|
||||
step_unattended() {
|
||||
skipped unattended && { log "skip unattended"; return; }
|
||||
log "unattended-upgrades + needrestart (list-only)…"
|
||||
cat >/etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
APT::Periodic::AutocleanInterval "7";
|
||||
EOF
|
||||
cat >/etc/apt/apt.conf.d/50unattended-upgrades.kei <<'EOF'
|
||||
Unattended-Upgrade::Origins-Pattern {
|
||||
"origin=Debian,codename=${distro_codename}-security";
|
||||
"origin=Debian,codename=${distro_codename}-updates";
|
||||
"origin=Ubuntu,archive=${distro_codename}-security";
|
||||
};
|
||||
Unattended-Upgrade::Automatic-Reboot "false";
|
||||
Unattended-Upgrade::MailReport "on-change";
|
||||
EOF
|
||||
# needrestart: list services, suppress TTY prompts (non-TTY cron safe).
|
||||
if [ -f /etc/needrestart/needrestart.conf ]; then
|
||||
sed -i "s/^#\?\$nrconf{restart}.*/\$nrconf{restart} = 'l';/" /etc/needrestart/needrestart.conf
|
||||
sed -i "s/^#\?\$nrconf{kernelhints}.*/\$nrconf{kernelhints} = -1;/" /etc/needrestart/needrestart.conf
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ main
|
||||
log "start: admin=$ADMIN_USER ssh=$SSH_PORT extra-ports=${ALLOW_PORTS[*]:-none}"
|
||||
step_apt
|
||||
step_admin_user
|
||||
step_ssh
|
||||
step_ufw
|
||||
step_fail2ban
|
||||
step_auditd
|
||||
step_unattended
|
||||
log "done. Next: verify via _primitives/_rust/ssh-check + firewall-diff."
|
||||
164
_primitives/provision-hetzner.sh
Executable file
164
_primitives/provision-hetzner.sh
Executable file
|
|
@ -0,0 +1,164 @@
|
|||
#!/usr/bin/env bash
|
||||
# provision-hetzner.sh — idempotent Hetzner Cloud server provisioning.
|
||||
# Wraps the `hcloud` CLI. Install path:
|
||||
# $HOME/.claude/agents/_primitives/provision-hetzner.sh
|
||||
#
|
||||
# USAGE
|
||||
# provision-hetzner.sh create <name> [--type cx22|cax11] [--location fsn1] \
|
||||
# [--image debian-12] [--ssh-key <id>] \
|
||||
# [--firewall <name>] [--user-data <file>]
|
||||
# provision-hetzner.sh status <name>
|
||||
# provision-hetzner.sh destroy <name> [--force]
|
||||
# provision-hetzner.sh list
|
||||
#
|
||||
# ENV (RULE 0.8 — secrets single source)
|
||||
# HCLOUD_TOKEN — Hetzner API token (REQUIRED). Source:
|
||||
# $(grep ^HCLOUD_TOKEN ~/.claude/secrets/.env | cut -d= -f2)
|
||||
#
|
||||
# EXIT
|
||||
# 0 ok
|
||||
# 1 usage / missing args / missing deps / unknown command
|
||||
# 2 hcloud API error (non-idempotent path — inspect stderr)
|
||||
#
|
||||
# IDEMPOTENCY
|
||||
# `create <name>` on an existing server prints its IP + exits 0.
|
||||
# `destroy <name>` on a missing server exits 0 (nothing to do).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
log() { printf '[%s] [provision-hetzner] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
|
||||
die() { log "ERROR: $*"; exit "${2:-2}"; }
|
||||
|
||||
check_deps() {
|
||||
command -v hcloud >/dev/null 2>&1 || \
|
||||
die "hcloud CLI missing. Install: brew install hcloud (macOS) | https://github.com/hetznercloud/cli/releases" 1
|
||||
command -v jq >/dev/null 2>&1 || die "jq missing. brew install jq" 1
|
||||
[ -n "${HCLOUD_TOKEN:-}" ] || die "HCLOUD_TOKEN not set. Source ~/.claude/secrets/.env first." 1
|
||||
}
|
||||
|
||||
# Print server JSON if it exists, empty string otherwise. Never fails.
|
||||
server_json() {
|
||||
local name="$1"
|
||||
hcloud server describe "$name" -o json 2>/dev/null || true
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
check_deps
|
||||
hcloud server list -o 'columns=id,name,status,ipv4,location,server_type,created'
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
check_deps
|
||||
local name="${1:-}"; [ -n "$name" ] || die "status: <name> required" 1
|
||||
local json; json=$(server_json "$name")
|
||||
if [ -z "$json" ]; then
|
||||
echo "absent"
|
||||
return 0
|
||||
fi
|
||||
printf 'name=%s\nstatus=%s\nipv4=%s\nlocation=%s\ntype=%s\n' \
|
||||
"$(jq -r .name <<<"$json")" \
|
||||
"$(jq -r .status <<<"$json")" \
|
||||
"$(jq -r '.public_net.ipv4.ip // "-"' <<<"$json")" \
|
||||
"$(jq -r .datacenter.location.name <<<"$json")" \
|
||||
"$(jq -r .server_type.name <<<"$json")"
|
||||
}
|
||||
|
||||
cmd_create() {
|
||||
check_deps
|
||||
local name="${1:-}"; shift || true
|
||||
[ -n "$name" ] || die "create: <name> required" 1
|
||||
|
||||
local type="cx22" location="fsn1" image="debian-12"
|
||||
local ssh_key="" firewall="" user_data=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--type) type="$2"; shift 2 ;;
|
||||
--location) location="$2"; shift 2 ;;
|
||||
--image) image="$2"; shift 2 ;;
|
||||
--ssh-key) ssh_key="$2"; shift 2 ;;
|
||||
--firewall) firewall="$2"; shift 2 ;;
|
||||
--user-data) user_data="$2"; shift 2 ;;
|
||||
*) die "create: unknown flag '$1'" 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Idempotent fast-path: if the server already exists, just print its IP.
|
||||
local existing; existing=$(server_json "$name")
|
||||
if [ -n "$existing" ]; then
|
||||
local ip; ip=$(jq -r '.public_net.ipv4.ip // "-"' <<<"$existing")
|
||||
log "server '$name' already exists → $ip (no-op)"
|
||||
echo "$ip"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local args=(server create
|
||||
--name "$name"
|
||||
--type "$type"
|
||||
--image "$image"
|
||||
--location "$location"
|
||||
--label "project=kei"
|
||||
)
|
||||
[ -n "$ssh_key" ] && args+=(--ssh-key "$ssh_key")
|
||||
[ -n "$firewall" ] && args+=(--firewall "$firewall")
|
||||
[ -n "$user_data" ] && { [ -r "$user_data" ] || die "user-data not readable: $user_data" 1; args+=(--user-data-from-file "$user_data"); }
|
||||
|
||||
log "creating '$name' ($type @ $location, image=$image)…"
|
||||
hcloud "${args[@]}" -o json >/tmp/provision-hetzner-$$.json
|
||||
local ip; ip=$(jq -r '.server.public_net.ipv4.ip' /tmp/provision-hetzner-$$.json)
|
||||
rm -f /tmp/provision-hetzner-$$.json
|
||||
[ "$ip" != "null" ] && [ -n "$ip" ] || die "create returned no IPv4 (check stderr)"
|
||||
log "created '$name' → $ip"
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
cmd_destroy() {
|
||||
check_deps
|
||||
local name="${1:-}"; shift || true
|
||||
[ -n "$name" ] || die "destroy: <name> required" 1
|
||||
local force=""
|
||||
[ "${1:-}" = "--force" ] && force=1
|
||||
|
||||
local existing; existing=$(server_json "$name")
|
||||
if [ -z "$existing" ]; then
|
||||
log "server '$name' absent (no-op)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -z "$force" ]; then
|
||||
printf 'Destroy server "%s"? [y/N] ' "$name" >&2
|
||||
read -r ans
|
||||
[ "$ans" = "y" ] || [ "$ans" = "Y" ] || { log "aborted"; return 1; }
|
||||
fi
|
||||
|
||||
log "deleting '$name'…"
|
||||
hcloud server delete "$name" >&2
|
||||
log "deleted '$name'"
|
||||
}
|
||||
|
||||
main() {
|
||||
local cmd="${1:-}"; shift || true
|
||||
case "$cmd" in
|
||||
create) cmd_create "$@" ;;
|
||||
destroy) cmd_destroy "$@" ;;
|
||||
status) cmd_status "$@" ;;
|
||||
list) cmd_list "$@" ;;
|
||||
-h|--help|"") cat <<EOF >&2
|
||||
provision-hetzner.sh — idempotent Hetzner Cloud server provisioning.
|
||||
USAGE
|
||||
provision-hetzner.sh create <name> [--type cx22|cax11] [--location fsn1] \\
|
||||
[--image debian-12] [--ssh-key <id>] \\
|
||||
[--firewall <name>] [--user-data <file>]
|
||||
provision-hetzner.sh status <name>
|
||||
provision-hetzner.sh destroy <name> [--force]
|
||||
provision-hetzner.sh list
|
||||
|
||||
ENV
|
||||
HCLOUD_TOKEN (required) — load via: source ~/.claude/secrets/.env
|
||||
EOF
|
||||
[ "$cmd" = "-h" ] || [ "$cmd" = "--help" ] && exit 0 || exit 1
|
||||
;;
|
||||
*) die "unknown command '$cmd'. Run --help." 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
196
_primitives/provision-vultr.sh
Executable file
196
_primitives/provision-vultr.sh
Executable file
|
|
@ -0,0 +1,196 @@
|
|||
#!/usr/bin/env bash
|
||||
# provision-vultr.sh — idempotent Vultr VPS provisioning.
|
||||
# Wraps the `vultr-cli` v3. Install path:
|
||||
# $HOME/.claude/agents/_primitives/provision-vultr.sh
|
||||
#
|
||||
# USAGE
|
||||
# provision-vultr.sh create <label> [--plan vc2-1c-1gb] [--region ams] \
|
||||
# [--os-id 2136] [--ssh-key <id>] \
|
||||
# [--firewall <group-id>] [--user-data <file>]
|
||||
# provision-vultr.sh status <label>
|
||||
# provision-vultr.sh destroy <label> [--force]
|
||||
# provision-vultr.sh list
|
||||
#
|
||||
# ENV (RULE 0.8 — secrets single source)
|
||||
# VULTR_API_KEY — Vultr API key (REQUIRED). Source:
|
||||
# $(grep ^VULTR_API_KEY ~/.claude/secrets/.env | cut -d= -f2)
|
||||
#
|
||||
# NOTES
|
||||
# * vultr-cli v3: `vultr-cli instance create …` (not `server`).
|
||||
# * --os-id 2136 = Debian 12 x86_64 (subject to change; verify via
|
||||
# `vultr-cli os list | grep Debian`). We do NOT hard-code the ID.
|
||||
# * Vultr identifies instances by UUID; we use the human-friendly `label`
|
||||
# field for idempotency. Labels must be unique within the account.
|
||||
#
|
||||
# EXIT
|
||||
# 0 ok
|
||||
# 1 usage / missing args / missing deps / unknown command
|
||||
# 2 vultr API error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
log() { printf '[%s] [provision-vultr] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
|
||||
die() { log "ERROR: $*"; exit "${2:-2}"; }
|
||||
|
||||
check_deps() {
|
||||
command -v vultr-cli >/dev/null 2>&1 || \
|
||||
die "vultr-cli missing. Install: brew install vultr/vultr-cli/vultr-cli | https://github.com/vultr/vultr-cli" 1
|
||||
command -v jq >/dev/null 2>&1 || die "jq missing. brew install jq" 1
|
||||
[ -n "${VULTR_API_KEY:-}" ] || die "VULTR_API_KEY not set. Source ~/.claude/secrets/.env first." 1
|
||||
}
|
||||
|
||||
# Return JSON of instance with matching label, or empty string.
|
||||
instance_json_by_label() {
|
||||
local label="$1"
|
||||
vultr-cli instance list -o json 2>/dev/null \
|
||||
| jq -c --arg l "$label" '.instances[] | select(.label == $l)' \
|
||||
| head -n1
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
check_deps
|
||||
vultr-cli instance list -o json \
|
||||
| jq -r '.instances[] | [.label, .region, .plan, .status, .main_ip] | @tsv'
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
check_deps
|
||||
local label="${1:-}"; [ -n "$label" ] || die "status: <label> required" 1
|
||||
local json; json=$(instance_json_by_label "$label")
|
||||
if [ -z "$json" ]; then
|
||||
echo "absent"
|
||||
return 0
|
||||
fi
|
||||
printf 'label=%s\nid=%s\nstatus=%s\npower=%s\nip=%s\nregion=%s\nplan=%s\n' \
|
||||
"$(jq -r .label <<<"$json")" \
|
||||
"$(jq -r .id <<<"$json")" \
|
||||
"$(jq -r .status <<<"$json")" \
|
||||
"$(jq -r .power_status <<<"$json")" \
|
||||
"$(jq -r .main_ip <<<"$json")" \
|
||||
"$(jq -r .region <<<"$json")" \
|
||||
"$(jq -r .plan <<<"$json")"
|
||||
}
|
||||
|
||||
resolve_debian_12_os() {
|
||||
# Return the OS id for "Debian 12 x64" (subject to Vultr catalog updates).
|
||||
vultr-cli os list -o json \
|
||||
| jq -r '.os[] | select(.name | test("Debian 12.*x64"; "i")) | .id' \
|
||||
| head -n1
|
||||
}
|
||||
|
||||
cmd_create() {
|
||||
check_deps
|
||||
local label="${1:-}"; shift || true
|
||||
[ -n "$label" ] || die "create: <label> required" 1
|
||||
|
||||
local plan="vc2-1c-1gb" region="ams" os_id=""
|
||||
local ssh_key="" firewall="" user_data=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--plan) plan="$2"; shift 2 ;;
|
||||
--region) region="$2"; shift 2 ;;
|
||||
--os-id) os_id="$2"; shift 2 ;;
|
||||
--ssh-key) ssh_key="$2"; shift 2 ;;
|
||||
--firewall) firewall="$2"; shift 2 ;;
|
||||
--user-data) user_data="$2"; shift 2 ;;
|
||||
*) die "create: unknown flag '$1'" 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Idempotency.
|
||||
local existing; existing=$(instance_json_by_label "$label")
|
||||
if [ -n "$existing" ]; then
|
||||
local ip; ip=$(jq -r '.main_ip // "-"' <<<"$existing")
|
||||
log "instance '$label' already exists → $ip (no-op)"
|
||||
echo "$ip"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[ -z "$os_id" ] && { os_id=$(resolve_debian_12_os) || true; }
|
||||
[ -n "$os_id" ] || die "cannot resolve Debian 12 OS id. Pass --os-id explicitly." 1
|
||||
|
||||
local args=(instance create
|
||||
--region "$region"
|
||||
--plan "$plan"
|
||||
--os "$os_id"
|
||||
--label "$label"
|
||||
--tags "project=kei"
|
||||
)
|
||||
[ -n "$ssh_key" ] && args+=(--ssh-keys "$ssh_key")
|
||||
[ -n "$firewall" ] && args+=(--firewall-group-id "$firewall")
|
||||
if [ -n "$user_data" ]; then
|
||||
[ -r "$user_data" ] || die "user-data not readable: $user_data" 1
|
||||
# vultr-cli expects base64 for userdata.
|
||||
args+=(--userdata "$(base64 < "$user_data" | tr -d '\n')")
|
||||
fi
|
||||
|
||||
log "creating '$label' ($plan @ $region, os=$os_id)…"
|
||||
vultr-cli "${args[@]}" -o json >/tmp/provision-vultr-$$.json
|
||||
local ip; ip=$(jq -r '.instance.main_ip' /tmp/provision-vultr-$$.json)
|
||||
rm -f /tmp/provision-vultr-$$.json
|
||||
# Vultr assigns IP asynchronously — re-poll if empty.
|
||||
if [ "$ip" = "" ] || [ "$ip" = "null" ] || [ "$ip" = "0.0.0.0" ]; then
|
||||
log "IP pending — polling instance status up to 60s…"
|
||||
for _ in $(seq 1 30); do
|
||||
sleep 2
|
||||
ip=$(instance_json_by_label "$label" | jq -r '.main_ip // ""')
|
||||
[ -n "$ip" ] && [ "$ip" != "0.0.0.0" ] && break
|
||||
done
|
||||
fi
|
||||
[ -n "$ip" ] && [ "$ip" != "0.0.0.0" ] || die "create: no IPv4 after 60s poll"
|
||||
log "created '$label' → $ip"
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
cmd_destroy() {
|
||||
check_deps
|
||||
local label="${1:-}"; shift || true
|
||||
[ -n "$label" ] || die "destroy: <label> required" 1
|
||||
local force=""
|
||||
[ "${1:-}" = "--force" ] && force=1
|
||||
|
||||
local existing; existing=$(instance_json_by_label "$label")
|
||||
if [ -z "$existing" ]; then
|
||||
log "instance '$label' absent (no-op)"
|
||||
return 0
|
||||
fi
|
||||
local id; id=$(jq -r .id <<<"$existing")
|
||||
|
||||
if [ -z "$force" ]; then
|
||||
printf 'Destroy instance "%s" (%s)? [y/N] ' "$label" "$id" >&2
|
||||
read -r ans
|
||||
[ "$ans" = "y" ] || [ "$ans" = "Y" ] || { log "aborted"; return 1; }
|
||||
fi
|
||||
|
||||
log "deleting '$label' ($id)…"
|
||||
vultr-cli instance delete "$id" >&2
|
||||
log "deleted '$label'"
|
||||
}
|
||||
|
||||
main() {
|
||||
local cmd="${1:-}"; shift || true
|
||||
case "$cmd" in
|
||||
create) cmd_create "$@" ;;
|
||||
destroy) cmd_destroy "$@" ;;
|
||||
status) cmd_status "$@" ;;
|
||||
list) cmd_list "$@" ;;
|
||||
-h|--help|"") cat <<EOF >&2
|
||||
provision-vultr.sh — idempotent Vultr VPS provisioning.
|
||||
USAGE
|
||||
provision-vultr.sh create <label> [--plan vc2-1c-1gb] [--region ams] \\
|
||||
[--os-id <id>] [--ssh-key <id>] \\
|
||||
[--firewall <group-id>] [--user-data <file>]
|
||||
provision-vultr.sh status <label>
|
||||
provision-vultr.sh destroy <label> [--force]
|
||||
provision-vultr.sh list
|
||||
|
||||
ENV
|
||||
VULTR_API_KEY (required) — load via: source ~/.claude/secrets/.env
|
||||
EOF
|
||||
[ "$cmd" = "-h" ] || [ "$cmd" = "--help" ] && exit 0 || exit 1
|
||||
;;
|
||||
*) die "unknown command '$cmd'. Run --help." 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Loading…
Reference in a new issue