ergosteur

ergosteur / create-agent-user.sh

Last active 2 weeks ago

Like 1

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# With --remove it does the reverse instead: ends the account's sessions,
25# deletes the user (and home dir), and undoes the sudoers / greeter-hiding
26# config written in steps 4 and 6. Installed packages are left in place.
27#
28# Run it AS ROOT, directly on the target host:
29# sudo ./create-agent-user.sh [OPTIONS]
30#
31# It is NOT meant to be piped from curl. Download it, read it, then run it.
32# -----------------------------------------------------------------------------
33
34set -uo pipefail # deliberately NOT -e: many steps are best-effort and checked
35 # explicitly with die() where failure must actually stop us.
36
37SCRIPT_VERSION="1.3.0"
38
39# =============================================================================
40# CONFIG / DEFAULTS -- everything below is overridable via CLI flags.
41# Edit these to change the baked-in defaults for your fleet.
42# =============================================================================
43
44AGENT_USER="agent-user" # account name to create
45AGENT_UID="60123" # fixed UID. Sits in the reserved 60001-61183
46 # gap: above the human range (<=60000) and
47 # below the systemd DynamicUser floor (61184).
48 # Well clear of SSSD AD idmap (200000+).
49 # Validated as free at runtime regardless.
50AGENT_GECOS="AI agent access account"
51REF_UID="1000" # the "default user" whose groups we clone
52
53PASSPHRASE_WORDS=6 # words in the generated passphrase
54PASSPHRASE_SEP='!' # separator between words
55RANDOM_PW_LEN=13 # length of the fallback random password
56MIN_WORD_LEN=4 # dict-word length filter (inclusive)
57MAX_WORD_LEN=8
58
59SUDO_MODE="nopasswd" # "nopasswd" | "password"
60DO_INSTALL=1 # 1 = install tooling, 0 = skip (--no-install)
61DICT_PROMPT=1 # 1 = offer to install a dict if missing
62SSHD_CHECK=1 # 1 = require a running sshd before proceeding
63ROTATE=0 # 1 = rotate creds/groups if user exists
64REMOVE=0 # 1 = delete the account + its config (--remove)
65KEEP_HOME=0 # 1 = with --remove, leave the home dir on disk
66ASSUME_YES=0 # 1 = with --remove, skip the confirmation prompt
67NONINTERACTIVE=0 # 1 = never prompt; take safe fallbacks
68PW_MODE="auto" # "auto" | "passphrase" | "random"
69UNIQUE_KEYPAIR=0 # 1 = agent mints a keypair per host
70 # (~/.ssh/<user>/id_ed25519_<host>); default is one
71 # shared agent keypair (~/.ssh/<user>/id_ed25519),
72 # reused across hosts. Either way it is never the
73 # interactive user's own key.
74
75AGENT_TARGET_HOST="" # override reachable address in the output
76SSH_PORT="" # override advertised SSH port (else detected)
77INFO_FILE_NAME="AGENT_INFO.md" # written into the account's home dir
78PROMPT_FILE_NAME="AGENT_PROMPT.txt" # copy of the final agent-prompt block,
79 # also written into the account's home dir
80
81# Dictionaries we look for, in order of preference:
82DICT_CANDIDATES=(
83 /usr/share/dict/words
84 /usr/share/dict/american-english
85 /usr/share/dict/british-english
86 /usr/dict/words
87)
88
89# =============================================================================
90# Logging helpers
91# =============================================================================
92if [ -t 2 ]; then
93 C_RED=$'\033[31m'; C_YLW=$'\033[33m'; C_GRN=$'\033[32m'
94 C_BLU=$'\033[34m'; C_RST=$'\033[0m'
95else
96 C_RED=""; C_YLW=""; C_GRN=""; C_BLU=""; C_RST=""
97fi
98info() { printf '%s[*]%s %s\n' "$C_BLU" "$C_RST" "$*" >&2; }
99ok() { printf '%s[+]%s %s\n' "$C_GRN" "$C_RST" "$*" >&2; }
100warn() { printf '%s[!]%s %s\n' "$C_YLW" "$C_RST" "$*" >&2; }
101die() { printf '%s[x]%s %s\n' "$C_RED" "$C_RST" "$*" >&2; exit 1; }
102
103usage() {
104 cat >&2 <<EOF
105create-agent-user.sh v$SCRIPT_VERSION
106
107Usage: sudo ./create-agent-user.sh [OPTIONS]
108
109 --rotate Rotate creds/groups if $AGENT_USER already exists
110 (default: refuse if the account exists).
111 --remove Delete $AGENT_USER (sessions, account, home dir) and undo
112 the sudoers + greeter-hiding config. Installed packages
113 are left alone. Prompts for confirmation.
114 --keep-home With --remove: keep the home directory (the password
115 file $PROMPT_FILE_NAME is still deleted).
116 --yes With --remove: skip the confirmation prompt (required
117 when non-interactive).
118 --sudo-password Require a password for sudo (default: NOPASSWD).
119 --no-install Skip all package installation.
120 --agent-target-host H Address to advertise in the output block.
121 --uid N Override the fixed UID (default: $AGENT_UID).
122 --username NAME Override the account name (default: $AGENT_USER).
123 --ssh-port N Advertise this SSH port (default: auto-detect).
124 --passphrase Force a dictionary passphrase.
125 --random-password Force a random password.
126 --words N Passphrase word count (default: $PASSPHRASE_WORDS).
127 --no-sshd-check Do not require a running SSH server.
128 --non-interactive Never prompt; take safe fallbacks.
129 --unique-keypair Agent prompt tells the AI to mint a separate keypair for
130 THIS host (~/.ssh/$AGENT_USER/id_ed25519_<host>). Default:
131 one shared agent keypair (~/.ssh/$AGENT_USER/id_ed25519),
132 reused across hosts. Never the interactive user's key.
133 -h, --help Show this help.
134EOF
135 exit "${1:-0}"
136}
137
138# =============================================================================
139# Argument parsing
140# =============================================================================
141while [ $# -gt 0 ]; do
142 case "$1" in
143 --rotate) ROTATE=1 ;;
144 --remove|--cleanup) REMOVE=1 ;;
145 --keep-home) KEEP_HOME=1 ;;
146 --yes|-y) ASSUME_YES=1 ;;
147 --sudo-password) SUDO_MODE="password" ;;
148 --no-install) DO_INSTALL=0 ;;
149 --agent-target-host) AGENT_TARGET_HOST="${2:?}"; shift ;;
150 --uid) AGENT_UID="${2:?}"; shift ;;
151 --username) AGENT_USER="${2:?}"; shift ;;
152 --ssh-port) SSH_PORT="${2:?}"; shift ;;
153 --passphrase) PW_MODE="passphrase" ;;
154 --random-password) PW_MODE="random" ;;
155 --words) PASSPHRASE_WORDS="${2:?}"; shift ;;
156 --no-sshd-check) SSHD_CHECK=0 ;;
157 --non-interactive) NONINTERACTIVE=1 ;;
158 --unique-keypair) UNIQUE_KEYPAIR=1 ;;
159 -h|--help) usage 0 ;;
160 *) warn "Unknown option: $1"; usage 1 ;;
161 esac
162 shift
163done
164
165# No controlling terminal on stdin -> we cannot prompt.
166[ -t 0 ] || NONINTERACTIVE=1
167
168ask_yes_no() {
169 # $1 = prompt, $2 = default (y/n). Honors --non-interactive.
170 local prompt="$1" def="${2:-n}" reply
171 if [ "$NONINTERACTIVE" -eq 1 ]; then
172 [ "$def" = "y" ] && return 0 || return 1
173 fi
174 local hint="[y/N]"; [ "$def" = "y" ] && hint="[Y/n]"
175 read -r -p "$prompt $hint " reply || reply=""
176 reply="${reply:-$def}"
177 case "$reply" in [Yy]*) return 0 ;; *) return 1 ;; esac
178}
179
180# =============================================================================
181# Preconditions
182# =============================================================================
183[ "$(id -u)" -eq 0 ] || die "Must run as root (use sudo)."
184
185# =============================================================================
186# --remove: undo everything this script provisions, then exit.
187# Runs before the sshd / distro / password steps: none of them apply here.
188# =============================================================================
189remove_agent() {
190 [ "$ROTATE" -eq 0 ] || die "--remove and --rotate are mutually exclusive."
191 command -v userdel >/dev/null 2>&1 || die "Required tool missing: userdel"
192 getent passwd "$AGENT_USER" >/dev/null \
193 || die "Account '$AGENT_USER' does not exist; nothing to remove."
194
195 local entry uid home gecos sudoers marker
196 entry="$(getent passwd "$AGENT_USER")"
197 uid="$(cut -d: -f3 <<<"$entry")"
198 gecos="$(cut -d: -f5 <<<"$entry")"
199 home="$(cut -d: -f6 <<<"$entry")"
200 sudoers="/etc/sudoers.d/$AGENT_USER"
201 marker="# Managed by create-agent-user.sh"
202
203 # Never delete a system account or a human's account by typo / wrong --username.
204 [ "$uid" -ge 1000 ] || die "Refusing to remove '$AGENT_USER': uid $uid is a system account."
205 if ! grep -qsF "$marker" "$sudoers" && [ "$gecos" != "$AGENT_GECOS" ]; then
206 die "'$AGENT_USER' doesn't look like it was created by this script (no sudoers marker, different GECOS). Refusing."
207 fi
208 # Killing the account's sessions would kill us too.
209 [ "${SUDO_USER:-}" != "$AGENT_USER" ] && [ "$(logname 2>/dev/null || true)" != "$AGENT_USER" ] \
210 || die "Running as/through '$AGENT_USER' itself; run --remove from a different account."
211
212 warn "About to remove '$AGENT_USER' (uid $uid):"
213 warn " - kill its sessions/processes and delete its crontab"
214 if [ "$KEEP_HOME" -eq 1 ]; then
215 warn " - delete the account; KEEP $home (minus $PROMPT_FILE_NAME)"
216 else
217 warn " - delete the account and $home"
218 fi
219 warn " - remove $sudoers, its AccountsService entry, and greeter-hiding rules"
220 if [ "$ASSUME_YES" -ne 1 ]; then
221 [ "$NONINTERACTIVE" -eq 0 ] || die "Not interactive: pass --yes to confirm removal."
222 local reply
223 read -r -p "Type the username to confirm: " reply || reply=""
224 [ "$reply" = "$AGENT_USER" ] || die "Aborted."
225 fi
226
227 # Sessions + processes + scheduled jobs, or userdel refuses / leaves debris.
228 command -v loginctl >/dev/null 2>&1 && loginctl terminate-user "$AGENT_USER" >/dev/null 2>&1
229 pkill -KILL -u "$AGENT_USER" >/dev/null 2>&1
230 command -v crontab >/dev/null 2>&1 && crontab -r -u "$AGENT_USER" >/dev/null 2>&1
231 sleep 1
232
233 # The credential-bearing file goes even when the rest of the home is kept.
234 [ -d "$home" ] && rm -f "$home/$PROMPT_FILE_NAME"
235
236 if [ "$KEEP_HOME" -eq 1 ]; then
237 userdel "$AGENT_USER" || die "userdel failed."
238 else
239 userdel -r "$AGENT_USER" || die "userdel -r failed."
240 fi
241 ok "Account '$AGENT_USER' deleted."
242
243 # sudo
244 if grep -qsF "$marker" "$sudoers"; then
245 rm -f "$sudoers" && ok "Removed $sudoers"
246 elif [ -e "$sudoers" ]; then
247 warn "Leaving $sudoers in place: it isn't marked as managed by this script."
248 fi
249
250 # Greeter hiding: AccountsService entry, SDDM drop-in, LightDM token.
251 rm -f "/var/lib/AccountsService/users/$AGENT_USER" \
252 "/etc/sddm.conf.d/10-hide-$AGENT_USER.conf"
253 local ldm=/etc/lightdm/users.conf esc
254 if [ -f "$ldm" ] && grep -qE "^\s*hidden-users=.*(^|[ =])$AGENT_USER( |\$)" "$ldm"; then
255 esc="$(printf '%s' "$AGENT_USER" | sed 's/[][\.*^$/]/\\&/g')"
256 sed -i -E "/^\s*hidden-users=/{s/([ =])${esc}( |\$)/\1/;s/[[:space:]]+\$//}" "$ldm"
257 fi
258 ok "Greeter-hiding config removed."
259
260 [ -n "$(getent passwd "$AGENT_USER")" ] && die "'$AGENT_USER' still resolves after removal (network/SSSD-backed?)."
261
262 warn "Not undone (not tracked by this script):"
263 warn " - packages it installed (git, jq, tmux, lshw, ...)"
264 warn " - any off-host record: the stored credential (e.g. the bws secret ${AGENT_USER}-<host>) and"
265 warn " pointers in the agent's CLAUDE.md / ~/.config/agent-hosts/"
266 ok "Removal complete."
267 exit 0
268}
269[ "$REMOVE" -eq 1 ] && remove_agent
270
271for bin in getent useradd usermod chpasswd visudo shuf install; do
272 command -v "$bin" >/dev/null 2>&1 || die "Required tool missing: $bin"
273done
274
275ssh_running() {
276 pgrep -x sshd >/dev/null 2>&1 && return 0
277 systemctl is-active --quiet sshd 2>/dev/null && return 0
278 systemctl is-active --quiet ssh 2>/dev/null && return 0
279 command -v ss >/dev/null 2>&1 && ss -tlnH 2>/dev/null | grep -qE ':22\b' && return 0
280 return 1
281}
282if [ "$SSHD_CHECK" -eq 1 ]; then
283 ssh_running || die "No running SSH server detected. Start sshd, or pass --no-sshd-check."
284 ok "SSH server detected."
285fi
286
287# =============================================================================
288# Distro detection -> PKG_FAMILY + package name arrays
289# =============================================================================
290PKG_FAMILY=""
291DNF="dnf"
292detect_distro() {
293 local id="" like=""
294 if [ -r /etc/os-release ]; then
295 # Source in a SUBSHELL so os-release vars (VERSION, ID, NAME, ...) never
296 # leak into and clobber this script's own globals.
297 # shellcheck disable=SC1091
298 id="$( . /etc/os-release; printf '%s' "${ID:-}" )"
299 like="$( . /etc/os-release; printf '%s' "${ID_LIKE:-}" )"
300 fi
301 case " $id $like " in
302 *" debian "*|*" ubuntu "*) PKG_FAMILY="debian" ;;
303 *" fedora "*|*" rhel "*|*" centos "*) PKG_FAMILY="rhel" ;;
304 *" arch "*) PKG_FAMILY="arch" ;;
305 *)
306 # Fall back on whichever package manager exists.
307 if command -v apt-get >/dev/null; then PKG_FAMILY="debian"
308 elif command -v dnf >/dev/null; then PKG_FAMILY="rhel"
309 elif command -v yum >/dev/null; then PKG_FAMILY="rhel"; DNF="yum"
310 elif command -v pacman >/dev/null; then PKG_FAMILY="arch"
311 else die "Unsupported distro: could not find apt/dnf/pacman."
312 fi ;;
313 esac
314 command -v dnf >/dev/null 2>&1 || DNF="yum"
315
316 case "$PKG_FAMILY" in
317 debian)
318 SUDO_GROUP="sudo"
319 PKGS_AGENT=(git jq curl rsync tmux ca-certificates python3 python3-venv python3-pip pipx)
320 PKGS_HW=(lshw lsscsi hdparm inxi pciutils usbutils dmidecode smartmontools)
321 PKG_DICT="wamerican" ;;
322 rhel)
323 SUDO_GROUP="wheel"
324 PKGS_AGENT=(git jq curl rsync tmux ca-certificates python3 python3-pip pipx)
325 PKGS_HW=(lshw lsscsi hdparm inxi pciutils usbutils dmidecode smartmontools)
326 PKG_DICT="words" ;;
327 arch)
328 SUDO_GROUP="wheel"
329 PKGS_AGENT=(git jq curl rsync tmux ca-certificates python python-pip python-pipx)
330 PKGS_HW=(lshw lsscsi hdparm inxi pciutils usbutils dmidecode smartmontools)
331 PKG_DICT="words" ;;
332 esac
333}
334detect_distro
335ok "Distro family: $PKG_FAMILY (sudo group: $SUDO_GROUP)"
336
337# Virtual machine or container? Physical-hardware tools are dead weight there:
338# no real disk to SMART-query, no DMI table worth dumping. On a VM, pciutils,
339# usbutils, lshw and lsscsi stay (they describe the virtual devices); inside a
340# container there is no hardware of its own to enumerate, so they go too. inxi
341# stays in both.
342PKGS_HW_VM_SKIP=(smartmontools hdparm dmidecode)
343PKGS_HW_CONTAINER_SKIP=("${PKGS_HW_VM_SKIP[@]}" lshw lsscsi pciutils usbutils)
344VIRT_KIND="none" # none | vm | container
345VIRT_TYPE="none" # kvm, vmware, lxc, docker, podman, ... ("vm" / "container" if unknown)
346detect_virt() {
347 local v=""
348 if command -v systemd-detect-virt >/dev/null 2>&1; then
349 v="$(systemd-detect-virt --container 2>/dev/null)"
350 if [ -n "$v" ] && [ "$v" != "none" ]; then VIRT_KIND="container"; VIRT_TYPE="$v"; return; fi
351 v="$(systemd-detect-virt --vm 2>/dev/null)"
352 if [ -n "$v" ] && [ "$v" != "none" ]; then VIRT_KIND="vm"; VIRT_TYPE="$v"; fi
353 return # systemd knows and says bare metal
354 fi
355 # No systemd: container marker files, then the CPUID hypervisor cpuinfo flag.
356 if [ -e /run/.containerenv ] || [ -e /.dockerenv ]; then
357 VIRT_KIND="container"; VIRT_TYPE="container"; return
358 fi
359 if grep -qm1 -E '^flags.*[[:space:]]hypervisor([[:space:]]|$)' /proc/cpuinfo 2>/dev/null; then
360 VIRT_KIND="vm"; VIRT_TYPE="vm"
361 fi
362}
363detect_virt
364if [ "$VIRT_KIND" != "none" ]; then
365 if [ "$VIRT_KIND" = "container" ]; then _skip=("${PKGS_HW_CONTAINER_SKIP[@]}"); else _skip=("${PKGS_HW_VM_SKIP[@]}"); fi
366 _hw=()
367 for _p in "${PKGS_HW[@]}"; do
368 case " ${_skip[*]} " in
369 *" $_p "*) ;;
370 *) _hw+=("$_p") ;;
371 esac
372 done
373 PKGS_HW=("${_hw[@]}")
374 ok "Running in a $VIRT_KIND ($VIRT_TYPE); skipping hardware tools: ${_skip[*]}"
375fi
376
377# =============================================================================
378# Package installation helpers (best-effort)
379# =============================================================================
380PKG_REFRESHED=0
381pkg_refresh() {
382 [ "$PKG_REFRESHED" -eq 1 ] && return 0
383 PKG_REFRESHED=1
384 case "$PKG_FAMILY" in
385 debian) DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true ;;
386 arch) pacman -Sy --noconfirm >/dev/null 2>&1 || true ;;
387 rhel) : ;; # dnf resolves metadata on demand
388 esac
389}
390pkg_install_one() {
391 local p="$1"
392 case "$PKG_FAMILY" in
393 debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$p" >/dev/null 2>&1 ;;
394 rhel) $DNF install -y "$p" >/dev/null 2>&1 ;;
395 arch) pacman -S --noconfirm --needed "$p" >/dev/null 2>&1 ;;
396 esac
397}
398INSTALLED_OK=()
399INSTALLED_FAIL=()
400pkg_install_list() {
401 # Best-effort: never aborts the script. Records per-package outcome.
402 pkg_refresh
403 local p
404 for p in "$@"; do
405 if pkg_install_one "$p"; then
406 INSTALLED_OK+=("$p")
407 else
408 INSTALLED_FAIL+=("$p")
409 fi
410 done
411}
412
413# =============================================================================
414# Reference user (uid=1000): clone its supplementary groups + login shell
415# =============================================================================
416REF_USER="$(getent passwd "$REF_UID" | cut -d: -f1)"
417SUPP_GROUPS=()
418REF_SHELL="/bin/bash"
419if [ -n "$REF_USER" ]; then
420 info "Cloning groups from reference user: $REF_USER (uid $REF_UID)"
421 ref_primary="$(id -gn "$REF_USER" 2>/dev/null || echo)"
422 ref_shell="$(getent passwd "$REF_USER" | cut -d: -f7)"
423 [ -n "$ref_shell" ] && [ -x "$ref_shell" ] && REF_SHELL="$ref_shell"
424 for g in $(id -nG "$REF_USER" 2>/dev/null); do
425 [ "$g" = "$ref_primary" ] && continue
426 [ "$g" = "$REF_USER" ] && continue
427 SUPP_GROUPS+=("$g")
428 done
429else
430 warn "No uid=$REF_UID user found; falling back to '$SUDO_GROUP' group only."
431 getent group "$SUDO_GROUP" >/dev/null && SUPP_GROUPS+=("$SUDO_GROUP")
432fi
433SUPP_CSV="$(IFS=,; echo "${SUPP_GROUPS[*]:-}")"
434info "Supplementary groups: ${SUPP_CSV:-<none>}"
435info "Login shell: $REF_SHELL"
436
437# =============================================================================
438# Password generation
439# =============================================================================
440find_dict() {
441 local d
442 for d in "${DICT_CANDIDATES[@]}"; do
443 if [ -r "$d" ] && [ "$(grep -cE "^[a-z]{${MIN_WORD_LEN},${MAX_WORD_LEN}}\$" "$d" 2>/dev/null)" -ge 100 ]; then
444 echo "$d"; return 0
445 fi
446 done
447 return 1
448}
449gen_passphrase() {
450 local dict="$1" picked out="" w
451 mapfile -t picked < <(grep -E "^[a-z]{${MIN_WORD_LEN},${MAX_WORD_LEN}}\$" "$dict" | shuf -n "$PASSPHRASE_WORDS")
452 [ "${#picked[@]}" -lt "$PASSPHRASE_WORDS" ] && return 1
453 for w in "${picked[@]}"; do
454 out+="${w^}${PASSPHRASE_SEP}" # Capitalize each word
455 done
456 out+="$(( (RANDOM % 90) + 10 ))" # trailing 2-digit for complexity
457 printf '%s' "$out"
458}
459gen_random_pw() {
460 # Unambiguous set: no I l O 0 1 o.
461 local U='ABCDEFGHJKMNPQRSTUVWXYZ' L='abcdefghijkmnpqrstuvwxyz' \
462 D='23456789' S='!@#%^*-_=+' all pw="" i
463 all="$U$L$D$S"
464 pw+="${U:RANDOM%${#U}:1}"
465 pw+="${L:RANDOM%${#L}:1}"
466 pw+="${D:RANDOM%${#D}:1}"
467 pw+="${S:RANDOM%${#S}:1}"
468 for ((i=${#pw}; i<RANDOM_PW_LEN; i++)); do pw+="${all:RANDOM%${#all}:1}"; done
469 printf '%s' "$pw" | fold -w1 | shuf | tr -d '\n'
470}
471
472AGENT_PASSWORD=""
473PW_KIND=""
474make_password() {
475 local dict=""
476 case "$PW_MODE" in
477 random)
478 AGENT_PASSWORD="$(gen_random_pw)"; PW_KIND="random-${RANDOM_PW_LEN}ch" ;;
479 passphrase)
480 dict="$(find_dict)" || die "--passphrase requested but no usable dictionary found."
481 AGENT_PASSWORD="$(gen_passphrase "$dict")" || die "Passphrase generation failed."
482 PW_KIND="passphrase-${PASSPHRASE_WORDS}w" ;;
483 auto)
484 if dict="$(find_dict)"; then
485 AGENT_PASSWORD="$(gen_passphrase "$dict")" && PW_KIND="passphrase-${PASSPHRASE_WORDS}w"
486 fi
487 if [ -z "$AGENT_PASSWORD" ]; then
488 # No dict. Offer to install one, else random.
489 if [ "$DO_INSTALL" -eq 1 ] && [ "$DICT_PROMPT" -eq 1 ] \
490 && ask_yes_no "No word list found. Install '$PKG_DICT' for a passphrase?" y; then
491 pkg_install_list "$PKG_DICT"
492 if dict="$(find_dict)"; then
493 AGENT_PASSWORD="$(gen_passphrase "$dict")" && PW_KIND="passphrase-${PASSPHRASE_WORDS}w"
494 fi
495 fi
496 fi
497 if [ -z "$AGENT_PASSWORD" ]; then
498 warn "Using random password (no dictionary available)."
499 AGENT_PASSWORD="$(gen_random_pw)"; PW_KIND="random-${RANDOM_PW_LEN}ch"
500 fi ;;
501 esac
502 [ -n "$AGENT_PASSWORD" ] || die "Failed to generate a password."
503}
504
505# =============================================================================
506# Create (or rotate) the account
507# =============================================================================
508USER_EXISTS=0
509getent passwd "$AGENT_USER" >/dev/null && USER_EXISTS=1
510
511if [ "$USER_EXISTS" -eq 1 ] && [ "$ROTATE" -eq 0 ]; then
512 die "Account '$AGENT_USER' already exists. Re-run with --rotate to rotate its credentials."
513fi
514
515if [ "$USER_EXISTS" -eq 0 ]; then
516 # UID collision check (real users, winbind mappings, anything).
517 uid_owner="$(getent passwd "$AGENT_UID" | cut -d: -f1)"
518 if [ -n "$uid_owner" ]; then
519 die "UID $AGENT_UID already in use by '$uid_owner'. Re-run with --uid <N>."
520 fi
521 info "Creating account '$AGENT_USER' (uid $AGENT_UID)"
522 useradd --create-home --uid "$AGENT_UID" --shell "$REF_SHELL" \
523 --comment "$AGENT_GECOS" "$AGENT_USER" \
524 || die "useradd failed."
525 ok "Account created."
526else
527 existing_uid="$(id -u "$AGENT_USER")"
528 warn "Rotating existing account '$AGENT_USER' (uid $existing_uid); UID left unchanged."
529 AGENT_UID="$existing_uid"
530fi
531
532# Apply supplementary groups (idempotent).
533if [ -n "$SUPP_CSV" ]; then
534 usermod -aG "$SUPP_CSV" "$AGENT_USER" || warn "usermod -aG partially failed."
535fi
536
537# Set the password.
538make_password
539printf '%s:%s\n' "$AGENT_USER" "$AGENT_PASSWORD" | chpasswd \
540 || die "Failed to set password."
541ok "Password set ($PW_KIND)."
542
543AGENT_HOME="$(getent passwd "$AGENT_USER" | cut -d: -f6)"
544[ -n "$AGENT_HOME" ] || die "Could not determine home directory."
545
546# Pre-create ~/.ssh so the agent can drop in a key cleanly.
547install -d -m 700 -o "$AGENT_USER" -g "$AGENT_USER" "$AGENT_HOME/.ssh"
548
549# =============================================================================
550# sudo configuration
551# =============================================================================
552SUDOERS_FILE="/etc/sudoers.d/$AGENT_USER"
553if [ "$SUDO_MODE" = "nopasswd" ]; then
554 SUDO_RULE="$AGENT_USER ALL=(ALL:ALL) NOPASSWD:ALL"
555else
556 SUDO_RULE="$AGENT_USER ALL=(ALL:ALL) ALL"
557fi
558printf '# Managed by create-agent-user.sh\n%s\n' "$SUDO_RULE" > "$SUDOERS_FILE"
559chmod 0440 "$SUDOERS_FILE"
560if ! visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1; then
561 rm -f "$SUDOERS_FILE"
562 die "Generated sudoers file failed validation; removed it."
563fi
564ok "sudo configured ($SUDO_MODE)."
565
566# =============================================================================
567# Hide from graphical greeters (GDM / SDDM / LightDM) via AccountsService
568# plus greeter-specific excludes for whichever are installed.
569# =============================================================================
570greeter_present() {
571 case "$1" in
572 gdm) command -v gdm >/dev/null || command -v gdm3 >/dev/null || [ -d /etc/gdm ] || [ -d /etc/gdm3 ] ;;
573 sddm) command -v sddm >/dev/null || [ -f /etc/sddm.conf ] || [ -d /etc/sddm.conf.d ] ;;
574 lightdm) command -v lightdm >/dev/null || [ -d /etc/lightdm ] ;;
575 esac
576}
577
578# AccountsService: honored by GDM and, in modern setups, by SDDM/LightDM too.
579install -d -m 775 /var/lib/AccountsService/users 2>/dev/null || true
580cat > "/var/lib/AccountsService/users/$AGENT_USER" <<EOF
581[User]
582SystemAccount=true
583EOF
584
585# SDDM drop-in.
586if greeter_present sddm; then
587 install -d -m 755 /etc/sddm.conf.d
588 cat > "/etc/sddm.conf.d/10-hide-$AGENT_USER.conf" <<EOF
589[Users]
590HideUsers=$AGENT_USER
591EOF
592 info "SDDM hide rule written."
593fi
594
595# LightDM users.conf.
596if greeter_present lightdm; then
597 LDM=/etc/lightdm/users.conf
598 if [ -f "$LDM" ] && grep -qE '^\s*hidden-users=' "$LDM"; then
599 grep -qE "hidden-users=.*(^|[ =])$AGENT_USER($|[ ])" "$LDM" \
600 || sed -i "s/^\(\s*hidden-users=.*\)/\1 $AGENT_USER/" "$LDM"
601 else
602 install -d -m 755 /etc/lightdm
603 printf '[UserList]\nhidden-users=nobody nobody4 noaccess %s\n' "$AGENT_USER" >> "$LDM"
604 fi
605 info "LightDM hide rule written."
606fi
607ok "Account hidden from installed greeters."
608
609# =============================================================================
610# Install tooling (best-effort)
611# =============================================================================
612if [ "$DO_INSTALL" -eq 1 ]; then
613 info "Installing agent tooling (best-effort): ${PKGS_AGENT[*]}"
614 info "Installing hardware-inspection tooling (best-effort): ${PKGS_HW[*]}"
615 pkg_install_list "${PKGS_AGENT[@]}" "${PKGS_HW[@]}"
616 ok "Installed (${#INSTALLED_OK[@]}): ${INSTALLED_OK[*]:-<none>}"
617 [ "${#INSTALLED_FAIL[@]}" -gt 0 ] && warn "Not installed (${#INSTALLED_FAIL[@]}): ${INSTALLED_FAIL[*]}"
618else
619 info "Skipping package installation (--no-install)."
620fi
621
622# =============================================================================
623# Determine reachable host + SSH port for the output block
624#
625# Preference order: FQDN, then a static IP, then the short hostname (works via
626# mDNS/local resolution on many LANs), then a plain DHCP-assigned IP as last
627# resort. ADDR_KIND records which one was picked so the output can flag the
628# weaker choices (short hostname / DHCP IP) instead of presenting them as if
629# they were as durable as an FQDN or static IP.
630# =============================================================================
631PRIMARY_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}}')"
632
633is_static_ip() {
634 # Best-effort: returns 0=static, 1=dhcp, 2=unknown. Checked in order of
635 # how common each network stack is; the first one that gives a definite
636 # answer wins.
637 local iface="$1" conn method ifidx
638 [ -n "$iface" ] || return 2
639
640 if command -v nmcli >/dev/null 2>&1; then
641 conn="$(nmcli -t -f GENERAL.CONNECTION device show "$iface" 2>/dev/null | cut -d: -f2-)"
642 if [ -n "$conn" ] && [ "$conn" != "--" ]; then
643 method="$(nmcli -t -f ipv4.method connection show "$conn" 2>/dev/null | cut -d: -f2-)"
644 case "$method" in
645 manual) return 0 ;;
646 auto) return 1 ;;
647 esac
648 fi
649 fi
650
651 ifidx="$(cat "/sys/class/net/$iface/ifindex" 2>/dev/null)"
652 [ -n "$ifidx" ] && [ -f "/run/systemd/netif/leases/$ifidx" ] && return 1
653
654 ls /var/lib/dhcp/dhclient*"$iface"*.lease* >/dev/null 2>&1 && return 1
655 ls /var/lib/dhcpcd*/*.lease >/dev/null 2>&1 && return 1
656
657 if [ -f /etc/network/interfaces ] \
658 && grep -qE "iface[[:space:]]+${iface}[[:space:]]+inet[[:space:]]+static" /etc/network/interfaces 2>/dev/null; then
659 return 0
660 fi
661
662 return 2
663}
664
665# NOTE: detect_host sets OUT_HOST/ADDR_KIND directly (globals) rather than
666# echoing a return value, because it must run in THIS shell, not a
667# command-substitution subshell — otherwise ADDR_KIND would be lost the
668# moment the function returns.
669ADDR_KIND="" # override | fqdn | static-ip | short-hostname | dhcp-ip
670OUT_HOST=""
671detect_host() {
672 if [ -n "$AGENT_TARGET_HOST" ]; then
673 ADDR_KIND="override"; OUT_HOST="$AGENT_TARGET_HOST"; return
674 fi
675
676 local ip fqdn short
677 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}}')"
678 [ -z "$ip" ] && ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
679
680 fqdn="$(hostname -A 2>/dev/null | awk '{print $1}')"
681 if [ -n "$fqdn" ] && [[ "$fqdn" == *.* ]]; then
682 ADDR_KIND="fqdn"; OUT_HOST="$fqdn"; return
683 fi
684
685 if [ -n "$ip" ]; then
686 is_static_ip "$PRIMARY_IFACE"
687 if [ "$?" -eq 0 ]; then
688 ADDR_KIND="static-ip"; OUT_HOST="$ip"; return
689 fi
690 fi
691
692 short="$(hostname 2>/dev/null)"
693 if [ -n "$short" ] && [ "$short" != "localhost" ]; then
694 ADDR_KIND="short-hostname"; OUT_HOST="$short"; return
695 fi
696
697 ADDR_KIND="dhcp-ip"; OUT_HOST="$ip"
698}
699detect_port() {
700 [ -n "$SSH_PORT" ] && { echo "$SSH_PORT"; return; }
701 local p
702 p="$(sshd -T 2>/dev/null | awk '/^port /{print $2; exit}')"
703 [ -z "$p" ] && p=22
704 echo "$p"
705}
706detect_host
707OUT_PORT="$(detect_port)"
708HOSTNAME_FQDN="$(hostname -f 2>/dev/null || hostname)"
709
710ADDR_WARNING=""
711case "$ADDR_KIND" in
712 short-hostname)
713 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." ;;
714 dhcp-ip)
715 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." ;;
716esac
717
718# Representative package-manager invocation + human-readable distro name, so
719# the agent knows immediately how to install things without waiting to read
720# AGENT_INFO.md.
721case "$PKG_FAMILY" in
722 debian) PKG_MGR_CMD="apt-get install -y <pkg>" ;;
723 rhel) PKG_MGR_CMD="$DNF install -y <pkg>" ;;
724 arch) PKG_MGR_CMD="pacman -S --noconfirm <pkg>" ;;
725esac
726DISTRO_PRETTY="$( [ -r /etc/os-release ] && . /etc/os-release; printf '%s' "${PRETTY_NAME:-$PKG_FAMILY}" )"
727
728# =============================================================================
729# x86-64 microarchitecture level (nice to know; explains SIGILL-on-old-CPU)
730# =============================================================================
731x86_64_level() {
732 [ "$(uname -m)" = "x86_64" ] || { echo "n/a ($(uname -m))"; return; }
733 local f lvl=1
734 f=" $(grep -m1 '^flags' /proc/cpuinfo | cut -d: -f2) "
735 has() { [[ $f == *" $1 "* ]]; }
736 if has cx16 && has lahf_lm && has popcnt && has sse4_1 && has sse4_2 && has ssse3; then lvl=2; fi
737 if [ "$lvl" -ge 2 ] && has avx && has avx2 && has bmi1 && has bmi2 && has f16c && has fma && has movbe && has abm; then lvl=3; fi
738 if [ "$lvl" -ge 3 ] && has avx512f && has avx512bw && has avx512cd && has avx512dq && has avx512vl; then lvl=4; fi
739 echo "x86-64-v$lvl"
740}
741
742# =============================================================================
743# Write the system-info file into the account's home directory
744# =============================================================================
745INFO_PATH="$AGENT_HOME/$INFO_FILE_NAME"
746try() { "$@" 2>/dev/null || true; }
747{
748 echo "# Agent host reference — $HOSTNAME_FQDN"
749 echo
750 echo "> Written by create-agent-user.sh v$SCRIPT_VERSION. This file is here for the"
751 echo "> agent to read after logging in. It contains no credentials."
752 echo
753 echo "## Identity"
754 echo '```'
755 echo "hostname : $HOSTNAME_FQDN"
756 echo "account : $AGENT_USER (uid $AGENT_UID)"
757 echo "virt : $VIRT_TYPE ($VIRT_KIND)"
758 echo "sudo : $SUDO_MODE"
759 echo "shell : $REF_SHELL"
760 echo "groups : ${SUPP_CSV:-<none>}"
761 echo '```'
762 echo
763 echo "## OS & kernel"
764 echo '```'
765 try grep -E '^(PRETTY_NAME|VERSION|ID)=' /etc/os-release
766 echo "kernel : $(uname -r)"
767 echo "arch : $(uname -m)"
768 echo '```'
769 echo
770 echo "## CPU"
771 echo '```'
772 try grep -m1 'model name' /proc/cpuinfo
773 echo "cores : $(nproc 2>/dev/null || echo '?')"
774 echo "x86 level: $(x86_64_level)"
775 echo '```'
776 echo
777 echo "## Memory"
778 echo '```'
779 try free -h
780 echo '```'
781 echo
782 echo "## Block devices"
783 echo '```'
784 if command -v lsblk >/dev/null; then try lsblk -o NAME,SIZE,TYPE,MODEL,MOUNTPOINT; fi
785 if command -v lsscsi >/dev/null; then echo; try lsscsi; fi
786 echo '```'
787 echo
788 echo "## GPU / display"
789 echo '```'
790 if command -v lspci >/dev/null; then try lspci | grep -iE 'vga|3d|display'; fi
791 echo '```'
792 echo
793 echo "## Network"
794 echo '```'
795 if command -v ip >/dev/null; then try ip -brief addr; else try hostname -I; fi
796 echo '```'
797 echo
798 echo "## Installed agent tooling"
799 echo '```'
800 for t in git python3 python pip pip3 pipx jq curl rsync tmux; do
801 if command -v "$t" >/dev/null 2>&1; then
802 printf '%-8s %s\n' "$t" "$("$t" --version 2>&1 | head -n1)"
803 fi
804 done
805 echo '```'
806 echo
807 echo "## Notes"
808 echo "- Provisioned for AI agent access. Add host-specific notes below."
809 echo
810} > "$INFO_PATH"
811chown "$AGENT_USER:$AGENT_USER" "$INFO_PATH"
812chmod 640 "$INFO_PATH"
813ok "System info written to $INFO_PATH"
814
815# =============================================================================
816# Final output: short block to paste into an AI agent prompt
817# =============================================================================
818HOST_SHORT="${HOSTNAME_FQDN%%.*}"
819# The literal ~ is intentional: these paths are prompt text for the agent, not expanded here.
820# shellcheck disable=SC2088
821if [ "$UNIQUE_KEYPAIR" -eq 1 ]; then
822 KEY_PATH="~/.ssh/${AGENT_USER}/id_ed25519_${HOST_SHORT}"
823 KEY_INTRO="a DEDICATED keypair for this host only"
824 KEY_STEP="generate it"
825 KEY_COMMENT="${AGENT_USER}@${HOST_SHORT}"
826else
827 KEY_PATH="~/.ssh/${AGENT_USER}/id_ed25519"
828 KEY_INTRO="the shared agent keypair (one key reused across hosts)"
829 KEY_STEP="if it doesn't exist yet, generate it (if it does, reuse it — don't overwrite)"
830 KEY_COMMENT="${AGENT_USER}"
831fi
832
833SUDO_NOTE="nopasswd"; [ "$SUDO_MODE" = "password" ] && SUDO_NOTE="password"
834TODAY="$(date +%Y-%m-%d 2>/dev/null || echo unknown)"
835
836PROMPT_BLOCK="$(cat <<EOF
837
838$(printf '%s' "$C_GRN")============================================================
839 DONE — copy everything between the lines into your AI agent
840============================================================$(printf '%s' "$C_RST")
841
842You have been granted an account with full sudo on a Linux host. Here are the
843credentials:
844
845 host: $OUT_HOST
846 port: $OUT_PORT
847 username: $AGENT_USER
848 uid: $AGENT_UID
849 password: "$AGENT_PASSWORD"
850 sudo: $SUDO_NOTE
851 hostname: $HOSTNAME_FQDN
852 distro: $DISTRO_PRETTY ($PKG_FAMILY; install with: $PKG_MGR_CMD)
853 provisioned: $TODAY
854$( [ -n "$ADDR_WARNING" ] && printf '\nNOTE: %s\n' "$ADDR_WARNING" )
855Do this:
856
8571. Ask the user where this machine's connection info should be recorded —
858 project level or user level, the same choice as picking a scope when adding
859 an MCP server — and wait for the answer rather than guessing:
860 - user level: a pointer in your global CLAUDE.md / AGENTS.md, so every
861 session on this machine can find this host.
862 - project level: a pointer in this project's CLAUDE.md / AGENTS.md, so
863 only work in this project sees it (use a local-only file such as
864 CLAUDE.local.md if the repo is shared).
865 Either way, store the credential itself where this machine keeps its
866 secrets, and put only a *pointer* to it in that doc, so this access is
867 discoverable later — not just for this one session. Never inline the
868 password into a doc that gets loaded into context or committed to a repo.
869 - PREFERRED, if this machine uses Bitwarden Secrets Manager (\`bws\` on
870 PATH, token in the keyring): store it as a secret, e.g.
871 BWS_ACCESS_TOKEN="\$(secret-tool lookup service bws account default)" \\
872 bws secret create ${AGENT_USER}-${HOSTNAME_FQDN%%.*} "<password>" <project-id> \\
873 --note "host=$OUT_HOST user=$AGENT_USER uid=$AGENT_UID sudo=$SUDO_NOTE"
874 and record the returned secret id in your pointer. Fetch later with
875 \`bws secret get <id> | jq -r .value\`.
876 - FALLBACK, otherwise: write it to ~/.config/agent-hosts/${HOSTNAME_FQDN}.yaml
877 (outside any repo, whichever scope was chosen) and \`chmod 600\` it
878 (install a YAML parser — yq, or python3 + PyYAML — if none is present).
879 Judge which fits this machine.
880
8812. SSH in and read ~/$INFO_FILE_NAME on the host — it has the machine's
882 OS / CPU / memory / disk / GPU inventory and notes. Keep the useful parts
883 alongside your stored credential / pointer. (This exact prompt is also
884 saved at ~/$PROMPT_FILE_NAME on the host, in case it's needed again later.)
885
8863. Verify access end-to-end and report results:
887 - SSH login succeeds
888 - \`sudo -n true\` succeeds (sudo works)
889 - an SFTP/SCP round-trip of a small test file succeeds
890
8914. Harden: switch to key auth using $KEY_INTRO — do not
892 reuse the interactive user's own key (~/.ssh/id_*). Keep it in its own
893 subdirectory, where no tool treats it as a default identity. Key path:
894 $KEY_PATH — $KEY_STEP:
895 mkdir -p -m 700 ~/.ssh/${AGENT_USER}
896 ssh-keygen -t ed25519 -N "" -C "$KEY_COMMENT" -f $KEY_PATH
897 Append the .pub to ${AGENT_HOME}/.ssh/authorized_keys (name the key
898 explicitly, e.g. \`ssh-copy-id -i <that .pub> ...\`, and do not load it into
899 ssh-agent), then add a dedicated alias to ~/.ssh/config (chmod 600) — an
900 alias, not a \`Host $OUT_HOST\` block, so it can't hijack the user's own
901 ssh to this machine:
902 Host ${AGENT_USER}-${HOST_SHORT}
903 HostName $OUT_HOST
904 Port $OUT_PORT
905 User $AGENT_USER
906 IdentityFile $KEY_PATH
907 IdentitiesOnly yes
908 Confirm \`ssh ${AGENT_USER}-${HOST_SHORT}\` logs in without a password,
909 record the alias in your pointer, and prefer the key over the password from
910 then on.
911
912============================================================
913
914EOF
915)"
916
917printf '%s\n' "$PROMPT_BLOCK"
918
919# Also drop a plain-text (no ANSI) copy next to AGENT_INFO.md, so the prompt
920# survives a lost terminal/scrollback — it's not otherwise persisted anywhere.
921# Contains the password: keep it as locked-down as the account's own auth.
922PROMPT_PATH="$AGENT_HOME/$PROMPT_FILE_NAME"
923printf '%s\n' "$PROMPT_BLOCK" | sed -E 's/\x1b\[[0-9;]*m//g' > "$PROMPT_PATH"
924chown "$AGENT_USER:$AGENT_USER" "$PROMPT_PATH"
925chmod 600 "$PROMPT_PATH"
926ok "Agent prompt saved to $PROMPT_PATH"
927
928ok "Provisioning complete."
929