b0abf99d1b
Phase 4.2: SAC_SUPPRESS_FAIL_COUNT during lifecycle shutdown so restart during SAC deploy does not trip Telegram fallback. Add docs/sac-ingest.ru.md for SAC_TIMEOUT_SEC guidance, smoke test, and release manifest.
435 lines
14 KiB
Bash
435 lines
14 KiB
Bash
#!/bin/bash
|
|
# Path permission helpers for ssh-monitor, sac-client, watchdog, updater.
|
|
# shellcheck shell=bash
|
|
|
|
ssh_monitor_config_peek() {
|
|
local key="$1" file="$2" default="${3:-}"
|
|
local line val
|
|
[ -f "$file" ] || {
|
|
printf '%s\n' "$default"
|
|
return 0
|
|
}
|
|
line="$(grep -E "^[[:space:]]*${key}=" "$file" 2>/dev/null | tail -1)" || {
|
|
printf '%s\n' "$default"
|
|
return 0
|
|
}
|
|
val="${line#*=}"
|
|
val="${val#"${val%%[![:space:]]*}"}"
|
|
val="${val%"${val##*[![:space:]]}"}"
|
|
val="${val#\"}"
|
|
val="${val%\"}"
|
|
val="${val#\'}"
|
|
val="${val%\'}"
|
|
printf '%s\n' "$val"
|
|
}
|
|
|
|
# --- Безопасная загрузка /etc/ssh-monitor.conf (2.3.0+: без source / выполнения bash) ---
|
|
|
|
_ssh_monitor_config_trim() {
|
|
local v="$1"
|
|
v="${v#"${v%%[![:space:]]*}"}"
|
|
v="${v%"${v##*[![:space:]]}"}"
|
|
printf '%s' "$v"
|
|
}
|
|
|
|
ssh_monitor_config_is_known_key() {
|
|
case "$1" in
|
|
TELEGRAM_BOT_TOKEN | TELEGRAM_CHAT_ID | NOTIFY_ORDER | \
|
|
UseSAC | SAC_URL | SAC_API_KEY | SAC_SPOOL_DIR | SAC_FAIL_COUNT_FILE | \
|
|
SAC_FALLBACK_FAILURES | SAC_TIMEOUT_SEC | SAC_SPOOL_FLUSH_MAX_FILES | \
|
|
SAC_SPOOL_MAX_AGE_HOURS | SAC_TLS_INSECURE | SAC_SEND_HEARTBEAT | SAC_AGENT_ID_FILE | \
|
|
CONFIG_STRICT_PERMS | \
|
|
MAIL_SMTP_HOST | MAIL_SMTP_PORT | MAIL_SMTP_USER | MAIL_SMTP_PASSWORD | \
|
|
MAIL_FROM | MAIL_TO | MAIL_SMTP_STARTTLS | MAIL_SMTP_SSL | \
|
|
LOG_FILE | LAST_HEARTBEAT_FILE | HEARTBEAT_INTERVAL_MINUTES | LAST_REPORT_FILE | \
|
|
LAST_SSH_CHECK_FILE | LAST_SUDO_CHECK_FILE | LAST_SECURITY_EVENTS_FILE | LAST_LOGIND_CHECK_FILE | \
|
|
DAILY_REPORT_ENABLED | DAILY_REPORT_HOUR | DAILY_REPORT_TZ | DAILY_REPORT_TOP_IPS | \
|
|
NOTIFY_TZ | SERVER_DISPLAY_NAME | SERVER_IPV4 | SSH_ACCEPT_NOTIFY_DEDUP_SEC | \
|
|
ENABLE_LOGIND_MONITOR | LOGIND_NOTIFY_NEW | LOGIND_NOTIFY_REMOVED | LOGIND_NOTIFY_FAILED | LOGIND_SKIP_REMOTE | \
|
|
PROMETHEUS_TEXTFILE_DIR | HEALTHCHECK_STATUS_FILE | \
|
|
BRUTE_WINDOW_SEC | BRUTE_MIN_FAILS | BRUTE_NOTIFY_COOLDOWN_SEC | \
|
|
ENABLE_IP_BAN | BAN_TIME | MAX_ATTEMPTS | BAN_CHECK_INTERVAL | MONITOR_INTERVAL | BAN_LIST_FILE | \
|
|
IPSET_NAME_V4 | IPSET_NAME_V6 | IPSET_MAXELEM | \
|
|
WHITELIST_IPS | WHITELIST_SUBNETS | \
|
|
WATCHDOG_MAX_HEARTBEAT_AGE | WATCHDOG_LOG_FILE | WATCHDOG_SERVICE_NAME | WATCHDOG_NOTIFY_ON_RECOVERY | \
|
|
GIT_REF | GIT_VERIFY_MODE | GIT_ALLOW_RESET | GIT_BRANCH | REPO_URL)
|
|
return 0
|
|
;;
|
|
BACKUP_WEBHOOK_URL)
|
|
return 2
|
|
;;
|
|
esac
|
|
return 1
|
|
}
|
|
|
|
# Разбор строки KEY=value. Устанавливает _CFG_KEY / _CFG_VAL.
|
|
# 0 ok | 1 пусто/комментарий | 2 битая строка | 3 подстановка shell в value
|
|
ssh_monitor_config_parse_line() {
|
|
local line="$1" raw_val
|
|
_CFG_KEY="" _CFG_VAL=""
|
|
line="$(_ssh_monitor_config_trim "$line")"
|
|
[ -n "$line" ] || return 1
|
|
[[ "$line" == \#* ]] && return 1
|
|
if [[ ! "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
|
return 2
|
|
fi
|
|
_CFG_KEY="${BASH_REMATCH[1]}"
|
|
raw_val="${BASH_REMATCH[2]}"
|
|
raw_val="$(_ssh_monitor_config_trim "$raw_val")"
|
|
if [ -z "$raw_val" ]; then
|
|
_CFG_VAL=""
|
|
return 0
|
|
fi
|
|
case "$raw_val" in
|
|
\"*\")
|
|
_CFG_VAL="${raw_val#\"}"
|
|
_CFG_VAL="${_CFG_VAL%\"}"
|
|
;;
|
|
\'*\')
|
|
_CFG_VAL="${raw_val#\'}"
|
|
_CFG_VAL="${_CFG_VAL%\'}"
|
|
;;
|
|
*)
|
|
_CFG_VAL="$raw_val"
|
|
;;
|
|
esac
|
|
if [[ "$_CFG_VAL" == *'$('* || "$_CFG_VAL" == *'`'* ]]; then
|
|
return 3
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
ssh_monitor_config_load_file() {
|
|
local file="$1"
|
|
local line rc line_no=0
|
|
[ -f "$file" ] || return 1
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
line_no=$((line_no + 1))
|
|
ssh_monitor_config_parse_line "$line"
|
|
rc=$?
|
|
if [ "$rc" -eq 1 ]; then
|
|
continue
|
|
fi
|
|
if [ "$rc" -eq 2 ]; then
|
|
printf 'WARN: конфиг %s:%s: пропуск нераспознанной строки: %s\n' "$file" "$line_no" "$line" >&2
|
|
continue
|
|
fi
|
|
if [ "$rc" -eq 3 ]; then
|
|
printf 'ERROR: конфиг %s:%s: недопустимая подстановка shell в %s=\n' "$file" "$line_no" "$_CFG_KEY" >&2
|
|
return 1
|
|
fi
|
|
ssh_monitor_config_is_known_key "$_CFG_KEY"
|
|
rc=$?
|
|
if [ "$rc" -eq 2 ]; then
|
|
printf 'WARN: конфиг %s:%s: BACKUP_WEBHOOK_URL удалён в 2.3.0-SAC — игнорируется\n' "$file" "$line_no" >&2
|
|
continue
|
|
fi
|
|
if [ "$rc" -eq 1 ]; then
|
|
printf 'WARN: конфиг %s:%s: неизвестный ключ %s (игнорируется)\n' "$file" "$line_no" "$_CFG_KEY" >&2
|
|
continue
|
|
fi
|
|
printf -v "$_CFG_KEY" '%s' "$_CFG_VAL"
|
|
done <"$file"
|
|
return 0
|
|
}
|
|
|
|
ssh_monitor_validate_ipv4() {
|
|
local ip="$1" o
|
|
ip="$(_ssh_monitor_config_trim "$ip")"
|
|
[[ "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1
|
|
for o in "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}"; do
|
|
((10#$o <= 255)) || return 1
|
|
done
|
|
return 0
|
|
}
|
|
|
|
ssh_monitor_validate_cidr_v4() {
|
|
local cidr="$1" net mask
|
|
cidr="$(_ssh_monitor_config_trim "$cidr")"
|
|
[[ "$cidr" == */* ]] || return 1
|
|
net="${cidr%/*}"
|
|
mask="${cidr#*/}"
|
|
[[ "$mask" =~ ^[0-9]+$ ]] && [ "$mask" -ge 0 ] && [ "$mask" -le 32 ] || return 1
|
|
ssh_monitor_validate_ipv4 "$net"
|
|
}
|
|
|
|
ssh_monitor_validate_http_url() {
|
|
local name="$1" value="$2" optional="${3:-0}"
|
|
value="$(_ssh_monitor_config_trim "$value")"
|
|
if [ -z "$value" ]; then
|
|
[ "$optional" = "1" ] && return 0
|
|
printf 'ERROR: %s: пустой URL\n' "$name" >&2
|
|
return 1
|
|
fi
|
|
if [[ ! "$value" =~ ^https?://[^/[:space:]]+ ]]; then
|
|
printf 'ERROR: %s: ожидается http(s)://host…, получено %s\n' "$name" "$value" >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
ssh_monitor_validate_hostname() {
|
|
local name="$1" value="$2"
|
|
value="$(_ssh_monitor_config_trim "$value")"
|
|
[ -z "$value" ] && return 0
|
|
if [[ ! "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ ]]; then
|
|
printf 'ERROR: %s: недопустимый hostname %s\n' "$name" "$value" >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Проверка значений после load_config (exit 1 при ERROR).
|
|
ssh_monitor_validate_config_values() {
|
|
local err=0 mode item
|
|
|
|
if [ -n "${SAC_URL:-}" ]; then
|
|
ssh_monitor_validate_http_url "SAC_URL" "${SAC_URL}" 1 || err=1
|
|
fi
|
|
if [ -n "${MAIL_SMTP_HOST:-}" ]; then
|
|
ssh_monitor_validate_hostname "MAIL_SMTP_HOST" "${MAIL_SMTP_HOST}" || err=1
|
|
fi
|
|
if [ -n "${SERVER_IPV4:-}" ]; then
|
|
ssh_monitor_validate_ipv4 "${SERVER_IPV4}" || {
|
|
printf 'ERROR: SERVER_IPV4: некорректный IPv4 %s\n' "${SERVER_IPV4}" >&2
|
|
err=1
|
|
}
|
|
fi
|
|
|
|
mode="$(printf '%s' "${UseSAC:-off}" | tr '[:upper:]' '[:lower:]')"
|
|
case "$mode" in
|
|
off | exclusive | dual | fallback) ;;
|
|
*)
|
|
printf 'ERROR: UseSAC="%s" (допустимо: off, exclusive, dual, fallback)\n' "${UseSAC:-}" >&2
|
|
err=1
|
|
;;
|
|
esac
|
|
|
|
mode="$(printf '%s' "${GIT_VERIFY_MODE:-off}" | tr '[:upper:]' '[:lower:]')"
|
|
case "$mode" in
|
|
off | tag | commit) ;;
|
|
*)
|
|
printf 'ERROR: GIT_VERIFY_MODE="%s" (допустимо: off, tag, commit)\n' "${GIT_VERIFY_MODE:-}" >&2
|
|
err=1
|
|
;;
|
|
esac
|
|
|
|
for item in "${WHITELIST_IPS[@]:-}"; do
|
|
item="$(_ssh_monitor_config_trim "$item")"
|
|
[ -z "$item" ] && continue
|
|
if ! ssh_monitor_validate_ipv4 "$item"; then
|
|
printf 'ERROR: WHITELIST_IPS: некорректный IPv4 %s\n' "$item" >&2
|
|
err=1
|
|
fi
|
|
done
|
|
for item in "${WHITELIST_SUBNETS[@]:-}"; do
|
|
item="$(_ssh_monitor_config_trim "$item")"
|
|
[ -z "$item" ] && continue
|
|
if ! ssh_monitor_validate_cidr_v4 "$item"; then
|
|
printf 'ERROR: WHITELIST_SUBNETS: некорректный CIDR %s\n' "$item" >&2
|
|
err=1
|
|
fi
|
|
done
|
|
|
|
return "$err"
|
|
}
|
|
|
|
ssh_monitor_truthy() {
|
|
case "${1,,}" in
|
|
1 | yes | true | on) return 0 ;;
|
|
esac
|
|
return 1
|
|
}
|
|
|
|
ssh_monitor_path_mode() {
|
|
local path="$1"
|
|
[ -e "$path" ] || return 1
|
|
stat -c '%a' "$path" 2>/dev/null || stat -f '%OLp' "$path" 2>/dev/null
|
|
}
|
|
|
|
ssh_monitor_path_owner() {
|
|
local path="$1"
|
|
[ -e "$path" ] || return 1
|
|
stat -c '%U:%G' "$path" 2>/dev/null
|
|
}
|
|
|
|
ssh_monitor_is_root_owned() {
|
|
local path="$1" own
|
|
[ -e "$path" ] || return 0
|
|
own="$(ssh_monitor_path_owner "$path" 2>/dev/null || true)"
|
|
[ "$own" = "root:root" ]
|
|
}
|
|
|
|
ssh_monitor_secure_dir() {
|
|
local dir="$1"
|
|
[ -n "$dir" ] || return 1
|
|
mkdir -p "$dir" 2>/dev/null || return 1
|
|
chmod 700 "$dir" 2>/dev/null || true
|
|
chown root:root "$dir" 2>/dev/null || true
|
|
}
|
|
|
|
ssh_monitor_secure_file() {
|
|
local file="$1"
|
|
local dir
|
|
[ -n "$file" ] || return 1
|
|
dir="$(dirname "$file")"
|
|
ssh_monitor_secure_dir "$dir" || true
|
|
if [ ! -f "$file" ]; then
|
|
: >"$file" 2>/dev/null || touch "$file" 2>/dev/null || return 1
|
|
fi
|
|
chmod 600 "$file" 2>/dev/null || true
|
|
chown root:root "$file" 2>/dev/null || true
|
|
}
|
|
|
|
# Существующий файл или каталог: 600/700 root:root; для отсутствующего пути — только родительский каталог.
|
|
ssh_monitor_harden_path() {
|
|
local path="$1"
|
|
[ -n "$path" ] || return 0
|
|
if [ -d "$path" ]; then
|
|
ssh_monitor_secure_dir "$path"
|
|
elif [ -f "$path" ] || [ -L "$path" ]; then
|
|
ssh_monitor_secure_file "$path"
|
|
else
|
|
local parent
|
|
parent="$(dirname "$path")"
|
|
[ -n "$parent" ] && [ "$parent" != "." ] && ssh_monitor_secure_dir "$parent"
|
|
fi
|
|
}
|
|
|
|
ssh_monitor_warn_path_perms() {
|
|
local path="$1" kind="$2" max_mode="${3:-600}"
|
|
local mode own msg
|
|
[ -e "$path" ] || return 0
|
|
if ! ssh_monitor_is_root_owned "$path"; then
|
|
own="$(ssh_monitor_path_owner "$path" 2>/dev/null || echo '?')"
|
|
msg="WARN: ${kind} ${path}: владелец ${own}, ожидается root:root"
|
|
printf '%s\n' "$msg" >&2
|
|
[ -n "${write_log:-}" ] && write_log "$msg" || true
|
|
fi
|
|
mode="$(ssh_monitor_path_mode "$path" 2>/dev/null || echo "")"
|
|
if [ -n "$mode" ] && [ "$((10#$mode))" -gt "$((10#$max_mode))" ]; then
|
|
msg="WARN: ${kind} ${path}: mode ${mode}, рекомендуется не шире ${max_mode}"
|
|
printf '%s\n' "$msg" >&2
|
|
[ -n "${write_log:-}" ] && write_log "$msg" || true
|
|
fi
|
|
}
|
|
|
|
ssh_monitor_check_config_file_perms() {
|
|
local file="${1:-/etc/ssh-monitor.conf}"
|
|
local strict="${2:-0}"
|
|
local mode msg
|
|
[ -f "$file" ] || return 0
|
|
if ! ssh_monitor_is_root_owned "$file"; then
|
|
msg="WARN: конфиг ${file}: владелец $(ssh_monitor_path_owner "$file" 2>/dev/null || echo '?'), ожидается root:root"
|
|
printf '%s\n' "$msg" >&2
|
|
[ -n "${write_log:-}" ] && write_log "$msg" || true
|
|
if ssh_monitor_truthy "$strict"; then
|
|
printf '%s\n' "CONFIG_STRICT_PERMS=1: исправьте владельца конфига (chown root:root ${file})" >&2
|
|
return 1
|
|
fi
|
|
fi
|
|
mode="$(ssh_monitor_path_mode "$file" 2>/dev/null || echo "")"
|
|
case "$mode" in
|
|
600 | 400) ;;
|
|
*)
|
|
msg="WARN: конфиг ${file}: mode ${mode:-?}, рекомендуется 600 или 400 (chmod 600 ${file})"
|
|
printf '%s\n' "$msg" >&2
|
|
[ -n "${write_log:-}" ] && write_log "$msg" || true
|
|
if ssh_monitor_truthy "$strict"; then
|
|
printf '%s\n' "CONFIG_STRICT_PERMS=1: ослабленные права конфига недопустимы" >&2
|
|
return 1
|
|
fi
|
|
;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
# Подпись сервера в Telegram/webhook (как в ssh-monitor: SERVER_DISPLAY_NAME или hostname + IPv4).
|
|
ssh_monitor_server_display_name_with_ip() {
|
|
local hn ip
|
|
hn="${SERVER_DISPLAY_NAME:-}"
|
|
if [ -z "$hn" ]; then
|
|
hn="$(hostname 2>/dev/null | tr -d '\r\n')"
|
|
[ -z "$hn" ] && hn="(unknown)"
|
|
fi
|
|
ip="${SERVER_IPV4:-}"
|
|
if [ -z "$ip" ] && command -v ip >/dev/null 2>&1; then
|
|
ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '/\bsrc\b/ {for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit }}')"
|
|
[ -z "$ip" ] && ip="$(ip -4 route get 8.8.8.8 2>/dev/null | awk '/\bsrc\b/ {for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit }}')"
|
|
fi
|
|
if [ -z "$ip" ] && command -v hostname >/dev/null 2>&1 && hostname -I >/dev/null 2>&1; then
|
|
ip="$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -Ev '^(127\.|0\.0\.0\.0)$' | head -n1)"
|
|
fi
|
|
if [ -n "$ip" ]; then
|
|
printf '%s (%s)' "$hn" "$ip"
|
|
else
|
|
printf '%s' "$hn"
|
|
fi
|
|
}
|
|
|
|
ssh_monitor_message_ensure_server_line() {
|
|
local msg="$1"
|
|
if [[ "$msg" == *'🖥️ Сервер:'* ]]; then
|
|
printf '%s' "$msg"
|
|
return 0
|
|
fi
|
|
local first rest
|
|
first="${msg%%$'\n'*}"
|
|
if [ "$first" = "$msg" ]; then
|
|
printf '%s\n🖥️ Сервер: %s' "$first" "$(ssh_monitor_server_display_name_with_ip)"
|
|
else
|
|
rest="${msg#*$'\n'}"
|
|
printf '%s\n🖥️ Сервер: %s\n%s' "$first" "$(ssh_monitor_server_display_name_with_ip)" "$rest"
|
|
fi
|
|
}
|
|
|
|
# Флаг штатного SAC/cron-update: подавляет lifecycle/watchdog Telegram на время прогона updater.
|
|
SSH_MONITOR_UPDATE_STATE_DEFAULT="/var/lib/ssh-monitor/agent-update-in-progress"
|
|
SSH_MONITOR_UPDATE_STATE_MAX_AGE_SEC="${SSH_MONITOR_UPDATE_STATE_MAX_AGE_SEC:-900}"
|
|
|
|
ssh_monitor_update_state_file() {
|
|
printf '%s\n' "${SSH_MONITOR_UPDATE_STATE_FILE:-$SSH_MONITOR_UPDATE_STATE_DEFAULT}"
|
|
}
|
|
|
|
ssh_monitor_update_in_progress() {
|
|
local f ts now age
|
|
f="$(ssh_monitor_update_state_file)"
|
|
[ -f "$f" ] || return 1
|
|
ts="$(tr -d '\r\n' <"$f" 2>/dev/null || true)"
|
|
[[ "$ts" =~ ^[0-9]+$ ]] || return 1
|
|
now=$(date '+%s')
|
|
age=$((now - ts))
|
|
[ "$age" -le "$SSH_MONITOR_UPDATE_STATE_MAX_AGE_SEC" ]
|
|
}
|
|
|
|
ssh_monitor_update_mark_begin() {
|
|
local f dir
|
|
f="$(ssh_monitor_update_state_file)"
|
|
dir="$(dirname "$f")"
|
|
ssh_monitor_secure_dir "$dir" 2>/dev/null || mkdir -p "$dir" 2>/dev/null || true
|
|
date '+%s' >"$f"
|
|
ssh_monitor_secure_file "$f" 2>/dev/null || chmod 600 "$f" 2>/dev/null || true
|
|
}
|
|
|
|
ssh_monitor_update_mark_end() {
|
|
local f
|
|
f="$(ssh_monitor_update_state_file)"
|
|
rm -f "$f" 2>/dev/null || true
|
|
}
|
|
|
|
# SAC/cron update: не слать sudo-alert на штатные команды updater/bootstrap.
|
|
ssh_monitor_sudo_is_sac_maintenance() {
|
|
local cmd="$1"
|
|
[ -n "$cmd" ] || return 1
|
|
if ssh_monitor_update_in_progress 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
case "$cmd" in
|
|
*update_ssh_monitor.sh* | *UPDATE_VIA_SAC* | *update_script.log* | \
|
|
*"/opt/scripts/update"*"ssh-monitor"* | *agent-update-in-progress*)
|
|
return 0
|
|
;;
|
|
esac
|
|
return 1
|
|
}
|