Merge branch 'ldap-ng-dev' of github.com:FAUSheppy/signal-http-gateway into ldap-ng-dev

This commit is contained in:
2023-07-17 00:23:52 +02:00
4 changed files with 144 additions and 37 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
*.swp *.swp
instance/
__pycache__/ __pycache__/
signal_targets.txt signal_targets.txt

View File

@@ -2,9 +2,12 @@
import argparse import argparse
import flask import flask
import sys
import subprocess import subprocess
import os import os
from functools import wraps from functools import wraps
import datetime
import secrets
import ldaptools import ldaptools
import messagetools import messagetools
@@ -21,28 +24,88 @@ from sqlalchemy.sql.expression import func
HOST = "icinga.atlantishq.de" HOST = "icinga.atlantishq.de"
SIGNAL_USER_FILE = "signal_targets.txt" SIGNAL_USER_FILE = "signal_targets.txt"
app = flask.Flask("Signal Notification Gateway") app = flask.Flask("Signal Notification Gateway")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///sqlite.db"
db = SQLAlchemy(app) db = SQLAlchemy(app)
class Status(db.Model): class DispatchObject(db.Model):
__tablename__ = "dispatch_queue" __tablename__ = "dispatch_queue"
service = Column(String, primary_key=True) username = Column(String, primary_key=True)
timestamp = Column(Integer, primary_key=True) timestamp = Column(Integer, primary_key=True)
status = Column(String) phone = Column(String)
info_text = Column(String) email = Column(String)
message = Column(String, primary_key=True)
method = Column(String)
dispatch_secret = Column(String)
def login_required(f): def login_required(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
auth = flask.request.authorization 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 (flask.jsonify({ 'message' : 'Authentication required' }), 401)
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function 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"]) @app.route('/smart-send', methods=["POST"])
#@login_required @login_required
def smart_send_to_clients(): def smart_send_to_clients():
'''Send to clients based on querying the LDAP '''Send to clients based on querying the LDAP
requests MAY include: requests MAY include:
@@ -66,20 +129,37 @@ def smart_send_to_clients():
try: try:
message = messagetools.load_struct(struct) message = messagetools.load_struct(struct)
except messagetools.UnsupportedStruct as e: except messagetools.UnsupportedStruct as e:
return (408, e.response()) return (e.response(), 408)
persons = ldaptools.select_targets(users, groups, app.config["LDAP_ARGS"]) persons = ldaptools.select_targets(users, groups, app.config["LDAP_ARGS"])
save_in_dispatch_queue(persons, message) save_in_dispatch_queue(persons, message)
return (200, "OK") return ("OK", 200)
def save_in_dispatch_queue(persons, message): 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(): def create_app():
db.create_all()
app.config["PASSWORD"] = os.environ["SIGNAL_API_PASS"] app.config["PASSWORD"] = os.environ.get("SIGNAL_GATEWAY_PASS")
app.config["SIGNAL_CLI_BIN"] = os.environ["SIGNAL_CLI_BIN"] if not app.config["PASSWORD"]:
print("Missing ENV Variable SIGNAL_GATEWAY_PASS", file=sys.stderr)
sys.exit(1)
ldap_args = { ldap_args = {
"LDAP_SERVER" : os.environ["LDAP_SERVER"], "LDAP_SERVER" : os.environ["LDAP_SERVER"],
@@ -113,7 +193,7 @@ if __name__ == "__main__":
"LDAP_BIND_PW" : args.ldap_manager_password, "LDAP_BIND_PW" : args.ldap_manager_password,
"LDAP_BASE_DN" : args.ldap_base_dn, "LDAP_BASE_DN" : args.ldap_base_dn,
} }
if not any([value is None for value in ldap_args.values()]): if not any([value is None for value in ldap_args.values()]):
app.config["LDAP_ARGS"] = ldap_args app.config["LDAP_ARGS"] = ldap_args
else: else:

View File

@@ -14,7 +14,13 @@ class Person:
self.username = username self.username = username
self.name = name self.name = name
self.email = email 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): 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) 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: if not username:
return None return None
if uid_is_cn:
username = username.split(",")[0].split("=")[1]
search_filter = "(&(objectClass=inetOrgPerson)(uid={username}))".format(username=username) search_filter = "(&(objectClass=inetOrgPerson)(uid={username}))".format(username=username)
results = ldap_query(search_filter, ldap_args) results = ldap_query(search_filter, ldap_args)
@@ -68,8 +77,11 @@ def get_members_of_group(group, ldap_args):
if not group: if not group:
return [] return []
search_filter = "(&(objectClass=groupOfNames)(cn={group_name})".format(group) search_filter = "(&(objectClass=groupOfNames)(cn={group_name}))".format(group_name=group)
results = ldap_query(search_filter, ldap_args)
# 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: if not results:
return [] return []
@@ -80,15 +92,12 @@ def get_members_of_group(group, ldap_args):
persons = [] persons = []
for member in members: for member in members:
user_dn = member.decode("utf-8") user_cn = member.decode("utf-8")
user_filter = "(objectClass=inetOrgPerson)" person_obj = get_user_by_uid(user_cn, ldap_args, uid_is_cn=True)
results = ldap_query(user_filter, ldap_args, alt_base_dn=user_dn)
if not results: if not person_obj:
continue continue
cn, entry = results[0]
person_obj = _person_from_search_result(cn, entry)
persons.append(person_obj) persons.append(person_obj)
return persons 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)) persons.append(get_user_by_uid(username, ldap_args))
elif groups: elif groups:
for group in groups: for group in groups:
persons.append(get_members_of_group(group, ldap_args)) persons += get_members_of_group(group, ldap_args)
else: else:
# send to administrators # # send to administrators #
persons.append(get_members_of_group()) persons += get_members_of_group(admin_group, ldap_args)
return persons return set(persons)

View File

@@ -1,38 +1,44 @@
#!/usr/bin/python3 #!/usr/bin/python3
import argparse import argparse
import sys
import flask import flask
import subprocess import subprocess
import os import os
import requests import requests
from functools import wraps from functools import wraps
signal_cli_bin = "signal-cli" HTTP_NOT_FOUND = 404
def signal_send(user, message): def signal_send(user, message):
'''Send message via signal'''
cmd = [signal_send, "send", "-m", message, user] cmd = [signal_cli_bin, "send", "-m", message, user]
p = subprocess.run(cmd) p = subprocess.run(cmd)
p.wait()
def confirm_dispatch(target, uid): 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__": if __name__ == "__main__":
# set signal cli from env #
signal_cli_bin = os.environ["SIGNAL_CLI_BIN"] signal_cli_bin = os.environ["SIGNAL_CLI_BIN"]
parser = argparse.ArgumentParser(description='Query Atlantis Dispatch for Signal', parser = argparse.ArgumentParser(description='Query Atlantis Dispatch for Signal',
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--target', required=True) parser.add_argument('--target', required=True)
parser.add_argument('--method', default="signal")
parser.add_argument('--no-confirm', action="store_true")
args = parser.parse_args() 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 # # check status #
if response.status_code == HTTP_NOT_FOUND: if response.status_code == HTTP_NOT_FOUND:
@@ -40,15 +46,26 @@ if __name__ == "__main__":
response.raise_for_status() response.raise_for_status()
dispatch_confirmed = []
for entry in response.json(): for entry in response.json():
user = entry["person"] user = entry["person"]
message = entry["message"] message = entry["message"]
uid_list = entry["uids"]
# send message # # 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
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) sys.exit(0)