diff --git a/.gitignore b/.gitignore index 4cf6d62..3717fe5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.swp +instance/ __pycache__/ signal_targets.txt diff --git a/interface.py b/interface.py index bccfdb4..e89333b 100755 --- a/interface.py +++ b/interface.py @@ -2,9 +2,12 @@ import argparse import flask +import sys import subprocess import os from functools import wraps +import datetime +import secrets import ldaptools import messagetools @@ -21,28 +24,88 @@ 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) 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 + +@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() + 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" : str(tupel[0]), "message" : tupel[1], "method" : method, "uids" : dispatch_secrets } + 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 +@login_required def smart_send_to_clients(): '''Send to clients based on querying the LDAP requests MAY include: @@ -66,20 +129,37 @@ 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() + 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) ldap_args = { "LDAP_SERVER" : os.environ["LDAP_SERVER"], @@ -113,7 +193,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: diff --git a/ldaptools.py b/ldaptools.py index 3894c2c..ba07765 100644 --- a/ldaptools.py +++ b/ldaptools.py @@ -14,7 +14,13 @@ class Person: self.username = username self.name = name self.email = email - self.pohon = phone + 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): @@ -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 @@ -103,9 +112,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(admin_group, ldap_args) - return persons + return set(persons) diff --git a/signal-query-dispatch.py b/signal-query-dispatch.py index 36373c8..faf71d6 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: @@ -40,15 +46,26 @@ if __name__ == "__main__": response.raise_for_status() + dispatch_confirmed = [] for entry in response.json(): user = entry["person"] message = entry["message"] + uid_list = entry["uids"] # 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: + for uid in uid_list: + if uid not in dispatch_confirmed: + confirm_dispatch(args.target, uid) + dispatch_confirmed.append(uid) + else: + continue sys.exit(0)