delayed login alerts to prevent spam with mass logins

This commit is contained in:
Alex 2025-01-07 17:42:00 +03:00
parent 0e177210f6
commit b1b06b2e51
8 changed files with 100 additions and 7 deletions

View file

@ -12,6 +12,7 @@ DISCLAIMER: This repository does not have anything to do with the LEGO Group. "l
* Invite the bot account to the room (you have to accept the invite manually) * Invite the bot account to the room (you have to accept the invite manually)
* Copy `config.example.json` to `config.json`, edit as necessary * Copy `config.example.json` to `config.json`, edit as necessary
* Run `alerting/login.py` once to login into Matrix * Run `alerting/login.py` once to login into Matrix
* (optional) Create an `alerting` group and give `config.json`'s ownership and read rights to it, to allow sending alerts from less-privileged users
### Setting up login alerts ### Setting up login alerts

53
alerting/delayed.py Normal file
View file

@ -0,0 +1,53 @@
import asyncio
import json
import logging
import os
import signal
import tempfile
from dataclasses import asdict
from alerting.alerts import Alert, send_alert
from misc.common import TMP_DIR
handler_lock = asyncio.Lock()
async def handler():
"""
Is executed when SIGUSR1 is received.
Checks for new alerts, sends them in 10 seconds
"""
alert_counts: list[list] = []
await asyncio.sleep(10)
async with handler_lock:
alert_files = [f for f in os.listdir(str(TMP_DIR)) if f.startswith("alert-")]
for fname in alert_files:
fpath = str(TMP_DIR / fname)
with open(fpath) as f:
alert = Alert(**json.load(f))
for pair in alert_counts:
if alert == pair[0]:
pair[1] += 1
break
else:
alert_counts.append([alert, 1])
os.unlink(fpath)
for pair in alert_counts:
if pair[1] > 1:
pair[0].message += f" ({pair[1]} times)"
pair[0].html_message += f" ({pair[1]} times)"
logging.info(f"Sending delayed alert of type {pair[0].alert_type}")
await send_alert(pair[0])
def send_alert_delayed(alert: Alert) -> None:
"""
Put alert into a temporary file to be sent later.
Intended as an IPC mechanism
"""
alert_json = json.dumps(asdict(alert))
with tempfile.NamedTemporaryFile(prefix="alert-", dir=str(TMP_DIR), delete=False) as af:
af.write(alert_json.encode())
with open(str(TMP_DIR / "pid")) as pf:
pid = int(pf.read().strip())
os.kill(pid, signal.SIGUSR1)

View file

@ -1,2 +1,3 @@
Defaults env_keep += "SSH_CLIENT" Defaults env_keep += "SSH_CLIENT"
ALL ALL=(ALL:ALL) NOPASSWD: /opt/lego-monitoring/wrappers/send_login_alert.sh ALL ALL=(ALL:ALL) NOPASSWD:SETENV: /opt/lego-monitoring/wrappers/send_login_alert.sh
ALL ALL=(ALL:ALL) NOPASSWD:SETENV: /opt/lego-monitoring/wrappers/send_local_login_alert.sh

View file

@ -1,4 +1,6 @@
import os import os
import tempfile
from pathlib import Path from pathlib import Path
CONFIG_FILE = (Path(os.path.dirname(os.path.realpath(__file__))) / ".." / "config.json").resolve() CONFIG_FILE = (Path(os.path.dirname(os.path.realpath(__file__))) / ".." / "config.json").resolve()
TMP_DIR = Path(tempfile.gettempdir()) / "lego-monitoring"

View file

@ -1,9 +1,12 @@
import asyncio import asyncio
import logging
import os import os
import socket import socket
import sys import sys
import traceback
from alerting import alerts from alerting import alerts
from alerting.delayed import send_alert_delayed
from alerting.enum import AlertType, Severity from alerting.enum import AlertType, Severity
from misc.config import get_config from misc.config import get_config
@ -14,33 +17,37 @@ async def main():
try: try:
from_where = os.environ["SSH_CLIENT"].split()[0] from_where = os.environ["SSH_CLIENT"].split()[0]
except: except:
from_where = os.ttyname(sys.stdout.fileno()) from_where = "localhost"
is_local = True is_local = True
else: else:
is_local = False is_local = False
if not is_local and len(sys.argv) > 1 and sys.argv[1] == "local-only":
return
try: try:
actual_user = os.environ["SUDO_USER"] actual_user = os.environ["SUDO_USER"]
except Exception as exc: except Exception as exc:
await alerts.send_alert( await alerts.send_alert(
alerts.Alert( alerts.Alert(
alert_type=AlertType.ERROR, alert_type=AlertType.ERROR,
message=f"Failed to determine username for login from {from_where}, see logs", message=f"Failed to determine username for login from {from_where}: {repr(exc)}, see logs",
severity=Severity.CRITICAL, severity=Severity.CRITICAL,
) )
) )
logging.error(traceback.format_exc())
return return
if not is_local: if not is_local:
rdns_result = socket.getnameinfo((from_where, 0), 0)[0] rdns_result = socket.getnameinfo((from_where, 0), 0)[0]
message = f"Login from {from_where} as {actual_user} on `{check_config.hostname}`" message = f"Login from {from_where} as {actual_user} on {check_config.hostname}"
html_message = f"Login from <code>{from_where}</code> ({rdns_result}) as {actual_user} on <code>{check_config.hostname}</code>" html_message = f"Login from <code>{from_where}</code> ({rdns_result}) as {actual_user} on <code>{check_config.hostname}</code>"
else: else:
message = f"Login from {from_where} as {actual_user} on {check_config.hostname}" message = f"Login from {from_where} as {actual_user} on {check_config.hostname}"
html_message = f"Login from {from_where} as {actual_user} on <code>{check_config.hostname}</code>" html_message = f"Login from {from_where} as {actual_user} on <code>{check_config.hostname}</code>"
alert = alerts.Alert(alert_type=AlertType.LOGIN, message=message, severity=Severity.INFO, html_message=html_message) alert = alerts.Alert(alert_type=AlertType.LOGIN, message=message, severity=Severity.INFO, html_message=html_message)
await alerts.send_alert(alert) send_alert_delayed(alert)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -2,11 +2,14 @@
import asyncio import asyncio
import datetime import datetime
import logging import logging
import os
import shutil
import signal import signal
from alerting import alerts from alerting import alerts, delayed
from misc import checks, cvars from misc import checks, cvars
from misc.checkers import interval_checker, scheduled_checker from misc.checkers import interval_checker, scheduled_checker
from misc.common import TMP_DIR
from misc.config import get_config from misc.config import get_config
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -19,9 +22,28 @@ def stop_gracefully(signum, frame):
stopping = True stopping = True
def create_temp_dir() -> None:
try:
os.mkdir(str(TMP_DIR))
except FileExistsError:
pass
os.chmod(str(TMP_DIR), 0o770)
try:
shutil.chown(str(TMP_DIR), user="root", group="alerting")
except LookupError:
shutil.chown(str(TMP_DIR), user="root", group="root")
with open(str(TMP_DIR / "pid"), "w") as pf:
pf.write(str(os.getpid()))
async def main(): async def main():
create_temp_dir()
signal.signal(signal.SIGTERM, stop_gracefully) signal.signal(signal.SIGTERM, stop_gracefully)
loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGUSR1, lambda: asyncio.create_task(delayed.handler()))
cvars.config.set(get_config()) cvars.config.set(get_config())
client = await alerts.get_client() client = await alerts.get_client()
@ -46,6 +68,7 @@ async def main():
while True: while True:
if stopping: if stopping:
await client.close() await client.close()
shutil.rmtree(str(TMP_DIR))
raise SystemExit raise SystemExit
else: else:
await asyncio.sleep(3) await asyncio.sleep(3)

View file

@ -4,8 +4,10 @@ mydir=$(dirname "$0")
sudo "$mydir/send_login_alert.sh" sudo "$mydir/send_login_alert.sh"
shell=$(getent passwd $LOGNAME | cut -d: -f7) shell=$(getent passwd $LOGNAME | cut -d: -f7)
if [[ -n $SSH_ORIGINAL_COMMAND ]] # command given, so run it if [ "$SSH_ORIGINAL_COMMAND" = "internal-sftp" ] # command given, so run it
then then
exec /usr/lib/ssh/sftp-server
elif [[ -n $SSH_ORIGINAL_COMMAND ]]; then
exec "$shell" -c "$SSH_ORIGINAL_COMMAND" exec "$shell" -c "$SSH_ORIGINAL_COMMAND"
else # no command, so interactive login shell else # no command, so interactive login shell
exec "$shell" -il exec "$shell" -il

View file

@ -0,0 +1,4 @@
#!/bin/bash
mydir=$(dirname "$0")
"$mydir/../.venv/bin/python" "$mydir/../send_login_alert.py" "local-only"