Add audit logging with weekly rotation
This commit is contained in:
@@ -22,6 +22,12 @@ alerts:
|
|||||||
smart_cooldown_sec: 21600
|
smart_cooldown_sec: 21600
|
||||||
smart_temp_warn: 50
|
smart_temp_warn: 50
|
||||||
|
|
||||||
|
audit:
|
||||||
|
enabled: true
|
||||||
|
path: "/var/server-bot/audit.log"
|
||||||
|
rotate_when: "W0"
|
||||||
|
backup_count: 8
|
||||||
|
|
||||||
arcane:
|
arcane:
|
||||||
base_url: "http://localhost:3552"
|
base_url: "http://localhost:3552"
|
||||||
api_key: "arc_..."
|
api_key: "arc_..."
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from services.npmplus import fetch_certificates, format_certificates
|
|||||||
import state
|
import state
|
||||||
from state import UPDATES_CACHE, REBOOT_PENDING
|
from state import UPDATES_CACHE, REBOOT_PENDING
|
||||||
from services.metrics import summarize
|
from services.metrics import summarize
|
||||||
|
from services.audit import read_audit_tail
|
||||||
|
|
||||||
|
|
||||||
@dp.message(F.text == "💽 Disks")
|
@dp.message(F.text == "💽 Disks")
|
||||||
@@ -68,6 +69,17 @@ async def metrics(msg: Message):
|
|||||||
await msg.answer(summarize(state.METRICS_STORE, minutes=15), reply_markup=system_kb)
|
await msg.answer(summarize(state.METRICS_STORE, minutes=15), reply_markup=system_kb)
|
||||||
|
|
||||||
|
|
||||||
|
@dp.message(F.text == "🧾 Audit")
|
||||||
|
async def audit_log(msg: Message):
|
||||||
|
if not is_admin_msg(msg):
|
||||||
|
return
|
||||||
|
text = read_audit_tail(cfg, limit=200)
|
||||||
|
if text.startswith("⚠️") or text.startswith("ℹ️"):
|
||||||
|
await msg.answer(text, reply_markup=system_kb)
|
||||||
|
else:
|
||||||
|
await msg.answer(text, reply_markup=system_kb, parse_mode="Markdown")
|
||||||
|
|
||||||
|
|
||||||
@dp.message(F.text == "🔒 SSL")
|
@dp.message(F.text == "🔒 SSL")
|
||||||
async def ssl_certs(msg: Message):
|
async def ssl_certs(msg: Message):
|
||||||
if not is_admin_msg(msg):
|
if not is_admin_msg(msg):
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ artifacts_kb = ReplyKeyboardMarkup(
|
|||||||
|
|
||||||
system_kb = ReplyKeyboardMarkup(
|
system_kb = ReplyKeyboardMarkup(
|
||||||
keyboard=[
|
keyboard=[
|
||||||
[KeyboardButton(text="💽 Disks"), KeyboardButton(text="🔐 Security")],
|
[KeyboardButton(text="💽 Disks"), KeyboardButton(text="🔐 Security"), KeyboardButton(text="🧾 Audit")],
|
||||||
[KeyboardButton(text="🌐 URLs"), KeyboardButton(text="📈 Metrics"), KeyboardButton(text="🔒 SSL")],
|
[KeyboardButton(text="🌐 URLs"), KeyboardButton(text="📈 Metrics"), KeyboardButton(text="🔒 SSL")],
|
||||||
[KeyboardButton(text="📦 Updates"), KeyboardButton(text="⬆️ Upgrade")],
|
[KeyboardButton(text="📦 Updates"), KeyboardButton(text="⬆️ Upgrade")],
|
||||||
[KeyboardButton(text="🧱 Hardware"), KeyboardButton(text="🔄 Reboot"), KeyboardButton(text="⬅️ Назад")],
|
[KeyboardButton(text="🧱 Hardware"), KeyboardButton(text="🔄 Reboot"), KeyboardButton(text="⬅️ Назад")],
|
||||||
|
|||||||
2
main.py
2
main.py
@@ -8,6 +8,7 @@ from services.alerts import monitor_resources, monitor_smart
|
|||||||
from services.metrics import MetricsStore, start_sampler
|
from services.metrics import MetricsStore, start_sampler
|
||||||
from services.queue import worker as queue_worker
|
from services.queue import worker as queue_worker
|
||||||
from services.notify import notify
|
from services.notify import notify
|
||||||
|
from services.audit import AuditMiddleware
|
||||||
import state
|
import state
|
||||||
import handlers.menu
|
import handlers.menu
|
||||||
import handlers.status
|
import handlers.status
|
||||||
@@ -29,6 +30,7 @@ async def notify_start():
|
|||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
dp.update.outer_middleware(AuditMiddleware(cfg))
|
||||||
state.DOCKER_MAP.clear()
|
state.DOCKER_MAP.clear()
|
||||||
state.DOCKER_MAP.update(await discover_containers(cfg))
|
state.DOCKER_MAP.update(await discover_containers(cfg))
|
||||||
if cfg.get("docker", {}).get("watchdog", True):
|
if cfg.get("docker", {}).get("watchdog", True):
|
||||||
|
|||||||
116
services/audit.py
Normal file
116
services/audit.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
|
||||||
|
|
||||||
|
def _get_audit_path(cfg: dict[str, Any]) -> str:
|
||||||
|
return cfg.get("audit", {}).get("path", "/var/server-bot/audit.log")
|
||||||
|
|
||||||
|
|
||||||
|
def get_audit_logger(cfg: dict[str, Any]) -> logging.Logger:
|
||||||
|
logger = logging.getLogger("audit")
|
||||||
|
if logger.handlers:
|
||||||
|
return logger
|
||||||
|
|
||||||
|
path = _get_audit_path(cfg)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
|
||||||
|
rotate_when = cfg.get("audit", {}).get("rotate_when", "W0")
|
||||||
|
backup_count = int(cfg.get("audit", {}).get("backup_count", 8))
|
||||||
|
handler = TimedRotatingFileHandler(
|
||||||
|
path,
|
||||||
|
when=rotate_when,
|
||||||
|
interval=1,
|
||||||
|
backupCount=backup_count,
|
||||||
|
encoding="utf-8",
|
||||||
|
utc=True,
|
||||||
|
)
|
||||||
|
formatter = logging.Formatter("%(asctime)s\t%(message)s")
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.propagate = False
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def _user_label(message: Message | CallbackQuery) -> str:
|
||||||
|
user = message.from_user
|
||||||
|
if not user:
|
||||||
|
return "unknown"
|
||||||
|
parts = [user.username, user.first_name, user.last_name]
|
||||||
|
label = " ".join(p for p in parts if p)
|
||||||
|
return label or str(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_action(text: str, limit: int = 200) -> str:
|
||||||
|
cleaned = " ".join(text.split())
|
||||||
|
if len(cleaned) > limit:
|
||||||
|
return cleaned[:limit] + "…"
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class AuditMiddleware(BaseMiddleware):
|
||||||
|
def __init__(self, cfg: dict[str, Any]) -> None:
|
||||||
|
self.cfg = cfg
|
||||||
|
self.logger = get_audit_logger(cfg)
|
||||||
|
|
||||||
|
async def __call__(self, handler, event, data):
|
||||||
|
if not self.cfg.get("audit", {}).get("enabled", True):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
action: Optional[str] = None
|
||||||
|
if isinstance(event, Message):
|
||||||
|
if event.text:
|
||||||
|
action = _normalize_action(event.text)
|
||||||
|
elif event.caption:
|
||||||
|
action = _normalize_action(event.caption)
|
||||||
|
else:
|
||||||
|
action = f"<{event.content_type}>"
|
||||||
|
elif isinstance(event, CallbackQuery):
|
||||||
|
if event.data:
|
||||||
|
action = _normalize_action(f"callback:{event.data}")
|
||||||
|
else:
|
||||||
|
action = "callback:<empty>"
|
||||||
|
|
||||||
|
if action:
|
||||||
|
chat_id = event.chat.id if isinstance(event, Message) else event.message.chat.id
|
||||||
|
user_id = event.from_user.id if event.from_user else "unknown"
|
||||||
|
label = _user_label(event)
|
||||||
|
self.logger.info(
|
||||||
|
"user_id=%s\tuser=%s\tchat_id=%s\taction=%s",
|
||||||
|
user_id,
|
||||||
|
label,
|
||||||
|
chat_id,
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
def read_audit_tail(cfg: dict[str, Any], limit: int = 200) -> str:
|
||||||
|
path = _get_audit_path(cfg)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return "⚠️ Audit log not found"
|
||||||
|
|
||||||
|
lines = deque(maxlen=limit)
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
for line in f:
|
||||||
|
lines.append(line.rstrip())
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return "ℹ️ Audit log is empty"
|
||||||
|
|
||||||
|
header = f"🧾 Audit log ({datetime.now(timezone.utc):%Y-%m-%d %H:%M UTC})"
|
||||||
|
body = "\n".join(lines)
|
||||||
|
max_body = 3500
|
||||||
|
if len(body) > max_body:
|
||||||
|
body = body[-max_body:]
|
||||||
|
body = "...(truncated)\n" + body
|
||||||
|
return f"{header}\n```\n{body}\n```"
|
||||||
Reference in New Issue
Block a user