refacto: setup new dispatch

This commit is contained in:
2024-02-16 13:57:16 +01:00
parent df93ee47ab
commit 8cb412c74b
7 changed files with 134 additions and 3 deletions

25
server/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM python:3.9-slim-buster
RUN apt update
RUN apt install python3-pip -y
RUN apt install libsasl2-dev python-dev libldap2-dev libssl-dev -y
RUN python3 -m pip install --upgrade pip
RUN apt install curl -y
RUN apt autoremove -y
RUN apt clean
WORKDIR /app
RUN python3 -m pip install waitress
COPY req.txt .
RUN python3 -m pip install --no-cache-dir -r req.txt
# precreate database directory for mount (will otherwise be created at before_first_request)
COPY ./ .
RUN mkdir /app/instance/
EXPOSE 5000/tcp
ENTRYPOINT ["waitress-serve"]
CMD ["--host", "0.0.0.0", "--port", "5000", "--call", "app:createApp" ]

8
server/app.py Normal file
View File

@@ -0,0 +1,8 @@
import interface as main
import os
import sys
def createApp(envivorment=None, start_response=None):
with main.app.app_context():
main.create_app()
return main.app

232
server/interface.py Executable file
View File

@@ -0,0 +1,232 @@
#!/usr/bin/python3
import argparse
import flask
import sys
import subprocess
import os
import datetime
import secrets
import ldaptools
import messagetools
from sqlalchemy import Column, Integer, String, Boolean, or_, and_
from sqlalchemy.orm import sessionmaker
from sqlalchemy.exc import IntegrityError
from sqlalchemy.sql import func
import sqlalchemy
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.sql.expression import func
HOST = "icinga.atlantishq.de"
app = flask.Flask("Signal Notification Gateway")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///sqlite.db"
db = SQLAlchemy(app)
class DispatchObject(db.Model):
__tablename__ = "dispatch_queue"
username = Column(String, primary_key=True)
timestamp = Column(Integer, primary_key=True)
phone = Column(String)
email = Column(String)
message = Column(String, primary_key=True)
method = Column(String)
dispatch_secret = Column(String)
dispatch_error = Column(String)
@app.route('/get-dispatch-status')
def get_dispatch_status():
'''Retrive the status of a specific dispatch by it's secret'''
secret = flask.request.args.get("secret")
do = db.session.query(DispatchObject).filter(DispatchObject.dispatch_secret == secret).first()
if not do:
return ("Not in Queue", 200)
else:
return ("Waiting for dispatch", 200)
@app.route('/get-dispatch')
def get_dispatch():
'''Retrive consolidated list of dispatched objects'''
method = flask.request.args.get("method")
timeout = flask.request.args.get("timeout") or 5 # timeout in seconds
if not method:
return (500, "Missing Dispatch Target (signal|email|phone|ntfy|all)")
# prevent message floods #
timeout_cutoff = datetime.datetime.now() - datetime.timedelta(seconds=timeout)
timeout_cutoff_timestamp = timeout_cutoff.timestamp()
lines_unfiltered = db.session.query(DispatchObject)
lines_timeout = lines_unfiltered.filter(DispatchObject.timestamp < timeout_cutoff_timestamp)
if method != "all":
dispatch_objects = lines_timeout.filter(DispatchObject.method == method).all()
else:
dispatch_objects = lines_timeout.all()
# accumulate messages by person #
dispatch_by_person = dict()
dispatch_secrets = []
for dobj in dispatch_objects:
if dobj.username not in dispatch_by_person:
dispatch_by_person.update({ dobj.username : dobj.message })
dispatch_secrets.append(dobj.dispatch_secret)
else:
dispatch_by_person[dobj.username] += "\n{}".format(dobj.message)
dispatch_secrets.append(dobj.dispatch_secret)
response = [ { "person" : tupel[0].decode("utf-8"),
"message" : tupel[1],
"method" : method,
"uids" : dispatch_secrets
} for tupel in dispatch_by_person.items() ]
# add phone numbers and emails #
for obj in response:
for person in dispatch_objects:
if obj["person"] == person.username.decode("utf-8"):
if person.email:
obj.update({ "email" : person.email.decode("utf-8") })
if person.phone:
obj.update({ "phone" : person.phone.decode("utf-8") })
return flask.jsonify(response)
@app.route('/confirm-dispatch', methods=["POST"])
def confirm_dispatch():
'''Confirm that a message has been dispatched by replying with its dispatch secret/uid'''
confirms = flask.request.json
for c in confirms:
uid = c["uid"]
dpo = db.session.query(DispatchObject).filter(DispatchObject.dispatch_secret == uid).first()
if not dpo:
return ("No pending dispatch for this UID/Secret", 404)
db.session.delete(dpo)
db.session.commit()
return ("", 204)
@app.route('/smart-send', methods=["POST"])
def smart_send_to_clients():
'''Send to clients based on querying the LDAP
requests MAY include:
- list of usernames under key "users"
- list of groups under key "groups"
- neither of the above to automatically target the configured administrators group"
retuest MUST include:
- message as STRING in field "msg"
OR
- supported struct of type "ICINGA|ZABBIX|GENERIC" (see docs) in field "data"
'''
instructions = flask.request.json
users = instructions.get("users")
groups = instructions.get("groups")
message = instructions.get("msg")
struct = instructions.get("data")
if struct:
try:
message = messagetools.load_struct(struct)
except messagetools.UnsupportedStruct as e:
print(str(e), file=sys.stderr)
return (e.response(), 408)
persons = ldaptools.select_targets(users, groups, app.config["LDAP_ARGS"])
dispatch_secrets = save_in_dispatch_queue(persons, message)
return flask.jsonify(dispatch_secrets)
def save_in_dispatch_queue(persons, message):
dispatch_secrets = []
for p in persons:
if not p:
continue
# this secret will be needed to confirm the message as dispatched #
dispatch_secret = secrets.token_urlsafe(32)
obj = DispatchObject(username=p.username,
phone=p.phone,
email=p.email,
method="signal",
timestamp=datetime.datetime.now().timestamp(),
dispatch_secret=dispatch_secret,
message=message)
db.session.merge(obj)
db.session.commit()
dispatch_secrets.append(dispatch_secret)
return dispatch_secrets
def create_app():
db.create_all()
if not app.config.get("LDAP_NO_READ_ENV"):
ldap_args = {
"LDAP_SERVER" : os.environ["LDAP_SERVER"],
"LDAP_BIND_DN" : os.environ["LDAP_BIND_DN"],
"LDAP_BIND_PW" : os.environ["LDAP_BIND_PW"],
"LDAP_BASE_DN" : os.environ["LDAP_BASE_DN"]
}
app.config["LDAP_ARGS"] = ldap_args
print("Setting LDAP_ARGS...")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Simple Telegram Notification Interface',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--interface', default="localhost", help='Interface on which to listen')
parser.add_argument('--port', default="5000", help='Port on which to listen')
parser.add_argument("--signal-cli-bin", default=None, type=str,
help="Path to signal-cli binary if no in $PATH")
parser.add_argument('--ldap-server')
parser.add_argument('--ldap-base-dn')
parser.add_argument('--ldap-manager-dn')
parser.add_argument('--ldap-manager-password')
args = parser.parse_args()
# define ldap args #
ldap_args = {
"LDAP_SERVER" : args.ldap_server,
"LDAP_BIND_DN" : args.ldap_manager_dn,
"LDAP_BIND_PW" : args.ldap_manager_password,
"LDAP_BASE_DN" : args.ldap_base_dn,
}
app.config["LDAP_NO_READ_ENV"] = True
if not any([value is None for value in ldap_args.values()]):
app.config["LDAP_ARGS"] = ldap_args
else:
app.config["LDAP_ARGS"] = None
with app.app_context():
create_app()
app.run(host=args.interface, port=args.port, debug=True)

119
server/ldaptools.py Normal file
View File

@@ -0,0 +1,119 @@
import ldap
import sys
class Person:
def __init__(self, cn, username, name, email, phone):
self.cn = cn
self.username = username
self.name = name
self.email = email
self.phone = phone
def __eq__(self, other):
return other.cn == self.cn
def __hash__(self):
return hash(self.cn)
def ldap_query(search_filter, ldap_args, alt_base_dn=None):
ldap_server = ldap_args["LDAP_SERVER"]
manager_dn = ldap_args["LDAP_BIND_DN"]
manager_pw = ldap_args["LDAP_BIND_PW"]
base_dn = ldap_args["LDAP_BASE_DN"]
# for example a specific user dn #
if alt_base_dn:
base_dn = alt_base_dn
# estabilish connection
conn = ldap.initialize(ldap_server)
conn.simple_bind_s(manager_dn, manager_pw)
# search in scope #
search_scope = ldap.SCOPE_SUBTREE
search_results = conn.search_s(base_dn, search_scope, search_filter)
# unbind from connection and return #
conn.unbind_s()
return search_results
def _person_from_search_result(cn, entry):
username = entry.get("uid", [None])[0]
name = entry.get("firstName", [None])[0]
email = entry.get("email", [None])[0]
phone = entry.get("telephoneNumber", [None])[0]
return Person(cn, username, name, email, phone)
def get_user_by_uid(username, ldap_args, uid_is_cn=False):
if not username:
print("WARNING: get_user_by_uid called with empty username", file=sys.stderr)
return None
if uid_is_cn:
username = username.split(",")[0].split("=")[1]
search_filter = "(&(objectClass=inetOrgPerson)(uid={username}))".format(username=username)
results = ldap_query(search_filter, ldap_args)
if not results or len(results) < 1:
print("WARNING: {} not found, no dispatch saved".format(username), file=sys.stderr)
return None
cn, p = results[0]
return _person_from_search_result(cn, p)
def get_members_of_group(group, ldap_args):
if not group:
return []
search_filter = "(&(objectClass=groupOfNames)(cn={group_name}))".format(group_name=group)
# TODO wtf is this btw??
base_dn = ldap_args["LDAP_BASE_DN"]
groups_dn = ",".join([ s.replace("People","groups") for s in base_dn.split(",")])
results = ldap_query(search_filter, ldap_args, alt_base_dn=groups_dn)
if not results:
return []
group_dn, entry = results[0]
members = entry.get("member", [])
persons = []
for member in members:
user_cn = member.decode("utf-8")
person_obj = get_user_by_uid(user_cn, ldap_args, uid_is_cn=True)
if not person_obj:
continue
persons.append(person_obj)
return persons
def select_targets(users, groups, ldap_args, admin_group="pki"):
'''Returns a list of persons to send notifications to'''
persons = []
# FIXME better handling of empty owner/groups
if users and not any([ not s for s in users]):
for username in users:
persons.append(get_user_by_uid(username, ldap_args))
elif groups and not any([ not s for s in groups ]):
for group in groups:
persons += get_members_of_group(group, ldap_args)
else:
# send to administrators #
persons += get_members_of_group(admin_group, ldap_args)
return set(persons)

47
server/messagetools.py Normal file
View File

@@ -0,0 +1,47 @@
class UnsupportedStruct(Exception):
def __init__(self, struct):
self.message = "{} is invalid struct and not a message".format(str(struct))
super().__init__(self.message)
def make_icinga_message(struct):
first_line = "{state} - {service} ({host})".format(state=struct.get("service_state"),
service=struct.get("service_name"), host=struct.get("service_host"))
second_line = struct.get("service_output") or ""
#third_line = "Direkt-Link: {link}".format(link=struct.get("icingaweb_url"))
if not struct.get("owners") and not struct.get("owner-groups"):
fourth_line = "Notification to: admins (default)"
else:
owners = struct.get("owners") or []
groups = struct.get("owner-groups") or []
groups_strings = [ g + "-group" for g in groups ]
fourth_line = "Notification to: " + ", ".join(owners) + " " + ", ".join(groups_strings)
if struct.get("comment"):
fith_line = "Extra Comment: \n{}".format(struct.get("comment"))
return "\n".join([first_line, second_line, fourth_line, fith_line])
else:
return "\n".join([first_line, second_line, fourth_line])
def make_generic_message(struct):
msg = struct.get("message") or struct.get("msg")
return msg
def load_struct(struct):
if type(struct) == str:
return struct
elif not struct.get("type"):
raise UnsupportedStruct(struct)
if struct.get("type") == "icinga":
return make_icinga_message(struct)
elif struct.get("type") == "generic":
return make_generic_message(struct)
else:
raise UnsupportedStruct(struct)

5
server/req.txt Normal file
View File

@@ -0,0 +1,5 @@
python-ldap
flask
flask-sqlalchemy
sqlalchemy
python-ldap