Skip to content

Commit 5b524cd

Browse files
committed
create-pve-access-token.sh
1 parent 1f9ea43 commit 5b524cd

1 file changed

Lines changed: 253 additions & 0 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
#!/usr/bin/env bash
2+
#
3+
# create-pve-access-token.sh — provision a Proxmox VE API token for server4home.
4+
#
5+
# Runs **on the Proxmox host**, as root (uses `pveum`, the local PVE CLI).
6+
# Creates a dedicated PVE-realm user, generates an API token, grants it
7+
# Administrator at /, and validates the token works against the local API
8+
# before printing a secrets.yaml-ready block.
9+
#
10+
# Idempotent: re-running the script after a successful run is safe.
11+
# Token-recreation (`--rotate`) is opt-in because rotating invalidates
12+
# any existing kubeconfigs / runner instances using the old secret.
13+
#
14+
# ─── Why every step is where it is ────────────────────────────────────────
15+
# The history this script encodes:
16+
#
17+
# 1. `realm=pve` (NOT pam). PAM-realm users have to be existing Linux
18+
# accounts; we just want a PVE-internal identity that exists only to
19+
# hold the token. The `pve` realm is built for this.
20+
#
21+
# 2. Token **privilege separation = 1** (PVE's default). Means the token
22+
# carries its own ACL, independent of the user's. That's what we want
23+
# for an automation credential: scope is on the token, not the user.
24+
# The corollary, and the foot-gun we hit before: ACL must be granted
25+
# with `-tokens <name>`, NOT just on the user. The script does both
26+
# because doubling up is harmless and protects against someone later
27+
# flipping privsep off and wondering why the token still works.
28+
#
29+
# 3. Role = Administrator at `/`. Yes, that's broad. We tried minimum-
30+
# permission setups and discovered that the PVE `args` config field
31+
# ("only root over local CLI can set this") is enforced regardless of
32+
# role — so no API-token grant unlocks it. The runner sidesteps that
33+
# by SSHing in as root for `qm set --args`. Administrator is the
34+
# simplest role that makes the rest of the API surface work.
35+
#
36+
# 4. The token is *separate from web UI access*. Tokens can't be used
37+
# to log into the PVE web UI — that needs a user+password. Keep your
38+
# human-login admin user (`developer@pam` or similar) untouched; this
39+
# script doesn't change that.
40+
#
41+
# ─── Usage ────────────────────────────────────────────────────────────────
42+
# scp tools/scripts/create-pve-access-token.sh root@pve:/tmp/
43+
# ssh root@pve bash /tmp/create-pve-access-token.sh
44+
#
45+
# Options (all have sane defaults):
46+
# --user <name> PVE-realm user (default: server4home-bot)
47+
# --token <name> token id (default: deploy)
48+
# --role <role> role granted at / (default: Administrator)
49+
# --rotate delete + recreate the token if it already exists
50+
# (default: keep the existing token, just re-verify)
51+
# --write-secret <path> also write the credential to this file in
52+
# secrets.yaml format (default: print to stdout only)
53+
# --help, -h show this help
54+
55+
set -euo pipefail
56+
57+
# ─── Defaults ─────────────────────────────────────────────────────────────
58+
PVE_USER="server4home"
59+
REALM="pve"
60+
TOKEN_NAME="deploy"
61+
ROLE="Administrator"
62+
ROTATE=0
63+
WRITE_SECRET_TO=""
64+
65+
usage() { sed -n '2,/^set -euo/p' "$0" | sed 's/^# \{0,1\}//' | head -n -2; }
66+
67+
while [[ $# -gt 0 ]]; do
68+
case "$1" in
69+
--user) PVE_USER="$2"; shift 2 ;;
70+
--token) TOKEN_NAME="$2"; shift 2 ;;
71+
--role) ROLE="$2"; shift 2 ;;
72+
--rotate) ROTATE=1; shift ;;
73+
--write-secret) WRITE_SECRET_TO="$2"; shift 2 ;;
74+
-h|--help) usage; exit 0 ;;
75+
*) echo "unknown arg: $1" >&2; usage >&2; exit 2 ;;
76+
esac
77+
done
78+
79+
FULL_TOKEN_ID="${PVE_USER}@${REALM}!${TOKEN_NAME}"
80+
TOKEN_SECRET="" # filled in by create_api_token() or kept empty on no-rotate
81+
82+
log() { printf '[create-access-token] %s\n' "$*"; }
83+
die() { printf '[create-access-token] ERROR: %s\n' "$*" >&2; exit 1; }
84+
85+
# ─── 0. Sanity checks ─────────────────────────────────────────────────────
86+
sanity_checks() {
87+
[[ $EUID -eq 0 ]] || die "must run as root on the PVE host (uses pveum)"
88+
command -v pveum >/dev/null || die "pveum not found — is this a PVE host?"
89+
command -v curl >/dev/null || die "curl not found"
90+
command -v jq >/dev/null || die "jq not found (apt-get install -y jq)"
91+
}
92+
93+
# ─── 1. Create the PVE-realm user (idempotent) ────────────────────────────
94+
ensure_user() {
95+
if pveum user list --output-format json | jq -e \
96+
".[] | select(.userid==\"${PVE_USER}@${REALM}\")" >/dev/null; then
97+
log "user ${PVE_USER}@${REALM}: already exists"
98+
else
99+
log "creating user ${PVE_USER}@${REALM}"
100+
pveum user add "${PVE_USER}@${REALM}" \
101+
--comment "server4home runner automation; do not delete"
102+
fi
103+
}
104+
105+
# ─── 2. Create (or rotate) the API token ──────────────────────────────────
106+
# Token privilege separation = 1 (default) is what we want — the ACL we
107+
# grant in step 3 is bound to the TOKEN, not inherited from the user.
108+
ensure_token() {
109+
local exists=0
110+
if pveum user token list "${PVE_USER}@${REALM}" --output-format json \
111+
| jq -e ".[] | select(.tokenid==\"${TOKEN_NAME}\")" >/dev/null; then
112+
exists=1
113+
fi
114+
115+
if (( exists )) && (( ! ROTATE )); then
116+
log "token ${FULL_TOKEN_ID}: already exists (use --rotate to replace)"
117+
return 0
118+
fi
119+
120+
if (( exists )) && (( ROTATE )); then
121+
log "rotating token ${FULL_TOKEN_ID} — deleting existing"
122+
pveum user token remove "${PVE_USER}@${REALM}" "${TOKEN_NAME}"
123+
fi
124+
125+
log "creating token ${FULL_TOKEN_ID} (privsep=1)"
126+
local token_json
127+
token_json=$(pveum user token add "${PVE_USER}@${REALM}" "${TOKEN_NAME}" \
128+
--privsep 1 \
129+
--output-format json)
130+
TOKEN_SECRET=$(echo "$token_json" | jq -r '.value')
131+
[[ -n "$TOKEN_SECRET" && "$TOKEN_SECRET" != "null" ]] \
132+
|| die "could not extract token secret from pveum output"
133+
}
134+
135+
# ─── 3. Grant ACL to the token (and to the user, defensively) ────────────
136+
# The `-tokens` flag is the load-bearing one with privsep=1: it grants the
137+
# permission to THIS token specifically. We also grant on the user level
138+
# so the credential keeps working if someone later flips privsep off.
139+
ensure_acl() {
140+
log "granting ${ROLE} at / to token ${FULL_TOKEN_ID}"
141+
# `pveum acl modify --tokens` wants the FULL token id (`user@realm!name`),
142+
# not just the token name — same format the auth header uses. Passing only
143+
# the short name dies with: "tokens: invalid format - value 'X' does not
144+
# look like a valid token ID".
145+
pveum acl modify / \
146+
--roles "${ROLE}" \
147+
--users "${PVE_USER}@${REALM}" \
148+
--tokens "${FULL_TOKEN_ID}"
149+
150+
log "also granting ${ROLE} at / to user ${PVE_USER}@${REALM} (privsep insurance)"
151+
pveum acl modify / \
152+
--roles "${ROLE}" \
153+
--users "${PVE_USER}@${REALM}"
154+
}
155+
156+
# ─── 4. Validate — two distinct calls catch different misconfigs ─────────
157+
# Read-class call: /access/permissions confirms the auth header parses and
158+
# the token has SOME ACL. Empty permissions means privsep ACL is missing.
159+
# Write-class lookup: /cluster/nextid is read-only but exercises a code
160+
# path that several misconfigurations fail differently from /permissions.
161+
validate() {
162+
if [[ -z "$TOKEN_SECRET" ]]; then
163+
log "no fresh secret to validate (token was kept, not rotated)"
164+
log "skipping validation — re-run with --rotate if you need to re-verify"
165+
return 0
166+
fi
167+
168+
local auth="Authorization: PVEAPIToken=${FULL_TOKEN_ID}=${TOKEN_SECRET}"
169+
local base="https://127.0.0.1:8006/api2/json"
170+
local body status
171+
172+
log "[validation 1/2] GET /access/permissions"
173+
body=$(curl --silent --insecure --max-time 10 \
174+
--write-out '\n__STATUS:%{http_code}' \
175+
-H "$auth" "${base}/access/permissions") \
176+
|| die "curl /access/permissions failed (network / PVE down?)"
177+
status="${body##*__STATUS:}"
178+
body="${body%__STATUS:*}"
179+
[[ "$status" == "200" ]] \
180+
|| die "/access/permissions returned HTTP $status; token auth broken"
181+
182+
# Confirm the token actually has '/' in its permissions map (not just
183+
# that the auth header parsed). Empty {} means the token has zero ACL.
184+
if ! echo "$body" | jq -e '.data | type=="object" and length>0' >/dev/null; then
185+
die "token authenticated but has NO permissions — did the ACL grant " \
186+
"land on the user instead of the token? Check 'pveum acl list' " \
187+
"shows a /token/${PVE_USER}@${REALM}!${TOKEN_NAME} entry."
188+
fi
189+
log " ok — token has permissions: $(echo "$body" | jq -c '.data | keys')"
190+
191+
log "[validation 2/2] GET /cluster/nextid"
192+
status=$(curl --silent --insecure --max-time 10 \
193+
--write-out '%{http_code}' --output /dev/null \
194+
-H "$auth" "${base}/cluster/nextid") \
195+
|| die "curl /cluster/nextid failed"
196+
[[ "$status" == "200" ]] \
197+
|| die "/cluster/nextid returned HTTP $status; token lacks Datastore.Audit or similar"
198+
log " ok"
199+
}
200+
201+
# ─── 5. Output ────────────────────────────────────────────────────────────
202+
print_summary() {
203+
if [[ -z "$TOKEN_SECRET" ]]; then
204+
cat <<EOF
205+
206+
============================================================
207+
Token ${FULL_TOKEN_ID} already exists; secret not re-printed.
208+
Re-run with --rotate to mint a fresh secret.
209+
============================================================
210+
EOF
211+
return 0
212+
fi
213+
214+
local credential="PVEAPIToken=${FULL_TOKEN_ID}=${TOKEN_SECRET}"
215+
cat <<EOF
216+
217+
============================================================
218+
Proxmox API token ready.
219+
220+
Token ID: ${FULL_TOKEN_ID}
221+
Role: ${ROLE} at /
222+
Privilege sep: 1 (token has its own ACL — what we want)
223+
224+
Paste this into secrets/secrets.yaml on your workstation:
225+
------------------------------------------------------------
226+
"proxmox/api-token": "${credential}"
227+
------------------------------------------------------------
228+
229+
OR — if you keep a per-host overlay:
230+
------------------------------------------------------------
231+
<your-vm-hostname>:
232+
"proxmox/api-token": "${credential}"
233+
------------------------------------------------------------
234+
235+
The credential string IS the secret. It cannot be recovered
236+
from Proxmox later. Save it now.
237+
============================================================
238+
EOF
239+
240+
if [[ -n "$WRITE_SECRET_TO" ]]; then
241+
umask 077
242+
printf '"proxmox/api-token": "%s"\n' "$credential" > "$WRITE_SECRET_TO"
243+
log "wrote credential to $WRITE_SECRET_TO (mode 0600)"
244+
fi
245+
}
246+
247+
# ─── main ─────────────────────────────────────────────────────────────────
248+
sanity_checks
249+
ensure_user
250+
ensure_token
251+
ensure_acl
252+
validate
253+
print_summary

0 commit comments

Comments
 (0)