Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env bash
- set -Eeuo pipefail
- export PATH=/usr/sbin:/usr/bin:/sbin:/bin
- CONFIG_DIR=/etc/ai-readonly
- CONTAINERS_FILE="$CONFIG_DIR/containers.allow"
- UNITS_FILE="$CONFIG_DIR/units.allow"
- MAX_OUTPUT_BYTES=2097152
- COMMAND_TIMEOUT_SECONDS=20
- DOCKER_BIN=/usr/bin/docker
- SYSTEMCTL_BIN=/usr/bin/systemctl
- JOURNALCTL_BIN=/usr/bin/journalctl
- TIMEOUT_BIN=/usr/bin/timeout
- SED_BIN=/usr/bin/sed
- HEAD_BIN=/usr/bin/head
- log_denied() {
- local reason="$1"
- /usr/bin/logger --tag ai-readonly -- "denied user=${SUDO_USER:-${USER:-unknown}} reason=${reason}" 2>/dev/null || true
- printf 'Denied: %s\n' "$reason" >&2
- exit 126
- }
- # The forced command starts as the unprivileged account. It forwards exactly one
- # bounded line to the root-owned parser through an exact sudoers command. No
- # client-supplied argument is passed to sudo or a shell.
- if ((EUID != 0)); then
- requested="${SSH_ORIGINAL_COMMAND:-}"
- ((${#requested} <= 512)) || log_denied "command too long"
- set +e
- printf '%s\n' "$requested" | /usr/bin/sudo -n -- /usr/local/libexec/ai-readonly-command
- status="${PIPESTATUS[1]}"
- set -e
- exit "$status"
- fi
- exec 9>/run/ai-readonly.lock
- /usr/bin/flock -w 5 9 || log_denied "another request is already running"
- IFS= read -r requested || requested=""
- ((${#requested} <= 512)) || log_denied "command too long"
- # Accept a deliberately tiny command language. Quotes, substitutions, pipes,
- # redirections and shell metacharacters are not part of the protocol.
- if [[ -n "$requested" && ! "$requested" =~ ^[A-Za-z0-9._@:/\ -]+$ ]]; then
- log_denied "unsupported characters"
- fi
- read -r action arg1 arg2 arg3 extra <<<"$requested"
- [[ -z "${extra:-}" ]] || log_denied "too many arguments"
- ssh_connection="${SSH_CONNECTION:-}"
- source_ip="${ssh_connection%% *}"
- /usr/bin/logger --tag ai-readonly -- \
- "source=${source_ip:-local} action=${action:-help} target=${arg1:-none}" 2>/dev/null || true
- require_file() {
- local file="$1"
- [[ -f "$file" ]] || log_denied "server allowlist is missing"
- [[ ! -L "$file" ]] || log_denied "server allowlist must not be a symlink"
- [[ "$(/usr/bin/stat -c '%U:%G:%a' "$file")" == "root:root:644" ]] \
- || log_denied "unsafe allowlist ownership or mode"
- }
- is_allowed_line() {
- local value="$1" file="$2"
- require_file "$file"
- grep -Fxq -- "$value" "$file"
- }
- require_container() {
- local name="$1"
- [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ ]] \
- || log_denied "invalid container name"
- is_allowed_line "$name" "$CONTAINERS_FILE" \
- || log_denied "container is not allowlisted"
- }
- require_unit() {
- local unit="$1"
- [[ "$unit" =~ ^[A-Za-z0-9_.@-]+\.(service|socket|timer)$ ]] \
- || log_denied "invalid systemd unit"
- is_allowed_line "$unit" "$UNITS_FILE" \
- || log_denied "systemd unit is not allowlisted"
- }
- validate_integer() {
- local value="$1" minimum="$2" maximum="$3" label="$4" numeric
- [[ "$value" =~ ^[0-9]+$ ]] || log_denied "$label must be an integer"
- ((${#value} <= 10)) || log_denied "$label is too large"
- numeric=$((10#$value))
- ((numeric >= minimum && numeric <= maximum)) \
- || log_denied "$label is outside the allowed range"
- }
- redact_stream() {
- # Plain-text rules first; JSON-shaped rules after. Order matters: the plain
- # "Authorization:" rule would otherwise match inside JSON strings after the
- # surrounding double-quote has been stripped.
- "$SED_BIN" -E \
- -e 's#(https?://)[^/@[:space:]]+:[^/@[:space:]]+@#\1<redacted>@#g' \
- -e 's#([Aa]uthorization:[[:space:]]*).*#\1<redacted>#g' \
- -e 's#([Cc]ookie:[[:space:]]*).*#\1<redacted>#g' \
- -e 's#([Ss]et-[Cc]ookie:[[:space:]]*).*#\1<redacted>#g' \
- -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' \
- -e 's#eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+#<redacted-jwt>#g' \
- -e 's#("[Aa]uthorization"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("[Cc]ookie"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("[Ss]et-[Cc]ookie"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("[Aa]uthorization"[[:space:]]*:[[:space:]]*[Bb]earer[[:space:]]*)[^",}]+#\1<redacted>#g' \
- -e 's#("refresh_token"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("access_token"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("access[_-]?key"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("api[_-]?key"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("service[_-]?role[_-]?key"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("anon[_-]?key"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("jwt[_-]?secret"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("session"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("otp"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("code"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("[Pp]assword"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("[Ss]ecret"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("[Tt]oken"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#("email"[[:space:]]*:[[:space:]]*")[^"]*(")#\1<redacted>\2#g' \
- -e 's#[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}#<redacted-email>#g'
- }
- run_bounded() {
- local status
- set +e
- "$TIMEOUT_BIN" --signal=TERM --kill-after=2 "$COMMAND_TIMEOUT_SECONDS" "$@" 2>&1 \
- | redact_stream \
- | "$HEAD_BIN" -c "$MAX_OUTPUT_BYTES"
- status="${PIPESTATUS[0]}"
- set -e
- case "$status" in
- 0 | 141) return 0 ;;
- 124 | 137)
- printf '\nCommand timed out.\n' >&2
- return 124
- ;;
- *) return "$status" ;;
- esac
- }
- run_bounded_cat() {
- local status
- set +e
- redact_stream \
- | "$HEAD_BIN" -c "$MAX_OUTPUT_BYTES"
- status="${PIPESTATUS[0]}"
- set -e
- case "$status" in
- 0 | 141) return 0 ;;
- 124 | 137)
- printf '\nCommand timed out.\n' >&2
- return 124
- ;;
- *) return "$status" ;;
- esac
- }
- container_exists() {
- "$DOCKER_BIN" container inspect "$1" >/dev/null 2>&1
- }
- container_status() {
- local name="$1"
- require_container "$name"
- container_exists "$name" || log_denied "allowlisted container does not exist"
- run_bounded "$DOCKER_BIN" container inspect \
- --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}}' \
- "$name"
- }
- list_containers() {
- local name
- local -a names=()
- require_file "$CONTAINERS_FILE"
- while IFS= read -r name; do
- [[ -n "$name" && "$name" != \#* ]] || continue
- container_exists "$name" && names+=("$name")
- done <"$CONTAINERS_FILE"
- if ((${#names[@]} == 0)); then
- printf 'No allowlisted containers currently exist.\n'
- return 0
- fi
- run_bounded "$DOCKER_BIN" container inspect \
- --format 'name={{.Name}} image={{.Config.Image}} state={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}} restarts={{.RestartCount}}' \
- "${names[@]}"
- }
- list_services() {
- local unit
- require_file "$UNITS_FILE"
- while IFS= read -r unit; do
- [[ -n "$unit" && "$unit" != \#* ]] || continue
- printf '%-44s active=%-10s enabled=%s\n' \
- "$unit" \
- "$($SYSTEMCTL_BIN is-active "$unit" 2>/dev/null || true)" \
- "$($SYSTEMCTL_BIN is-enabled "$unit" 2>/dev/null || true)"
- done <"$UNITS_FILE" | "$HEAD_BIN" -c "$MAX_OUTPUT_BYTES"
- }
- show_help() {
- cat <<'HELP'
- Allowed commands:
- help
- host-summary
- disk-summary
- listening-ports
- failed-services
- containers
- docker-status <container>
- docker-health <container>
- docker-logs <container> [lines=200] [minutes=60]
- docker-stats [container|all]
- docker-df
- services
- service-status <unit>
- service-logs <unit> [lines=200] [minutes=60]
- Limits:
- lines: 1..1000
- minutes: 1..10080
- output: bounded and best-effort secret redaction is applied
- Not available:
- shell, file reads, docker exec/cp/compose/config, arbitrary inspect,
- container lifecycle changes, sudo commands, port forwarding or PTY.
- HELP
- }
- case "${action:-}" in
- "" | help)
- [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "help takes no arguments"
- show_help
- ;;
- host-summary)
- [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "host-summary takes no arguments"
- {
- printf 'hostname=%s\n' "$(/bin/hostname)"
- printf 'kernel=%s\n' "$(/bin/uname -srmo)"
- printf 'uptime=%s\n' "$(/usr/bin/uptime -p 2>/dev/null || true)"
- printf 'load=%s\n' "$(cut -d' ' -f1-3 /proc/loadavg)"
- /usr/bin/free -h
- } | run_bounded_cat
- ;;
- disk-summary)
- [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "disk-summary takes no arguments"
- run_bounded /usr/bin/df -hP -x tmpfs -x devtmpfs -x squashfs
- ;;
- listening-ports)
- [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "listening-ports takes no arguments"
- run_bounded /usr/bin/ss -lntuH
- ;;
- failed-services)
- [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "failed-services takes no arguments"
- run_bounded "$SYSTEMCTL_BIN" --failed --no-legend --no-pager
- ;;
- containers)
- [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "containers takes no arguments"
- list_containers
- ;;
- docker-status)
- [[ -n "${arg1:-}" && -z "${arg2:-}${arg3:-}" ]] || log_denied "usage: docker-status <container>"
- container_status "$arg1"
- ;;
- docker-health)
- [[ -n "${arg1:-}" && -z "${arg2:-}${arg3:-}" ]] || log_denied "usage: docker-health <container>"
- require_container "$arg1"
- container_exists "$arg1" || log_denied "allowlisted container does not exist"
- run_bounded "$DOCKER_BIN" container inspect \
- --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}}' \
- "$arg1"
- ;;
- docker-logs)
- [[ -n "${arg1:-}" ]] || log_denied "usage: docker-logs <container> [lines] [minutes]"
- lines="${arg2:-200}"
- minutes="${arg3:-60}"
- validate_integer "$lines" 1 1000 "lines"
- validate_integer "$minutes" 1 10080 "minutes"
- require_container "$arg1"
- container_exists "$arg1" || log_denied "allowlisted container does not exist"
- run_bounded "$DOCKER_BIN" logs \
- --timestamps \
- --tail "$lines" \
- --since "${minutes}m" \
- "$arg1"
- ;;
- docker-stats)
- target="${arg1:-all}"
- [[ -z "${arg2:-}${arg3:-}" ]] || log_denied "usage: docker-stats [container|all]"
- if [[ "$target" == "all" ]]; then
- names=()
- require_file "$CONTAINERS_FILE"
- while IFS= read -r name; do
- [[ -n "$name" && "$name" != \#* ]] || continue
- container_exists "$name" && names+=("$name")
- done <"$CONTAINERS_FILE"
- ((${#names[@]} > 0)) || log_denied "no allowlisted containers currently exist"
- run_bounded "$DOCKER_BIN" stats --no-stream \
- --format 'name={{.Name}} cpu={{.CPUPerc}} memory={{.MemUsage}} memory_percent={{.MemPerc}} net={{.NetIO}} block={{.BlockIO}} pids={{.PIDs}}' \
- "${names[@]}"
- else
- require_container "$target"
- container_exists "$target" || log_denied "allowlisted container does not exist"
- run_bounded "$DOCKER_BIN" stats --no-stream \
- --format 'name={{.Name}} cpu={{.CPUPerc}} memory={{.MemUsage}} memory_percent={{.MemPerc}} net={{.NetIO}} block={{.BlockIO}} pids={{.PIDs}}' \
- "$target"
- fi
- ;;
- docker-df)
- [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "docker-df takes no arguments"
- run_bounded "$DOCKER_BIN" system df
- ;;
- services)
- [[ -z "${arg1:-}${arg2:-}${arg3:-}" ]] || log_denied "services takes no arguments"
- list_services
- ;;
- service-status)
- [[ -n "${arg1:-}" && -z "${arg2:-}${arg3:-}" ]] || log_denied "usage: service-status <unit>"
- require_unit "$arg1"
- run_bounded "$SYSTEMCTL_BIN" show "$arg1" \
- --no-pager \
- --property=Id,LoadState,ActiveState,SubState,UnitFileState,Result,NRestarts,ExecMainStartTimestamp,ExecMainExitTimestamp,ExecMainStatus
- ;;
- service-logs)
- [[ -n "${arg1:-}" ]] || log_denied "usage: service-logs <unit> [lines] [minutes]"
- lines="${arg2:-200}"
- minutes="${arg3:-60}"
- validate_integer "$lines" 1 1000 "lines"
- validate_integer "$minutes" 1 10080 "minutes"
- require_unit "$arg1"
- run_bounded "$JOURNALCTL_BIN" \
- --unit "$arg1" \
- --lines "$lines" \
- --since "${minutes} minutes ago" \
- --no-pager \
- --output=short-iso
- ;;
- *)
- log_denied "command is not allowlisted"
- ;;
- esac
Advertisement
Add Comment
Please, Sign In to add comment