66 Commits

Author SHA1 Message Date
sheppy d8fa3d5352 fix: no env-var as non-param
ci / docker (push) Failing after 0s
2026-06-06 15:16:48 +02:00
sheppy ebe082b43f fix: read dispatch token/settings token from env independant from LDAP 2026-06-06 14:38:58 +02:00
sheppy 5e5d74bb67 feat: creating a webhook will no return it's idenity 2026-06-06 14:22:12 +02:00
sheppy 55cc4cfeef fix: add missing imports
ci / docker (push) Successful in 51s
2026-05-06 22:50:00 +02:00
sheppy b9a5e7f32f feat: check db connection on health endpoint 2026-05-06 22:02:26 +02:00
sheppy f3903b3936 feat: minimal logging for debugging server & fix import
ci / docker (push) Successful in 1m10s
2026-03-28 22:17:16 +01:00
sheppy 39f795bbb6 feat: minimal logging for debugging 2026-03-28 22:12:29 +01:00
sheppy f468a789e3 fix: make polling interval configurable
ci / docker (push) Successful in 1m15s
2026-03-11 15:40:04 +01:00
sheppy 527fa97dc8 fix: prevent substitutions=none if yaml is empty 2026-03-11 15:17:34 +01:00
sheppy 000b7d2f43 fix: handle bytes input from ldap better 2026-03-11 15:10:55 +01:00
sheppy f7b6ccb740 fix: add psycopg2
ci / docker (push) Successful in 1m0s
2026-03-10 20:59:21 +01:00
sheppy e81a69cffd fix: use message as link source fallback
ci / docker (push) Successful in 1m9s
2026-02-28 23:54:06 +01:00
sheppy 792b162025 fix: correctly import re 2026-02-28 23:50:47 +01:00
sheppy 4b2d97fa87 fix: allow link parameter in dq 2026-02-28 23:44:37 +01:00
sheppy b2292943cd fix: remove README references to signal 2026-02-28 23:31:09 +01:00
sheppy 69161135ec fix: set new app name & make db configurable 2026-02-28 23:19:37 +01:00
sheppy f08f6a2953 remove: signal support 2026-02-28 23:14:41 +01:00
sheppy 4e4f53b330 feat: support dynamic links in msg or link-field
ci / docker (push) Successful in 1m0s
2025-06-05 21:45:16 +02:00
sheppy 35f9fc2a99 feat: support 'message' & 'link' fields 2025-06-05 21:35:31 +02:00
sheppy 53e6f32a18 add: header token auth 2025-06-05 20:59:01 +02:00
sheppy f2137e7e4c fix: deal with empty body or title
ci / docker (push) Failing after 15s
2025-01-05 15:30:12 +01:00
sheppy c577802e63 fi: limit title and body length 2025-01-05 15:21:28 +01:00
sheppy 2a74d9816f fix: honor 429 response 2025-01-05 15:19:53 +01:00
sheppy 178ba5451d fix: make os header optional
ci / docker (push) Failing after 47s
2024-11-23 23:22:14 +01:00
sheppy 14612016af feat: add opensearch support 2024-11-23 23:15:31 +01:00
sheppy df1dfd8b0c change: status as json instead of string
ci / docker (push) Failing after 4s
2024-11-14 22:02:00 +01:00
sheppy acf88ffa6e feat: downtime status information 2024-11-14 22:02:00 +01:00
sheppy 7fa965a92c fix: change output to stderr
ci / docker (push) Failing after 7s
2024-11-15 00:01:57 +01:00
sheppy e416149d35 fix: output users 2024-11-14 23:56:53 +01:00
sheppy 181b3dae14 fix: add return view to endpoint 2024-11-14 23:54:39 +01:00
sheppy c10bdf1fb7 fix: output downtime after reading args 2024-11-14 23:53:24 +01:00
sheppy 6783426e5f fix: add minutes default and cast 2024-11-14 23:50:15 +01:00
sheppy bc837169ff feat: implement downtime setting 2024-11-14 23:29:09 +01:00
sheppy 6e2e5e73da fix: handle empty string
ci / docker (push) Failing after 6s
2024-11-03 14:11:12 +01:00
sheppy 2305bc9789 fix: include yaml for server & docker build 2024-11-03 14:11:12 +01:00
sheppy cdb4a8aeb9 feat: implement substitution map 2024-11-03 14:11:12 +01:00
sheppy 85f72290b8 change: disable signal master method, switch to 'any'
ci / docker (push) Failing after 6s
2024-11-01 11:59:49 +01:00
sheppy 4d26d45515 feat: health/root endpoint for monitoring
ci / docker (push) Has been cancelled
2024-10-10 00:17:46 +02:00
sheppy e7ba6d64b3 update: run build on schedule
ci / docker (push) Failing after 4s
2024-09-27 17:04:03 +02:00
sheppy caf79e15e4 update: new registry path/project 2024-05-16 19:59:01 +02:00
sheppy 0c386062e8 feat: webhook support 2024-02-19 20:46:07 +01:00
sheppy f9bd4a2f6f fix: auth paranthesis 2024-02-19 18:40:44 +01:00
sheppy 85f0179d80 feat: direct token auth & webhook path auth support 2024-02-19 18:19:54 +01:00
sheppy 1ebd9db897 fix: correctly use newly created settings 2024-02-19 17:29:21 +01:00
sheppy 0d344be8f7 change: autocreate settings on get 2024-02-19 17:06:29 +01:00
sheppy dadccccbc5 fix: s-typo 2024-02-19 16:47:52 +01:00
sheppy 2b7034df0d fix: username encoding utf-8 2024-02-19 16:42:46 +01:00
sheppy 38e60c6898 fix: filtering for any method based on user prio 2024-02-19 16:28:38 +01:00
sheppy 2a277230b2 fix: user attribute name 2024-02-19 16:18:57 +01:00
sheppy d0109e080a fix: support any-method for legacy signal query 2024-02-19 16:14:08 +01:00
sheppy a2b045cf97 fix: don't output serialize if it is empty 2024-02-19 15:45:28 +01:00
sheppy f0bd08025e fix: directly passthrough user
..to prevent problems with mail internal hosts
2024-02-19 01:46:19 +01:00
sheppy 2c7dadbf35 fix: make smtp port configurable 2024-02-19 01:39:27 +01:00
sheppy 61bc04418b fix: select correct email-address field 2024-02-19 01:28:03 +01:00
sheppy 45ab33dad7 feat: settings api 2024-02-18 19:03:44 +01:00
sheppy cdab4d39cd feat: basic method=any & prio support 2024-02-18 17:55:45 +01:00
sheppy 00bb802327 fix: legacy signal dispatcher with new confirm 2024-02-18 17:32:10 +01:00
sheppy 1b3885850b fix: remove user/address distinction 2024-02-17 18:32:47 +01:00
sheppy 49d46dc8f7 fix: smtp connection 2024-02-17 18:29:16 +01:00
sheppy 8c7a748222 fix: misc smtp login & error reporting fixes 2024-02-17 18:07:32 +01:00
sheppy fd5754e1c2 fix: env not read correctly for dispatcher server 2024-02-17 17:26:12 +01:00
sheppy bdf67ec7fc Merge branch 'dev' 2024-02-17 17:11:40 +01:00
sheppy bc9f5ffd86 Merge branch 'master' of github.com:FAUSheppy/signal-http-gateway 2024-02-16 13:58:38 +01:00
sheppy 6760198760 fix: skip certain conditions 2024-02-15 16:44:45 +01:00
sheppy 2c202940d8 fix: dont ack failed signal sends 2024-02-15 16:28:29 +01:00
sheppy 5810e408c8 fix: add sqlite.db 2024-02-15 16:28:14 +01:00
11 changed files with 361 additions and 191 deletions
+4 -2
View File
@@ -5,6 +5,8 @@ on:
branches: branches:
- "master" - "master"
- "ldap-ng-dev" - "ldap-ng-dev"
schedule:
- cron: "0 2 * * 0"
jobs: jobs:
docker: docker:
@@ -35,7 +37,7 @@ jobs:
context: ./server/ context: ./server/
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
tags: "${{ secrets.REGISTRY }}/athq/event-dispatcher:latest" tags: "${{ secrets.REGISTRY }}/atlantishq/event-dispatcher:latest"
- -
name: Build and push event-dispatcher name: Build and push event-dispatcher
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3
@@ -43,4 +45,4 @@ jobs:
context: ./client/ context: ./client/
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
tags: "${{ secrets.REGISTRY }}/athq/event-dispatcher-worker:latest" tags: "${{ secrets.REGISTRY }}/atlantishq/event-dispatcher-worker:latest"
+3
View File
@@ -4,3 +4,6 @@ sqlite.db
instance/ instance/
__pycache__/ __pycache__/
signal_targets.txt signal_targets.txt
sqlite.db
substitutions.yaml
test.env
+3 -91
View File
@@ -1,93 +1,5 @@
# HTTP->Signal Gateway Notification Service # HTTP -> Notification Service
Simplistic server to listing for HTTP queries, specifically from Icinga or Grafana and send out Signal-Messages. Simplistic server to listing for HTTP queries, specifically from Icinga or Grafana and send out Alert-Messages.
# Signal Cli Setup
You need `glibc>=2.29`, check this first with `ldd --version` (for Debian this means bullseye or later).
Clone the following repositories
https://github.com/AsamK/signal-cli
https://github.com/signalapp/libsignal-client/
https://github.com/signalapp/zkgroup
Install the prerequisites (potentially non-exaustive list):
apt install gradle
https://www.rust-lang.org/tools/install (as current user)
Go to signal-cli project-root:
./gradlew build
./gradlew installDist
Go to libsignal-client project-root, change to java-directory and make sure to remove android from the build options, otherwise this will take ages:
cd java
sed -i "s/, ':android'//" settings.gradle
./build_jni.sh desktop
Go to zkgroup project-root and build it:
make libzkgroup
You need to make the build libraries available for java, either copy them to the java-library path (make sure they are readable for all users) or add them to the *LD\_LIBRARY\_PATH* enviroment variable whenever you intend to use the signal-cli binary.
To get the default java-library-path execute:
java -XshowSettings:properties 2>&1 | grep java.library
Usually on linux that's `/usr/java/packages/lib/`, though this directory might not exist yet, so:
sudo mkdir -p /usr/java/packages/lib/
sudo cp libsignal-client/target/release/libsignal_jni.so /usr/java/packages/lib/
sudo cp zkgroup/target/release/libzkgroup.so /usr/java/packages/lib/
sudo chmod a+rX /usr/java/packages/lib/
Or:
LD_LIBRARY_PATH=LD_LIBRARY_PATH:~/libsignal-client/target/release/:~/path/to/...
Now go to signal-cli project-root, we will have to make some preparations. First prepare your phone number, if you use a number which does not support SMS, use the `--voice`-switch to receive a call instead. Your full phone number means your number, including your country code (including a leading `+`), your area code (without any leading zeros).
You also need a captcha-token, for this open a browser tab first. Then open the developer console, then *make sure to have 'persist-logs' on*, and only *after* that navigate to:
https://signalcaptchas.org/registration/generate.html
You may or may not actually have to solve a chaptcha, in the console, after you the check succeeded,you will likely get a popup to open signal, ignore that and look into the dev-console, there should be something along the lines of:
Navigated to: signalchaptcha://very_very_loooooooooooong_token
Copy everything after `signalchaptcha://` and use it as the token for the `--captcha`-argument. Be advised, the token isn't valid very long:
cd build/install/signal-cli/bin/signal-cli
signal-cli -u FULL_PHONE_NUMBER register --voice --captcha 'TOKEN'
You will now get a SMS/call with the verification-code, which you can use with:
signal-cli -u FULL_PHONE_NUMBER verify CODE
You should consider setting a pin directly after, for help with this and other options use:
signal-cli -h
You should use `signal-cli receive` regulary, otherwise your account will be flagged inactive and potentially deleted. You may ommit the `-u` option if you only have registered one account with this user on this machine. Data (including private keys) are saved to `~/.local/share/signal-cli/`.
# Server Setup
Add the target number(s) (one per line) to signal\_targets.txt, then set the a enviroment variable `SIGNAL_API_PASS`, which must be used withing a basic authentication during access to the gateway. Finally execute the server:
usage: interface.py [-h] [--interface INTERFACE]
[--port PORT]
[--signal-cli-bin SIGNAL_CLI_BIN]
optional arguments:
-h, --help show this help message and exit
--interface INTERFACE
Interface on which to listen (default: localhost)
--port PORT Port on which to listen (default: 5000)
--signal-cli-bin SIGNAL_CLI_BIN
Path to signal-cli binary if no in $PATH (default: None)
`SIGNAL_CLI_BIN` can also be set as an environment variable, which will overwrite any command line option.
# HTTP Request # HTTP Request
The HTTP request must be a *POST*-request, with *Content-Type: application/json* and a json-field containing the key *"message"* with the value being the message you want to send. The HTTP request must be a *POST*-request, with *Content-Type: application/json* and a json-field containing the key *"message"* with the value being the message you want to send.
@@ -99,7 +11,7 @@ The following locations are supported:
# Example (curl) # Example (curl)
curl -u nobody:SIGNAL_API_PASS -X POST -H "Content-Type: application/json" --data '{"message":"hallo world"}' localhost:5000/send-all curl -u nobody:API_PASS -X POST -H "Content-Type: application/json" --data '{"message":"hello world"}' localhost:5000/send-all
# Additional Packages Required # Additional Packages Required
+1 -1
View File
@@ -1,7 +1,7 @@
FROM alpine FROM alpine
WORKDIR /app/ WORKDIR /app/
RUN apk --update --no-cache add python3 py3-requests RUN apk --update --no-cache add python3 py3-requests py3-psycopg2
COPY ./*.py ./ COPY ./*.py ./
+56 -19
View File
@@ -6,13 +6,15 @@ import argparse
import subprocess import subprocess
import os import os
import requests import requests
import re
import smtphelper import smtphelper
import json import json
import datetime
HTTP_NOT_FOUND = 404 HTTP_NOT_FOUND = 404
DISPATCH_SERVER = None DISPATCH_SERVER = None
AUTH = None DISPATCH_ACCESS_TOKEN = None
def debug_send(uuid, data, fail_it=False): def debug_send(uuid, data, fail_it=False):
'''Dummy function to print and ack a dispatch for debugging''' '''Dummy function to print and ack a dispatch for debugging'''
@@ -24,12 +26,19 @@ def debug_send(uuid, data, fail_it=False):
confirm_dispatch(uuid) confirm_dispatch(uuid)
def email_send(dispatch_uuid, email_address, message, smtp_target, smtp_user, smtp_pass): def email_send(dispatch_uuid, email_address, message, smtp_target,
smtp_target_port, smtp_user, smtp_pass):
'''Send message via email''' '''Send message via email'''
if not email_address:
print("Missing E-Mail Address for STMP send", file=sys.stderr)
report_failed_dispatch(dispatch_uuid, "Missing email-field in dispatch infor")
return
subject = "Atlantis Dispatch" subject = "Atlantis Dispatch"
smtphelper.smtp_send(smtp_target, smtp_user, smtp_pass, email_address, subject, message) smtphelper.smtp_send(smtp_target, smtp_target_port, smtp_user, smtp_pass, email_address,
report_failed_dispatch(uuid, "Email dispatch not yet implemented") subject, message)
confirm_dispatch(dispatch_uuid)
def ntfy_api_get_topic(ntfy_api_server, ntfy_api_token, username): def ntfy_api_get_topic(ntfy_api_server, ntfy_api_token, username):
'''Get the topic of the user''' '''Get the topic of the user'''
@@ -47,15 +56,32 @@ def ntfy_api_get_topic(ntfy_api_server, ntfy_api_token, username):
print(r.text) print(r.text)
return r.json().get("topic") return r.json().get("topic")
def ntfy_send(dispatch_uuid, user_topic, title, message, ntfy_push_target, ntfy_user, ntfy_pass): def ntfy_send(dispatch_uuid, user_topic, title, message, link,
ntfy_push_target, ntfy_user, ntfy_pass):
'''Send message via NTFY topic''' '''Send message via NTFY topic'''
# check message for links #
if not link:
pattern = r"https:\/\/[^\s]+"
match = re.search(pattern, message)
if match:
link = match.group(0)
# limit message length and title #
title = title or ""
message = message or ""
message = message[:1024]
title = title[:512]
if not user_topic: if not user_topic:
report_failed_dispatch(dispatch_uuid, "No user topic") report_failed_dispatch(dispatch_uuid, "No user topic")
return return
try: try:
# build message # # build message #
payload = { payload = {
"topic" : user_topic, "topic" : user_topic,
@@ -64,13 +90,16 @@ def ntfy_send(dispatch_uuid, user_topic, title, message, ntfy_push_target, ntfy_
#"tags" : [], #"tags" : [],
"priority" : 4, "priority" : 4,
#"attach" : None, #"attach" : None,
"click" : "https://vid.pr0gramm.com/2022/11/06/ed66c8c5a9cd1a3b.mp4", "click" : link,
#"actions" : [] #"actions" : []
} }
# send # # send #
r = requests.post(ntfy_push_target, auth=(ntfy_user, ntfy_pass), json=payload) r = requests.post(ntfy_push_target, auth=(ntfy_user, ntfy_pass), json=payload)
print(r.status_code, r.text, payload) print(r.status_code, r.text, payload)
if r.status_code == 429: # rate-limit
time.sleep(60)
r.raise_for_status() r.raise_for_status()
# talk to dispatch # # talk to dispatch #
@@ -85,7 +114,7 @@ def report_failed_dispatch(uuid, error):
'''Inform the server that the dispatch has failed''' '''Inform the server that the dispatch has failed'''
payload = [{ "uuid" : uuid, "error" : error }] payload = [{ "uuid" : uuid, "error" : error }]
response = requests.post(DISPATCH_SERVER + "/report-dispatch-failed", json=payload, auth=AUTH) response = requests.post(DISPATCH_SERVER + "/report-dispatch-failed", json=payload)
if response.status_code not in [200, 204]: if response.status_code not in [200, 204]:
print("Failed to report back failed dispatch for {} ({})".format( print("Failed to report back failed dispatch for {} ({})".format(
@@ -95,7 +124,7 @@ def confirm_dispatch(uuid):
'''Confirm to server that message has been dispatched and can be removed''' '''Confirm to server that message has been dispatched and can be removed'''
payload = [{ "uuid" : uuid }] payload = [{ "uuid" : uuid }]
response = requests.post(DISPATCH_SERVER + "/confirm-dispatch", json=payload, auth=AUTH) response = requests.post(DISPATCH_SERVER + "/confirm-dispatch", json=payload)
if response.status_code not in [200, 204]: if response.status_code not in [200, 204]:
print("Failed to confirm dispatch with server for {} ({})".format( print("Failed to confirm dispatch with server for {} ({})".format(
@@ -108,8 +137,7 @@ if __name__ == "__main__":
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--dispatch-server') parser.add_argument('--dispatch-server')
parser.add_argument('--dispatch-user') parser.add_argument('--dispatch-access-token')
parser.add_argument('--dispatch-password')
parser.add_argument('--ntfy-api-server') parser.add_argument('--ntfy-api-server')
parser.add_argument('--ntfy-api-token') parser.add_argument('--ntfy-api-token')
@@ -121,18 +149,19 @@ if __name__ == "__main__":
parser.add_argument('--smtp-target') parser.add_argument('--smtp-target')
parser.add_argument('--smtp-user') parser.add_argument('--smtp-user')
parser.add_argument('--smtp-pass') parser.add_argument('--smtp-pass')
parser.add_argument('--smtp-port', type=int)
parser.add_argument('--loop', default=True, action=argparse.BooleanOptionalAction) parser.add_argument('--loop', default=True, action=argparse.BooleanOptionalAction)
args = parser.parse_args() args = parser.parse_args()
# set dispatch server & authentication #
DISPATCH_SERVER = args.dispatch_server
AUTH = (args.dispatch_user, args.dispatch_password)
dispatch_server = args.dispatch_server or os.environ.get("DISPATCH_SERVER") dispatch_server = args.dispatch_server or os.environ.get("DISPATCH_SERVER")
dispatch_user = args.dispatch_user or os.environ.get("DISPATCH_USER") dispatch_access_token = args.dispatch_access_token or os.environ.get("DISPATCH_ACCESS_TOKEN")
dispatch_password = args.dispatch_password or os.environ.get("DISPATCH_PASSWORD")
# set dispatch server & authentication global #
DISPATCH_SERVER = dispatch_server
DISPATCH_ACCESS_TOKEN = dispatch_access_token
ntfy_api_server = args.ntfy_api_server or os.environ.get("NTFY_API_SERVER") ntfy_api_server = args.ntfy_api_server or os.environ.get("NTFY_API_SERVER")
ntfy_api_token = args.ntfy_api_token or os.environ.get("NTFY_API_TOKEN") ntfy_api_token = args.ntfy_api_token or os.environ.get("NTFY_API_TOKEN")
@@ -144,12 +173,16 @@ if __name__ == "__main__":
smtp_target = args.smtp_target or os.environ.get("SMTP_TARGET") smtp_target = args.smtp_target or os.environ.get("SMTP_TARGET")
smtp_user = args.smtp_user or os.environ.get("SMTP_USER") smtp_user = args.smtp_user or os.environ.get("SMTP_USER")
smtp_pass = args.smtp_pass or os.environ.get("SMTP_PASS") smtp_pass = args.smtp_pass or os.environ.get("SMTP_PASS")
smtp_port = args.smtp_port or os.environ.get("SMTP_PORT")
polling_interval = int(os.environ.get("POLLING_INTERVAL_SECONDS") or 5)
first_run = True first_run = True
while args.loop or first_run: while args.loop or first_run:
# request dispatches # # request dispatches #
response = requests.get(args.dispatch_server + "/get-dispatch?method=all&timeout=0", auth=AUTH) response = requests.get(dispatch_server +
"/get-dispatch?method=all&timeout=0&dispatch-access-token={}".format(DISPATCH_ACCESS_TOKEN))
# check status # # check status #
if response.status_code == HTTP_NOT_FOUND: if response.status_code == HTTP_NOT_FOUND:
@@ -172,20 +205,24 @@ if __name__ == "__main__":
method = entry["method"] method = entry["method"]
message = entry["message"] message = entry["message"]
title = entry.get("title") title = entry.get("title")
link = entry.get("link")
# method dependent fields # # method dependent fields #
phone = entry.get("phone") phone = entry.get("phone")
email_address = entry.get("email") email_address = entry.get("email")
# send message # # send message #
print(f"Sending: {method} {hash(str(title))} @ {datetime.datetime.now()}",
file=sys.stderr)
if method == "signal": if method == "signal":
pass pass
elif method == "ntfy": elif method == "ntfy":
user_topic = ntfy_api_get_topic(ntfy_api_server, ntfy_api_token, user) user_topic = ntfy_api_get_topic(ntfy_api_server, ntfy_api_token, user)
ntfy_send(dispatch_uuid, user_topic, title, message, ntfy_send(dispatch_uuid, user_topic, title, message, link,
ntfy_push_target, ntfy_user, ntfy_pass) ntfy_push_target, ntfy_user, ntfy_pass)
elif method == "email": elif method == "email":
email_send(dispatch_uuid, email_address, message, smtp_target, smtp_user, smtp_pass) email_send(dispatch_uuid, email_address, message, smtp_target,
smtp_port, smtp_user, smtp_pass)
elif method == "debug": elif method == "debug":
debug_send(dispatch_uuid, entry) debug_send(dispatch_uuid, entry)
elif method == "debug-fail": elif method == "debug-fail":
@@ -196,7 +233,7 @@ if __name__ == "__main__":
# wait a moment # # wait a moment #
if args.loop: if args.loop:
time.sleep(5) time.sleep(polling_interval)
# handle non-loop runs # # handle non-loop runs #
first_run = False first_run = False
+3 -3
View File
@@ -2,10 +2,10 @@ import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
def smtp_send(server, user, password, recipient, subject, body): def smtp_send(server, port, user, password, recipient, subject, body):
# Email and password for authentication # Email and password for authentication
sender_email = f'{user}@{server}' sender_email = user
sender_password = password sender_password = password
# Recipient email address # Recipient email address
@@ -13,7 +13,7 @@ def smtp_send(server, user, password, recipient, subject, body):
# SMTP server details # SMTP server details
smtp_server = server smtp_server = server
smtp_port = 587 # Default port for TLS connection smtp_port = port or 25
# Create a message # Create a message
message = MIMEMultipart() message = MIMEMultipart()
+1 -1
View File
@@ -14,7 +14,7 @@ RUN python3 -m pip install --no-cache-dir --break-system-packages -r req.txt
# precreate database directory for mount (will otherwise be created at before_first_request) # precreate database directory for mount (will otherwise be created at before_first_request)
COPY ./ . COPY ./ .
RUN mkdir /app/instance/ RUN mkdir -p /app/instance/
EXPOSE 5000/tcp EXPOSE 5000/tcp
+256 -50
View File
@@ -7,24 +7,69 @@ import subprocess
import os import os
import datetime import datetime
import secrets import secrets
import yaml
import ldaptools import ldaptools
import messagetools import messagetools
from sqlalchemy import Column, Integer, String, Boolean, or_, and_ from sqlalchemy import Column, Integer, String, Boolean, or_, and_, text
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.sql import func from sqlalchemy.sql import func
import sqlalchemy import sqlalchemy
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
OPENSEARCH_HEADER_SEPERATOR = ","
HOST = "icinga.atlantishq.de" HOST = "icinga.atlantishq.de"
app = flask.Flask("Signal Notification Gateway") app = flask.Flask("Atlantis Notification Gateway & Dispatcher")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///sqlite.db" app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DB_URL") or "sqlite:///sqlite.db"
db = SQLAlchemy(app) db = SQLAlchemy(app)
BAD_DISPATCH_ACCESS_TOKEN = "Invalid or missing dispatch-access-token parameter in URL"
def _apply_substitution(string):
if not string:
return string
for replace, match in app.config["SUBSTITUTIONS"].items():
string = string.replace(match, replace)
return string
class WebHookPaths(db.Model):
__tablename__ = "webhook_paths"
username = Column(String, primary_key=True)
path = Column(String, primary_key=True)
class UserSettings(db.Model):
__tablename__ = "user_settings"
username = Column(String, primary_key=True)
signal_priority = Column(Integer) # legacy, no longer used
email_priority = Column(Integer)
ntfy_priority = Column(Integer)
def get_highest_prio_method(self):
if self.email_priority >= self.ntfy_priority:
return "email"
else:
return "ntfy"
def serizalize(self):
return {
"username" : self.username,
"email_priority" : self.email_priority,
"ntfy_priority" : self.ntfy_priority,
}
class DispatchObject(db.Model): class DispatchObject(db.Model):
__tablename__ = "dispatch_queue" __tablename__ = "dispatch_queue"
@@ -37,19 +82,22 @@ class DispatchObject(db.Model):
title = Column(String) title = Column(String)
message = Column(String, primary_key=True) message = Column(String, primary_key=True)
method = Column(String) method = Column(String)
link = Column(String)
dispatch_secret = Column(String) dispatch_secret = Column(String)
dispatch_error = Column(String) dispatch_error = Column(String)
def serialize(self): def serialize(self):
ret = { ret = {
"person" : self.username, # legacy field TODO remove at some point "person" : self.username, # legacy field TODO remove at some point
"username" : self.username, "username" : self.username,
"timestamp" : self.timestamp, "timestamp" : self.timestamp,
"phone" : self.phone, "phone" : self.phone,
"email" : self.email, "email" : self.email,
"title" : self.title, "title" : _apply_substitution(self.title),
"message" : self.message, "message" : _apply_substitution(self.message),
"link" : self.link,
"uuid" : self.dispatch_secret, "uuid" : self.dispatch_secret,
"method" : self.method, "method" : self.method,
"error" : self.dispatch_error, "error" : self.dispatch_error,
@@ -60,6 +108,17 @@ class DispatchObject(db.Model):
if type(value) == bytes: if type(value) == bytes:
ret[key] = value.decode("utf-8") ret[key] = value.decode("utf-8")
if ret["method"] == "any":
user_settings = db.session.query(UserSettings).filter(
UserSettings.username == ret["username"]).first()
if not user_settings and self.email:
ret["method"] = "email"
elif user_settings:
ret["method"] = user_settings.get_highest_prio_method()
else:
ret["method"] = "ntfy"
return ret return ret
@app.route('/get-dispatch-status') @app.route('/get-dispatch-status')
@@ -74,6 +133,96 @@ def get_dispatch_status():
return ("Waiting for dispatch", 200) return ("Waiting for dispatch", 200)
@app.route('/webhooks', methods=["GET", "POST", "DELETE"])
def webhooks():
# check static access token #
token = flask.request.args.get("token")
if token != app.config["SETTINGS_ACCESS_TOKEN"]:
return ("SETTINGS_ACCESS_TOKEN incorrect. Refusing to access webhooks", 401)
user = flask.request.args.get("user")
if not user:
return ("Missing user paramter in URL", 500)
if flask.request.method == "POST":
posted = WebHookPaths(username=user, path=secrets.token_urlsafe(20))
db.session.merge(posted)
db.session.commit()
return flask.jsonify({ "webhook-identity": posted.path})
elif flask.request.method == "GET":
webhooks = db.session.query(WebHookPaths).filter(WebHookPaths.username==user).all()
if not webhooks:
return flask.jsonify([])
else:
return flask.jsonify([ wh.path for wh in webhooks])
elif flask.request.method == "DELETE":
path = flask.request.json["path"]
webhook_to_be_deleted = db.session.query(WebHookPaths).filter(WebHookPaths.username==user,
WebHookPaths.path==path).first()
if not webhook_to_be_deleted:
return ("Webhook to be deleted was not found ({}, {})".format(user, path), 404)
else:
db.session.delete(webhook_to_be_deleted)
db.session.commit()
return ("", 204)
@app.route('/downtime', methods=["GET", "DELETE","POST"])
def downtime():
# check static access token #
token = flask.request.args.get("token")
if token != app.config["SETTINGS_ACCESS_TOKEN"]:
return ("SETTINGS_ACCESS_TOKEN incorrect. Refusing to access downtime settings", 401)
if flask.request.method == "DELETE":
app.config["DOWNTIME"] = datetime.datetime.now()
return ('Downtime successfully disabled', 200)
elif flask.request.method == "POST":
minutes = int(flask.request.args.get("minutes") or 5)
app.config["DOWNTIME"] = datetime.datetime.now() + datetime.timedelta(minutes=minutes)
return ('Downtime set to {}'.format(app.config["DOWNTIME"].isoformat(), 204))
elif flask.request.method == "GET":
dt = app.config["DOWNTIME"]
if dt < datetime.datetime.now():
return flask.jsonify({"title" : "No Downtime set at the moment", "message" : ""})
else:
delta = int((dt - datetime.datetime.now()).total_seconds()/60)
return flask.jsonify({"title" : "Downtime set for {}m until {}".format(delta, dt.isoformat()),
"message" : ""})
@app.route('/settings', methods=["GET", "POST"])
def settings():
# check static access token #
token = flask.request.args.get("token")
if token != app.config["SETTINGS_ACCESS_TOKEN"]:
return ("SETTINGS_ACCESS_TOKEN incorrect. Refusing to access settings", 401)
user = flask.request.args.get("user")
if not user:
return ("Missing user paramter in URL", 500)
if flask.request.method == "POST":
posted = UserSettings(username=user,
signal_priority=-1,
email_priority=flask.request.json.get("email_priority") or 0,
ntfy_priority=flask.request.json.get("ntfy_priority") or 0)
db.session.merge(posted)
db.session.commit()
return ('', 204)
if flask.request.method == "GET":
user_settings = db.session.query(UserSettings).filter(UserSettings.username==user).first()
if not user_settings:
posted = UserSettings(username=user, signal_priority=-1, email_priority=7, ntfy_priority=3)
db.session.merge(posted)
db.session.commit()
user_settings = posted
return flask.jsonify(user_settings.serizalize())
@app.route('/get-dispatch') @app.route('/get-dispatch')
def get_dispatch(): def get_dispatch():
'''Retrive consolidated list of dispatched objects''' '''Retrive consolidated list of dispatched objects'''
@@ -82,8 +231,12 @@ def get_dispatch():
timeout = flask.request.args.get("timeout") or 5 # timeout in seconds timeout = flask.request.args.get("timeout") or 5 # timeout in seconds
timeout = int(timeout) timeout = int(timeout)
dispatch_acces_token = flask.request.args.get("dispatch-access-token") or ""
if dispatch_acces_token != app.config["DISPATCH_ACCESS_TOKEN"]:
return (BAD_DISPATCH_ACCESS_TOKEN, 401)
if not method: if not method:
return (500, "Missing Dispatch Target (signal|email|phone|ntfy|all)") return (500, "Missing Dispatch Target (email|phone|ntfy|all|any)")
# prevent message floods # # prevent message floods #
timeout_cutoff = datetime.datetime.now() - datetime.timedelta(seconds=timeout) timeout_cutoff = datetime.datetime.now() - datetime.timedelta(seconds=timeout)
@@ -93,43 +246,19 @@ def get_dispatch():
lines_timeout = lines_unfiltered.filter(DispatchObject.timestamp < timeout_cutoff_timestamp) lines_timeout = lines_unfiltered.filter(DispatchObject.timestamp < timeout_cutoff_timestamp)
if method != "all": if method != "all":
dispatch_objects = lines_timeout.filter(DispatchObject.method==method).all() dispatch_objects = lines_timeout.filter(DispatchObject.method==method).all()
dispatch_objects_any = lines_timeout.filter(DispatchObject.method=="any").all()
for d in dispatch_objects_any:
user_str = str(d.username, "utf-8")
user_settings = db.session.query(UserSettings).filter(UserSettings.username==user_str).first()
if user_settings and user_settings.get_highest_prio_method() == method:
dispatch_objects += [d]
else: else:
dispatch_objects = lines_timeout.all() dispatch_objects = lines_timeout.all()
# TODO THIS IS THE NEW MASTER PART
if method and method != "signal":
print([ d.serialize() for d in dispatch_objects])
return flask.jsonify([ d.serialize() for d in dispatch_objects]) return flask.jsonify([ d.serialize() for d in dispatch_objects])
else:
# TODO THIS PART WILL BE REMOVED ##
# 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('/report-dispatch-failed', methods=["POST"]) @app.route('/report-dispatch-failed', methods=["POST"])
def reject_dispatch(): def reject_dispatch():
@@ -174,8 +303,9 @@ def confirm_dispatch():
return ("", 204) return ("", 204)
@app.route('/smart-send/<path:path>', methods=["POST"])
@app.route('/smart-send', methods=["POST"]) @app.route('/smart-send', methods=["POST"])
def smart_send_to_clients(): def smart_send_to_clients(path=None):
'''Send to clients based on querying the LDAP '''Send to clients based on querying the LDAP
requests MAY include: requests MAY include:
- list of usernames under key "users" - list of usernames under key "users"
@@ -187,13 +317,51 @@ def smart_send_to_clients():
- supported struct of type "ICINGA|ZABBIX|GENERIC" (see docs) in field "data" - supported struct of type "ICINGA|ZABBIX|GENERIC" (see docs) in field "data"
''' '''
instructions = flask.request.json if flask.request.headers.get("opensearch"):
instructions = {}
users = flask.request.headers.get("opensearch-users")
groups = flask.request.headers.get("opensearch-groups")
if groups and OPENSEARCH_HEADER_SEPERATOR in groups:
groups = groups.split(OPENSEARCH_HEADER_SEPERATOR)
if users and OPENSEARCH_HEADER_SEPERATOR in users:
users = users.split(OPENSEARCH_HEADER_SEPERATOR)
message = flask.request.get_data(as_text=True)
title = "Opensearch Alert"
method = None
else:
instructions = flask.request.json
users = instructions.get("users") users = instructions.get("users")
groups = instructions.get("groups") groups = instructions.get("groups")
message = instructions.get("msg") message = instructions.get("msg") or instructions.get("message")
title = instructions.get("title") title = instructions.get("title")
method = instructions.get("method") method = instructions.get("method")
link = instructions.get("link")
if app.config["DOWNTIME"] > datetime.datetime.now():
print("Ignoring because of Downtime:", title, message, users, file=sys.stderr)
print("Downtime until", app.config["DOWNTIME"].isoformat(), file=sys.stderr)
return ("Ignored because of Downtime", 200)
# authenticated by access token or webhook path #
dispatch_acces_token = flask.request.args.get("dispatch-access-token") or ""
if not dispatch_acces_token:
dispatch_acces_token = flask.request.headers.get("Dispatcher-Token") or ""
if path:
webhook_path = db.session.query(WebHookPaths).filter(WebHookPaths.path==path).first()
if webhook_path:
users = webhook_path.username
groups = None
else:
return ("Invalid Webhook path", 401)
elif dispatch_acces_token != app.config["DISPATCH_ACCESS_TOKEN"]:
return (BAD_DISPATCH_ACCESS_TOKEN, 401)
# allow single use string instead of array # # allow single use string instead of array #
if type(users) == str: if type(users) == str:
@@ -213,12 +381,14 @@ def smart_send_to_clients():
else: else:
persons = ldaptools.select_targets(users, groups, app.config["LDAP_ARGS"]) persons = ldaptools.select_targets(users, groups, app.config["LDAP_ARGS"])
dispatch_secrets = save_in_dispatch_queue(persons, title, message, method) dispatch_secrets = save_in_dispatch_queue(persons, title, message, method, link)
return flask.jsonify(dispatch_secrets) return flask.jsonify(dispatch_secrets)
def save_in_dispatch_queue(persons, title, message, method): def save_in_dispatch_queue(persons, title, message, method, link=""):
now_str = str(datetime.datetime.now())
print(f"Scheduling message to {abs(hash(str(persons)))} @ {now_str}", file=sys.stderr)
dispatch_secrets = [] dispatch_secrets = []
for p in persons: for p in persons:
@@ -229,8 +399,15 @@ def save_in_dispatch_queue(persons, title, message, method):
# this secret will be needed to confirm the message as dispatched # # this secret will be needed to confirm the message as dispatched #
dispatch_secret = secrets.token_urlsafe(32) dispatch_secret = secrets.token_urlsafe(32)
# TODO fix this master_method = "any"
master_method = "signal"
# handle bytes input #
def normalize(v):
return v.decode("utf-8") if isinstance(v, bytes) else v
p.username = normalize(p.username)
p.phone = normalize(p.phone)
p.email = normalize(p.email)
obj = DispatchObject(username=p.username, obj = DispatchObject(username=p.username,
phone=p.phone, phone=p.phone,
@@ -239,6 +416,7 @@ def save_in_dispatch_queue(persons, title, message, method):
timestamp=datetime.datetime.now().timestamp(), timestamp=datetime.datetime.now().timestamp(),
dispatch_secret=dispatch_secret, dispatch_secret=dispatch_secret,
title=title, title=title,
link=link,
message=message) message=message)
db.session.merge(obj) db.session.merge(obj)
@@ -248,10 +426,21 @@ def save_in_dispatch_queue(persons, title, message, method):
return dispatch_secrets return dispatch_secrets
@app.route("/")
@app.route("/health")
def health():
try:
db.session.execute(text("SELECT 1"))
return ({"status": "ok"}, 200)
except Exception as e:
return ({"status": "error", "message": str(e)}, 500)
def create_app(): def create_app():
db.create_all() db.create_all()
app.config["LDAP_NO_READ_ENV"] = os.environ.get("LDAP_NO_READ_ENV") or app.config.get("LDAP_NO_READ_ENV")
if not app.config.get("LDAP_NO_READ_ENV"): if not app.config.get("LDAP_NO_READ_ENV"):
ldap_args = { ldap_args = {
"LDAP_SERVER" : os.environ["LDAP_SERVER"], "LDAP_SERVER" : os.environ["LDAP_SERVER"],
@@ -260,7 +449,20 @@ def create_app():
"LDAP_BASE_DN" : os.environ["LDAP_BASE_DN"] "LDAP_BASE_DN" : os.environ["LDAP_BASE_DN"]
} }
app.config["LDAP_ARGS"] = ldap_args app.config["LDAP_ARGS"] = ldap_args
print("Setting LDAP_ARGS...")
app.config["SETTINGS_ACCESS_TOKEN"] = os.environ["SETTINGS_ACCESS_TOKEN"]
app.config["DISPATCH_ACCESS_TOKEN"] = os.environ["DISPATCH_ACCESS_TOKEN"]
substitution_config_file = os.environ.get("SUBSTITUTION_MAP") or "substitutions.yaml"
app.config["SUBSTITUTIONS"] = {}
if os.path.isfile(substitution_config_file):
with open(substitution_config_file) as f:
app.config["SUBSTITUTIONS"] = yaml.safe_load(f) or {}
print("Loaded subs:", substitution_config_file, app.config["SUBSTITUTIONS"], file=sys.stderr)
# set small downtime #
app.config["DOWNTIME"] = datetime.datetime.now() + datetime.timedelta(minutes=1)
if __name__ == "__main__": if __name__ == "__main__":
@@ -269,14 +471,15 @@ if __name__ == "__main__":
parser.add_argument('--interface', default="localhost", help='Interface on which to listen') 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('--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-server')
parser.add_argument('--ldap-base-dn') parser.add_argument('--ldap-base-dn')
parser.add_argument('--ldap-manager-dn') parser.add_argument('--ldap-manager-dn')
parser.add_argument('--ldap-manager-password') parser.add_argument('--ldap-manager-password')
parser.add_argument('--settings-access-token')
parser.add_argument('--dispatch-access-token')
args = parser.parse_args() args = parser.parse_args()
# define ldap args # # define ldap args #
@@ -288,6 +491,9 @@ if __name__ == "__main__":
} }
app.config["LDAP_NO_READ_ENV"] = True app.config["LDAP_NO_READ_ENV"] = True
app.config["SETTINGS_ACCESS_TOKEN"] = args.settings_access_token
app.config["DISPATCH_ACCESS_TOKEN"] = args.dispatch_access_token
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:
+1 -1
View File
@@ -44,7 +44,7 @@ def _person_from_search_result(cn, entry):
username = entry.get("uid", [None])[0] username = entry.get("uid", [None])[0]
name = entry.get("firstName", [None])[0] name = entry.get("firstName", [None])[0]
email = entry.get("email", [None])[0] email = entry.get("mail", [None])[0]
phone = entry.get("telephoneNumber", [None])[0] phone = entry.get("telephoneNumber", [None])[0]
return Person(cn, username, name, email, phone) return Person(cn, username, name, email, phone)
+2
View File
@@ -1,4 +1,6 @@
python-ldap python-ldap
psycopg2-binary
pyyaml
flask flask
flask-sqlalchemy flask-sqlalchemy
sqlalchemy sqlalchemy
+21 -13
View File
@@ -14,8 +14,7 @@ def signal_send(phone, message):
'''Send message via signal''' '''Send message via signal'''
cmd = [signal_cli_bin, "send", "-m", "'{}'".format(message.replace("'","")), phone] cmd = [signal_cli_bin, "send", "-m", "'{}'".format(message.replace("'","")), phone]
p = subprocess.run(cmd) p = subprocess.run(cmd)
# TODO check return code # p.check_returncode()
def report_dispatch_error(target, uid, error): def report_dispatch_error(target, uid, error):
'''Report an error for a give dispatch''' '''Report an error for a give dispatch'''
@@ -25,8 +24,7 @@ def report_dispatch_error(target, uid, error):
def confirm_dispatch(target, uid): def confirm_dispatch(target, uid):
'''Confirm to server that message has been dispatched and can be removed''' '''Confirm to server that message has been dispatched and can be removed'''
response = requests.post(target + "/confirm-dispatch", json=[{ "uid" : uid }], response = requests.post(target + "/confirm-dispatch", json=[{ "uuid" : uid }])
auth=(args.user, args.password))
if response.status_code not in [200, 204]: if response.status_code not in [200, 204]:
print("Failed to confirm disptach with server for {} ({})".format( print("Failed to confirm disptach with server for {} ({})".format(
@@ -52,8 +50,8 @@ if __name__ == "__main__":
signal_cli_bin = args.signal_cli_bin signal_cli_bin = args.signal_cli_bin
# request dispatches # # request dispatches #
response = requests.get(args.target + "/get-dispatch?method={}".format(args.method), response = requests.get(args.target +
auth=(args.user, args.password)) "/get-dispatch?method={}&dispatch-access-token={}".format(args.method, args.password))
# check status # # check status #
if response.status_code == HTTP_NOT_FOUND: if response.status_code == HTTP_NOT_FOUND:
@@ -64,21 +62,31 @@ if __name__ == "__main__":
# track dispatches that were confirmed to avoid duplicate confirmation # # track dispatches that were confirmed to avoid duplicate confirmation #
dispatch_confirmed = [] dispatch_confirmed = []
dispatch_failed = []
errors = {}
# track failed dispatches #
errors = dict()
# iterate over dispatch requests #
for entry in response.json(): for entry in response.json():
print(entry)
user = entry["person"] user = entry["person"]
phone = entry["phone"] phone = entry.get("phone")
if not phone:
print("No phone number! Skipping...", file=sys.stderr)
continue
message = entry["message"] message = entry["message"]
uid_list = entry["uids"] uid_list = entry["uids"]
# send message # # send message #
if entry["method"] == "signal": if entry["method"] == "signal":
uid, error = signal_send(phone, message) try:
signal_send(phone, message)
except subprocess.CalledProcessError as e:
for uid in uid_list:
errors.update({uid:str(e)})
print("Dispatch failed {}".format(e))
continue
else: else:
print("Unsupported dispatch method {}".format(entry["method"]), print("Unsupported dispatch method {}".format(entry["method"]),
sys=sys.stderr) sys=sys.stderr)
@@ -89,7 +97,7 @@ if __name__ == "__main__":
if uid not in dispatch_confirmed: if uid not in dispatch_confirmed:
# confirm or report fail # # confirm or report fail #
if errors[uid]: if errors.get(uid):
report_dispatch_error(args.target, uid, errors[uid]) report_dispatch_error(args.target, uid, errors[uid])
else: else:
confirm_dispatch(args.target, uid) confirm_dispatch(args.target, uid)