From 49ff66fb31a3583d5a6191276444982115f080e8 Mon Sep 17 00:00:00 2001 From: Yannik Schmidt Date: Sat, 15 Jul 2023 14:01:33 +0200 Subject: [PATCH 1/9] wip: finalize dispatch poller --- signal-query-dispatch.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/signal-query-dispatch.py b/signal-query-dispatch.py index 36373c8..f5f92b9 100755 --- a/signal-query-dispatch.py +++ b/signal-query-dispatch.py @@ -1,38 +1,44 @@ #!/usr/bin/python3 import argparse +import sys import flask import subprocess import os import requests from functools import wraps -signal_cli_bin = "signal-cli" +HTTP_NOT_FOUND = 404 def signal_send(user, message): - - cmd = [signal_send, "send", "-m", message, user] + '''Send message via signal''' + cmd = [signal_cli_bin, "send", "-m", message, user] p = subprocess.run(cmd) - p.wait() - def confirm_dispatch(target, uid): - response = requests.post(target, json=[{ "uid" : uid }]) + '''Confirm to server that message has been dispatched and can be removed''' + response = requests.post(target + "/confirm-dispatch", json=[{ "uid" : uid }]) + + if response.status_code not in [200, 204]: + print("Failed to confirm disptach with server for {} ({})".format(uid, response.text), file=sys.stderr) if __name__ == "__main__": + # set signal cli from env # signal_cli_bin = os.environ["SIGNAL_CLI_BIN"] parser = argparse.ArgumentParser(description='Query Atlantis Dispatch for Signal', formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('--target', required=True) + parser.add_argument('--method', default="signal") + parser.add_argument('--no-confirm', action="store_true") args = parser.parse_args() - response = requests.get(args.target) - HTTP_NOT_FOUND = 404 + + response = requests.get(args.target + "/get-dispatch?method={}".format(args.method)) # check status # if response.status_code == HTTP_NOT_FOUND: @@ -44,11 +50,16 @@ if __name__ == "__main__": user = entry["person"] message = entry["message"] + uid = entry["uid"] # send message # - signal_send(user, message) + if entry["method"] == "signal": + signal_send(user, message) + else: + print("Unsupported dispatch method {}".format(entry["method"]), sys=sys.stderr) # confirm dispatch - confirm_dispatch(uid) + if not args.no_confirm: + confirm_dispatch(args.target, uid) sys.exit(0) From ef4876bf2219308c878fa416672cd43c4eb9ffcc Mon Sep 17 00:00:00 2001 From: Yannik Schmidt Date: Sat, 15 Jul 2023 14:01:59 +0200 Subject: [PATCH 2/9] fix: query list nesting --- ldaptools.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ldaptools.py b/ldaptools.py index 3894c2c..29b6823 100644 --- a/ldaptools.py +++ b/ldaptools.py @@ -14,7 +14,7 @@ class Person: self.username = username self.name = name self.email = email - self.pohon = phone + self.phone = phone def ldap_query(search_filter, ldap_args, alt_base_dn=None): @@ -103,9 +103,9 @@ def select_targets(users, groups, ldap_args, admin_group="pki"): persons.append(get_user_by_uid(username, ldap_args)) elif groups: for group in groups: - persons.append(get_members_of_group(group, ldap_args)) + persons += get_members_of_group(group, ldap_args) else: # send to administrators # - persons.append(get_members_of_group()) + persons += get_members_of_group() return persons From 6dea0170b40879b583fdf8b9fd064504f985be8f Mon Sep 17 00:00:00 2001 From: Yannik Schmidt Date: Sat, 15 Jul 2023 14:02:18 +0200 Subject: [PATCH 3/9] feat: implement dispatch queue --- interface.py | 94 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/interface.py b/interface.py index da85b65..f5b4060 100755 --- a/interface.py +++ b/interface.py @@ -5,6 +5,8 @@ import flask import subprocess import os from functools import wraps +import datetime +import secrets import ldaptools import messagetools @@ -21,16 +23,20 @@ from sqlalchemy.sql.expression import func HOST = "icinga.atlantishq.de" SIGNAL_USER_FILE = "signal_targets.txt" app = flask.Flask("Signal Notification Gateway") +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///sqlite.db" db = SQLAlchemy(app) -class Status(db.Model): +class DispatchObject(db.Model): __tablename__ = "dispatch_queue" - service = Column(String, primary_key=True) - timestamp = Column(Integer, primary_key=True) - status = Column(String) - info_text = Column(String) + 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) def login_required(f): @wraps(f) @@ -41,6 +47,57 @@ def login_required(f): return f(*args, **kwargs) return decorated_function + +@app.route('/get-dispatch') +def get_dispatch(): + '''Retrive consolidated list of dispatched objects''' + + method = flask.request.args.get("method") + if not method: + return (500, "Missing Dispatch Target (signal|email|phone)") + + # prevent message floods # + timeout_cutoff = datetime.datetime.now() - datetime.timedelta(seconds=5) + timeout_cutoff_timestamp = timeout_cutoff.timestamp() + + lines_unfiltered = db.session.query(DispatchObject) + lines_timeout = lines_unfiltered.filter(DispatchObject.timestamp < timeout_cutoff_timestamp) + dispatch_objects = lines_timeout.filter(DispatchObject.method == method).all() + + # accumulate messages by person # + dispatch_by_person = dict() + for dobj in dispatch_objects: + if dobj.username not in dispatch_by_person: + dispatch_by_person.update({ dobj.username : dobj.message }) + else: + dispatch_by_person[dobj.username] += "\n{}".format(dobj.message) + + response = [ { "person" : tupel[0], "message" : tupel[1], "method" : method, "uid" : dobj.dispatch_secret } + for tupel in dispatch_by_person.items() ] + + 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"]) #@login_required def smart_send_to_clients(): @@ -66,20 +123,33 @@ def smart_send_to_clients(): try: message = messagetools.load_struct(struct) except messagetools.UnsupportedStruct as e: - return (408, e.response()) + return (e.response(), 408) persons = ldaptools.select_targets(users, groups, app.config["LDAP_ARGS"]) save_in_dispatch_queue(persons, message) - return (200, "OK") + return ("OK", 200) + def save_in_dispatch_queue(persons, message): - pass + + for p in persons: + + # 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() def create_app(): - - app.config["PASSWORD"] = os.environ["SIGNAL_API_PASS"] - app.config["SIGNAL_CLI_BIN"] = os.environ["SIGNAL_CLI_BIN"] + db.create_all() if __name__ == "__main__": @@ -105,7 +175,7 @@ if __name__ == "__main__": "LDAP_BIND_PW" : args.ldap_manager_password, "LDAP_BASE_DN" : args.ldap_base_dn, } - + if not any([value is None for value in ldap_args.values()]): app.config["LDAP_ARGS"] = ldap_args else: From b2dfb4a1a77636a608127e5b34551a4558920daf Mon Sep 17 00:00:00 2001 From: Yannik Schmidt Date: Sat, 15 Jul 2023 14:02:43 +0200 Subject: [PATCH 4/9] fix: add sqlite-instance/ dir --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4cf6d62..3717fe5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.swp +instance/ __pycache__/ signal_targets.txt From 63469b013c8dca653d581874a83a6408efa93025 Mon Sep 17 00:00:00 2001 From: Yannik Schmidt Date: Sat, 15 Jul 2023 14:08:10 +0200 Subject: [PATCH 5/9] fix: correctly confirm all relevant dispatches ..and not just the first --- interface.py | 5 ++++- signal-query-dispatch.py | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/interface.py b/interface.py index f5b4060..3970911 100755 --- a/interface.py +++ b/interface.py @@ -66,13 +66,16 @@ def get_dispatch(): # 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], "message" : tupel[1], "method" : method, "uid" : dobj.dispatch_secret } + response = [ { "person" : tupel[0], "message" : tupel[1], "method" : method, "uids" : dispatch_secrets } for tupel in dispatch_by_person.items() ] return flask.jsonify(response) diff --git a/signal-query-dispatch.py b/signal-query-dispatch.py index f5f92b9..422fff4 100755 --- a/signal-query-dispatch.py +++ b/signal-query-dispatch.py @@ -50,7 +50,7 @@ if __name__ == "__main__": user = entry["person"] message = entry["message"] - uid = entry["uid"] + uid_list = entry["uids"] # send message # if entry["method"] == "signal": @@ -60,6 +60,7 @@ if __name__ == "__main__": # confirm dispatch if not args.no_confirm: - confirm_dispatch(args.target, uid) + for uid in uid_list: + confirm_dispatch(args.target, uid) sys.exit(0) From a81fafb9c72744bf0f479a53fd7485612313977f Mon Sep 17 00:00:00 2001 From: Yannik Schmidt Date: Sun, 16 Jul 2023 13:21:04 +0200 Subject: [PATCH 6/9] fix: prevent double confirmations --- signal-query-dispatch.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/signal-query-dispatch.py b/signal-query-dispatch.py index 422fff4..faf71d6 100755 --- a/signal-query-dispatch.py +++ b/signal-query-dispatch.py @@ -46,6 +46,7 @@ if __name__ == "__main__": response.raise_for_status() + dispatch_confirmed = [] for entry in response.json(): user = entry["person"] @@ -61,6 +62,10 @@ if __name__ == "__main__": # confirm dispatch if not args.no_confirm: for uid in uid_list: - confirm_dispatch(args.target, uid) + if uid not in dispatch_confirmed: + confirm_dispatch(args.target, uid) + dispatch_confirmed.append(uid) + else: + continue sys.exit(0) From 749282cb92318e956df6f0102945f1b4ea5a4d21 Mon Sep 17 00:00:00 2001 From: Yannik Schmidt Date: Sun, 16 Jul 2023 13:21:29 +0200 Subject: [PATCH 7/9] fix: ldap group based selection --- ldaptools.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/ldaptools.py b/ldaptools.py index 29b6823..ba07765 100644 --- a/ldaptools.py +++ b/ldaptools.py @@ -16,6 +16,12 @@ class Person: 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"] @@ -48,11 +54,14 @@ def _person_from_search_result(cn, entry): return Person(cn, username, name, email, phone) -def get_user_by_uid(username, ldap_args): +def get_user_by_uid(username, ldap_args, uid_is_cn=False): if not username: 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) @@ -68,8 +77,11 @@ def get_members_of_group(group, ldap_args): if not group: return [] - search_filter = "(&(objectClass=groupOfNames)(cn={group_name})".format(group) - results = ldap_query(search_filter, ldap_args) + search_filter = "(&(objectClass=groupOfNames)(cn={group_name}))".format(group_name=group) + + # TODO wtf is this btw?? + 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 [] @@ -80,15 +92,12 @@ def get_members_of_group(group, ldap_args): persons = [] for member in members: - user_dn = member.decode("utf-8") - user_filter = "(objectClass=inetOrgPerson)" - results = ldap_query(user_filter, ldap_args, alt_base_dn=user_dn) + user_cn = member.decode("utf-8") + person_obj = get_user_by_uid(user_cn, ldap_args, uid_is_cn=True) - if not results: + if not person_obj: continue - cn, entry = results[0] - person_obj = _person_from_search_result(cn, entry) persons.append(person_obj) return persons @@ -106,6 +115,6 @@ def select_targets(users, groups, ldap_args, admin_group="pki"): persons += get_members_of_group(group, ldap_args) else: # send to administrators # - persons += get_members_of_group() + persons += get_members_of_group(admin_group, ldap_args) - return persons + return set(persons) From cddf6cd7db5a0cb2e70b727de6cef2e87f7fb253 Mon Sep 17 00:00:00 2001 From: Yannik Schmidt Date: Sun, 16 Jul 2023 13:21:42 +0200 Subject: [PATCH 8/9] fix: cn/uid encoding --- interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface.py b/interface.py index 3970911..7a18c57 100755 --- a/interface.py +++ b/interface.py @@ -75,7 +75,7 @@ def get_dispatch(): dispatch_by_person[dobj.username] += "\n{}".format(dobj.message) dispatch_secrets.append(dobj.dispatch_secret) - response = [ { "person" : tupel[0], "message" : tupel[1], "method" : method, "uids" : dispatch_secrets } + response = [ { "person" : str(tupel[0]), "message" : tupel[1], "method" : method, "uids" : dispatch_secrets } for tupel in dispatch_by_person.items() ] return flask.jsonify(response) From 5e0003954fb4bfc01cd391cf2ad1f1ef90635b72 Mon Sep 17 00:00:00 2001 From: Yannik Schmidt Date: Sun, 16 Jul 2023 22:28:55 +0200 Subject: [PATCH 9/9] change: renable authentication --- interface.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/interface.py b/interface.py index 7a18c57..21d89ba 100755 --- a/interface.py +++ b/interface.py @@ -2,6 +2,7 @@ import argparse import flask +import sys import subprocess import os from functools import wraps @@ -42,7 +43,9 @@ def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): auth = flask.request.authorization - if not auth or not auth.password == app.config["PASSWORD"]: + print(auth.password) + print(type(auth.password)) + if not auth or not auth.lower() == app.config["PASSWORD"]: return (flask.jsonify({ 'message' : 'Authentication required' }), 401) return f(*args, **kwargs) return decorated_function @@ -102,7 +105,7 @@ def confirm_dispatch(): @app.route('/smart-send', methods=["POST"]) -#@login_required +@login_required def smart_send_to_clients(): '''Send to clients based on querying the LDAP requests MAY include: @@ -153,6 +156,10 @@ def save_in_dispatch_queue(persons, message): def create_app(): db.create_all() + app.config["PASSWORD"] = os.environ.get("SIGNAL_GATEWAY_PASS") + if not app.config["PASSWORD"]: + print("Missing ENV Variable SIGNAL_GATEWAY_PASS", file=sys.stderr) + sys.exit(1) if __name__ == "__main__":