#!/bin/bash # uptime-monitor installer # Usage: curl -s https://scrp.pipefox.xyz | sudo bash set -e RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' info() { echo -e "${GREEN}[OK]${NC} $1"; } warn() { echo -e "${YELLOW}[!]${NC} $1"; } error() { echo -e "${RED}[ERR]${NC} $1"; exit 1; } step() { echo -e "${CYAN}[>>]${NC} $1"; } echo "" echo " ============================================= " echo " Uptime Monitor -- installer " echo " ============================================= " echo "" # ── 1. Root check ────────────────────────────────────────────────────────────── [[ $EUID -ne 0 ]] && error "Запустите от root: curl -s https://scrp.pipefox.xyz | sudo bash" # ── 2. systemd check ─────────────────────────────────────────────────────────── if ! systemctl --version &>/dev/null; then error "systemd не найден. Требуется Linux Mint 20+ (Ubuntu 20.04+)" fi info "systemd найден" # ── 3. journalctl check ──────────────────────────────────────────────────────── if ! command -v journalctl &>/dev/null; then error "journalctl не найден. Убедитесь что systemd-journald установлен" fi info "journalctl найден" # ── 4. python3 check / install ───────────────────────────────────────────────── if ! command -v python3 &>/dev/null; then warn "python3 не найден -- устанавливаю..." step "Обновляю список пакетов apt..." apt-get update -qq || error "apt-get update не удался. Проверьте подключение к интернету." apt-get install -y -q python3 || error "Не удалось установить python3" info "python3 установлен" else PY_VER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || echo "0.0") PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1) PY_MINOR=$(echo "$PY_VER" | cut -d. -f2) if [[ $PY_MAJOR -lt 3 ]] || [[ $PY_MAJOR -eq 3 && $PY_MINOR -lt 6 ]]; then error "Требуется Python 3.6+, найден $PY_VER" fi info "python3 $PY_VER найден" fi # ── 5. Создать директорию ────────────────────────────────────────────────────── INSTALL_DIR="/opt/uptime-monitor" step "Создаю $INSTALL_DIR" mkdir -p "$INSTALL_DIR" # ── 6. Записать monitor.py ──────────────────────────────────────────────────── step "Записываю /opt/uptime-monitor/monitor.py" cat > "$INSTALL_DIR/monitor.py" << 'PYEOF' #!/usr/bin/env python3 """ uptime-monitor Запускается при загрузке системы (systemd oneshot). Анализирует предыдущий период офлайн, находит пересечение с рабочими часами (Пн-Пт 10:00-20:00 Europe/Belgrade) и отправляет ntfy-уведомление. Зависимости: только Python 3.6+ stdlib + journalctl. """ import os import sys import re import json import time import subprocess import urllib.request import urllib.parse import base64 from datetime import datetime, timedelta # Устанавливаем часовой пояс до любых операций с датами os.environ['TZ'] = 'Europe/Belgrade' time.tzset() NTFY_SERVER = "https://ntfy.001035.xyz" NTFY_USER = "linuxonline" NTFY_PASS = "HW53qSQAUCGu91gY16Jd" WORK_START = 10 # 10:00 WORK_END = 20 # 20:00 MIN_OFFLINE = 10 # минут -- ниже этого порога не репортим LOG_FILE = "/var/log/uptime-monitor.log" DAYS_RU = [ "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье" ] def log(msg): try: ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') with open(LOG_FILE, 'a') as f: f.write(f"[{ts}] {msg}\n") except Exception: pass def get_hostname(): try: return subprocess.check_output(["hostname"], text=True).strip() except Exception: return "linux-machine" def _parse_json(output): """Парсит NDJSON или JSON-массив из journalctl --list-boots -o json.""" entries = [] try: parsed = json.loads(output) entries = parsed if isinstance(parsed, list) else [parsed] except (json.JSONDecodeError, ValueError): for line in output.splitlines(): line = line.strip() if not line: continue try: entries.append(json.loads(line)) except (json.JSONDecodeError, ValueError): pass sessions = [] for d in entries: try: first_us = int(d['first_entry']) last_us = int(d['last_entry']) first = datetime.fromtimestamp(first_us / 1_000_000) last = datetime.fromtimestamp(last_us / 1_000_000) if last > first: sessions.append((first, last)) except (KeyError, ValueError, TypeError, OSError): continue return sessions def _parse_text(output): """Парсит текстовый вывод journalctl --list-boots (fallback для старых systemd).""" sessions = [] ts_re = re.compile(r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})') for line in output.splitlines(): matches = ts_re.findall(line) if len(matches) >= 2: try: start = datetime.strptime(matches[0], '%Y-%m-%d %H:%M:%S') end = datetime.strptime(matches[1], '%Y-%m-%d %H:%M:%S') if end > start: sessions.append((start, end)) except ValueError: continue return sessions def get_boot_sessions(): """Возвращает отсортированный список (start, end) загрузок системы.""" # Попытка 1: JSON (systemd >= 249 -- Linux Mint 21+ / Ubuntu 22.04+) try: r = subprocess.run( ["journalctl", "--list-boots", "--no-pager", "--output=json"], capture_output=True, text=True, timeout=30, env=os.environ ) if r.returncode == 0 and r.stdout.strip(): sessions = _parse_json(r.stdout) if sessions: log(f"JSON: получено {len(sessions)} сессий") return sorted(sessions, key=lambda x: x[0]) except Exception as e: log(f"JSON-парсинг не удался: {e}") # Попытка 2: текстовый вывод (всегда доступен) try: r = subprocess.run( ["journalctl", "--list-boots", "--no-pager"], capture_output=True, text=True, timeout=30, env=os.environ ) if r.returncode == 0: sessions = _parse_text(r.stdout) log(f"Text: получено {len(sessions)} сессий") return sorted(sessions, key=lambda x: x[0]) except Exception as e: log(f"Text-парсинг не удался: {e}") return [] def get_work_overlap(start: datetime, end: datetime): """Находит подинтервалы [start,end] попадающие в рабочие часы рабочих дней.""" result = [] day = start.replace(hour=0, minute=0, second=0, microsecond=0) end_day = end.replace(hour=0, minute=0, second=0, microsecond=0) while day <= end_day: if day.weekday() < 5: # 0=Пн ... 4=Пт ws = day.replace(hour=WORK_START, minute=0, second=0, microsecond=0) we = day.replace(hour=WORK_END, minute=0, second=0, microsecond=0) seg_s = max(start, ws) seg_e = min(end, we) if seg_e > seg_s: result.append((seg_s, seg_e)) day += timedelta(days=1) return result def fmt_dur(seconds): m = int(seconds // 60) if m < 60: return f"{m}мин" h, m = divmod(m, 60) return f"{h}ч {m}мин" if m else f"{h}ч" def send_ntfy(topic, title, body): safe_topic = urllib.parse.quote(str(topic), safe='') url = f"{NTFY_SERVER}/{safe_topic}" cred = base64.b64encode(f"{NTFY_USER}:{NTFY_PASS}".encode()).decode() req = urllib.request.Request( url, data=body.encode('utf-8'), headers={ "Authorization": f"Basic {cred}", "Title": title, "Content-Type": "text/plain; charset=utf-8", }, method="POST" ) with urllib.request.urlopen(req, timeout=15) as r: return r.status < 300 def main(): log("=== uptime-monitor started ===") hostname = get_hostname() log(f"Hostname: {hostname}") sessions = get_boot_sessions() log(f"Boot sessions: {len(sessions)}") if len(sessions) < 2: log("Недостаточно сессий -- нечего анализировать") return # Последний период офлайн = промежуток перед текущей загрузкой offline_start = sessions[-2][1] # конец предыдущей сессии (выключение) offline_end = sessions[-1][0] # начало текущей загрузки gap_min = (offline_end - offline_start).total_seconds() / 60 log(f"Offline: {offline_start.strftime('%Y-%m-%d %H:%M')} -- " f"{offline_end.strftime('%Y-%m-%d %H:%M')} ({gap_min:.0f}мин)") if gap_min < MIN_OFFLINE: log(f"Пауза {gap_min:.0f}мин < {MIN_OFFLINE}мин -- пропускаем") return # Пересечение с рабочим временем segments = get_work_overlap(offline_start, offline_end) # Отфильтровать короткие сегменты segments = [ (s, e) for s, e in segments if (e - s).total_seconds() >= MIN_OFFLINE * 60 ] if not segments: log("Нет сегментов в рабочее время -- уведомление не нужно") return # Группировка по дате для красивого сообщения by_date = {} total = 0 for s, e in segments: by_date.setdefault(s.date(), []).append((s, e)) total += (e - s).total_seconds() lines = ["Отсутствие в рабочее время (Пн-Пт, 10:00-20:00):\n"] for date in sorted(by_date): wd = DAYS_RU[date.weekday()] lines.append(f"{wd} {date.strftime('%d.%m.%Y')}:") for s, e in by_date[date]: dur = (e - s).total_seconds() lines.append(f" * {s.strftime('%H:%M')} - {e.strftime('%H:%M')} ({fmt_dur(dur)})") lines.append(f"\nИтого offline: {fmt_dur(total)}") lines.append(f"Вернулся в сеть: {datetime.now().strftime('%d.%m.%Y в %H:%M')}") title = f"[{hostname}] offline в рабочее время" body = "\n".join(lines) log(f"Отправляю ntfy -> топик '{hostname}'") try: ok = send_ntfy(hostname, title, body) log(f"ntfy: {'OK' if ok else 'FAIL'}") except Exception as exc: log(f"ntfy ошибка: {exc}") if __name__ == "__main__": main() PYEOF chmod 755 "$INSTALL_DIR/monitor.py" info "monitor.py записан" # ── 7. Записать heartbeat.py ────────────────────────────────────────────────── step "Записываю /opt/uptime-monitor/heartbeat.py" cat > "$INSTALL_DIR/heartbeat.py" << 'HBEOF' #!/usr/bin/env python3 """ uptime-monitor heartbeat client. Отправляет пакет на сервер каждые 10 минут в рабочее время (Пн-Пт 09:50-20:10). Сервер фиксирует присутствие; если пакет не пришёл >15 мин -> ntfy-алерт. """ import os import sys import time import subprocess import urllib.request import urllib.parse from datetime import datetime os.environ['TZ'] = 'Europe/Belgrade' time.tzset() HEARTBEAT_SERVER = "https://scrp.pipefox.xyz/hb" # Чуть шире рабочих часов чтобы не было ложных срабатываний на границе WINDOW_START_MIN = 9 * 60 + 50 # 09:50 WINDOW_END_MIN = 20 * 60 + 10 # 20:10 def in_window(): now = datetime.now() if now.weekday() >= 5: # Сб/Вс return False t = now.hour * 60 + now.minute return WINDOW_START_MIN <= t <= WINDOW_END_MIN def get_hostname(): try: return subprocess.check_output(["hostname"], text=True).strip() except Exception: return "unknown-host" def send_heartbeat(hostname): safe = urllib.parse.quote(hostname, safe='') url = f"{HEARTBEAT_SERVER}/{safe}" req = urllib.request.Request(url, method="GET") with urllib.request.urlopen(req, timeout=10) as r: return r.status == 200 if __name__ == "__main__": if not in_window(): sys.exit(0) try: send_heartbeat(get_hostname()) except Exception: sys.exit(0) # Молчим — не мешаем системе HBEOF chmod 755 "$INSTALL_DIR/heartbeat.py" info "heartbeat.py записан" # ── 8. Записать systemd service и timer для heartbeat ───────────────────────── step "Создаю heartbeat timer (каждые 10 минут)" cat > /etc/systemd/system/uptime-monitor-heartbeat.service << 'SVCEOF2' [Unit] Description=Uptime Monitor - send heartbeat [Service] Type=oneshot ExecStart=/usr/bin/python3 /opt/uptime-monitor/heartbeat.py User=root StandardOutput=journal StandardError=journal SuccessExitStatus=0 1 SVCEOF2 cat > /etc/systemd/system/uptime-monitor-heartbeat.timer << 'TMREOF' [Unit] Description=Uptime Monitor - heartbeat every 10 minutes [Timer] OnBootSec=2min OnUnitActiveSec=10min Unit=uptime-monitor-heartbeat.service [Install] WantedBy=timers.target TMREOF info "heartbeat service/timer записаны" # ── 9. Записать systemd service (boot monitor) ──────────────────────────────── step "Создаю systemd service" cat > /etc/systemd/system/uptime-monitor.service << 'SVCEOF' [Unit] Description=Uptime Monitor - offline notifier via ntfy After=network-online.target Wants=network-online.target TimeoutStartSec=120 [Service] Type=oneshot # Пауза чтобы DHCP/сеть успела подняться ExecStartPre=/bin/sleep 15 ExecStart=/usr/bin/python3 /opt/uptime-monitor/monitor.py User=root StandardOutput=journal StandardError=journal # Код 1 не считается ошибкой (скрипт вышел "ничего не отправлять") SuccessExitStatus=0 1 [Install] WantedBy=multi-user.target SVCEOF info "uptime-monitor.service записан" # ── 10. Включить все сервисы ────────────────────────────────────────────────── step "Включаю автозапуск всех сервисов" systemctl daemon-reload systemctl enable uptime-monitor.service systemctl enable --now uptime-monitor-heartbeat.timer info "Boot-monitor и heartbeat включены" # ── 11. Итог ───────────────────────────────────────────────────────────────── HOSTNAME=$(hostname) echo "" echo " Установка завершена!" echo "" echo " Машина: $HOSTNAME" echo " ntfy топик: $HOSTNAME" echo " ntfy сервер: https://ntfy.001035.xyz" echo "" echo " Режим 1 (heartbeat, реальное время):" echo " - Пакет каждые 10 мин в Пн-Пт 09:50-20:10" echo " - Если пакет не пришёл >15 мин -> алерт сразу" echo " - Когда вернулся -> уведомление 'снова онлайн'" echo "" echo " Режим 2 (boot-анализ, при включении):" echo " - Анализирует лог загрузок" echo " - Если был офлайн Пн-Пт 10:00-20:00 >10 мин -> сводка" echo "" echo " Лог: /var/log/uptime-monitor.log" echo " Статус heartbeat: systemctl status uptime-monitor-heartbeat.timer" echo " Статус boot: systemctl status uptime-monitor" echo ""