diff --git a/README.md b/README.md index 75eb583..96d6123 100644 --- a/README.md +++ b/README.md @@ -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) * Copy `config.example.json` to `config.json`, edit as necessary * 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 diff --git a/alerting/delayed.py b/alerting/delayed.py new file mode 100644 index 0000000..107f78c --- /dev/null +++ b/alerting/delayed.py @@ -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) diff --git a/assets/lego-login-alert b/assets/lego-login-alert index b816f8b..f505d46 100644 --- a/assets/lego-login-alert +++ b/assets/lego-login-alert @@ -1,2 +1,3 @@ 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 diff --git a/misc/common.py b/misc/common.py index f41c0c2..eedcbf5 100644 --- a/misc/common.py +++ b/misc/common.py @@ -1,4 +1,6 @@ import os +import tempfile from pathlib import Path CONFIG_FILE = (Path(os.path.dirname(os.path.realpath(__file__))) / ".." / "config.json").resolve() +TMP_DIR = Path(tempfile.gettempdir()) / "lego-monitoring" diff --git a/send_login_alert.py b/send_login_alert.py index b8f8898..00229f0 100644 --- a/send_login_alert.py +++ b/send_login_alert.py @@ -1,9 +1,12 @@ import asyncio +import logging import os import socket import sys +import traceback from alerting import alerts +from alerting.delayed import send_alert_delayed from alerting.enum import AlertType, Severity from misc.config import get_config @@ -14,33 +17,37 @@ async def main(): try: from_where = os.environ["SSH_CLIENT"].split()[0] except: - from_where = os.ttyname(sys.stdout.fileno()) + from_where = "localhost" is_local = True else: is_local = False + if not is_local and len(sys.argv) > 1 and sys.argv[1] == "local-only": + return + try: actual_user = os.environ["SUDO_USER"] except Exception as exc: await alerts.send_alert( alerts.Alert( 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, ) ) + logging.error(traceback.format_exc()) return if not is_local: 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 {from_where} ({rdns_result}) as {actual_user} on {check_config.hostname}" else: message = f"Login from {from_where} as {actual_user} on {check_config.hostname}" html_message = f"Login from {from_where} as {actual_user} on {check_config.hostname}" 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__": diff --git a/service.py b/service.py index 4412970..08978fb 100755 --- a/service.py +++ b/service.py @@ -2,11 +2,14 @@ import asyncio import datetime import logging +import os +import shutil import signal -from alerting import alerts +from alerting import alerts, delayed from misc import checks, cvars from misc.checkers import interval_checker, scheduled_checker +from misc.common import TMP_DIR from misc.config import get_config logging.basicConfig(level=logging.INFO) @@ -19,9 +22,28 @@ def stop_gracefully(signum, frame): 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(): + create_temp_dir() + 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()) client = await alerts.get_client() @@ -46,6 +68,7 @@ async def main(): while True: if stopping: await client.close() + shutil.rmtree(str(TMP_DIR)) raise SystemExit else: await asyncio.sleep(3) diff --git a/wrappers/login_wrapper.sh b/wrappers/login_wrapper.sh index a69899b..10887b9 100755 --- a/wrappers/login_wrapper.sh +++ b/wrappers/login_wrapper.sh @@ -4,8 +4,10 @@ mydir=$(dirname "$0") sudo "$mydir/send_login_alert.sh" 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 + exec /usr/lib/ssh/sftp-server +elif [[ -n $SSH_ORIGINAL_COMMAND ]]; then exec "$shell" -c "$SSH_ORIGINAL_COMMAND" else # no command, so interactive login shell exec "$shell" -il diff --git a/wrappers/send_local_login_alert.sh b/wrappers/send_local_login_alert.sh new file mode 100755 index 0000000..541c615 --- /dev/null +++ b/wrappers/send_local_login_alert.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +mydir=$(dirname "$0") +"$mydir/../.venv/bin/python" "$mydir/../send_login_alert.py" "local-only"