Speeedfire

Untitled

Aug 11th, 2026
85
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 12.98 KB | None | 0 0
  1. #!/usr/bin/env bash
  2. set -Eeuo pipefail
  3. export PATH=/usr/sbin:/usr/bin:/sbin:/bin
  4.  
  5. CONFIG_DIR=/etc/ai-readonly
  6. CONTAINERS_FILE="$CONFIG_DIR/containers.allow"
  7. UNITS_FILE="$CONFIG_DIR/units.allow"
  8. MAX_OUTPUT_BYTES=2097152
  9. COMMAND_TIMEOUT_SECONDS=20
  10.  
  11. DOCKER_BIN=/usr/bin/docker
  12. SYSTEMCTL_BIN=/usr/bin/systemctl
  13. JOURNALCTL_BIN=/usr/bin/journalctl
  14. TIMEOUT_BIN=/usr/bin/timeout
  15. SED_BIN=/usr/bin/sed
  16. HEAD_BIN=/usr/bin/head
  17.  
  18. log_denied() {
  19. local reason="$1"
  20. /usr/bin/logger --tag ai-readonly -- "denied user=${SUDO_USER:-${USER:-unknown}} reason=${reason}" 2>/dev/null || true
  21. printf 'Denied: %s\n' "$reason" >&2
  22. exit 126
  23. }
  24.  
  25. # The forced command starts as the unprivileged account. It forwards exactly one
  26. # bounded line to the root-owned parser through an exact sudoers command. No
  27. # client-supplied argument is passed to sudo or a shell.
  28. if ((EUID != 0)); then
  29. requested="${SSH_ORIGINAL_COMMAND:-}"
  30. ((${#requested} <= 512)) || log_denied "command too long"
  31.  
  32. set +e
  33. printf '%s\n' "$requested" | /usr/bin/sudo -n -- /usr/local/libexec/ai-readonly-command
  34. status="${PIPESTATUS[1]}"
  35. set -e
  36. exit "$status"
  37. fi
  38.  
  39. exec 9>/run/ai-readonly.lock
  40. /usr/bin/flock -w 5 9 || log_denied "another request is already running"
  41.  
  42. IFS= read -r requested || requested=""
  43. ((${#requested} <= 512)) || log_denied "command too long"
  44.  
  45. # Accept a deliberately tiny command language. Quotes, substitutions, pipes,
  46. # redirections and shell metacharacters are not part of the protocol.
  47. if [[ -n "$requested" && ! "$requested" =~ ^[A-Za-z0-9._@:/\ -]+$ ]]; then
  48. log_denied "unsupported characters"
  49. fi
  50.  
  51. read -r action arg1 arg2 arg3 extra <<<"$requested"
  52. [[ -z "${extra:-}" ]] || log_denied "too many arguments"
  53.  
  54. ssh_connection="${SSH_CONNECTION:-}"
  55. source_ip="${ssh_connection%% *}"
  56. /usr/bin/logger --tag ai-readonly -- \
  57. "source=${source_ip:-local} action=${action:-help} target=${arg1:-none}" 2>/dev/null || true
  58.  
  59. require_file() {
  60. local file="$1"
  61. [[ -f "$file" ]] || log_denied "server allowlist is missing"
  62. [[ ! -L "$file" ]] || log_denied "server allowlist must not be a symlink"
  63. [[ "$(/usr/bin/stat -c '%U:%G:%a' "$file")" == "root:root:644" ]] \
  64. || log_denied "unsafe allowlist ownership or mode"
  65. }
  66.  
  67. is_allowed_line() {
  68. local value="$1" file="$2"
  69. require_file "$file"
  70. grep -Fxq -- "$value" "$file"
  71. }
  72.  
  73. require_container() {
  74. local name="$1"
  75. [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ ]] \
  76. || log_denied "invalid container name"
  77. is_allowed_line "$name" "$CONTAINERS_FILE" \
  78. || log_denied "container is not allowlisted"
  79. }
  80.  
  81. require_unit() {
  82. local unit="$1"
  83. [[ "$unit" =~ ^[A-Za-z0-9_.@-]+\.(service|socket|timer)$ ]] \
  84. || log_denied "invalid systemd unit"
  85. is_allowed_line "$unit" "$UNITS_FILE" \
  86. || log_denied "systemd unit is not allowlisted"
  87. }
  88.  
  89. validate_integer() {
  90. local value="$1" minimum="$2" maximum="$3" label="$4" numeric
  91. [[ "$value" =~ ^[0-9]+$ ]] || log_denied "$label must be an integer"
  92. ((${#value} <= 10)) || log_denied "$label is too large"
  93. numeric=$((10#$value))
  94. ((numeric >= minimum && numeric <= maximum)) \
  95. || log_denied "$label is outside the allowed range"
  96. }
  97.  
  98. redact_stream() {
  99. # Plain-text rules first; JSON-shaped rules after. Order matters: the plain
  100. # "Authorization:" rule would otherwise match inside JSON strings after the
  101. # surrounding double-quote has been stripped.
  102. "$SED_BIN" -E \
  103. -e 's#(https?://)[^/@[:space:]]+:[^/@[:space:]]+@#\1<redacted>@#g' \
  104. -e 's#([Aa]uthorization:[[:space:]]*).*#\1<redacted>#g' \
  105. -e 's#([Cc]ookie:[[:space:]]*).*#\1<redacted>#g' \
  106. -e 's#([Ss]et-[Cc]ookie:[[:space:]]*).*#\1<redacted>#g' \
  107. -e 's#(password|passwd|secret|token|api[_-]?key|access[_-]?key|service[_-]?role[_-]?key|anon[_-]?key|jwt[_-]?secret|refresh[_-]?token|otp|code|session)([[:space:]]*[:=][[:space:]]*)[^[:space:],;}]+#\1\2<redacted>#Ig' \
  108. -e 's#eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+#<redacted-jwt>#g' \
  109. -e 's#("[Aa]uthorization"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  110. -e 's#("[Cc]ookie"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  111. -e 's#("[Ss]et-[Cc]ookie"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  112. -e 's#("[Aa]uthorization"[[:space:]]*:[[:space:]]*[Bb]earer[[:space:]]*)[^",}]+#\1<redacted>#g' \
  113. -e 's#("refresh_token"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  114. -e 's#("access_token"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  115. -e 's#("access[_-]?key"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  116. -e 's#("api[_-]?key"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  117. -e 's#("service[_-]?role[_-]?key"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  118. -e 's#("anon[_-]?key"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  119. -e 's#("jwt[_-]?secret"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  120. -e 's#("session"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  121. -e 's#("otp"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  122. -e 's#("code"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  123. -e 's#("[Pp]assword"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  124. -e 's#("[Ss]ecret"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  125. -e 's#("[Tt]oken"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  126. -e 's#("email"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
  127. -e 's#[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}#<redacted-email>#g'
  128. }
  129.  
  130. run_bounded() {
  131. local status
  132. set +e
  133. "$TIMEOUT_BIN" --signal=TERM --kill-after=2 "$COMMAND_TIMEOUT_SECONDS" "$@" 2>&1 \
  134. | redact_stream \
  135. | "$HEAD_BIN" -c "$MAX_OUTPUT_BYTES"
  136. status="${PIPESTATUS[0]}"
  137. set -e
  138.  
  139. case "$status" in
  140. 0 | 141) return 0 ;;
  141. 124 | 137)
  142. printf '\nCommand timed out.\n' >&2
  143. return 124
  144. ;;
  145. *) return "$status" ;;
  146. esac
  147. }
  148.  
  149. run_bounded_cat() {
  150. local status
  151. set +e
  152. redact_stream \
  153. | "$HEAD_BIN" -c "$MAX_OUTPUT_BYTES"
  154. status="${PIPESTATUS[0]}"
  155. set -e
  156.  
  157. case "$status" in
  158. 0 | 141) return 0 ;;
  159. 124 | 137)
  160. printf '\nCommand timed out.\n' >&2
  161. return 124
  162. ;;
  163. *) return "$status" ;;
  164. esac
  165. }
  166.  
  167. container_exists() {
  168. "$DOCKER_BIN" container inspect "$1" >/dev/null 2>&1
  169. }
  170.  
  171. container_status() {
  172. local name="$1"
  173. require_container "$name"
  174. container_exists "$name" || log_denied "allowlisted container does not exist"
  175.  
  176. run_bounded "$DOCKER_BIN" container inspect \
  177. --format 'name={{.Name}} image={{.Config.Image}} state={{.State.Status}} running={{.State.Running}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}} restarts={{.RestartCount}} started={{.State.StartedAt}} finished={{.State.FinishedAt}}' \
  178. "$name"
  179. }
  180.  
  181. list_containers() {
  182. local name
  183. local -a names=()
  184. require_file "$CONTAINERS_FILE"
  185.  
  186. while IFS= read -r name; do
  187. [[ -n "$name" && "$name" != \#* ]] || continue
  188. container_exists "$name" && names+=("$name")
  189. done <"$CONTAINERS_FILE"
  190.  
  191. if ((${#names[@]} == 0)); then
  192. printf 'No allowlisted containers currently exist.\n'
  193. return 0
  194. fi
  195.  
  196. run_bounded "$DOCKER_BIN" container inspect \
  197. --format 'name={{.Name}} image={{.Config.Image}} state={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}} restarts={{.RestartCount}}' \
  198. "${names[@]}"
  199. }
  200.  
  201. list_services() {
  202. local unit
  203. require_file "$UNITS_FILE"
  204.  
  205. while IFS= read -r unit; do
  206. [[ -n "$unit" && "$unit" != \#* ]] || continue
  207. printf '%-44s active=%-10s enabled=%s\n' \
  208. "$unit" \
  209. "$($SYSTEMCTL_BIN is-active "$unit" 2>/dev/null || true)" \
  210. "$($SYSTEMCTL_BIN is-enabled "$unit" 2>/dev/null || true)"
  211. done <"$UNITS_FILE" | "$HEAD_BIN" -c "$MAX_OUTPUT_BYTES"
  212. }
  213.  
  214. show_help() {
  215. cat <<'HELP'
  216. Allowed commands:
  217. help
  218. host-summary
  219. disk-summary
  220. listening-ports
  221. failed-services
  222. containers
  223. docker-status <container>
  224. docker-health <container>
  225. docker-logs <container> [lines=200] [minutes=60]
  226. docker-stats [container|all]
  227. docker-df
  228. services
  229. service-status <unit>
  230. service-logs <unit> [lines=200] [minutes=60]
  231.  
  232. Limits:
  233. lines: 1..1000
  234. minutes: 1..10080
  235. output: bounded and best-effort secret redaction is applied
  236.  
  237. Not available:
  238. shell, file reads, docker exec/cp/compose/config, arbitrary inspect,
  239. container lifecycle changes, sudo commands, port forwarding or PTY.
  240. HELP
  241. }
  242.  
  243. case "${action:-}" in
  244. "" | help)
  245. [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "help takes no arguments"
  246. show_help
  247. ;;
  248.  
  249. host-summary)
  250. [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "host-summary takes no arguments"
  251. {
  252. printf 'hostname=%s\n' "$(/bin/hostname)"
  253. printf 'kernel=%s\n' "$(/bin/uname -srmo)"
  254. printf 'uptime=%s\n' "$(/usr/bin/uptime -p 2>/dev/null || true)"
  255. printf 'load=%s\n' "$(cut -d' ' -f1-3 /proc/loadavg)"
  256. /usr/bin/free -h
  257. } | run_bounded_cat
  258. ;;
  259.  
  260. disk-summary)
  261. [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "disk-summary takes no arguments"
  262. run_bounded /usr/bin/df -hP -x tmpfs -x devtmpfs -x squashfs
  263. ;;
  264.  
  265. listening-ports)
  266. [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "listening-ports takes no arguments"
  267. run_bounded /usr/bin/ss -lntuH
  268. ;;
  269.  
  270. failed-services)
  271. [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "failed-services takes no arguments"
  272. run_bounded "$SYSTEMCTL_BIN" --failed --no-legend --no-pager
  273. ;;
  274.  
  275. containers)
  276. [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "containers takes no arguments"
  277. list_containers
  278. ;;
  279.  
  280. docker-status)
  281. [[ -n "${arg1:-}" && -z "${arg2:-}${arg3:-}" ]] || log_denied "usage: docker-status <container>"
  282. container_status "$arg1"
  283. ;;
  284.  
  285. docker-health)
  286. [[ -n "${arg1:-}" && -z "${arg2:-}${arg3:-}" ]] || log_denied "usage: docker-health <container>"
  287. require_container "$arg1"
  288. container_exists "$arg1" || log_denied "allowlisted container does not exist"
  289. run_bounded "$DOCKER_BIN" container inspect \
  290. --format 'name={{.Name}} state={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}} failing_streak={{if .State.Health}}{{.State.Health.FailingStreak}}{{else}}0{{end}}' \
  291. "$arg1"
  292. ;;
  293.  
  294. docker-logs)
  295. [[ -n "${arg1:-}" ]] || log_denied "usage: docker-logs <container> [lines] [minutes]"
  296. lines="${arg2:-200}"
  297. minutes="${arg3:-60}"
  298. validate_integer "$lines" 1 1000 "lines"
  299. validate_integer "$minutes" 1 10080 "minutes"
  300. require_container "$arg1"
  301. container_exists "$arg1" || log_denied "allowlisted container does not exist"
  302. run_bounded "$DOCKER_BIN" logs \
  303. --timestamps \
  304. --tail "$lines" \
  305. --since "${minutes}m" \
  306. "$arg1"
  307. ;;
  308.  
  309. docker-stats)
  310. target="${arg1:-all}"
  311. [[ -z "${arg2:-}${arg3:-}" ]] || log_denied "usage: docker-stats [container|all]"
  312.  
  313. if [[ "$target" == "all" ]]; then
  314. names=()
  315. require_file "$CONTAINERS_FILE"
  316. while IFS= read -r name; do
  317. [[ -n "$name" && "$name" != \#* ]] || continue
  318. container_exists "$name" && names+=("$name")
  319. done <"$CONTAINERS_FILE"
  320. ((${#names[@]} > 0)) || log_denied "no allowlisted containers currently exist"
  321. run_bounded "$DOCKER_BIN" stats --no-stream \
  322. --format 'name={{.Name}} cpu={{.CPUPerc}} memory={{.MemUsage}} memory_percent={{.MemPerc}} net={{.NetIO}} block={{.BlockIO}} pids={{.PIDs}}' \
  323. "${names[@]}"
  324. else
  325. require_container "$target"
  326. container_exists "$target" || log_denied "allowlisted container does not exist"
  327. run_bounded "$DOCKER_BIN" stats --no-stream \
  328. --format 'name={{.Name}} cpu={{.CPUPerc}} memory={{.MemUsage}} memory_percent={{.MemPerc}} net={{.NetIO}} block={{.BlockIO}} pids={{.PIDs}}' \
  329. "$target"
  330. fi
  331. ;;
  332.  
  333. docker-df)
  334. [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "docker-df takes no arguments"
  335. run_bounded "$DOCKER_BIN" system df
  336. ;;
  337.  
  338. services)
  339. [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "services takes no arguments"
  340. list_services
  341. ;;
  342.  
  343. service-status)
  344. [[ -n "${arg1:-}" && -z "${arg2:-}${arg3:-}" ]] || log_denied "usage: service-status <unit>"
  345. require_unit "$arg1"
  346. run_bounded "$SYSTEMCTL_BIN" show "$arg1" \
  347. --no-pager \
  348. --property=Id,LoadState,ActiveState,SubState,UnitFileState,Result,NRestarts,ExecMainStartTimestamp,ExecMainExitTimestamp,ExecMainStatus
  349. ;;
  350.  
  351. service-logs)
  352. [[ -n "${arg1:-}" ]] || log_denied "usage: service-logs <unit> [lines] [minutes]"
  353. lines="${arg2:-200}"
  354. minutes="${arg3:-60}"
  355. validate_integer "$lines" 1 1000 "lines"
  356. validate_integer "$minutes" 1 10080 "minutes"
  357. require_unit "$arg1"
  358. run_bounded "$JOURNALCTL_BIN" \
  359. --unit "$arg1" \
  360. --lines "$lines" \
  361. --since "${minutes} minutes ago" \
  362. --no-pager \
  363. --output=short-iso
  364. ;;
  365.  
  366. *)
  367. log_denied "command is not allowlisted"
  368. ;;
  369. esac
Advertisement
Add Comment
Please, Sign In to add comment