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"