Сверка fr1 показала, что дока расходится с фактом по десятку пунктов —
значит нужен не разовый проход, а контракт и инструмент.
CLAUDE.md — правило «тронул сервер, обнови servers/<хост>.md в том же
прогоне» плюс стиль: факт + причина + что сломается молча. Лежит именно
в CLAUDE.md, потому что AGENTS.md автоматически агенту не подгружается.
.claude/skills/server-audit/ — метод сверки и накопленные грабли: где
дока врёт чаще всего, почему ufw не отвечает на вопрос об открытых
портах, почему нужен up -d --dry-run, почему «executed successfully»
не доказывает работу задания.
scripts/snapshot.sh — инвентаризация живого состояния за один проход:
только чтение, без sudo, секретов не печатает, недоступное под root
помечает и подсказывает команду.
Первый же прогон скрипта нашёл две вещи, обе описаны:
- IPv6 не «починился», а мигает: за один день FAILED -> REACHABLE ->
FAILED без единой правки конфига. Описан как мигающий, с выводом
ничего на него не завязывать.
- В основном sshd_config стоит PermitRootLogin yes (правка шаблона
Contabo). Root-вход закрыт только потому, что Include дроп-инов
стоит выше, а sshd берёт первое значение. Уберёшь дроп-ин —
включится.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
115 lines
6.1 KiB
Bash
Executable File
115 lines
6.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Инвентаризация живого состояния сервера — только чтение, sudo не требуется.
|
|
#
|
|
# Смысл: сверка описания с фактом стоит дорого не из-за писанины, а из-за
|
|
# выяснения, что на сервере вообще происходит. Этот скрипт собирает всё, что
|
|
# понадобилось при сверке fr1 2026-09-08, за один проход.
|
|
#
|
|
# ./scripts/snapshot.sh rus-161 # по ssh
|
|
# ./scripts/snapshot.sh rus-161 > /tmp/fr1.txt
|
|
#
|
|
# Секретов не печатает: содержимое .env, токенов и ключей не читается —
|
|
# только факт наличия файла и права.
|
|
#
|
|
# Часть данных требует root и будет помечена как недоступная: правила ufw,
|
|
# сертификаты, /var/log/syslog, кроны других пользователей. Их надо
|
|
# добирать вручную через `ssh -t <хост> sudo ...`.
|
|
set -uo pipefail
|
|
|
|
HOST="${1:-}"
|
|
[ -z "$HOST" ] && { echo "usage: $0 <ssh-host>" >&2; exit 2; }
|
|
|
|
ssh -o BatchMode=yes "$HOST" 'bash -s' <<'REMOTE'
|
|
set +e
|
|
h() { printf "\n\n===== %s =====\n" "$1"; }
|
|
|
|
h "СИСТЕМА"
|
|
uname -srm; . /etc/os-release && echo "$PRETTY_NAME"
|
|
echo "hostname: $(hostname) / $(hostname -f 2>/dev/null)"
|
|
uptime; who -b
|
|
echo "vCPU: $(nproc)"; free -h | head -2; df -h / | tail -1
|
|
ls /var/run/reboot-required 2>/dev/null && echo "ТРЕБУЕТСЯ ПЕРЕЗАГРУЗКА"
|
|
|
|
h "ПОЛЬЗОВАТЕЛИ И ДОСТУП"
|
|
getent passwd | awk -F: '$3>=1000 && $3<65000 {print $1" uid="$3" shell="$7}'
|
|
echo "группа docker (= root-эквивалент): $(getent group docker)"
|
|
echo "группа sudo: $(getent group sudo)"
|
|
grep -hE "^(PermitRootLogin|PasswordAuthentication|AllowUsers|MaxAuthTries)" \
|
|
/etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf 2>/dev/null | sort -u
|
|
|
|
h "СЕТЬ"
|
|
ip -br addr | grep -vE "^(veth|br-|docker)"
|
|
ip route | head -5
|
|
ip -6 route | grep default
|
|
echo "-- NDP шлюза (FAILED = IPv6 не работает) --"; ip -6 neigh | grep -E "fe80::1 |router"
|
|
resolvectl status 2>/dev/null | grep -A1 "Current DNS"
|
|
|
|
h "ЧТО СЛУШАЕТ НАРУЖУ (а не ufw status!)"
|
|
# ufw НЕ управляет docker-портами: docker пишет их в iptables напрямую,
|
|
# поэтому единственный честный ответ даёт ss + docker ps.
|
|
ss -tlnH | awk '{print $4}' | sort -u
|
|
echo "-- udp --"; ss -ulnH | awk '{print $4}' | sort -u
|
|
|
|
h "DOCKER"
|
|
docker --version 2>/dev/null; docker compose version 2>/dev/null
|
|
docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null
|
|
echo; docker system df 2>/dev/null
|
|
echo "-- откуда запущен каждый контейнер (compose-проект и файл) --"
|
|
for c in $(docker ps -aq 2>/dev/null); do
|
|
docker inspect "$c" --format '{{.Name}} <- {{index .Config.Labels "com.docker.compose.project.config_files"}}'
|
|
done
|
|
echo "-- монтирования (host -> container) --"
|
|
for c in $(docker ps -aq 2>/dev/null); do
|
|
docker inspect "$c" --format '{{.Name}}:{{range .Mounts}} {{.Source}}->{{.Destination}}{{end}}'
|
|
done
|
|
echo "-- тома и их размер --"
|
|
for v in $(docker volume ls -q 2>/dev/null); do
|
|
printf "%s %s\n" "$(docker run --rm -v "$v":/v alpine du -sh /v 2>/dev/null | cut -f1)" "$v"
|
|
done
|
|
echo "-- сироты (dangling) --"; docker volume ls -qf dangling=true 2>/dev/null
|
|
|
|
h "ПРОЦЕССЫ ВНЕ КОНТЕЙНЕРОВ И ВНЕ SYSTEMD"
|
|
# Такие процессы не переживут перезагрузку и не видны cAdvisor.
|
|
ps -eo user:12,pid,ppid,etime:12,%cpu,rss,args --sort=-rss \
|
|
| grep -vE "\[|containerd-shim|/usr/lib/systemd|^USER" | head -25
|
|
|
|
h "РАСПИСАНИЯ"
|
|
echo "-- /etc/cron.d --"; for f in /etc/cron.d/*; do [ -f "$f" ] && { echo "# $f"; grep -vE '^#|^$|^SHELL|^PATH' "$f"; }; done
|
|
for d in hourly daily weekly monthly; do echo "-- cron.$d: $(ls /etc/cron.$d 2>/dev/null | tr '\n' ' ')"; done
|
|
echo "-- crontab текущего пользователя --"; crontab -l 2>/dev/null | grep -vE '^#|^$'
|
|
echo "-- кроны остальных пользователей: нужен root --"
|
|
sudo -n ls /var/spool/cron/crontabs 2>/dev/null || echo " (недоступно: ssh -t <хост> 'sudo ls /var/spool/cron/crontabs')"
|
|
echo "-- таймеры --"; systemctl list-timers --all --no-pager 2>/dev/null | head -20
|
|
|
|
h "NGINX / TLS"
|
|
nginx -v 2>&1; ls -l /etc/nginx/sites-enabled/ 2>/dev/null
|
|
grep -hE "server_name|proxy_pass|root |listen " /etc/nginx/sites-available/*.conf 2>/dev/null | sed 's/^ *//' | sort -u
|
|
echo "-- сертификаты: нужен root --"
|
|
sudo -n ls /etc/letsencrypt/live 2>/dev/null || echo " (недоступно)"
|
|
|
|
h "ЛОГИ И ИСТОРИЯ (для будущих разборов)"
|
|
cat /etc/systemd/journald.conf.d/*.conf 2>/dev/null | grep -vE '^#|^$'
|
|
journalctl --disk-usage 2>/dev/null | tail -1
|
|
echo "-- sysstat: глубина истории --"
|
|
grep -E '^HISTORY|^COMPRESSAFTER' /etc/sysstat/sysstat 2>/dev/null
|
|
ls /var/log/sysstat 2>/dev/null | head -3
|
|
echo " (файлы saDD = максимум месяц; saYYYYMMDD = HISTORY>28)"
|
|
|
|
h "ОБНОВЛЕНИЯ"
|
|
pro status --format json 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); print("Ubuntu Pro:", d["account"]["name"]); [print(" ",s["name"],s["status"]) for s in d["services"] if s["status"]=="enabled"]' 2>/dev/null
|
|
grep -hE "Update-Package-Lists|Unattended-Upgrade " /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null
|
|
snap list 2>/dev/null | tail -n +2 | awk '{print " snap:",$1,$2}'
|
|
snap refresh --time 2>/dev/null | head -2
|
|
|
|
h "ЧЕГО ЗДЕСЬ НЕТ — ДОБРАТЬ ВРУЧНУЮ ПОД ROOT"
|
|
cat <<'HINT'
|
|
ssh -t <хост> "sudo bash -c '
|
|
ufw status verbose
|
|
certbot certificates | grep -E \"Certificate Name|Expiry\"
|
|
ls /var/spool/cron/crontabs && for f in /var/spool/cron/crontabs/*; do echo \$f; grep -v ^# \$f; done
|
|
awg show 2>/dev/null | head -20
|
|
grep -c suspended /var/log/syslog
|
|
'"
|
|
HINT
|
|
REMOTE
|