Speeedfire

Untitled

Aug 11th, 2026
76
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 27.21 KB | None | 0 0
  1. #!/usr/bin/env bash
  2. set -Eeuo pipefail
  3.  
  4. # Tests may prepend fake binaries. Production always uses a deterministic PATH.
  5. if [[ "${TEST_MODE:-0}" == "1" ]]; then
  6. :
  7. else
  8. export PATH=/usr/sbin:/usr/bin:/sbin:/bin
  9. fi
  10.  
  11. AI_USER="${AI_USER:-aiops}"
  12. AI_ALLOWED_FROM="${AI_ALLOWED_FROM:-11.57.0.20/32}"
  13. COMPOSE_PROJECT="${COMPOSE_PROJECT:-qnap}"
  14. PUBLIC_KEY_FILE="${PUBLIC_KEY_FILE:-}"
  15. CONFIG_DIR="/etc/ai-readonly"
  16. RUNTIME_COMMAND="/usr/local/libexec/ai-readonly-command"
  17. SSHD_DROPIN="/etc/ssh/sshd_config.d/91-ai-readonly.conf"
  18. HARDENING_DROPIN="/etc/ssh/sshd_config.d/99-hardening.conf"
  19. SUDOERS_FILE="/etc/sudoers.d/ai-readonly"
  20. MANAGED_MARKER="$CONFIG_DIR/managed"
  21.  
  22. if [[ "${TEST_MODE:-0}" == "1" ]]; then
  23. if [[ -n "${TEST_CONFIG_DIR:-}" ]]; then
  24. CONFIG_DIR="$TEST_CONFIG_DIR"
  25. MANAGED_MARKER="$CONFIG_DIR/managed"
  26. fi
  27. if [[ -n "${TEST_RUNTIME_DIR:-}" ]]; then
  28. RUNTIME_COMMAND="$TEST_RUNTIME_DIR/ai-readonly-command"
  29. fi
  30. if [[ -n "${TEST_SSHD_DROPIN:-}" ]]; then
  31. SSHD_DROPIN="$TEST_SSHD_DROPIN"
  32. fi
  33. if [[ -n "${TEST_SUDOERS_FILE:-}" ]]; then
  34. SUDOERS_FILE="$TEST_SUDOERS_FILE"
  35. fi
  36. fi
  37.  
  38. SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  39. SOURCE_COMMAND="$SCRIPT_ROOT/ai-readonly-command.sh"
  40.  
  41. UNINSTALL=0
  42. ALLOW_ANY_SOURCE=0
  43. ACK_ANY_SOURCE=0
  44. DISCOVER_CONTAINERS=0
  45. ROTATE_PUBLIC_KEY=0
  46. EXTRA_CONTAINERS=()
  47. EXTRA_UNITS=()
  48.  
  49. INSTALLED_RUNTIME=0
  50. INSTALLED_ALLOWLISTS=0
  51. INSTALLED_SSHD_DROPIN=0
  52. INSTALLED_SUDOERS=0
  53. INSTALLED_AUTHORIZED_KEYS=0
  54. INSTALLED_MARKER=0
  55. INSTALL_COMMITTED=0
  56. INSTALL_ROLLED_BACK=0
  57. existing_install=0
  58. original_created_user=0
  59. created_user=0
  60. user_home=""
  61. SNAPSHOT_DIR=""
  62. SNAPSHOT_COMPLETE=0
  63. SSHD_RELOAD_ATTEMPTED=0
  64.  
  65. log() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
  66. warn() { printf '\n\033[1;33mWARNING: %s\033[0m\n' "$*" >&2; }
  67. die() {
  68. printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2
  69. exit 1
  70. }
  71.  
  72. expected_home_for() {
  73. if [[ "${TEST_MODE:-0}" == "1" ]]; then
  74. printf '%s/home/%s\n' "${TEST_TMP:-/tmp}" "$1"
  75. else
  76. printf '/home/%s\n' "$1"
  77. fi
  78. }
  79.  
  80. marker_field() {
  81. [[ -f "$2" ]] || return 0
  82. awk -F= -v key="$1" '$1 == key { print $2; exit }' "$2"
  83. }
  84.  
  85. reload_sshd() {
  86. if systemctl is-active --quiet ssh.service 2>/dev/null; then
  87. SSHD_RELOAD_ATTEMPTED=1
  88. systemctl reload ssh.service
  89. elif systemctl is-active --quiet sshd.service 2>/dev/null; then
  90. SSHD_RELOAD_ATTEMPTED=1
  91. systemctl reload sshd.service
  92. else
  93. warn "SSH service is not active; configuration was installed but not reloaded."
  94. fi
  95. }
  96.  
  97. SNAPSHOT_NAMES=(runtime containers units dropin sudoers authorized_keys marker)
  98.  
  99. snapshot_path_for() {
  100. case "$1" in
  101. runtime) printf '%s\n' "$RUNTIME_COMMAND" ;;
  102. containers) printf '%s\n' "$CONFIG_DIR/containers.allow" ;;
  103. units) printf '%s\n' "$CONFIG_DIR/units.allow" ;;
  104. dropin) printf '%s\n' "$SSHD_DROPIN" ;;
  105. sudoers) printf '%s\n' "$SUDOERS_FILE" ;;
  106. authorized_keys)
  107. [[ -n "$user_home" ]] || return 0
  108. printf '%s\n' "$user_home/.ssh/authorized_keys"
  109. ;;
  110. marker) printf '%s\n' "$MANAGED_MARKER" ;;
  111. esac
  112. }
  113.  
  114. # Publish SNAPSHOT_DIR only after every copy succeeded and a completion marker
  115. # exists. A failed snapshot therefore cannot be mistaken for a complete image.
  116. create_snapshot() {
  117. local tmp name path
  118. tmp="$(mktemp -d)" || return 1
  119. chmod 0700 "$tmp" || {
  120. rm -rf -- "$tmp" 2>/dev/null || true
  121. return 1
  122. }
  123.  
  124. for name in "${SNAPSHOT_NAMES[@]}"; do
  125. path="$(snapshot_path_for "$name")"
  126. [[ -n "$path" && -e "$path" ]] || continue
  127. if ! cp -a -- "$path" "$tmp/$name"; then
  128. rm -rf -- "$tmp" 2>/dev/null || true
  129. return 1
  130. fi
  131. done
  132.  
  133. if ! : >"$tmp/.complete"; then
  134. rm -rf -- "$tmp" 2>/dev/null || true
  135. return 1
  136. fi
  137. chmod 0600 "$tmp/.complete" || {
  138. rm -rf -- "$tmp" 2>/dev/null || true
  139. return 1
  140. }
  141.  
  142. SNAPSHOT_DIR="$tmp"
  143. SNAPSHOT_COMPLETE=1
  144. }
  145.  
  146. restore_snapshot() {
  147. [[ "$SNAPSHOT_COMPLETE" == "1" ]] || return 1
  148. [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" && -f "$SNAPSHOT_DIR/.complete" ]] || return 1
  149.  
  150. local rc=0 name path restore_tmp
  151. for name in "${SNAPSHOT_NAMES[@]}"; do
  152. path="$(snapshot_path_for "$name")"
  153. [[ -n "$path" ]] || continue
  154.  
  155. if [[ -e "$SNAPSHOT_DIR/$name" ]]; then
  156. mkdir -p "$(dirname "$path")" 2>/dev/null || {
  157. rc=1
  158. continue
  159. }
  160. restore_tmp="${path}.restore.$$"
  161. rm -f -- "$restore_tmp" 2>/dev/null || true
  162. if cp -a -- "$SNAPSHOT_DIR/$name" "$restore_tmp" && mv -f -- "$restore_tmp" "$path"; then
  163. :
  164. else
  165. rm -f -- "$restore_tmp" 2>/dev/null || true
  166. rc=1
  167. fi
  168. else
  169. rm -f -- "$path" 2>/dev/null || rc=1
  170. fi
  171. done
  172. return "$rc"
  173. }
  174.  
  175. rollback_install() {
  176. local rc=$?
  177. [[ "$INSTALL_COMMITTED" == "1" ]] && return 0
  178. if [[ "$INSTALL_ROLLED_BACK" == "1" ]]; then
  179. trap - EXIT
  180. exit "$rc"
  181. fi
  182. INSTALL_ROLLED_BACK=1
  183.  
  184. local restore_failed=0
  185. if [[ "$existing_install" == "1" ]]; then
  186. if [[ "$SNAPSHOT_COMPLETE" == "1" && -f "${SNAPSHOT_DIR:-}/.complete" ]]; then
  187. warn "Restoring the previous managed installation (rc=$rc)"
  188. restore_snapshot || restore_failed=1
  189. else
  190. # Snapshot creation is the last operation before any managed mutation.
  191. # If it did not complete, the original installation is still untouched.
  192. warn "Pre-update snapshot did not complete; the existing managed installation was left untouched (rc=$rc)"
  193. fi
  194. else
  195. warn "Rolling back partial install (rc=$rc)"
  196. if [[ "$INSTALLED_MARKER" == "1" ]]; then
  197. rm -f "$MANAGED_MARKER" 2>/dev/null || true
  198. fi
  199. if [[ "$INSTALLED_AUTHORIZED_KEYS" == "1" && -n "$user_home" ]]; then
  200. rm -f "$user_home/.ssh/authorized_keys" 2>/dev/null || true
  201. fi
  202. if [[ "$INSTALLED_SUDOERS" == "1" ]]; then
  203. rm -f "$SUDOERS_FILE" 2>/dev/null || true
  204. fi
  205. if [[ "$INSTALLED_SSHD_DROPIN" == "1" ]]; then
  206. rm -f "$SSHD_DROPIN" 2>/dev/null || true
  207. fi
  208. if [[ "$INSTALLED_RUNTIME" == "1" ]]; then
  209. rm -f "$RUNTIME_COMMAND" 2>/dev/null || true
  210. fi
  211. if [[ "$INSTALLED_ALLOWLISTS" == "1" ]]; then
  212. rm -rf "$CONFIG_DIR" 2>/dev/null || true
  213. fi
  214.  
  215. if [[ "$created_user" == "1" ]] && id "$AI_USER" >/dev/null 2>&1; then
  216. userdel --remove "$AI_USER" >/dev/null 2>&1 \
  217. || userdel "$AI_USER" >/dev/null 2>&1 \
  218. || true
  219. fi
  220. fi
  221.  
  222. if [[ "$SSHD_RELOAD_ATTEMPTED" == "1" ]]; then
  223. if "$SSHD_BIN" -t; then
  224. reload_sshd || warn "SSH reload failed during rollback; inspect the SSH service before retrying."
  225. else
  226. warn "sshd -t failed after rollback; keep the administrator session open and inspect /etc/ssh/sshd_config*."
  227. restore_failed=1
  228. fi
  229. fi
  230.  
  231. if ((restore_failed == 1)); then
  232. warn "Rollback could not fully reconcile the host. The snapshot is retained at ${SNAPSHOT_DIR:-<none>} for manual recovery."
  233. elif [[ -n "$SNAPSHOT_DIR" ]]; then
  234. rm -rf -- "$SNAPSHOT_DIR" 2>/dev/null || true
  235. fi
  236.  
  237. trap - EXIT
  238. exit "$rc"
  239. }
  240.  
  241. usage() {
  242. cat <<'USAGE'
  243. Install a forced-command, read-only SSH account for AI/automation diagnostics.
  244.  
  245. Usage:
  246. sudo ./scripts/install-ai-readonly-ssh.sh --public-key-file PATH [options]
  247. sudo ./scripts/install-ai-readonly-ssh.sh --uninstall [--user NAME]
  248.  
  249. Options:
  250. --public-key-file PATH File containing exactly one SSH public key.
  251. --rotate-public-key Explicitly replace the key of an existing managed
  252. installation. Without this flag, a different key is
  253. rejected. Not valid for a fresh installation.
  254. --allowed-from CIDRS Comma-separated IPv4/IPv6 addresses or CIDRs.
  255. Default: 11.57.0.20/32 (one WireGuard peer).
  256. --allow-any-source Omit the authorized_keys source restriction.
  257. Requires --acknowledge-any-source. Risky.
  258. --acknowledge-any-source Acknowledge the weakened source restriction.
  259. --user NAME Dedicated account name. Default: aiops.
  260. --compose-project NAME Compose project for optional discovery. Default: hrvlo.
  261. --discover-containers Opt in to docker ps container discovery. Off by default.
  262. --container NAME Add an exact container name. Repeatable.
  263. --unit UNIT Add an exact systemd unit. Repeatable.
  264. --uninstall Remove files managed by this installer.
  265. -h, --help Show this help.
  266.  
  267. Examples:
  268. sudo ./scripts/install-ai-readonly-ssh.sh \
  269. --public-key-file /root/ai-reader.pub \
  270. --allowed-from 11.57.0.20/32
  271.  
  272. sudo ./scripts/install-ai-readonly-ssh.sh \
  273. --public-key-file /root/ai-reader-next.pub \
  274. --rotate-public-key \
  275. --allowed-from 11.57.0.20/32
  276. USAGE
  277. }
  278.  
  279. while (($# > 0)); do
  280. case "$1" in
  281. --public-key-file)
  282. (($# >= 2)) || die "--public-key-file requires a path"
  283. PUBLIC_KEY_FILE="$2"
  284. shift 2
  285. ;;
  286. --rotate-public-key)
  287. ROTATE_PUBLIC_KEY=1
  288. shift
  289. ;;
  290. --allowed-from)
  291. (($# >= 2)) || die "--allowed-from requires a value"
  292. AI_ALLOWED_FROM="$2"
  293. shift 2
  294. ;;
  295. --allow-any-source)
  296. ALLOW_ANY_SOURCE=1
  297. shift
  298. ;;
  299. --acknowledge-any-source)
  300. ACK_ANY_SOURCE=1
  301. shift
  302. ;;
  303. --user)
  304. (($# >= 2)) || die "--user requires a value"
  305. AI_USER="$2"
  306. shift 2
  307. ;;
  308. --compose-project)
  309. (($# >= 2)) || die "--compose-project requires a value"
  310. COMPOSE_PROJECT="$2"
  311. shift 2
  312. ;;
  313. --discover-containers)
  314. DISCOVER_CONTAINERS=1
  315. shift
  316. ;;
  317. --container)
  318. (($# >= 2)) || die "--container requires a value"
  319. EXTRA_CONTAINERS+=("$2")
  320. shift 2
  321. ;;
  322. --unit)
  323. (($# >= 2)) || die "--unit requires a value"
  324. EXTRA_UNITS+=("$2")
  325. shift 2
  326. ;;
  327. --uninstall)
  328. UNINSTALL=1
  329. shift
  330. ;;
  331. -h | --help)
  332. usage
  333. exit 0
  334. ;;
  335. *) die "unknown argument: $1" ;;
  336. esac
  337. done
  338.  
  339. TEST_MODE="${TEST_MODE:-0}"
  340. if [[ "$TEST_MODE" != "1" ]]; then
  341. ((EUID == 0)) || die "run this installer with sudo or as root"
  342. [[ "$(uname -s)" == "Linux" ]] || die "Linux is required"
  343. fi
  344. [[ "$AI_USER" =~ ^[a-z_][a-z0-9_-]{0,30}$ ]] || die "invalid account name: $AI_USER"
  345. [[ "$COMPOSE_PROJECT" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$ ]] || die "invalid Compose project name"
  346.  
  347. if ((ALLOW_ANY_SOURCE == 1)) && ((ACK_ANY_SOURCE == 0)); then
  348. die "--allow-any-source requires --acknowledge-any-source"
  349. fi
  350. if ((UNINSTALL == 1)) && ((ROTATE_PUBLIC_KEY == 1)); then
  351. die "--rotate-public-key cannot be combined with --uninstall"
  352. fi
  353.  
  354. SSHD_BIN="$(command -v sshd 2>/dev/null || true)"
  355. [[ -n "$SSHD_BIN" ]] || SSHD_BIN=/usr/sbin/sshd
  356.  
  357. restore_staged_dropin() {
  358. local staged="$1"
  359. [[ -n "$staged" && -e "$staged" ]] || return 0
  360. install -d -o root -g root -m 0755 "$(dirname "$SSHD_DROPIN")" 2>/dev/null || true
  361. rm -f -- "$SSHD_DROPIN" 2>/dev/null || true
  362. cp -a -- "$staged" "$SSHD_DROPIN"
  363. }
  364.  
  365. uninstall_access() {
  366. [[ -f "$MANAGED_MARKER" ]] || die "managed marker not found; refusing to remove an unknown installation"
  367. # shellcheck disable=SC1090
  368. source "$MANAGED_MARKER"
  369. [[ "${MANAGED_AI_USER:-}" == "$AI_USER" ]] \
  370. || die "managed account is ${MANAGED_AI_USER:-unknown}; rerun with --user ${MANAGED_AI_USER:-<name>}"
  371.  
  372. log "Validating SSH configuration without the managed drop-in"
  373. local stage="" staged_dropin=""
  374. if [[ -e "$SSHD_DROPIN" ]]; then
  375. stage="$(mktemp -d)"
  376. chmod 0700 "$stage"
  377. staged_dropin="$stage/dropin"
  378. cp -a -- "$SSHD_DROPIN" "$staged_dropin" \
  379. || die "could not stage the SSH drop-in; no managed artifact was removed"
  380. rm -f -- "$SSHD_DROPIN"
  381. fi
  382.  
  383. install -d -o root -g root -m 0755 /run/sshd
  384. if ! "$SSHD_BIN" -t; then
  385. if [[ -n "$staged_dropin" ]]; then
  386. if restore_staged_dropin "$staged_dropin" && "$SSHD_BIN" -t; then
  387. rm -rf -- "$stage"
  388. else
  389. warn "Could not restore ${SSHD_DROPIN}; the staged copy is retained at ${staged_dropin}."
  390. fi
  391. fi
  392. die "sshd rejected the configuration without ${SSHD_DROPIN}; no managed artifact was removed"
  393. fi
  394.  
  395. if ! reload_sshd; then
  396. warn "SSH reload failed; restoring ${SSHD_DROPIN} so the running daemon and filesystem agree."
  397. if restore_staged_dropin "$staged_dropin" && "$SSHD_BIN" -t; then
  398. [[ -n "$stage" ]] && rm -rf -- "$stage"
  399. else
  400. warn "Could not restore ${SSHD_DROPIN}; the staged copy is retained at ${staged_dropin:-<none>}."
  401. fi
  402. reload_sshd || warn "SSH reload failed again; inspect the SSH service before retrying --uninstall."
  403. die "uninstall aborted before removing managed artifacts; the managed marker was retained"
  404. fi
  405.  
  406. [[ -n "$stage" ]] && rm -rf -- "$stage"
  407. log "Removing managed sudo, runtime and account artifacts"
  408. rm -f -- "$SUDOERS_FILE" "$RUNTIME_COMMAND"
  409.  
  410. user_home="$(getent passwd "$AI_USER" | cut -d: -f6 || true)"
  411. if [[ -n "$user_home" && "$user_home" == "$(expected_home_for "$AI_USER")" && "${CREATED_USER:-0}" == "1" ]]; then
  412. rm -rf -- "$user_home"
  413. fi
  414. if [[ "${CREATED_USER:-0}" == "1" ]] && id "$AI_USER" >/dev/null 2>&1; then
  415. userdel --remove "$AI_USER" >/dev/null 2>&1 || userdel "$AI_USER" >/dev/null 2>&1 || true
  416. fi
  417.  
  418. rm -rf -- "$CONFIG_DIR"
  419. log "Read-only AI SSH access removed"
  420. }
  421.  
  422. if ((UNINSTALL == 1)); then
  423. uninstall_access
  424. exit 0
  425. fi
  426.  
  427. [[ -n "$PUBLIC_KEY_FILE" ]] || die "--public-key-file is required"
  428. [[ -r "$PUBLIC_KEY_FILE" ]] || die "cannot read public key file: $PUBLIC_KEY_FILE"
  429. [[ -f "$SOURCE_COMMAND" ]] || die "missing companion command: $SOURCE_COMMAND"
  430.  
  431. for cmd in install useradd userdel usermod passwd sudo visudo stat grep sort awk sed systemctl ssh-keygen getent gpasswd mktemp tr cut flock find python3 cp mv rm mkdir chmod chown id docker; do
  432. command -v "$cmd" >/dev/null || die "required command is missing: $cmd"
  433. done
  434. [[ -x "$SSHD_BIN" ]] || die "sshd is not installed"
  435.  
  436. nonempty_key_lines="$(grep -cvE '^[[:space:]]*(#|$)' "$PUBLIC_KEY_FILE" || true)"
  437. [[ "$nonempty_key_lines" == "1" ]] || die "public key file must contain exactly one non-comment key"
  438. public_key="$(grep -vE '^[[:space:]]*(#|$)' "$PUBLIC_KEY_FILE")"
  439. read -r key_type key_blob _ <<<"$public_key"
  440. case "$key_type" in
  441. ssh-ed25519 | ssh-rsa | ecdsa-sha2-nistp256 | ecdsa-sha2-nistp384 | ecdsa-sha2-nistp521 | [email protected] | [email protected]) ;;
  442. *) die "unsupported or malformed SSH public key type: $key_type" ;;
  443. esac
  444. [[ "$key_blob" =~ ^[A-Za-z0-9+/]+={0,3}$ ]] || die "malformed SSH public key data"
  445.  
  446. key_tmp="$(mktemp)"
  447. trap 'rollback_install; rm -f "$key_tmp"' EXIT
  448. printf '%s\n' "$public_key" >"$key_tmp"
  449. ssh-keygen -l -f "$key_tmp" >/dev/null 2>&1 || die "ssh-keygen could not validate the public key"
  450.  
  451. if ((ALLOW_ANY_SOURCE == 0)); then
  452. [[ -n "$AI_ALLOWED_FROM" ]] || die "--allowed-from must not be empty"
  453. [[ "$AI_ALLOWED_FROM" =~ ^[0-9A-Fa-f:.,/]+$ ]] \
  454. || die "--allowed-from accepts only comma-separated IP addresses or CIDRs"
  455. python3 - "$AI_ALLOWED_FROM" <<'PY'
  456. import ipaddress
  457. import sys
  458. for value in sys.argv[1].split(','):
  459. if not value:
  460. raise SystemExit('empty source entry')
  461. ipaddress.ip_network(value, strict=False)
  462. PY
  463. fi
  464.  
  465. validate_container_name() {
  466. [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ ]] || die "invalid container name: $1"
  467. }
  468. validate_unit_name() {
  469. [[ "$1" =~ ^[A-Za-z0-9_.@-]+\.(service|socket|timer)$ ]] || die "invalid systemd unit: $1"
  470. }
  471. for item in "${EXTRA_CONTAINERS[@]}"; do validate_container_name "$item"; done
  472. for item in "${EXTRA_UNITS[@]}"; do validate_unit_name "$item"; done
  473.  
  474. if [[ -f "$MANAGED_MARKER" ]]; then
  475. existing_install=1
  476. original_created_user="$(marker_field CREATED_USER "$MANAGED_MARKER")"
  477. [[ "$original_created_user" == "1" ]] || original_created_user=0
  478. fi
  479. if ((ROTATE_PUBLIC_KEY == 1)) && ((existing_install == 0)); then
  480. die "--rotate-public-key requires an existing managed installation"
  481. fi
  482.  
  483. require_safe_target() {
  484. local target="$1" managed_ai_user="" marker_created="" target_uid existing_keys key_mode line_count
  485. if [[ -f "$MANAGED_MARKER" ]]; then
  486. managed_ai_user="$(marker_field MANAGED_AI_USER "$MANAGED_MARKER")"
  487. marker_created="$(marker_field CREATED_USER "$MANAGED_MARKER")"
  488. fi
  489.  
  490. if [[ -n "$managed_ai_user" && "$managed_ai_user" != "$target" ]]; then
  491. die "managed marker records MANAGED_AI_USER=${managed_ai_user}; rerun with --user ${managed_ai_user} or --uninstall first"
  492. fi
  493.  
  494. if id "$target" >/dev/null 2>&1; then
  495. if [[ -z "$managed_ai_user" || "$marker_created" != "1" ]]; then
  496. die "account $target already exists but is not marked as created by this installer; choose another --user"
  497. fi
  498. else
  499. return 0
  500. fi
  501.  
  502. target_uid="$(id -u "$target")"
  503. ((target_uid >= 1000)) || die "target account $target has UID $target_uid (< 1000); refusing to modify a system or human user"
  504. for group in sudo admin adm docker systemd-journal; do
  505. if id -nG "$target" | tr ' ' '\n' | grep -Fxq "$group"; then
  506. die "target account $target is in group $group; refusing to take over a privileged account"
  507. fi
  508. done
  509. if [[ -n "${SUDO_USER:-}" && "$target" == "$SUDO_USER" ]]; then
  510. die "refusing to modify the invoking administrator (${SUDO_USER})"
  511. fi
  512.  
  513. existing_keys="$(expected_home_for "$target")/.ssh/authorized_keys"
  514. if [[ -e "$existing_keys" ]]; then
  515. [[ -f "$existing_keys" && ! -L "$existing_keys" ]] || die "managed authorized_keys must be a regular file"
  516. key_mode="$(stat -c '%U:%G:%a' "$existing_keys" 2>/dev/null || true)"
  517. case "$key_mode" in
  518. root:root:644 | root:root:640 | root:root:600) ;;
  519. *) die "unsafe managed authorized_keys ownership or mode: ${key_mode:-unknown}" ;;
  520. esac
  521. line_count="$(grep -cvE '^[[:space:]]*(#|$)' "$existing_keys" || true)"
  522. [[ "$line_count" == "1" ]] || die "managed authorized_keys must contain exactly one key"
  523. grep -Fq "restrict,command=\"${RUNTIME_COMMAND}\"" "$existing_keys" \
  524. || die "authorized_keys is not managed by this installer"
  525.  
  526. if ! grep -Fq -- "$key_blob" "$existing_keys"; then
  527. if ((ROTATE_PUBLIC_KEY == 1)); then
  528. warn "Rotating the public key for managed account $target"
  529. else
  530. die "supplied public key differs from the managed key; rerun with --rotate-public-key to rotate it explicitly"
  531. fi
  532. fi
  533. fi
  534. }
  535. require_safe_target "$AI_USER"
  536.  
  537. fail_on_allowlist() {
  538. local effective="$1" directive value admits token
  539. for directive in allowusers denyusers allowgroups denygroups; do
  540. # OpenSSH 9.4+ emits each entry of a multi-value directive on its own
  541. # line (e.g. "allowusers alice" / "allowusers bob"). Older releases
  542. # joined them with spaces. Gather every matching line and collapse them
  543. # into a single space-separated list so the token check below stays
  544. # correct on both formats.
  545. value="$(awk -v key="$directive" 'tolower($1) == key {$1=""; sub(/^ /, ""); print}' <<<"$effective" | paste -sd' ' -)"
  546. case "$directive" in
  547. allowusers)
  548. if [[ -n "$value" ]]; then
  549. admits=0
  550. for token in $value; do
  551. if [[ "$token" == "$AI_USER" || "$token" == "*" ]]; then
  552. admits=1
  553. break
  554. fi
  555. done
  556. ((admits == 1)) || die "effective AllowUsers is '${value}'. The account $AI_USER is not in the allowlist; update ${HARDENING_DROPIN} and run sudo sshd -t"
  557. fi
  558. ;;
  559. allowgroups)
  560. [[ -z "$value" ]] || warn "Effective AllowGroups is configured: ${value}; confirm $AI_USER is admitted."
  561. ;;
  562. denyusers | denygroups)
  563. [[ -z "$value" ]] || warn "Effective ${directive} is configured: ${value}; confirm $AI_USER is not denied."
  564. ;;
  565. esac
  566. done
  567. }
  568.  
  569. log "Validating SSH allowlist for $AI_USER"
  570. fail_on_allowlist "$("$SSHD_BIN" -T -C "user=${AI_USER},host=localhost,addr=11.57.0.20")"
  571.  
  572. if id "$AI_USER" >/dev/null 2>&1; then
  573. log "Updating existing managed account: $AI_USER"
  574. else
  575. log "Creating dedicated account: $AI_USER"
  576. useradd --create-home --shell /bin/sh --user-group "$AI_USER"
  577. created_user=1
  578. fi
  579.  
  580. user_home="$(getent passwd "$AI_USER" | cut -d: -f6)"
  581. expected_home="$(expected_home_for "$AI_USER")"
  582. [[ "$user_home" == "$expected_home" ]] || die "managed account home is $user_home; expected $expected_home"
  583.  
  584. if ((existing_install == 1)); then
  585. create_snapshot || die "could not create a complete pre-update snapshot; no managed file was changed"
  586. fi
  587.  
  588. usermod --shell /bin/sh "$AI_USER"
  589. passwd --lock "$AI_USER" >/dev/null
  590. primary_group="$(id -gn "$AI_USER")"
  591. while IFS= read -r supplementary_group; do
  592. [[ -n "$supplementary_group" && "$supplementary_group" != "$primary_group" ]] || continue
  593. gpasswd -d "$AI_USER" "$supplementary_group" >/dev/null
  594. done < <(id -nG "$AI_USER" | tr ' ' '\n')
  595.  
  596. log "Installing root-owned command and allowlists"
  597. install -d -o root -g root -m 0755 "$(dirname "$RUNTIME_COMMAND")" "$CONFIG_DIR"
  598. install -o root -g root -m 0755 "$SOURCE_COMMAND" "$RUNTIME_COMMAND"
  599. INSTALLED_RUNTIME=1
  600. bash -n "$RUNTIME_COMMAND" || die "runtime command failed syntax check: $RUNTIME_COMMAND"
  601.  
  602. containers_tmp="$(mktemp)"
  603. units_tmp="$(mktemp)"
  604. sudoers_tmp="$(mktemp)"
  605. sshd_tmp="$(mktemp)"
  606. trap 'rollback_install; rm -f "$key_tmp" "$containers_tmp" "$units_tmp" "$sudoers_tmp" "$sshd_tmp"' EXIT
  607.  
  608. cat >"$containers_tmp" <<'CONTAINERS'
  609. alloy
  610. caddy
  611. content-import-worker
  612. db-backup
  613. db-backup-r2
  614. go-api
  615. redis
  616. vpn-dns
  617. CONTAINERS
  618.  
  619. if ((DISCOVER_CONTAINERS == 1)); then
  620. warn "Auto-discovering containers; review ${CONFIG_DIR}/containers.allow after installation."
  621. while IFS= read -r name; do
  622. [[ -n "$name" ]] || continue
  623. case "$name" in
  624. supabase-auth | supabase-db | supabase-rest | supabase-realtime | supabase-storage | supabase-meta | supabase-functions | supabase-studio | supabase-kong | supabase-supavisor | supabase-imgproxy | supabase-edge-runtime | supabase-logflare | supabase-vector | supabase-analytics) ;;
  625. *)
  626. validate_container_name "$name"
  627. printf '%s\n' "$name" >>"$containers_tmp"
  628. ;;
  629. esac
  630. done < <(docker ps -a --filter "label=com.docker.compose.project=${COMPOSE_PROJECT}" --format '{{.Names}}' 2>/dev/null || true)
  631. fi
  632. printf '%s\n' "${EXTRA_CONTAINERS[@]}" >>"$containers_tmp"
  633. sed -i '/^$/d' "$containers_tmp"
  634. sort -u "$containers_tmp" -o "$containers_tmp"
  635. install -o root -g root -m 0644 "$containers_tmp" "$CONFIG_DIR/containers.allow"
  636. INSTALLED_ALLOWLISTS=1
  637.  
  638. cat >"$units_tmp" <<'UNITS'
  639. cockpit.socket
  640. docker.service
  641. fail2ban.service
  642. backup-checksums.service
  643. backup-checksums.timer
  644. bootstrap-ssh-cutoff.timer
  645. docker-firewall.service
  646. host-firewall.service
  647. offhost-backup.service
  648. postgres-globals.service
  649. postgres-globals.timer
  650. vnstat-dashboard.timer
  651. UNITS
  652. if systemctl list-unit-files ssh.service --no-legend 2>/dev/null | grep -q '^ssh.service'; then
  653. printf '%s\n' ssh.service >>"$units_tmp"
  654. elif systemctl list-unit-files sshd.service --no-legend 2>/dev/null | grep -q '^sshd.service'; then
  655. printf '%s\n' sshd.service >>"$units_tmp"
  656. fi
  657. printf '%s\n' "${EXTRA_UNITS[@]}" >>"$units_tmp"
  658. sed -i '/^$/d' "$units_tmp"
  659. sort -u "$units_tmp" -o "$units_tmp"
  660. install -o root -g root -m 0644 "$units_tmp" "$CONFIG_DIR/units.allow"
  661.  
  662. # Preserve SSH_CONNECTION, SSH_CLIENT, and SSH_ORIGINAL_COMMAND across sudo
  663. # so the dispatcher's audit trail can attribute calls to the WireGuard peer
  664. # (11.57.0.20) instead of falling back to source=local when sudo strips the
  665. # env vars. Targeted via Defaults>aiops so the env_keep applies only to the
  666. # dedicated AI account and does not affect any other sudo invocations.
  667. printf 'Defaults>aiops env_keep += "SSH_CONNECTION SSH_CLIENT SSH_ORIGINAL_COMMAND"\n%s ALL=(root) NOPASSWD: %s ""\n' "$AI_USER" "$RUNTIME_COMMAND" >"$sudoers_tmp"
  668. chmod 0440 "$sudoers_tmp"
  669. visudo -cf "$sudoers_tmp" >/dev/null || die "generated sudoers rule is invalid"
  670.  
  671. cat >"$sshd_tmp" <<SSHD_CONFIG
  672. # Managed by scripts/install-ai-readonly-ssh.sh.
  673. Match User ${AI_USER}
  674. AuthenticationMethods publickey
  675. PasswordAuthentication no
  676. KbdInteractiveAuthentication no
  677. PubkeyAuthentication yes
  678. ForceCommand ${RUNTIME_COMMAND}
  679. DisableForwarding yes
  680. AllowAgentForwarding no
  681. AllowTcpForwarding no
  682. X11Forwarding no
  683. PermitTTY no
  684. PermitTunnel no
  685. PermitUserRC no
  686. MaxSessions 1
  687.  
  688. Match all
  689. SSHD_CONFIG
  690. install -o root -g root -m 0644 "$sshd_tmp" "$SSHD_DROPIN"
  691. INSTALLED_SSHD_DROPIN=1
  692. install -d -o root -g root -m 0755 /run/sshd
  693. "$SSHD_BIN" -t || die "sshd rejected the generated configuration"
  694.  
  695. install -o root -g root -m 0440 "$sudoers_tmp" "$SUDOERS_FILE"
  696. INSTALLED_SUDOERS=1
  697.  
  698. if ((created_user == 1)); then
  699. find "$user_home" -mindepth 1 -maxdepth 1 ! -name .ssh -exec rm -rf -- {} +
  700. rm -rf -- "$user_home/.ssh"
  701. fi
  702. install -d -o root -g root -m 0755 "$user_home" "$user_home/.ssh"
  703. key_options="restrict,command=\"${RUNTIME_COMMAND}\""
  704. if ((ALLOW_ANY_SOURCE == 0)); then
  705. key_options="from=\"${AI_ALLOWED_FROM}\",${key_options}"
  706. fi
  707. printf '%s %s\n' "$key_options" "$public_key" >"$user_home/.ssh/authorized_keys"
  708. chown root:root "$user_home/.ssh/authorized_keys"
  709. chmod 0644 "$user_home/.ssh/authorized_keys"
  710. INSTALLED_AUTHORIZED_KEYS=1
  711.  
  712. marker_created_user="$created_user"
  713. if ((created_user == 0)) && ((existing_install == 1)); then
  714. marker_created_user="$original_created_user"
  715. fi
  716. cat >"$MANAGED_MARKER" <<MARKER
  717. MANAGED_AI_USER=${AI_USER}
  718. CREATED_USER=${marker_created_user}
  719. MARKER
  720. chown root:root "$MANAGED_MARKER"
  721. chmod 0600 "$MANAGED_MARKER"
  722. INSTALLED_MARKER=1
  723.  
  724. log "Validating effective restrictions"
  725. effective="$("$SSHD_BIN" -T -C "user=${AI_USER},host=localhost,addr=11.57.0.20")"
  726. grep -Fq "forcecommand ${RUNTIME_COMMAND}" <<<"$effective" || die "ForceCommand is not effective for $AI_USER"
  727. grep -Fq 'passwordauthentication no' <<<"$effective" || die "password authentication is not disabled for $AI_USER"
  728. grep -Fq 'permittty no' <<<"$effective" || die "PTY is not disabled for $AI_USER"
  729. grep -Fq 'disableforwarding yes' <<<"$effective" || die "forwarding is not disabled for $AI_USER"
  730.  
  731. reload_sshd
  732. log "Testing the local forced-command path"
  733. test_output="$(sudo -u "$AI_USER" env SSH_ORIGINAL_COMMAND=help "$RUNTIME_COMMAND")"
  734. grep -Fq 'Allowed commands:' <<<"$test_output" || die "local command-path test failed"
  735.  
  736. INSTALL_COMMITTED=1
  737. trap - EXIT
  738. rm -f "$key_tmp" "$containers_tmp" "$units_tmp" "$sudoers_tmp" "$sshd_tmp"
  739. if [[ -n "$SNAPSHOT_DIR" ]]; then
  740. rm -rf -- "$SNAPSHOT_DIR"
  741. SNAPSHOT_DIR=""
  742. fi
  743.  
  744. cat <<SUMMARY
  745.  
  746. Read-only AI SSH access installed.
  747.  
  748. Account:
  749. ${AI_USER}
  750.  
  751. Public key:
  752. $([[ $ROTATE_PUBLIC_KEY -eq 1 ]] && printf '%s' 'rotated explicitly' || printf '%s' 'installed or unchanged')
  753.  
  754. Source restriction:
  755. $([[ $ALLOW_ANY_SOURCE -eq 1 ]] && printf '%s' 'none in authorized_keys' || printf '%s' "$AI_ALLOWED_FROM")
  756.  
  757. Allowed containers:
  758. $(sed 's/^/ /' "$CONFIG_DIR/containers.allow")
  759.  
  760. Allowed systemd units:
  761. $(sed 's/^/ /' "$CONFIG_DIR/units.allow")
  762.  
  763. Test:
  764. ssh ${AI_USER}@10.77.0.1 help
  765. ssh ${AI_USER}@10.77.0.1 containers
  766.  
  767. Uninstall:
  768. sudo ./scripts/install-ai-readonly-ssh.sh --user ${AI_USER} --uninstall
  769.  
  770. Keep the private key in the AI backend secret store, never in a prompt,
  771. repository, chat message or VPS filesystem.
  772. SUMMARY
Advertisement
Add Comment
Please, Sign In to add comment