feat: release 2.2.1-SAC — phase 2 manifest verify and quiet SAC updates

Pinned GIT_REF/verify, release manifest, no blind reset --hard; suppress
lifecycle and watchdog Telegram during updater; healthcheck via json.dumps.
This commit is contained in:
PTah
2026-07-08 11:46:50 +10:00
parent 275d305d62
commit 6df83f1838
13 changed files with 642 additions and 163 deletions
+271 -21
View File
@@ -5,6 +5,11 @@ UPDATE_DIR="/opt/scripts/update"
SCRIPT_NAME="ssh-monitor"
REPO_URL="${REPO_URL:-}"
GIT_BRANCH="${GIT_BRANCH:-main}"
GIT_REF="${GIT_REF:-}"
GIT_VERIFY_MODE="${GIT_VERIFY_MODE:-off}"
GIT_ALLOW_RESET="${GIT_ALLOW_RESET:-0}"
MANIFEST_VERIFIED=0
MANIFEST_STRICT=0
LOCAL_SCRIPT_PATH="/usr/local/bin/ssh-monitor"
LOG_FILE="/var/log/update_script.log"
@@ -24,6 +29,7 @@ SUMMARY_SERVICE_RESTART="—"
SUMMARY_UPDATER="—"
SUMMARY_WATCHDOG="—"
SUMMARY_HARDENING="—"
SUMMARY_MANIFEST="—"
CONFIG_FILE="/etc/ssh-monitor.conf"
DEPLOY_INSTALL_PATH="/opt/scripts/update_ssh_monitor.sh"
@@ -401,11 +407,12 @@ maybe_enable_deploy_for_missing_install() {
}
set_config_kv() {
local key="$1" val="$2" file="$3"
local key="$1" val="$2" file="$3" esc
esc=$(printf '%s' "$val" | sed -e 's/[\\&/]/\\&/g' -e 's/"/\\"/g')
if grep -qE "^[[:space:]]*${key}=" "$file" 2>/dev/null; then
sed -i -E "s/^[[:space:]]*${key}=.*/${key}=\"${val}\"/" "$file"
sed -i -E "s/^[[:space:]]*${key}=.*/${key}=\"${esc}\"/" "$file"
else
printf '%s="%s"\n' "$key" "$val" >>"$file"
printf '%s="%s"\n' "$key" "$esc" >>"$file"
fi
}
@@ -619,44 +626,238 @@ resolve_git_remote_name() {
printf '%s' "$name"
}
# fetch + ff-only pull; при расхождении истории (force-push) — reset --hard.
load_git_verify_settings() {
local v
if [ -f "$CONFIG_FILE" ]; then
if [ -z "${GIT_REF:-}" ]; then
v=$(config_conf_value GIT_REF "$CONFIG_FILE" 2>/dev/null || true)
[ -n "$v" ] && GIT_REF="$v"
fi
if [ -z "${GIT_VERIFY_MODE:-}" ] || [ "$GIT_VERIFY_MODE" = "off" ]; then
v=$(config_conf_value GIT_VERIFY_MODE "$CONFIG_FILE" 2>/dev/null || true)
[ -n "$v" ] && GIT_VERIFY_MODE="$v"
fi
if [ "${GIT_ALLOW_RESET:-0}" = "0" ]; then
v=$(config_conf_value GIT_ALLOW_RESET "$CONFIG_FILE" 2>/dev/null || true)
[ -n "$v" ] && GIT_ALLOW_RESET="$v"
fi
fi
GIT_VERIFY_MODE="$(printf '%s' "${GIT_VERIFY_MODE:-off}" | tr '[:upper:]' '[:lower:]')"
case "$GIT_VERIFY_MODE" in
off | tag | commit) ;;
*)
log_action "WARN: GIT_VERIFY_MODE=$GIT_VERIFY_MODE неизвестен — off"
GIT_VERIFY_MODE=off
;;
esac
case "${GIT_ALLOW_RESET:-0}" in
1 | yes | true | on) GIT_ALLOW_RESET=1 ;;
*) GIT_ALLOW_RESET=0 ;;
esac
case "${RELEASE_MANIFEST_STRICT:-0}" in
1 | yes | true | on) MANIFEST_STRICT=1 ;;
*) MANIFEST_STRICT=0 ;;
esac
}
verify_git_ref() {
local repo="$1" ref="$2" mode="$3"
local obj_type full
[ "$mode" = "off" ] && return 0
[ -n "$ref" ] || {
log_action "ERROR: GIT_VERIFY_MODE=$mode требует GIT_REF"
return 1
}
case "$mode" in
tag)
if ! git -C "$repo" rev-parse "refs/tags/$ref" >/dev/null 2>&1; then
log_action "ERROR: GIT_REF=$ref не найден как тег в клоне"
return 1
fi
obj_type=$(git -C "$repo" cat-file -t "refs/tags/$ref" 2>/dev/null || true)
if [ "$obj_type" != "tag" ] && [ "$obj_type" != "commit" ]; then
log_action "ERROR: GIT_REF=$ref: неожиданный тип объекта ($obj_type)"
return 1
fi
log_action "git verify: тег $ref OK (type=$obj_type)"
;;
commit)
full=$(git -C "$repo" rev-parse "$ref^{commit}" 2>/dev/null || true)
if [ -z "$full" ] || [ "${#full}" -ne 40 ]; then
log_action "ERROR: GIT_REF=$ref не является полным commit SHA"
return 1
fi
log_action "git verify: commit ${full:0:12}… OK"
;;
esac
return 0
}
find_release_manifest_path() {
local repo="$1" version="$2"
local p="$repo/release/manifest-${version}.json"
if [ -f "$p" ]; then
printf '%s\n' "$p"
return 0
fi
return 1
}
read_repo_release_version() {
local repo="$1" ver
ver=$(read_ssh_monitor_version "$repo/ssh-monitor")
if [ -z "$ver" ] && [ -f "$repo/version.txt" ]; then
ver=$(tr -d '\r\n' <"$repo/version.txt")
fi
printf '%s' "$ver"
}
verify_release_manifest() {
local repo="$1" version manifest_path rc
MANIFEST_VERIFIED=0
version=$(read_repo_release_version "$repo")
if [ -z "$version" ]; then
SUMMARY_MANIFEST="версия не определена"
log_action "WARN: не удалось определить версию релиза для manifest"
[ "$MANIFEST_STRICT" -eq 1 ] && return 1
return 0
fi
manifest_path=$(find_release_manifest_path "$repo" "$version" 2>/dev/null || true)
if [ -z "$manifest_path" ]; then
SUMMARY_MANIFEST="manifest-${version}.json не найден (skip)"
log_action "WARN: release/manifest-${version}.json не найден — проверка SHA пропущена"
if [ "$MANIFEST_STRICT" -eq 1 ]; then
log_action "ERROR: RELEASE_MANIFEST_STRICT=1 — manifest обязателен"
return 1
fi
return 0
fi
log_action "manifest: проверка $manifest_path"
if ! MANIFEST_PATH="$manifest_path" REPO_DIR="$repo" python3 <<'PY' >>"$LOG_FILE" 2>&1
import hashlib
import json
import os
import subprocess
import sys
manifest_path = os.environ["MANIFEST_PATH"]
repo = os.environ["REPO_DIR"]
with open(manifest_path, encoding="utf-8") as f:
manifest = json.load(f)
expected_commit = (manifest.get("git_commit") or "").strip()
if expected_commit:
actual = subprocess.check_output(["git", "-C", repo, "rev-parse", "HEAD"], text=True).strip()
if actual != expected_commit:
print(f"commit mismatch: HEAD={actual[:12]} manifest={expected_commit[:12]}", file=sys.stderr)
sys.exit(2)
files = manifest.get("files") or {}
for rel, spec in files.items():
path = os.path.join(repo, rel)
if not os.path.isfile(path):
print(f"missing file: {rel}", file=sys.stderr)
sys.exit(3)
want = str(spec).removeprefix("sha256:")
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
got = h.hexdigest()
if got != want:
print(f"sha256 mismatch: {rel}", file=sys.stderr)
sys.exit(4)
print("OK")
PY
then
SUMMARY_MANIFEST="ОШИБКА verify"
log_action "ERROR: manifest verify failed (см. $LOG_FILE)"
return 1
fi
MANIFEST_VERIFIED=1
SUMMARY_MANIFEST="OK ($version)"
log_action "manifest: verify OK ($version)"
return 0
}
# fetch + checkout GIT_REF или fast-forward по GIT_BRANCH; без reset --hard (кроме GIT_ALLOW_RESET=1).
sync_git_repository() {
local repo="$1" branch="${2:-main}"
local remote upstream git_before git_after
local remote upstream git_before git_after ref="${GIT_REF:-}"
remote="$(resolve_git_remote_name "$repo")"
upstream="${remote}/${branch}"
if [ -f "$repo/.git/MERGE_HEAD" ]; then
log_action "git: незавершённый merge — abort"
git -C "$repo" merge --abort >> "$LOG_FILE" 2>&1 || true
git -C "$repo" merge --abort >>"$LOG_FILE" 2>&1 || true
fi
git_before="$(get_git_short_rev "$repo")"
if [ -n "$ref" ]; then
log_action "git: fetch $remote (ref=$ref, verify=$GIT_VERIFY_MODE) → $REPO_URL"
if ! git -C "$repo" fetch --prune "$remote" \
"+refs/heads/*:refs/remotes/${remote}/*" \
"+refs/tags/*:refs/tags/*" >>"$LOG_FILE" 2>&1; then
SUMMARY_GIT="fetch FAILED ($git_before)"
log_action "ERROR: git fetch failed (см. $LOG_FILE)"
return 1
fi
if ! verify_git_ref "$repo" "$ref" "$GIT_VERIFY_MODE"; then
SUMMARY_GIT="verify FAILED ($ref)"
return 1
fi
if ! git -C "$repo" checkout -f "$ref" >>"$LOG_FILE" 2>&1; then
SUMMARY_GIT="checkout FAILED ($ref)"
log_action "ERROR: git checkout $ref failed (см. $LOG_FILE)"
return 1
fi
git_after="$(get_git_short_rev "$repo")"
SUMMARY_GIT="checkout $ref ok, $git_before$git_after"
log_action "git: checkout $ref OK ($git_before$git_after)"
return 0
fi
log_action "git: fetch --prune $remote $branch (было $git_before) → $REPO_URL"
if ! git -C "$repo" fetch --prune "$remote" "$branch" >> "$LOG_FILE" 2>&1; then
if ! git -C "$repo" fetch --prune "$remote" "$branch" >>"$LOG_FILE" 2>&1; then
SUMMARY_GIT="fetch FAILED ($git_before)"
log_action "ERROR: git fetch failed (см. $LOG_FILE)"
return 1
fi
if git -C "$repo" pull --ff-only "$remote" "$branch" >> "$LOG_FILE" 2>&1; then
if git -C "$repo" pull --ff-only "$remote" "$branch" >>"$LOG_FILE" 2>&1; then
git_after="$(get_git_short_rev "$repo")"
SUMMARY_GIT="pull ok, $git_before$git_after"
log_action "git: fast-forward OK ($git_before$git_after)"
return 0
fi
log_action "WARN: fast-forward невозможен (часто после force-push) — reset --hard $upstream"
if ! git -C "$repo" reset --hard "$upstream" >> "$LOG_FILE" 2>&1; then
SUMMARY_GIT="reset FAILED ($git_before)"
log_action "ERROR: git reset --hard $upstream failed (см. $LOG_FILE)"
return 1
if [ "$GIT_ALLOW_RESET" -eq 1 ]; then
log_action "WARN: fast-forward невозможен — GIT_ALLOW_RESET=1, reset --hard $upstream"
if ! git -C "$repo" reset --hard "$upstream" >>"$LOG_FILE" 2>&1; then
SUMMARY_GIT="reset FAILED ($git_before)"
log_action "ERROR: git reset --hard $upstream failed (см. $LOG_FILE)"
return 1
fi
git_after="$(get_git_short_rev "$repo")"
SUMMARY_GIT="reset ok, $git_before$git_after"
log_action "git reset --hard: $git_before$git_after"
return 0
fi
git_after="$(get_git_short_rev "$repo")"
SUMMARY_GIT="reset ok, $git_before$git_after"
log_action "git reset --hard: $git_before$git_after"
return 0
SUMMARY_GIT="diverged ($git_before)"
log_action "ERROR: git: история разошлась (ff-only невозможен). Ручной clone или GIT_ALLOW_RESET=1"
echo "ERROR: git pull --ff-only failed; история разошлась. Исправьте клон в $repo или задайте GIT_ALLOW_RESET=1 (риск)." >&2
return 1
}
read_ssh_monitor_version() {
@@ -705,6 +906,7 @@ log_update_summary() {
log_action "updater ($DEPLOY_INSTALL_PATH): $SUMMARY_UPDATER"
log_action "watchdog: $SUMMARY_WATCHDOG"
log_action "security hardening: $SUMMARY_HARDENING"
log_action "manifest: $SUMMARY_MANIFEST"
log_action "перезапуск ssh-monitor.service: $SUMMARY_SERVICE_RESTART"
log_action "полный лог: $LOG_FILE"
log_action "==========================="
@@ -1270,6 +1472,7 @@ deploy_post_install() {
sync_installed_updater_from_clone() {
local remote="$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh"
local self installed_path remote_sum installed_sum=""
local repo="$UPDATE_DIR/$SCRIPT_NAME" ver manifest_path=""
if [ ! -f "$remote" ]; then
SUMMARY_UPDATER="не найден в клоне"
@@ -1277,6 +1480,16 @@ sync_installed_updater_from_clone() {
return 0
fi
ver=$(read_repo_release_version "$repo")
if [ -n "$ver" ]; then
manifest_path=$(find_release_manifest_path "$repo" "$ver" 2>/dev/null || true)
if [ -n "$manifest_path" ] && [ "${MANIFEST_VERIFIED:-0}" != "1" ]; then
SUMMARY_UPDATER="manifest не verified"
log_action "ERROR: updater: $manifest_path не verified — копирование пропущено"
return 1
fi
fi
self="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || printf '%s' "$0")"
installed_path="$(readlink -f "$DEPLOY_INSTALL_PATH" 2>/dev/null || realpath "$DEPLOY_INSTALL_PATH" 2>/dev/null || printf '%s' "$DEPLOY_INSTALL_PATH")"
remote_sum=$(file_sha256 "$remote")
@@ -1318,6 +1531,14 @@ restart_ssh_monitor_service() {
SUMMARY_SERVICE_RESTART="выполнен"
log_action "ssh-monitor.service перезапущен"
debug_step "Сервис успешно перезапущен"
local i
for i in $(seq 1 30); do
if systemctl is-active --quiet ssh-monitor.service 2>/dev/null; then
sleep 2
break
fi
sleep 1
done
else
SUMMARY_SERVICE_RESTART="не удался"
log_action "WARNING: systemctl restart ssh-monitor.service не выполнен"
@@ -1344,9 +1565,16 @@ update_script() {
SUMMARY_CONFIG="—"
SUMMARY_GIT="—"
SUMMARY_HARDENING="—"
SUMMARY_MANIFEST="—"
SUMMARY_SERVICE_RESTART="не требовался"
log_action "Старт обновления (deploy=$DEPLOY_MODE, debug=$DEBUG)"
load_git_verify_settings
load_ssh_monitor_perms_lib || true
if declare -F ssh_monitor_update_mark_begin >/dev/null 2>&1; then
ssh_monitor_update_mark_begin
log_action "agent-update: state file установлен (подавление lifecycle/watchdog)"
fi
log_action "Старт обновления (deploy=$DEPLOY_MODE, debug=$DEBUG, git_ref=${GIT_REF:-$GIT_BRANCH})"
debug_step "Запуск функции обновления скрипта"
debug_step "Гарантируем наличие директории для работы"
@@ -1375,9 +1603,10 @@ update_script() {
log_action "WARNING: git sync не удался — remote в клоне может быть устаревшим"
fi
else
log_action "git: clone $REPO_URL$UPDATE_DIR/$SCRIPT_NAME"
debug_step "Выполняем git clone $REPO_URL $SCRIPT_NAME"
if ! git clone -b "$GIT_BRANCH" "$REPO_URL" "$SCRIPT_NAME" >> "$LOG_FILE" 2>&1; then
local clone_ref="${GIT_REF:-$GIT_BRANCH}"
log_action "git: clone -b $clone_ref $REPO_URL$UPDATE_DIR/$SCRIPT_NAME"
debug_step "Выполняем git clone -b $clone_ref $REPO_URL $SCRIPT_NAME"
if ! git clone -b "$clone_ref" "$REPO_URL" "$SCRIPT_NAME" >>"$LOG_FILE" 2>&1; then
SUMMARY_GIT="clone FAILED"
SUMMARY_SCRIPT="ошибка"
SUMMARY_SCRIPT_REASON="git clone failed"
@@ -1385,11 +1614,25 @@ update_script() {
log_update_summary
return 1
fi
if [ -n "${GIT_REF:-}" ] && ! verify_git_ref "$git_repo" "$GIT_REF" "$GIT_VERIFY_MODE"; then
SUMMARY_GIT="verify FAILED ($GIT_REF)"
SUMMARY_SCRIPT="ошибка"
SUMMARY_SCRIPT_REASON="GIT_REF verify failed"
log_update_summary
return 1
fi
git_after="$(get_git_short_rev "$git_repo")"
SUMMARY_GIT="clone ok, commit $git_after"
log_action "git clone: успех (commit $git_after)"
fi
if ! verify_release_manifest "$git_repo"; then
SUMMARY_SCRIPT="ошибка"
SUMMARY_SCRIPT_REASON="manifest verify failed"
log_update_summary
return 1
fi
sync_installed_updater_from_clone "$@"
actual_script_path="$(find_remote_ssh_monitor_path 2>/dev/null || true)"
@@ -1534,6 +1777,13 @@ fi
maybe_enable_deploy_for_missing_install
cleanup_update_state() {
if declare -F ssh_monitor_update_mark_end >/dev/null 2>&1; then
ssh_monitor_update_mark_end
fi
}
trap cleanup_update_state EXIT
debug_step "Начинаем проверку и установку ipset (только --deploy / first_deploy)"
if [ "$DEPLOY_MODE" -eq 1 ]; then
if ! ensure_ipset_installed; then