ergosteur

ergosteur / create-agent-user.sh

Last active 1 hour ago

Like 0

Synced from claude-config/scripts/create-agent-user.sh — do not edit directly, changes are overwritten on the next sync.

create-agent-user.sh Raw
1#!/usr/bin/env bash
2#
3# create-agent-user.sh
4# -----------------------------------------------------------------------------
5# Provision a local `agent-user` account on a Linux host so an AI agent can be
6# handed full (sudo) access to the machine over SSH.
7#
8# What it does, in order:
9# 1. Refuses to run unless invoked as root and an SSH server is running.
10# 2. Detects the distro family (Debian/Ubuntu, Fedora/RHEL, Arch/CachyOS).
11# 3. Creates `agent-user` as a normal user for that distro, with a fixed UID
12# and the SAME supplementary groups as the default uid=1000 user.
13# 4. Grants passwordless sudo (default) via /etc/sudoers.d.
14# 5. Sets a long word!word!word passphrase (from the system dict) or, if no
15# dict is available, a 13-char unambiguous random password.
16# 6. Hides the account from every installed graphical greeter (GDM/SDDM/
17# LightDM) while keeping it fully usable for VNC / X2Go / forwarded-X
18# desktop sessions.
19# 7. Installs lightweight agent + hardware-inspection tooling (best effort).
20# 8. Writes a system-info file into the account's home dir.
21# 9. Prints a short block of credentials + instructions to paste into an
22# AI agent prompt.
23#
24# Run it AS ROOT, directly on the target host:
25# sudo ./create-agent-user.sh [OPTIONS]
26#
27# It is NOT meant to be piped from curl. Download it, read it, then run it.
28# -----------------------------------------------------------------------------
29
30set -uo pipefail # deliberately NOT -e: many steps are best-effort and checked
31 # explicitly with die() where failure must actually stop us.
32
33SCRIPT_VERSION="1.2.0"
34
35# =============================================================================
36# CONFIG / DEFAULTS -- everything below is overridable via CLI flags.
37# Edit these to change the baked-in defaults for your fleet.
38# =============================================================================
39
40AGENT_USER="agent-user" # account name to create
41AGENT_UID="60123" # fixed UID. Sits in the reserved 60001-61183
42 # gap: above the human range (<=60000) and
43 # below the systemd DynamicUser floor (61184).
44 # Well clear of SSSD AD idmap (200000+).
45 # Validated as free at runtime regardless.
46AGENT_GECOS="AI agent access account"
47REF_UID="1000" # the "default user" whose groups we clone
48
49PASSPHRASE_WORDS=6 # words in the generated passphrase
50PASSPHRASE_SEP='!' # separator between words
51RANDOM_PW_LEN=13 # length of the fallback random password
52MIN_WORD_LEN=4 # dict-word length filter (inclusive)
53MAX_WORD_LEN=8
54
55SUDO_MODE="nopasswd" # "nopasswd" | "password"
56DO_INSTALL=1 # 1 = install tooling, 0 = skip (--no-install)
57DICT_PROMPT=1 # 1 = offer to install a dict if missing
58SSHD_CHECK=1 # 1 = require a running sshd before proceeding
59ROTATE=0 # 1 = rotate creds/groups if user exists
60NONINTERACTIVE=0 # 1 = never prompt; take safe fallbacks
61PW_MODE="auto" # "auto" | "passphrase" | "random"
62UNIQUE_KEYPAIR=0 # 1 = tell the agent to mint a per-host keypair
63 # (--unique-keypair); default just says "use key
64 # auth", since most agents already have one.
65
66AGENT_TARGET_HOST="" # override reachable address in the output
67SSH_PORT="" # override advertised SSH port (else detected)
68INFO_FILE_NAME="AGENT_INFO.md" # written into the account's home dir
69PROMPT_FILE_NAME="AGENT_PROMPT.txt" # copy of the final agent-prompt block,
70 # also written into the account's home dir
71
72# Dictionaries we look for, in order of preference:
73DICT_CANDIDATES=(
74 /usr/share/dict/words
75 /usr/share/dict/american-english
76 /usr/share/dict/british-english
77 /usr/dict/words
78)
79
80# =============================================================================
81# Logging helpers
82# =============================================================================
83if [ -t 2 ]; then
84 C_RED=$'\033[31m'; C_YLW=$'\033[33m'; C_GRN=$'\033[32m'
85 C_BLU=$'\033[34m'; C_RST=$'\033[0m'
86else
87 C_RED=""; C_YLW=""; C_GRN=""; C_BLU=""; C_RST=""
88fi
89info() { printf '%s[*]%s %s\n' "$C_BLU" "$C_RST" "$*" >&2; }
90ok() { printf '%s[+]%s %s\n' "$C_GRN" "$C_RST" "$*" >&2; }
91warn() { printf '%s[!]%s %s\n' "$C_YLW" "$C_RST" "$*" >&2; }
92die() { printf '%s[x]%s %s\n' "$C_RED" "$C_RST" "$*" >&2; exit 1; }
93
94usage() {
95 cat >&2 <<EOF
96create-agent-user.sh v$SCRIPT_VERSION
97
98Usage: sudo ./create-agent-user.sh [OPTIONS]
99
100 --rotate Rotate creds/groups if $AGENT_USER already exists
101 (default: refuse if the account exists).
102 --sudo-password Require a password for sudo (default: NOPASSWD).
103 --no-install Skip all package installation.
104 --agent-target-host H Address to advertise in the output block.
105 --uid N Override the fixed UID (default: $AGENT_UID).
106 --username NAME Override the account name (default: $AGENT_USER).
107 --ssh-port N Advertise this SSH port (default: auto-detect).
108 --passphrase Force a dictionary passphrase.
109 --random-password Force a random password.
110 --words N Passphrase word count (default: $PASSPHRASE_WORDS).
111 --no-sshd-check Do not require a running SSH server.
112 --non-interactive Never prompt; take safe fallbacks.
113 --unique-keypair Agent prompt tells the AI to mint a NEW per-host SSH
114 keypair for hardening (default: tells it to just use
115 whatever key auth it already has).
116 -h, --help Show this help.
117EOF
118 exit "${1:-0}"
119}
120
121# =============================================================================
122# Argument parsing
123# =============================================================================
124while [ $# -gt 0 ]; do
125 case "$1" in
126 --rotate) ROTATE=1 ;;
127 --sudo-password) SUDO_MODE="password" ;;
128 --no-install) DO_INSTALL=0 ;;
129 --agent-target-host) AGENT_TARGET_HOST="${2:?}"; shift ;;
130 --uid) AGENT_UID="${2:?}"; shift ;;
131 --username) AGENT_USER="${2:?}"; shift ;;
132 --ssh-port) SSH_PORT="${2:?}"; shift ;;
133 --passphrase) PW_MODE="passphrase" ;;
134 --random-password) PW_MODE="random" ;;
135 --words) PASSPHRASE_WORDS="${2:?}"; shift ;;
136 --no-sshd-check) SSHD_CHECK=0 ;;
137 --non-interactive) NONINTERACTIVE=1 ;;
138 --unique-keypair) UNIQUE_KEYPAIR=1 ;;
139 -h|--help) usage 0 ;;
140 *) warn "Unknown option: $1"; usage 1 ;;
141 esac
142 shift
143done
144
145# No controlling terminal on stdin -> we cannot prompt.
146[ -t 0 ] || NONINTERACTIVE=1
147
148ask_yes_no() {
149 # $1 = prompt, $2 = default (y/n). Honors --non-interactive.
150 local prompt="$1" def="${2:-n}" reply
151 if [ "$NONINTERACTIVE" -eq 1 ]; then
152 [ "$def" = "y" ] && return 0 || return 1
153 fi
154 local hint="[y/N]"; [ "$def" = "y" ] && hint="[Y/n]"
155 read -r -p "$prompt $hint " reply || reply=""
156 reply="${reply:-$def}"
157 case "$reply" in [Yy]*) return 0 ;; *) return 1 ;; esac
158}
159
160# =============================================================================
161# Preconditions
162# =============================================================================
163[ "$(id -u)" -eq 0 ] || die "Must run as root (use sudo)."
164
165for bin in getent useradd usermod chpasswd visudo shuf install; do
166 command -v "$bin" >/dev/null 2>&1 || die "Required tool missing: $bin"
167done
168
169ssh_running() {
170 pgrep -x sshd >/dev/null 2>&1 && return 0
171 systemctl is-active --quiet sshd 2>/dev/null && return 0
172 systemctl is-active --quiet ssh 2>/dev/null && return 0
173 command -v ss >/dev/null 2>&1 && ss -tlnH 2>/dev/null | grep -qE ':22\b' && return 0
174 return 1
175}
176if [ "$SSHD_CHECK" -eq 1 ]; then
177 ssh_running || die "No running SSH server detected. Start sshd, or pass --no-sshd-check."
178 ok "SSH server detected."
179fi
180
181# =============================================================================
182# Distro detection -> PKG_FAMILY + package name arrays
183# =============================================================================
184PKG_FAMILY=""
185DNF="dnf"
186detect_distro() {
187 local id="" like=""
188 if [ -r /etc/os-release ]; then
189 # Source in a SUBSHELL so os-release vars (VERSION, ID, NAME, ...) never
190 # leak into and clobber this script's own globals.
191 # shellcheck disable=SC1091
192 id="$( . /etc/os-release; printf '%s' "${ID:-}" )"
193 like="$( . /etc/os-release; printf '%s' "${ID_LIKE:-}" )"
194 fi
195 case " $id $like " in
196 *" debian "*|*" ubuntu "*) PKG_FAMILY="debian" ;;
197 *" fedora "*|*" rhel "*|*" centos "*) PKG_FAMILY="rhel" ;;
198 *" arch "*) PKG_FAMILY="arch" ;;
199 *)
200 # Fall back on whichever package manager exists.
201 if command -v apt-get >/dev/null; then PKG_FAMILY="debian"
202 elif command -v dnf >/dev/null; then PKG_FAMILY="rhel"
203 elif command -v yum >/dev/null; then PKG_FAMILY="rhel"; DNF="yum"
204 elif command -v pacman >/dev/null; then PKG_FAMILY="arch"
205 else die "Unsupported distro: could not find apt/dnf/pacman."
206 fi ;;
207 esac
208 command -v dnf >/dev/null 2>&1 || DNF="yum"
209
210 case "$PKG_FAMILY" in
211 debian)
212 SUDO_GROUP="sudo"
213 PKGS_AGENT=(git jq curl rsync tmux ca-certificates python3 python3-venv python3-pip pipx)
214 PKGS_HW=(lshw lsscsi hdparm inxi pciutils usbutils dmidecode smartmontools)
215 PKG_DICT="wamerican" ;;
216 rhel)
217 SUDO_GROUP="wheel"
218 PKGS_AGENT=(git jq curl rsync tmux ca-certificates python3 python3-pip pipx)
219 PKGS_HW=(lshw lsscsi hdparm inxi pciutils usbutils dmidecode smartmontools)
220 PKG_DICT="words" ;;
221 arch)
222 SUDO_GROUP="wheel"
223 PKGS_AGENT=(git jq curl rsync tmux ca-certificates python python-pip python-pipx)
224 PKGS_HW=(lshw lsscsi hdparm inxi pciutils usbutils dmidecode smartmontools)
225 PKG_DICT="words" ;;
226 esac
227}
228detect_distro
229ok "Distro family: $PKG_FAMILY (sudo group: $SUDO_GROUP)"
230
231# =============================================================================
232# Package installation helpers (best-effort)
233# =============================================================================
234PKG_REFRESHED=0
235pkg_refresh() {
236 [ "$PKG_REFRESHED" -eq 1 ] && return 0
237 PKG_REFRESHED=1
238 case "$PKG_FAMILY" in
239 debian) DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true ;;
240 arch) pacman -Sy --noconfirm >/dev/null 2>&1 || true ;;
241 rhel) : ;; # dnf resolves metadata on demand
242 esac
243}
244pkg_install_one() {
245 local p="$1"
246 case "$PKG_FAMILY" in
247 debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$p" >/dev/null 2>&1 ;;
248 rhel) $DNF install -y "$p" >/dev/null 2>&1 ;;
249 arch) pacman -S --noconfirm --needed "$p" >/dev/null 2>&1 ;;
250 esac
251}
252INSTALLED_OK=()
253INSTALLED_FAIL=()
254pkg_install_list() {
255 # Best-effort: never aborts the script. Records per-package outcome.
256 pkg_refresh
257 local p
258 for p in "$@"; do
259 if pkg_install_one "$p"; then
260 INSTALLED_OK+=("$p")
261 else
262 INSTALLED_FAIL+=("$p")
263 fi
264 done
265}
266
267# =============================================================================
268# Reference user (uid=1000): clone its supplementary groups + login shell
269# =============================================================================
270REF_USER="$(getent passwd "$REF_UID" | cut -d: -f1)"
271SUPP_GROUPS=()
272REF_SHELL="/bin/bash"
273if [ -n "$REF_USER" ]; then
274 info "Cloning groups from reference user: $REF_USER (uid $REF_UID)"
275 ref_primary="$(id -gn "$REF_USER" 2>/dev/null || echo)"
276 ref_shell="$(getent passwd "$REF_USER" | cut -d: -f7)"
277 [ -n "$ref_shell" ] && [ -x "$ref_shell" ] && REF_SHELL="$ref_shell"
278 for g in $(id -nG "$REF_USER" 2>/dev/null); do
279 [ "$g" = "$ref_primary" ] && continue
280 [ "$g" = "$REF_USER" ] && continue
281 SUPP_GROUPS+=("$g")
282 done
283else
284 warn "No uid=$REF_UID user found; falling back to '$SUDO_GROUP' group only."
285 getent group "$SUDO_GROUP" >/dev/null && SUPP_GROUPS+=("$SUDO_GROUP")
286fi
287SUPP_CSV="$(IFS=,; echo "${SUPP_GROUPS[*]:-}")"
288info "Supplementary groups: ${SUPP_CSV:-<none>}"
289info "Login shell: $REF_SHELL"
290
291# =============================================================================
292# Password generation
293# =============================================================================
294find_dict() {
295 local d
296 for d in "${DICT_CANDIDATES[@]}"; do
297 if [ -r "$d" ] && [ "$(grep -cE "^[a-z]{${MIN_WORD_LEN},${MAX_WORD_LEN}}\$" "$d" 2>/dev/null)" -ge 100 ]; then
298 echo "$d"; return 0
299 fi
300 done
301 return 1
302}
303gen_passphrase() {
304 local dict="$1" picked out="" w
305 mapfile -t picked < <(grep -E "^[a-z]{${MIN_WORD_LEN},${MAX_WORD_LEN}}\$" "$dict" | shuf -n "$PASSPHRASE_WORDS")
306 [ "${#picked[@]}" -lt "$PASSPHRASE_WORDS" ] && return 1
307 for w in "${picked[@]}"; do
308 out+="${w^}${PASSPHRASE_SEP}" # Capitalize each word
309 done
310 out+="$(( (RANDOM % 90) + 10 ))" # trailing 2-digit for complexity
311 printf '%s' "$out"
312}
313gen_random_pw() {
314 # Unambiguous set: no I l O 0 1 o.
315 local U='ABCDEFGHJKMNPQRSTUVWXYZ' L='abcdefghijkmnpqrstuvwxyz' \
316 D='23456789' S='!@#%^*-_=+' all pw="" i
317 all="$U$L$D$S"
318 pw+="${U:RANDOM%${#U}:1}"
319 pw+="${L:RANDOM%${#L}:1}"
320 pw+="${D:RANDOM%${#D}:1}"
321 pw+="${S:RANDOM%${#S}:1}"
322 for ((i=${#pw}; i<RANDOM_PW_LEN; i++)); do pw+="${all:RANDOM%${#all}:1}"; done
323 printf '%s' "$pw" | fold -w1 | shuf | tr -d '\n'
324}
325
326AGENT_PASSWORD=""
327PW_KIND=""
328make_password() {
329 local dict=""
330 case "$PW_MODE" in
331 random)
332 AGENT_PASSWORD="$(gen_random_pw)"; PW_KIND="random-${RANDOM_PW_LEN}ch" ;;
333 passphrase)
334 dict="$(find_dict)" || die "--passphrase requested but no usable dictionary found."
335 AGENT_PASSWORD="$(gen_passphrase "$dict")" || die "Passphrase generation failed."
336 PW_KIND="passphrase-${PASSPHRASE_WORDS}w" ;;
337 auto)
338 if dict="$(find_dict)"; then
339 AGENT_PASSWORD="$(gen_passphrase "$dict")" && PW_KIND="passphrase-${PASSPHRASE_WORDS}w"
340 fi
341 if [ -z "$AGENT_PASSWORD" ]; then
342 # No dict. Offer to install one, else random.
343 if [ "$DO_INSTALL" -eq 1 ] && [ "$DICT_PROMPT" -eq 1 ] \
344 && ask_yes_no "No word list found. Install '$PKG_DICT' for a passphrase?" y; then
345 pkg_install_list "$PKG_DICT"
346 if dict="$(find_dict)"; then
347 AGENT_PASSWORD="$(gen_passphrase "$dict")" && PW_KIND="passphrase-${PASSPHRASE_WORDS}w"
348 fi
349 fi
350 fi
351 if [ -z "$AGENT_PASSWORD" ]; then
352 warn "Using random password (no dictionary available)."
353 AGENT_PASSWORD="$(gen_random_pw)"; PW_KIND="random-${RANDOM_PW_LEN}ch"
354 fi ;;
355 esac
356 [ -n "$AGENT_PASSWORD" ] || die "Failed to generate a password."
357}
358
359# =============================================================================
360# Create (or rotate) the account
361# =============================================================================
362USER_EXISTS=0
363getent passwd "$AGENT_USER" >/dev/null && USER_EXISTS=1
364
365if [ "$USER_EXISTS" -eq 1 ] && [ "$ROTATE" -eq 0 ]; then
366 die "Account '$AGENT_USER' already exists. Re-run with --rotate to rotate its credentials."
367fi
368
369if [ "$USER_EXISTS" -eq 0 ]; then
370 # UID collision check (real users, winbind mappings, anything).
371 uid_owner="$(getent passwd "$AGENT_UID" | cut -d: -f1)"
372 if [ -n "$uid_owner" ]; then
373 die "UID $AGENT_UID already in use by '$uid_owner'. Re-run with --uid <N>."
374 fi
375 info "Creating account '$AGENT_USER' (uid $AGENT_UID)"
376 useradd --create-home --uid "$AGENT_UID" --shell "$REF_SHELL" \
377 --comment "$AGENT_GECOS" "$AGENT_USER" \
378 || die "useradd failed."
379 ok "Account created."
380else
381 existing_uid="$(id -u "$AGENT_USER")"
382 warn "Rotating existing account '$AGENT_USER' (uid $existing_uid); UID left unchanged."
383 AGENT_UID="$existing_uid"
384fi
385
386# Apply supplementary groups (idempotent).
387if [ -n "$SUPP_CSV" ]; then
388 usermod -aG "$SUPP_CSV" "$AGENT_USER" || warn "usermod -aG partially failed."
389fi
390
391# Set the password.
392make_password
393printf '%s:%s\n' "$AGENT_USER" "$AGENT_PASSWORD" | chpasswd \
394 || die "Failed to set password."
395ok "Password set ($PW_KIND)."
396
397AGENT_HOME="$(getent passwd "$AGENT_USER" | cut -d: -f6)"
398[ -n "$AGENT_HOME" ] || die "Could not determine home directory."
399
400# Pre-create ~/.ssh so the agent can drop in a key cleanly.
401install -d -m 700 -o "$AGENT_USER" -g "$AGENT_USER" "$AGENT_HOME/.ssh"
402
403# =============================================================================
404# sudo configuration
405# =============================================================================
406SUDOERS_FILE="/etc/sudoers.d/$AGENT_USER"
407if [ "$SUDO_MODE" = "nopasswd" ]; then
408 SUDO_RULE="$AGENT_USER ALL=(ALL:ALL) NOPASSWD:ALL"
409else
410 SUDO_RULE="$AGENT_USER ALL=(ALL:ALL) ALL"
411fi
412printf '# Managed by create-agent-user.sh\n%s\n' "$SUDO_RULE" > "$SUDOERS_FILE"
413chmod 0440 "$SUDOERS_FILE"
414if ! visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1; then
415 rm -f "$SUDOERS_FILE"
416 die "Generated sudoers file failed validation; removed it."
417fi
418ok "sudo configured ($SUDO_MODE)."
419
420# =============================================================================
421# Hide from graphical greeters (GDM / SDDM / LightDM) via AccountsService
422# plus greeter-specific excludes for whichever are installed.
423# =============================================================================
424greeter_present() {
425 case "$1" in
426 gdm) command -v gdm >/dev/null || command -v gdm3 >/dev/null || [ -d /etc/gdm ] || [ -d /etc/gdm3 ] ;;
427 sddm) command -v sddm >/dev/null || [ -f /etc/sddm.conf ] || [ -d /etc/sddm.conf.d ] ;;
428 lightdm) command -v lightdm >/dev/null || [ -d /etc/lightdm ] ;;
429 esac
430}
431
432# AccountsService: honored by GDM and, in modern setups, by SDDM/LightDM too.
433install -d -m 775 /var/lib/AccountsService/users 2>/dev/null || true
434cat > "/var/lib/AccountsService/users/$AGENT_USER" <<EOF
435[User]
436SystemAccount=true
437EOF
438
439# SDDM drop-in.
440if greeter_present sddm; then
441 install -d -m 755 /etc/sddm.conf.d
442 cat > "/etc/sddm.conf.d/10-hide-$AGENT_USER.conf" <<EOF
443[Users]
444HideUsers=$AGENT_USER
445EOF
446 info "SDDM hide rule written."
447fi
448
449# LightDM users.conf.
450if greeter_present lightdm; then
451 LDM=/etc/lightdm/users.conf
452 if [ -f "$LDM" ] && grep -qE '^\s*hidden-users=' "$LDM"; then
453 grep -qE "hidden-users=.*(^|[ =])$AGENT_USER($|[ ])" "$LDM" \
454 || sed -i "s/^\(\s*hidden-users=.*\)/\1 $AGENT_USER/" "$LDM"
455 else
456 install -d -m 755 /etc/lightdm
457 printf '[UserList]\nhidden-users=nobody nobody4 noaccess %s\n' "$AGENT_USER" >> "$LDM"
458 fi
459 info "LightDM hide rule written."
460fi
461ok "Account hidden from installed greeters."
462
463# =============================================================================
464# Install tooling (best-effort)
465# =============================================================================
466if [ "$DO_INSTALL" -eq 1 ]; then
467 info "Installing agent tooling (best-effort): ${PKGS_AGENT[*]}"
468 info "Installing hardware-inspection tooling (best-effort): ${PKGS_HW[*]}"
469 pkg_install_list "${PKGS_AGENT[@]}" "${PKGS_HW[@]}"
470 ok "Installed (${#INSTALLED_OK[@]}): ${INSTALLED_OK[*]:-<none>}"
471 [ "${#INSTALLED_FAIL[@]}" -gt 0 ] && warn "Not installed (${#INSTALLED_FAIL[@]}): ${INSTALLED_FAIL[*]}"
472else
473 info "Skipping package installation (--no-install)."
474fi
475
476# =============================================================================
477# Determine reachable host + SSH port for the output block
478#
479# Preference order: FQDN, then a static IP, then the short hostname (works via
480# mDNS/local resolution on many LANs), then a plain DHCP-assigned IP as last
481# resort. ADDR_KIND records which one was picked so the output can flag the
482# weaker choices (short hostname / DHCP IP) instead of presenting them as if
483# they were as durable as an FQDN or static IP.
484# =============================================================================
485PRIMARY_IFACE="$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')"
486
487is_static_ip() {
488 # Best-effort: returns 0=static, 1=dhcp, 2=unknown. Checked in order of
489 # how common each network stack is; the first one that gives a definite
490 # answer wins.
491 local iface="$1" conn method ifidx
492 [ -n "$iface" ] || return 2
493
494 if command -v nmcli >/dev/null 2>&1; then
495 conn="$(nmcli -t -f GENERAL.CONNECTION device show "$iface" 2>/dev/null | cut -d: -f2-)"
496 if [ -n "$conn" ] && [ "$conn" != "--" ]; then
497 method="$(nmcli -t -f ipv4.method connection show "$conn" 2>/dev/null | cut -d: -f2-)"
498 case "$method" in
499 manual) return 0 ;;
500 auto) return 1 ;;
501 esac
502 fi
503 fi
504
505 ifidx="$(cat "/sys/class/net/$iface/ifindex" 2>/dev/null)"
506 [ -n "$ifidx" ] && [ -f "/run/systemd/netif/leases/$ifidx" ] && return 1
507
508 ls /var/lib/dhcp/dhclient*"$iface"*.lease* >/dev/null 2>&1 && return 1
509 ls /var/lib/dhcpcd*/*.lease >/dev/null 2>&1 && return 1
510
511 if [ -f /etc/network/interfaces ] \
512 && grep -qE "iface[[:space:]]+${iface}[[:space:]]+inet[[:space:]]+static" /etc/network/interfaces 2>/dev/null; then
513 return 0
514 fi
515
516 return 2
517}
518
519# NOTE: detect_host sets OUT_HOST/ADDR_KIND directly (globals) rather than
520# echoing a return value, because it must run in THIS shell, not a
521# command-substitution subshell — otherwise ADDR_KIND would be lost the
522# moment the function returns.
523ADDR_KIND="" # override | fqdn | static-ip | short-hostname | dhcp-ip
524OUT_HOST=""
525detect_host() {
526 if [ -n "$AGENT_TARGET_HOST" ]; then
527 ADDR_KIND="override"; OUT_HOST="$AGENT_TARGET_HOST"; return
528 fi
529
530 local ip fqdn short
531 ip="$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')"
532 [ -z "$ip" ] && ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
533
534 fqdn="$(hostname -A 2>/dev/null | awk '{print $1}')"
535 if [ -n "$fqdn" ] && [[ "$fqdn" == *.* ]]; then
536 ADDR_KIND="fqdn"; OUT_HOST="$fqdn"; return
537 fi
538
539 if [ -n "$ip" ]; then
540 is_static_ip "$PRIMARY_IFACE"
541 if [ "$?" -eq 0 ]; then
542 ADDR_KIND="static-ip"; OUT_HOST="$ip"; return
543 fi
544 fi
545
546 short="$(hostname 2>/dev/null)"
547 if [ -n "$short" ] && [ "$short" != "localhost" ]; then
548 ADDR_KIND="short-hostname"; OUT_HOST="$short"; return
549 fi
550
551 ADDR_KIND="dhcp-ip"; OUT_HOST="$ip"
552}
553detect_port() {
554 [ -n "$SSH_PORT" ] && { echo "$SSH_PORT"; return; }
555 local p
556 p="$(sshd -T 2>/dev/null | awk '/^port /{print $2; exit}')"
557 [ -z "$p" ] && p=22
558 echo "$p"
559}
560detect_host
561OUT_PORT="$(detect_port)"
562HOSTNAME_FQDN="$(hostname -f 2>/dev/null || hostname)"
563
564ADDR_WARNING=""
565case "$ADDR_KIND" in
566 short-hostname)
567 ADDR_WARNING="No FQDN or static IP was found; advertising the short hostname '$OUT_HOST'. This may only resolve on the local network/mDNS domain — confirm it resolves before relying on it, or re-run with --agent-target-host." ;;
568 dhcp-ip)
569 ADDR_WARNING="No FQDN was found and the IP looks DHCP-assigned; advertising $OUT_HOST as-is may go stale if the lease changes. Prefer an FQDN if one exists, or re-run with --agent-target-host." ;;
570esac
571
572# Representative package-manager invocation + human-readable distro name, so
573# the agent knows immediately how to install things without waiting to read
574# AGENT_INFO.md.
575case "$PKG_FAMILY" in
576 debian) PKG_MGR_CMD="apt-get install -y <pkg>" ;;
577 rhel) PKG_MGR_CMD="$DNF install -y <pkg>" ;;
578 arch) PKG_MGR_CMD="pacman -S --noconfirm <pkg>" ;;
579esac
580DISTRO_PRETTY="$( [ -r /etc/os-release ] && . /etc/os-release; printf '%s' "${PRETTY_NAME:-$PKG_FAMILY}" )"
581
582# =============================================================================
583# x86-64 microarchitecture level (nice to know; explains SIGILL-on-old-CPU)
584# =============================================================================
585x86_64_level() {
586 [ "$(uname -m)" = "x86_64" ] || { echo "n/a ($(uname -m))"; return; }
587 local f lvl=1
588 f=" $(grep -m1 '^flags' /proc/cpuinfo | cut -d: -f2) "
589 has() { [[ $f == *" $1 "* ]]; }
590 if has cx16 && has lahf_lm && has popcnt && has sse4_1 && has sse4_2 && has ssse3; then lvl=2; fi
591 if [ "$lvl" -ge 2 ] && has avx && has avx2 && has bmi1 && has bmi2 && has f16c && has fma && has movbe && has abm; then lvl=3; fi
592 if [ "$lvl" -ge 3 ] && has avx512f && has avx512bw && has avx512cd && has avx512dq && has avx512vl; then lvl=4; fi
593 echo "x86-64-v$lvl"
594}
595
596# =============================================================================
597# Write the system-info file into the account's home directory
598# =============================================================================
599INFO_PATH="$AGENT_HOME/$INFO_FILE_NAME"
600try() { "$@" 2>/dev/null || true; }
601{
602 echo "# Agent host reference — $HOSTNAME_FQDN"
603 echo
604 echo "> Written by create-agent-user.sh v$SCRIPT_VERSION. This file is here for the"
605 echo "> agent to read after logging in. It contains no credentials."
606 echo
607 echo "## Identity"
608 echo '```'
609 echo "hostname : $HOSTNAME_FQDN"
610 echo "account : $AGENT_USER (uid $AGENT_UID)"
611 echo "sudo : $SUDO_MODE"
612 echo "shell : $REF_SHELL"
613 echo "groups : ${SUPP_CSV:-<none>}"
614 echo '```'
615 echo
616 echo "## OS & kernel"
617 echo '```'
618 try grep -E '^(PRETTY_NAME|VERSION|ID)=' /etc/os-release
619 echo "kernel : $(uname -r)"
620 echo "arch : $(uname -m)"
621 echo '```'
622 echo
623 echo "## CPU"
624 echo '```'
625 try grep -m1 'model name' /proc/cpuinfo
626 echo "cores : $(nproc 2>/dev/null || echo '?')"
627 echo "x86 level: $(x86_64_level)"
628 echo '```'
629 echo
630 echo "## Memory"
631 echo '```'
632 try free -h
633 echo '```'
634 echo
635 echo "## Block devices"
636 echo '```'
637 if command -v lsblk >/dev/null; then try lsblk -o NAME,SIZE,TYPE,MODEL,MOUNTPOINT; fi
638 if command -v lsscsi >/dev/null; then echo; try lsscsi; fi
639 echo '```'
640 echo
641 echo "## GPU / display"
642 echo '```'
643 if command -v lspci >/dev/null; then try lspci | grep -iE 'vga|3d|display'; fi
644 echo '```'
645 echo
646 echo "## Network"
647 echo '```'
648 if command -v ip >/dev/null; then try ip -brief addr; else try hostname -I; fi
649 echo '```'
650 echo
651 echo "## Installed agent tooling"
652 echo '```'
653 for t in git python3 python pip pip3 pipx jq curl rsync tmux; do
654 if command -v "$t" >/dev/null 2>&1; then
655 printf '%-8s %s\n' "$t" "$("$t" --version 2>&1 | head -n1)"
656 fi
657 done
658 echo '```'
659 echo
660 echo "## Notes"
661 echo "- Provisioned for AI agent access. Add host-specific notes below."
662 echo
663} > "$INFO_PATH"
664chown "$AGENT_USER:$AGENT_USER" "$INFO_PATH"
665chmod 640 "$INFO_PATH"
666ok "System info written to $INFO_PATH"
667
668# =============================================================================
669# Final output: short block to paste into an AI agent prompt
670# =============================================================================
671SUDO_NOTE="nopasswd"; [ "$SUDO_MODE" = "password" ] && SUDO_NOTE="password"
672TODAY="$(date +%Y-%m-%d 2>/dev/null || echo unknown)"
673
674PROMPT_BLOCK="$(cat <<EOF
675
676$(printf '%s' "$C_GRN")============================================================
677 DONE — copy everything between the lines into your AI agent
678============================================================$(printf '%s' "$C_RST")
679
680You have been granted an account with full sudo on a Linux host. Here are the
681credentials:
682
683 host: $OUT_HOST
684 port: $OUT_PORT
685 username: $AGENT_USER
686 uid: $AGENT_UID
687 password: "$AGENT_PASSWORD"
688 sudo: $SUDO_NOTE
689 hostname: $HOSTNAME_FQDN
690 distro: $DISTRO_PRETTY ($PKG_FAMILY; install with: $PKG_MGR_CMD)
691 provisioned: $TODAY
692$( [ -n "$ADDR_WARNING" ] && printf '\nNOTE: %s\n' "$ADDR_WARNING" )
693Do this:
694
6951. Store the credential where this machine keeps its secrets, and record a
696 pointer to it somewhere every future session will see (e.g. a global
697 CLAUDE.md / AGENTS.md), so this access is discoverable later — not just for
698 this one session. Never inline the password into a doc that gets loaded into
699 context or committed to a repo.
700 - PREFERRED, if this machine uses Bitwarden Secrets Manager (\`bws\` on
701 PATH, token in the keyring): store it as a secret, e.g.
702 BWS_ACCESS_TOKEN="\$(secret-tool lookup service bws account default)" \\
703 bws secret create ${AGENT_USER}-${HOSTNAME_FQDN%%.*} "<password>" <project-id> \\
704 --note "host=$OUT_HOST user=$AGENT_USER uid=$AGENT_UID sudo=$SUDO_NOTE"
705 and record the returned secret id in your pointer. Fetch later with
706 \`bws secret get <id> | jq -r .value\`.
707 - FALLBACK, otherwise: write it to ~/.config/agent-hosts/${HOSTNAME_FQDN}.yaml
708 and \`chmod 600\` it (install a YAML parser — yq, or python3 + PyYAML —
709 if none is present). Judge which fits this machine.
710
7112. SSH in and read ~/$INFO_FILE_NAME on the host — it has the machine's
712 OS / CPU / memory / disk / GPU inventory and notes. Keep the useful parts
713 alongside your stored credential / pointer. (This exact prompt is also
714 saved at ~/$PROMPT_FILE_NAME on the host, in case it's needed again later.)
715
7163. Verify access end-to-end and report results:
717 - SSH login succeeds
718 - \`sudo -n true\` succeeds (sudo works)
719 - an SFTP/SCP round-trip of a small test file succeeds
720
7214. Harden:$( [ "$UNIQUE_KEYPAIR" -eq 1 ] && cat <<KEYEOF
722 generate a NEW per-host SSH keypair (do not reuse an
723 existing default key), e.g.
724 ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_${AGENT_USER}-${HOSTNAME_FQDN%%.*} -N ""
725 append its public key to ${AGENT_HOME}/.ssh/authorized_keys, and confirm
726 key-based login works. Tools like ssh-copy-id default to "the newest key in
727 ~/.ssh" when no -i is given, so back-date this new keypair's mtime to just
728 before your existing default key's mtime (e.g. \`touch -d '<time>'
729 ~/.ssh/id_ed25519_${AGENT_USER}-${HOSTNAME_FQDN%%.*}{,.pub}\`) so it never
730 becomes an implicit default. Then prefer this key over the password.
731KEYEOF
732)$( [ "$UNIQUE_KEYPAIR" -eq 0 ] && cat <<KEYEOF
733 append your existing SSH public key (e.g.
734 ~/.ssh/id_ed25519.pub — generate one first if you don't have one) to
735 ${AGENT_HOME}/.ssh/authorized_keys, confirm key-based login works, and
736 prefer the key over the password from then on.
737KEYEOF
738)
739
740============================================================
741
742EOF
743)"
744
745printf '%s\n' "$PROMPT_BLOCK"
746
747# Also drop a plain-text (no ANSI) copy next to AGENT_INFO.md, so the prompt
748# survives a lost terminal/scrollback — it's not otherwise persisted anywhere.
749# Contains the password: keep it as locked-down as the account's own auth.
750PROMPT_PATH="$AGENT_HOME/$PROMPT_FILE_NAME"
751printf '%s\n' "$PROMPT_BLOCK" | sed -E 's/\x1b\[[0-9;]*m//g' > "$PROMPT_PATH"
752chown "$AGENT_USER:$AGENT_USER" "$PROMPT_PATH"
753chmod 600 "$PROMPT_PATH"
754ok "Agent prompt saved to $PROMPT_PATH"
755
756ok "Provisioning complete."
757