From fcebeda0d09d093117684be1194c2e66401b9d72 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Tue, 14 Oct 2025 01:47:33 -0700 Subject: [PATCH 01/14] WIP --- doorman/app.py | 18 ++------- envrc.example | 8 +++- requirements.txt | 2 + websocket_client.py | 90 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 websocket_client.py diff --git a/doorman/app.py b/doorman/app.py index 8eaa120..c26f722 100644 --- a/doorman/app.py +++ b/doorman/app.py @@ -21,7 +21,7 @@ LDAP_USER_DN = os.environ.get("DOORMAN_LDAP_USER_DN") LDAP_PASS = os.environ.get("DOORMAN_LDAP_PASS") -FANVIL_SSL = os.environ.get("DOORMAN_FANVIL_SSL", False) +FANVIL_URL = os.environ.get("DOORMAN_FANVIL_URL", "http://fanvil") FANVIL_VERIFY_CA = os.environ.get("DOORMAN_FANVIL_CA") FANVIL_USER = os.environ.get("DOORMAN_FANVIL_USER", "admin") FANVIL_PASS = os.environ.get("DOORMAN_FANVIL_PASS", "admin") @@ -44,22 +44,10 @@ def return_code_template(status): return f"""{status}""" -def open_door(remote_addr): - if FANVIL_SSL: - protocol = "https" - else: - protocol = "http" - - try: - reversed_dns = socket.gethostbyaddr(remote_addr) - host = reversed_dns[0] - except Exception: - app.logger.warn("Unable to get reverse DNS for request host") - host = remote_addr - +def open_door(url=FANVIL_URL): # Change code= to match the value in EGS settings > features > calling # password (* by default) - url = f"{protocol}://{host}/cgi-bin/ConfigManApp.com?Key=F_LOCK&code=*" + url = f"{FANVIL_URL}/cgi-bin/ConfigManApp.com?Key=F_LOCK&code=*" auth = HTTPBasicAuth(FANVIL_USER, FANVIL_PASS) requests.get(url, auth=auth, verify=FANVIL_VERIFY_CA) diff --git a/envrc.example b/envrc.example index 5687c7c..b28c834 100644 --- a/envrc.example +++ b/envrc.example @@ -11,8 +11,14 @@ export DOORMAN_LDAP_USE_SSL=1 export DOORMAN_LDAP_USER_DN="uid=doorman,cn=sysaccounts,cn=etc,dc=pawprint,dc=space" export DOORMAN_LDAP_PASS=foobar -export DOORMAN_FANVIL_SSL=1 +exoprt DOORMAN_FANVIL_URL=fanvil export DOORMAN_FANVIL_USER=admin export DOORMAN_FANVIL_PASS=admin # Unset to skip SSL verification export DOORMAN_FANVIL_CA=pawprint-ca.crt + +# Websocket connection to Member Matters +export DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access +export DOORMAN_MM_DEVICE_NAME=frontdoor_test +export DOORMAN_MM_API_KEY=super.secret + diff --git a/requirements.txt b/requirements.txt index a5e71de..00f5caa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ Flask>=2.2.5 ldap3>=2.9.1 requests~=2.32.3 #json_logging>=1.2 +websocket-client~=1.9.0 +rel==0.4.9.21 diff --git a/websocket_client.py b/websocket_client.py new file mode 100644 index 0000000..37f5560 --- /dev/null +++ b/websocket_client.py @@ -0,0 +1,90 @@ +import os +from enum import Enum +import _thread +import time +import json +import logging + +import websocket +import rel + +from doorman.app import open_door + +class AccessDeviceType(Enum): + DOOR = "door" + INTERLOCK = "interlock" + def __str__(self): + return f"{self.value}" + +MM_ACCESS_URL = os.environ.get("DOORMAN_MM_ACCESS_URL", "ws://membermatters.local/ws/access") +MM_DEVICE_NAME = os.environ.get("DOORMAN_MM_DEVICE_NAME", "Unnamed") +MM_DEVICE_TYPE = AccessDeviceType[os.environ.get("DOORMAN_MM_DEVICE_TYPE", "DOOR").upper()] +MM_DATA_FILE = os.environ.get("DOORMAN_MM_DATA_FILE", "/tmp/mm-doorman.json") +MM_API_KEY = os.environ.get("DOORMAN_MM_API_KEY", "unset") + +logger = logging.getLogger(__name__) + +def save_tags(data): + with open(MM_DATA_FILE, "w") as tags_file: + tags_doc = { + "tags": data["tags"], + "hash": data["hash"], + } + json.dump(tags_doc, tags_file) + + +def parse_command(data): + match data.get("command"): + case "ping": + websocket.send(json.dumps({"command": "pong"})) + + case "reboot": + # Not implemented (lol) + pass + + case "bump": + # Bump the door open + open_door() + + case "sync": + # Save synced tags to a file + save_tags(data) + + +def on_message(ws, message): + print(f"> {message}") + try: + data = json.loads(message) + parse_command(data) + except json.decoder.JSONDecodeError as e: + logger.exception(e) + return + + +def on_error(ws, error): + print(f"[ERROR]: {error}") + +def on_close(ws, close_status_code, close_msg): + print("### closed ###") + print("Status: {close_status_code}, Message: {close_msg}") + +def on_open(ws): + print("Opened connection") + # Send auth + auth_packet = { + "command": "authenticate", + "secret_key": MM_API_KEY, + } + ws.send(json.dumps(auth_packet)) + +if __name__ == "__main__": + #websocket.enableTrace(True) + ws = websocket.WebSocketApp(f"{MM_ACCESS_URL}/{MM_DEVICE_TYPE}/{MM_DEVICE_NAME}", + on_open=on_open, + on_message=on_message, + on_error=on_error, + on_close=on_close) + + ws.run_forever(dispatcher=rel, reconnect=5) # Set dispatcher to automatic reconnection, 5 second reconnect delay if connection closed unexpectedly + rel.signal(2, rel.abort) # Keyboard Interrupt + rel.dispatch() From d685a5184e1b8c469a013a39ba1dbfcd9a5c13ae Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Thu, 16 Oct 2025 23:31:12 -0700 Subject: [PATCH 02/14] Working MemberMatters websocket client --- .gitignore | 2 + Dockerfile | 1 + README.md | 30 ++++++++-- docs/mm-api-key.png | Bin 0 -> 48102 bytes doorman/__init__.py | 0 doorman/app.py | 34 ++++++++++- envrc.example | 16 ++--- requirements.txt | 7 ++- websocket_client.py | 142 ++++++++++++++++++++++++++------------------ 9 files changed, 158 insertions(+), 74 deletions(-) create mode 100644 docs/mm-api-key.png create mode 100644 doorman/__init__.py diff --git a/.gitignore b/.gitignore index a7207bb..e5e4ed9 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,5 @@ cython_debug/ .envrc .idea + +mm-doorman.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4c30dca..1bb1b4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ FROM tiangolo/meinheld-gunicorn-flask:python3.9 COPY ./doorman /app/doorman +COPY ./websocket_client.py /app COPY requirements.txt /app RUN pip install --upgrade pip RUN pip install -r /app/requirements.txt diff --git a/README.md b/README.md index 9c69732..7239bc7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # doorman -A solution for connecting the Fanvil i31s to FreeIPA +A solution for connecting the Fanvil i31s (or compatible) line of door phones to LDAP for access control. +Also supports the [MemberMatters](https://github.com/membermatters/MemberMatters) door controller protocol, with optional +fallback to LDAP. # Quickstart Required: - * python 3.7+ + * python 3.9+ Suggested: * [direnv](https://direnv.net/docs/installation.html) @@ -37,6 +39,26 @@ cp envrc.example .env # 3. Edit your config to suit -# 4. Run with: -docker run -d -p 5000:80 --name=doorman --env-file=.env doorman:latest +# 4. Run the webserver with: +docker run -d -p 5000:80 --name=doorman --env-file=.env -v ./mm-doorman.json:/tmp/mm-doorman.json doorman:latest + +# 5. For use with Member Matters, run the websocket client: +docker run -d --name=doorman_ws --env-file=.env -v ./mm-doorman.json:/tmp/mm-doorman.json doorman:latest python3 websocket_client.py ``` + +# Configure for use with MemberMatters + +## Add API key for access control device + +1. In the MemberMatters Django Admin (`/admin` URL), navigate to **Access** > **API Key for Access Controlled Devices**, and click **Add**. +2. Give your connector an unique name less than 50 characters. Set that same name for `DOORMAN_MM_DEVICE_NAME` variable for doorman. +3. Optionally set an expiration date for the key. Click **Save**. +4. The API key will be flashed at the top of the screen in an orange box. Use that value for `DOORMAN_MM_API_KEY`. + +![MM API key add screen](docs/mm-api-key.png) + +## Enable the connector device + +1. Run `websocket_client.py`. Doorman will try to register with the server, but the server will disconnect the connector until it is authorized. +2. In the Django Admin, click on **Access** > **Doors**, then click `New Device (doorman)` (the value in perens will match `DOORMAN_MM_DEVICE_NAME` from earlier). +3. Check `Is this device authorised to access the system?`, and set any of the option checkboxes according to your setup, and click **Save**. \ No newline at end of file diff --git a/docs/mm-api-key.png b/docs/mm-api-key.png new file mode 100644 index 0000000000000000000000000000000000000000..ceef572c1ec327c06f9ebf9233a5ba9335a8574c GIT binary patch literal 48102 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 Zl bool: + with open(MM_DATA_FILE) as f: + mm_data = json.load(f) + authorized_tags = mm_data.get("tags", []) + locked_out = mm_data.get("locked_out", False) + if locked_out: + app.logger.info("This MemberMatters device was set to locked_out by the server!") + return False + + app.logger.debug(f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}") + if card_number in authorized_tags: + app.logger.info(f"card_number: {card_number} is authorized by MemberMatters") + return True -def lookup_card(card_number): + app.logger.debug(f"card_number: {card_number} not authorized by MemberMatters") + return False + +def _lookup_ldap(card_number: str) -> bool: ldap_server = ldap3.Server(LDAP_SERVER, use_ssl=LDAP_USE_SSL) with ldap3.Connection(ldap_server, LDAP_USER_DN, LDAP_PASS, auto_bind=True) as conn: app.logger.info(conn) @@ -81,6 +102,13 @@ def lookup_card(card_number): return False +def lookup_card(card_number: str) -> bool: + ret = _lookup_mm(card_number) + if not ret: + ret = _lookup_ldap(card_number) + return ret + + def lookup_pin(input_value): for pin in ACCESS_PINS: if input_value == str(pin): diff --git a/envrc.example b/envrc.example index b28c834..9fdc8df 100644 --- a/envrc.example +++ b/envrc.example @@ -4,8 +4,16 @@ export FLASK_HOST=0.0.0.0 export FLASK_ENV=development export FLASK_APP=doorman.app +# Websocket connection to Member Matters +export DOORMAN_LDAP_ENABLE=true +export DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access +export DOORMAN_MM_DEVICE_NAME=frontdoor_test +export DOORMAN_MM_API_KEY=super.secret + +# LDAP Configuration +export DOORMAN_LDAP_ENABLE=true export DOORMAN_LDAP_SERVER=ipa.pawprint.space -export DOORMAN_LDAP_USE_SSL=1 +export DOORMAN_LDAP_USE_SSL=true # Service account details for LDAP bind export DOORMAN_LDAP_USER_DN="uid=doorman,cn=sysaccounts,cn=etc,dc=pawprint,dc=space" @@ -16,9 +24,3 @@ export DOORMAN_FANVIL_USER=admin export DOORMAN_FANVIL_PASS=admin # Unset to skip SSL verification export DOORMAN_FANVIL_CA=pawprint-ca.crt - -# Websocket connection to Member Matters -export DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access -export DOORMAN_MM_DEVICE_NAME=frontdoor_test -export DOORMAN_MM_API_KEY=super.secret - diff --git a/requirements.txt b/requirements.txt index 00f5caa..505775f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ -Flask>=2.2.5 +Flask>=3.1.2 ldap3>=2.9.1 -requests~=2.32.3 +requests~=2.32.5 #json_logging>=1.2 websocket-client~=1.9.0 -rel==0.4.9.21 +rel~=0.4.9.21 +rich~=14.2.0 diff --git a/websocket_client.py b/websocket_client.py index 37f5560..9c044e0 100644 --- a/websocket_client.py +++ b/websocket_client.py @@ -5,10 +5,11 @@ import json import logging +from rich.logging import RichHandler import websocket import rel -from doorman.app import open_door +from doorman.app import open_door, env_bool class AccessDeviceType(Enum): DOOR = "door" @@ -21,70 +22,97 @@ def __str__(self): MM_DEVICE_TYPE = AccessDeviceType[os.environ.get("DOORMAN_MM_DEVICE_TYPE", "DOOR").upper()] MM_DATA_FILE = os.environ.get("DOORMAN_MM_DATA_FILE", "/tmp/mm-doorman.json") MM_API_KEY = os.environ.get("DOORMAN_MM_API_KEY", "unset") - -logger = logging.getLogger(__name__) - -def save_tags(data): - with open(MM_DATA_FILE, "w") as tags_file: - tags_doc = { - "tags": data["tags"], - "hash": data["hash"], - } - json.dump(tags_doc, tags_file) - - -def parse_command(data): - match data.get("command"): - case "ping": +DEBUG = env_bool(os.environ.get("DEBUG", False)) + +logging.basicConfig(level=logging.NOTSET, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()]) +logger = logging.getLogger("doorman_client") +logger.setLevel(logging.INFO) +if DEBUG: + logger.setLevel(logging.DEBUG) + +class MMAccessClient(websocket.WebSocketApp): + def __init__(self, *args, **kwargs): + websocket.enableTrace(kwargs.get("debug", False)) + self.locked_out = False + super().__init__(f"{MM_ACCESS_URL}/{MM_DEVICE_TYPE}/{MM_DEVICE_NAME}", + on_open=self.on_open, + on_message=self.on_message, + on_error=self.on_error, + on_close=self.on_close) + + + def run(self): + self.run_forever(dispatcher=rel, + reconnect=15) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly + rel.signal(2, rel.abort) # Keyboard Interrupt + rel.dispatch() + + def save_tags(self, data): + with open(MM_DATA_FILE, "w") as tags_file: + tags_doc = { + "tags": data["tags"], + "hash": data["hash"], + "locked_out": self.locked_out, + } + json.dump(tags_doc, tags_file) + + def load_tags(self): + with open(MM_DATA_FILE) as tags_file: + data = json.load(tags_file) + self.locked_out = data.get("locked_out", False) + return data + + def update_device_locked_out(self, locked_out: bool) -> None: + logger.info(f"Update device lockout: locked_out = {locked_out}") + tags_doc = self.load_tags() + self.locked_out = locked_out + self.save_tags(tags_doc) + + def parse_command(self, data): + command = data.get("command") + if command == "ping": websocket.send(json.dumps({"command": "pong"})) - case "reboot": + elif command == "reboot": # Not implemented (lol) pass - case "bump": + elif command == "bump": # Bump the door open open_door() - case "sync": + elif command == "sync": # Save synced tags to a file - save_tags(data) - - -def on_message(ws, message): - print(f"> {message}") - try: - data = json.loads(message) - parse_command(data) - except json.decoder.JSONDecodeError as e: - logger.exception(e) - return - - -def on_error(ws, error): - print(f"[ERROR]: {error}") - -def on_close(ws, close_status_code, close_msg): - print("### closed ###") - print("Status: {close_status_code}, Message: {close_msg}") - -def on_open(ws): - print("Opened connection") - # Send auth - auth_packet = { - "command": "authenticate", - "secret_key": MM_API_KEY, - } - ws.send(json.dumps(auth_packet)) + self.save_tags(data) + + elif command == "update_device_locked_out": + self.update_device_locked_out(data.get("locked_out", False)) + + def on_message(self, ws, message): + logger.debug(f"> {message}") + try: + data = json.loads(message) + self.parse_command(data) + except json.decoder.JSONDecodeError as e: + logger.exception(e) + return + + def on_error(self, ws, error): + logger.error(f"[SOCKET ERROR]: {error}") + + def on_close(self, ws, close_status_code, close_msg): + logger.info("### closed ###") + logger.info(f"Status: {close_status_code}, Message: {close_msg}") + + def on_open(self, data): + logger.info(f"Opened connection to {self.url}, sending auth.") + # Send auth + auth_packet = { + "command": "authenticate", + "secret_key": MM_API_KEY, + } + self.send(json.dumps(auth_packet)) if __name__ == "__main__": - #websocket.enableTrace(True) - ws = websocket.WebSocketApp(f"{MM_ACCESS_URL}/{MM_DEVICE_TYPE}/{MM_DEVICE_NAME}", - on_open=on_open, - on_message=on_message, - on_error=on_error, - on_close=on_close) - - ws.run_forever(dispatcher=rel, reconnect=5) # Set dispatcher to automatic reconnection, 5 second reconnect delay if connection closed unexpectedly - rel.signal(2, rel.abort) # Keyboard Interrupt - rel.dispatch() + client = MMAccessClient(debug=DEBUG) + client.run() \ No newline at end of file From db1d5f374a0f0279b3fd687bda47cb46d3b84660 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Thu, 16 Oct 2025 23:41:36 -0700 Subject: [PATCH 03/14] Update envrc example to be .env compatible --- envrc.example | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/envrc.example b/envrc.example index 9fdc8df..ce0641e 100644 --- a/envrc.example +++ b/envrc.example @@ -1,26 +1,30 @@ -source venv/bin/activate - -export FLASK_HOST=0.0.0.0 -export FLASK_ENV=development -export FLASK_APP=doorman.app +FLASK_HOST=0.0.0.0 +FLASK_ENV=development +FLASK_APP=doorman.app # Websocket connection to Member Matters -export DOORMAN_LDAP_ENABLE=true -export DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access -export DOORMAN_MM_DEVICE_NAME=frontdoor_test -export DOORMAN_MM_API_KEY=super.secret +DOORMAN_MM_ENABLE=true +DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access +DOORMAN_MM_DEVICE_NAME=frontdoor_test +DOORMAN_MM_API_KEY=super.secret # LDAP Configuration -export DOORMAN_LDAP_ENABLE=true -export DOORMAN_LDAP_SERVER=ipa.pawprint.space -export DOORMAN_LDAP_USE_SSL=true +DOORMAN_LDAP_ENABLE=true +DOORMAN_LDAP_SERVER=ipa.pawprint.space +DOORMAN_LDAP_USE_SSL=true # Service account details for LDAP bind -export DOORMAN_LDAP_USER_DN="uid=doorman,cn=sysaccounts,cn=etc,dc=pawprint,dc=space" -export DOORMAN_LDAP_PASS=foobar +DOORMAN_LDAP_USER_DN="uid=doorman,cn=sysaccounts,cn=etc,dc=pawprint,dc=space" +DOORMAN_LDAP_PASS=foobar -exoprt DOORMAN_FANVIL_URL=fanvil -export DOORMAN_FANVIL_USER=admin -export DOORMAN_FANVIL_PASS=admin +DOORMAN_FANVIL_URL=https://fanvil.local +DOORMAN_FANVIL_USER=admin +DOORMAN_FANVIL_PASS=admin # Unset to skip SSL verification -export DOORMAN_FANVIL_CA=pawprint-ca.crt +DOORMAN_FANVIL_CA=pawprint-ca.crt + +# Uncomment for .envrc use: +#export FLASK_HOST FLASK_ENV FLASK_APP +#export DOORMAN_MM_ENABLE DOORMAN_MM_ACCESS_URL DOORMAN_MM_DEVICE_NAME DOORMAN_MM_API_KEY +#export DOORMAN_LDAP_ENABLE DOORMAN_LDAP_SERVER DOORMAN_LDAP_USE_SSL DOORMAN_LDAP_USER_DN DOORMAN_LDAP_PASS +#export DOORMAN_FANVIL_URL DOORMAN_FANVIL_PASS DOORMAN_FANVIL_CA \ No newline at end of file From 8acd0b879aea4e1e8e27729c53741b7440e96ce1 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Fri, 17 Oct 2025 00:01:35 -0700 Subject: [PATCH 04/14] pre-commit update and run --- .flake8 | 2 +- .gitignore | 2 +- .isort.cfg | 2 +- .pre-commit-config.yaml | 20 ++++++++++------- README.md | 2 +- doorman/app.py | 16 ++++++++++---- envrc.example | 2 +- websocket_client.py | 49 +++++++++++++++++++++++++---------------- 8 files changed, 59 insertions(+), 36 deletions(-) diff --git a/.flake8 b/.flake8 index 7b46844..a99d7a4 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] max-line-length = 100 -ignore = E203, E266, E501, W503 +ignore = E203, E266, E501, W503, E731 exclude = .yaml,.json [pycodestyle] max_line_length = 100 diff --git a/.gitignore b/.gitignore index e5e4ed9..6f0012c 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,4 @@ cython_debug/ .envrc .idea -mm-doorman.json \ No newline at end of file +mm-doorman.json diff --git a/.isort.cfg b/.isort.cfg index 3b6e9ad..2fa9621 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,2 @@ [settings] -known_third_party = flask,ldap3,requests +known_third_party = flask,ldap3,rel,requests,rich,websocket diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7539c68..cb27d7b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,20 +1,24 @@ -default_language_version: - python: python3.7 - exclude: ^(migrations|venv)/ repos: - repo: https://github.com/asottile/seed-isort-config - rev: v1.9.4 + rev: v2.2.0 hooks: - id: seed-isort-config - - repo: git://github.com/pre-commit/mirrors-isort - rev: v4.3.21 + - repo: https://github.com/pycqa/isort + rev: 7.0.0 hooks: - id: isort + name: isort (python) + + - repo: https://github.com/pycqa/flake8 + rev: 7.3.0 + hooks: + - id: flake8 + name: flake8 (python) - - repo: git://github.com/pre-commit/pre-commit-hooks + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.4.0 hooks: - id: check-builtin-literals @@ -24,6 +28,6 @@ repos: - id: check-ast - repo: https://github.com/ambv/black - rev: 19.10b0 + rev: 25.9.0 hooks: - id: black diff --git a/README.md b/README.md index 7239bc7..fb106b8 100644 --- a/README.md +++ b/README.md @@ -61,4 +61,4 @@ docker run -d --name=doorman_ws --env-file=.env -v ./mm-doorman.json:/tmp/mm-doo 1. Run `websocket_client.py`. Doorman will try to register with the server, but the server will disconnect the connector until it is authorized. 2. In the Django Admin, click on **Access** > **Doors**, then click `New Device (doorman)` (the value in perens will match `DOORMAN_MM_DEVICE_NAME` from earlier). -3. Check `Is this device authorised to access the system?`, and set any of the option checkboxes according to your setup, and click **Save**. \ No newline at end of file +3. Check `Is this device authorised to access the system?`, and set any of the option checkboxes according to your setup, and click **Save**. diff --git a/doorman/app.py b/doorman/app.py index 1a14c59..69ddcbc 100644 --- a/doorman/app.py +++ b/doorman/app.py @@ -57,16 +57,21 @@ def open_door(url=FANVIL_URL): requests.get(url, auth=auth, verify=FANVIL_VERIFY_CA) + def _lookup_mm(card_number: str) -> bool: with open(MM_DATA_FILE) as f: mm_data = json.load(f) authorized_tags = mm_data.get("tags", []) locked_out = mm_data.get("locked_out", False) if locked_out: - app.logger.info("This MemberMatters device was set to locked_out by the server!") + app.logger.info( + "This MemberMatters device was set to locked_out by the server!" + ) return False - app.logger.debug(f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}") + app.logger.debug( + f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}" + ) if card_number in authorized_tags: app.logger.info(f"card_number: {card_number} is authorized by MemberMatters") return True @@ -74,6 +79,7 @@ def _lookup_mm(card_number: str) -> bool: app.logger.debug(f"card_number: {card_number} not authorized by MemberMatters") return False + def _lookup_ldap(card_number: str) -> bool: ldap_server = ldap3.Server(LDAP_SERVER, use_ssl=LDAP_USE_SSL) with ldap3.Connection(ldap_server, LDAP_USER_DN, LDAP_PASS, auto_bind=True) as conn: @@ -81,7 +87,9 @@ def _lookup_ldap(card_number: str) -> bool: search_filter = f"(&(rfidbadge={card_number})(!(nsAccountLock=TRUE)))" app.logger.debug(f"SearchDN: {LDAP_BASE_DN} Search filter: {search_filter}") conn.search( - LDAP_BASE_DN, search_filter, attributes=["cn", "uid"], + LDAP_BASE_DN, + search_filter, + attributes=["cn", "uid"], ) app.logger.info(f"Response: {conn.response}") @@ -90,7 +98,7 @@ def _lookup_ldap(card_number: str) -> bool: # TODO: write cn to audit log (influxdb) if SUCCESS_WEBHOOK: webhook_data = {"_type": "CARD", "card_number": card_number} - webhook_data.update(conn.response[0]['attributes']) + webhook_data.update(conn.response[0]["attributes"]) try: requests.post(SUCCESS_WEBHOOK, webhook_data) except requests.RequestException as e: diff --git a/envrc.example b/envrc.example index ce0641e..c871dc6 100644 --- a/envrc.example +++ b/envrc.example @@ -27,4 +27,4 @@ DOORMAN_FANVIL_CA=pawprint-ca.crt #export FLASK_HOST FLASK_ENV FLASK_APP #export DOORMAN_MM_ENABLE DOORMAN_MM_ACCESS_URL DOORMAN_MM_DEVICE_NAME DOORMAN_MM_API_KEY #export DOORMAN_LDAP_ENABLE DOORMAN_LDAP_SERVER DOORMAN_LDAP_USE_SSL DOORMAN_LDAP_USER_DN DOORMAN_LDAP_PASS -#export DOORMAN_FANVIL_URL DOORMAN_FANVIL_PASS DOORMAN_FANVIL_CA \ No newline at end of file +#export DOORMAN_FANVIL_URL DOORMAN_FANVIL_PASS DOORMAN_FANVIL_CA diff --git a/websocket_client.py b/websocket_client.py index 9c044e0..665849b 100644 --- a/websocket_client.py +++ b/websocket_client.py @@ -1,49 +1,59 @@ -import os -from enum import Enum -import _thread -import time import json import logging +import os +from enum import Enum -from rich.logging import RichHandler -import websocket import rel +import websocket +from rich.logging import RichHandler + +from doorman.app import env_bool, open_door -from doorman.app import open_door, env_bool class AccessDeviceType(Enum): DOOR = "door" INTERLOCK = "interlock" + def __str__(self): return f"{self.value}" -MM_ACCESS_URL = os.environ.get("DOORMAN_MM_ACCESS_URL", "ws://membermatters.local/ws/access") + +MM_ACCESS_URL = os.environ.get( + "DOORMAN_MM_ACCESS_URL", "ws://membermatters.local/ws/access" +) MM_DEVICE_NAME = os.environ.get("DOORMAN_MM_DEVICE_NAME", "Unnamed") -MM_DEVICE_TYPE = AccessDeviceType[os.environ.get("DOORMAN_MM_DEVICE_TYPE", "DOOR").upper()] +MM_DEVICE_TYPE = AccessDeviceType[ + os.environ.get("DOORMAN_MM_DEVICE_TYPE", "DOOR").upper() +] MM_DATA_FILE = os.environ.get("DOORMAN_MM_DATA_FILE", "/tmp/mm-doorman.json") MM_API_KEY = os.environ.get("DOORMAN_MM_API_KEY", "unset") DEBUG = env_bool(os.environ.get("DEBUG", False)) -logging.basicConfig(level=logging.NOTSET, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()]) +logging.basicConfig( + level=logging.NOTSET, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()] +) logger = logging.getLogger("doorman_client") logger.setLevel(logging.INFO) if DEBUG: logger.setLevel(logging.DEBUG) + class MMAccessClient(websocket.WebSocketApp): def __init__(self, *args, **kwargs): websocket.enableTrace(kwargs.get("debug", False)) self.locked_out = False - super().__init__(f"{MM_ACCESS_URL}/{MM_DEVICE_TYPE}/{MM_DEVICE_NAME}", - on_open=self.on_open, - on_message=self.on_message, - on_error=self.on_error, - on_close=self.on_close) - + super().__init__( + f"{MM_ACCESS_URL}/{MM_DEVICE_TYPE}/{MM_DEVICE_NAME}", + on_open=self.on_open, + on_message=self.on_message, + on_error=self.on_error, + on_close=self.on_close, + ) def run(self): - self.run_forever(dispatcher=rel, - reconnect=15) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly + self.run_forever( + dispatcher=rel, reconnect=15 + ) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly rel.signal(2, rel.abort) # Keyboard Interrupt rel.dispatch() @@ -113,6 +123,7 @@ def on_open(self, data): } self.send(json.dumps(auth_packet)) + if __name__ == "__main__": client = MMAccessClient(debug=DEBUG) - client.run() \ No newline at end of file + client.run() From 817a904d00cb27322e32c4eafca2f478281032ce Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:39:27 -0800 Subject: [PATCH 05/14] Apply suggestions from code review Co-authored-by: Kataze the Skunk --- doorman/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doorman/app.py b/doorman/app.py index 69ddcbc..fbb4882 100644 --- a/doorman/app.py +++ b/doorman/app.py @@ -61,6 +61,9 @@ def open_door(url=FANVIL_URL): def _lookup_mm(card_number: str) -> bool: with open(MM_DATA_FILE) as f: mm_data = json.load(f) + app.logger.debug( + f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}" + ) authorized_tags = mm_data.get("tags", []) locked_out = mm_data.get("locked_out", False) if locked_out: @@ -69,9 +72,6 @@ def _lookup_mm(card_number: str) -> bool: ) return False - app.logger.debug( - f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}" - ) if card_number in authorized_tags: app.logger.info(f"card_number: {card_number} is authorized by MemberMatters") return True From 9bfd5f8f42bffb2471491128567ba8cc8dc91708 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Thu, 7 May 2026 16:11:43 -0700 Subject: [PATCH 06/14] Add door_access protocol support via threading - Add doorman/ipc.py with shared threading.Queue - Push door_access events from Flask on RFID auth success - Poll queue in websocket client, send door_access to MM server - Add main.py entrypoint running Flask + WS client in one process - Update Dockerfile to use main module with WEB_CONCURRENCY=1 --- Dockerfile | 4 +++- doorman/app.py | 8 ++++++++ doorman/ipc.py | 10 ++++++++++ main.py | 22 ++++++++++++++++++++++ websocket_client.py | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 doorman/ipc.py create mode 100644 main.py diff --git a/Dockerfile b/Dockerfile index 1bb1b4c..633cb87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,10 @@ FROM tiangolo/meinheld-gunicorn-flask:python3.9 COPY ./doorman /app/doorman COPY ./websocket_client.py /app +COPY ./main.py /app COPY requirements.txt /app RUN pip install --upgrade pip RUN pip install -r /app/requirements.txt -ENV MODULE_NAME=doorman.app +ENV MODULE_NAME=main +ENV WEB_CONCURRENCY=1 diff --git a/doorman/app.py b/doorman/app.py index fbb4882..e4b350a 100644 --- a/doorman/app.py +++ b/doorman/app.py @@ -9,6 +9,7 @@ from requests.auth import HTTPBasicAuth from . import fanvil +from .ipc import door_access_queue app = Flask(__name__) @@ -144,6 +145,13 @@ def auth(): app.logger.info(f"Got keypad input: {input_value}") success = lookup_pin(input_value) if success: + # Notify the websocket client to send a door_access event + if input_type == fanvil.CARD_ID: + door_access_queue.put({ + "id_number": input_value, + "success": True, + "method": "rfid", + }) return return_code_template(200), 200 return return_code_template(401), 401 diff --git a/doorman/ipc.py b/doorman/ipc.py new file mode 100644 index 0000000..5998606 --- /dev/null +++ b/doorman/ipc.py @@ -0,0 +1,10 @@ +""" +Inter-process communication between the Flask server and the websocket client. + +Uses a threading.Queue since both components run in the same process. +""" + +import queue + +# Flask pushes door_access events here; the websocket client consumes them. +door_access_queue: queue.Queue = queue.Queue() diff --git a/main.py b/main.py new file mode 100644 index 0000000..3859f8e --- /dev/null +++ b/main.py @@ -0,0 +1,22 @@ +""" +Unified entrypoint that runs the Flask server and the MemberMatters +websocket client in the same process. + +The Flask app is exposed as `app` for gunicorn/meinheld to pick up. +The websocket client runs in a daemon thread so it dies with the process. +""" + +import threading + +from doorman.app import app +from websocket_client import MMAccessClient, DEBUG + + +def start_websocket_client(): + client = MMAccessClient(debug=DEBUG) + client.run() + + +# Start the websocket client in a daemon thread +ws_thread = threading.Thread(target=start_websocket_client, daemon=True, name="mm-ws-client") +ws_thread.start() diff --git a/websocket_client.py b/websocket_client.py index 665849b..4ae1053 100644 --- a/websocket_client.py +++ b/websocket_client.py @@ -1,6 +1,8 @@ import json import logging import os +import queue +import time from enum import Enum import rel @@ -8,6 +10,7 @@ from rich.logging import RichHandler from doorman.app import env_bool, open_door +from doorman.ipc import door_access_queue class AccessDeviceType(Enum): @@ -54,9 +57,40 @@ def run(self): self.run_forever( dispatcher=rel, reconnect=15 ) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly + # Poll the door_access queue every 0.5s and forward events to the server + rel.timeout(0.5, self._poll_door_access_queue) rel.signal(2, rel.abort) # Keyboard Interrupt rel.dispatch() + def _poll_door_access_queue(self): + """Check the shared queue for door_access events and send them.""" + try: + while True: + event = door_access_queue.get_nowait() + self.send_door_access( + id_number=event["id_number"], + success=event["success"], + method=event.get("method", "rfid"), + ) + except queue.Empty: + pass + # Re-schedule ourselves — returning True keeps the rel timeout alive + return True + + def send_door_access(self, id_number: str, success: bool, method: str = "rfid"): + """Send a door_access command to the MemberMatters server.""" + packet = { + "command": "door_access", + "payload": { + "id_number": id_number, + "time": str(int(time.time())), + "success": success, + "method": method, + }, + } + logger.info(f"Sending door_access: id={id_number} success={success} method={method}") + self.send(json.dumps(packet)) + def save_tags(self, data): with open(MM_DATA_FILE, "w") as tags_file: tags_doc = { @@ -125,5 +159,6 @@ def on_open(self, data): if __name__ == "__main__": + # Standalone mode: run without Flask (useful for local testing) client = MMAccessClient(debug=DEBUG) client.run() From aab628b1077e75cfd48617661fd1b448c4a8ee3f Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Thu, 7 May 2026 16:18:24 -0700 Subject: [PATCH 07/14] Update pre-commit, apply formatting --- .isort.cfg | 1 + .pre-commit-config.yaml | 7 +++---- doorman/app.py | 14 ++++++++------ main.py | 8 +++++--- websocket_client.py | 4 +++- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.isort.cfg b/.isort.cfg index 2fa9621..90b4d3c 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,3 @@ [settings] +profile = black known_third_party = flask,ldap3,rel,requests,rich,websocket diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb27d7b..fea6d8b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: - id: seed-isort-config - repo: https://github.com/pycqa/isort - rev: 7.0.0 + rev: 9.0.0a3 hooks: - id: isort name: isort (python) @@ -19,15 +19,14 @@ repos: name: flake8 (python) - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.4.0 + rev: v6.0.0 hooks: - id: check-builtin-literals - id: trailing-whitespace - id: end-of-file-fixer - - id: flake8 - id: check-ast - repo: https://github.com/ambv/black - rev: 25.9.0 + rev: 26.3.1 hooks: - id: black diff --git a/doorman/app.py b/doorman/app.py index e4b350a..da1e465 100644 --- a/doorman/app.py +++ b/doorman/app.py @@ -62,10 +62,10 @@ def open_door(url=FANVIL_URL): def _lookup_mm(card_number: str) -> bool: with open(MM_DATA_FILE) as f: mm_data = json.load(f) + authorized_tags = mm_data.get("tags", []) app.logger.debug( f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}" ) - authorized_tags = mm_data.get("tags", []) locked_out = mm_data.get("locked_out", False) if locked_out: app.logger.info( @@ -147,11 +147,13 @@ def auth(): if success: # Notify the websocket client to send a door_access event if input_type == fanvil.CARD_ID: - door_access_queue.put({ - "id_number": input_value, - "success": True, - "method": "rfid", - }) + door_access_queue.put( + { + "id_number": input_value, + "success": True, + "method": "rfid", + } + ) return return_code_template(200), 200 return return_code_template(401), 401 diff --git a/main.py b/main.py index 3859f8e..01a5428 100644 --- a/main.py +++ b/main.py @@ -8,8 +8,8 @@ import threading -from doorman.app import app -from websocket_client import MMAccessClient, DEBUG +from doorman.app import app # noqa: F401 — gunicorn imports `app` from this module +from websocket_client import DEBUG, MMAccessClient def start_websocket_client(): @@ -18,5 +18,7 @@ def start_websocket_client(): # Start the websocket client in a daemon thread -ws_thread = threading.Thread(target=start_websocket_client, daemon=True, name="mm-ws-client") +ws_thread = threading.Thread( + target=start_websocket_client, daemon=True, name="mm-ws-client" +) ws_thread.start() diff --git a/websocket_client.py b/websocket_client.py index 4ae1053..702c4a8 100644 --- a/websocket_client.py +++ b/websocket_client.py @@ -88,7 +88,9 @@ def send_door_access(self, id_number: str, success: bool, method: str = "rfid"): "method": method, }, } - logger.info(f"Sending door_access: id={id_number} success={success} method={method}") + logger.info( + f"Sending door_access: id={id_number} success={success} method={method}" + ) self.send(json.dumps(packet)) def save_tags(self, data): From 17d512b1b2a065c5fa7eb7b81fc6de8bc81c933c Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Fri, 8 May 2026 12:30:58 -0700 Subject: [PATCH 08/14] Add LDAP notification, mypy type checking --- .isort.cfg | 2 +- .pre-commit-config.yaml | 13 +++++++++ doorman/app.py | 59 ++++++++++++++++++++++++++++------------- doorman/fanvil.py | 6 +++-- envrc.example | 4 +++ main.py | 2 +- requirements.txt | 1 + websocket_client.py | 24 +++++++++-------- 8 files changed, 78 insertions(+), 33 deletions(-) diff --git a/.isort.cfg b/.isort.cfg index 90b4d3c..e4272d7 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,3 +1,3 @@ [settings] profile = black -known_third_party = flask,ldap3,rel,requests,rich,websocket +known_third_party = apprise,flask,ldap3,rel,requests,rich,websocket diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fea6d8b..f2e14d1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,3 +30,16 @@ repos: rev: 26.3.1 hooks: - id: black + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.16.0 + hooks: + - id: mypy + additional_dependencies: + - flask + - types-requests + - apprise + - ldap3 + - websocket-client + - rel + - rich diff --git a/doorman/app.py b/doorman/app.py index da1e465..943b1b4 100644 --- a/doorman/app.py +++ b/doorman/app.py @@ -3,6 +3,7 @@ import os from functools import wraps +import apprise import ldap3 import requests from flask import Flask, Response, request @@ -13,7 +14,10 @@ app = Flask(__name__) -env_bool = lambda s: str(s).lower() in ("1", "t", "true", "y", "yes") + +def env_bool(s) -> bool: + return str(s).lower() in ("1", "t", "true", "y", "yes") + LDAP_ENABLE = env_bool(os.environ.get("DOORMAN_LDAP_ENABLE", True)) MM_ENABLE = env_bool(os.environ.get("DOORMAN_MM_ENABLE", True)) @@ -35,22 +39,23 @@ DOORBELL_WEBHOOK = os.environ.get("DOORMAN_DOORBELL_WEBHOOK") ACCESS_PINS = json.loads(os.environ.get("DOORMAN_ACCESS_PINS", "{}")) SUCCESS_WEBHOOK = os.environ.get("DOORMAN_SUCCESS_WEBHOOK") +LDAP_APPRISE_URL = os.environ.get("DOORMAN_LDAP_APPRISE_URL") def returns_xml(f): @wraps(f) - def decorated_function(*args, **kwargs): + def decorated_function(*args, **kwargs) -> Response: r, status = f(*args, **kwargs) return Response(r, content_type="application/xml; charset=utf-8", status=status) return decorated_function -def return_code_template(status): +def return_code_template(status: int) -> str: return f"""{status}""" -def open_door(url=FANVIL_URL): +def open_door(url: str = FANVIL_URL) -> None: # Change code= to match the value in EGS settings > features > calling # password (* by default) url = f"{FANVIL_URL}/cgi-bin/ConfigManApp.com?Key=F_LOCK&code=*" @@ -81,6 +86,22 @@ def _lookup_mm(card_number: str) -> bool: return False +def _notify_ldap(card_number: str, attributes: dict) -> None: + """Send a notification when access is granted via LDAP.""" + if not LDAP_APPRISE_URL: + return + ap = apprise.Apprise() + ap.add(LDAP_APPRISE_URL) + cn = attributes.get("cn", "unknown") + uid = attributes.get("uid", "unknown") + ap.notify( + title="Doorman", + body=( + f"Card {card_number} was granted access via LDAP.\n" f"CN: {cn}, UID: {uid}" + ), + ) + + def _lookup_ldap(card_number: str) -> bool: ldap_server = ldap3.Server(LDAP_SERVER, use_ssl=LDAP_USE_SSL) with ldap3.Connection(ldap_server, LDAP_USER_DN, LDAP_PASS, auto_bind=True) as conn: @@ -97,6 +118,8 @@ def _lookup_ldap(card_number: str) -> bool: if len(conn.response) == 1: app.logger.info(f"Card found: {conn.response[0]['attributes']}") # TODO: write cn to audit log (influxdb) + if LDAP_APPRISE_URL: + _notify_ldap(card_number, conn.response[0]["attributes"]) if SUCCESS_WEBHOOK: webhook_data = {"_type": "CARD", "card_number": card_number} webhook_data.update(conn.response[0]["attributes"]) @@ -112,13 +135,21 @@ def _lookup_ldap(card_number: str) -> bool: def lookup_card(card_number: str) -> bool: - ret = _lookup_mm(card_number) - if not ret: - ret = _lookup_ldap(card_number) - return ret + mm_authorized = _lookup_mm(card_number) + if mm_authorized: + # Only notify MemberMatters of access if the card was in its own tag list + door_access_queue.put( + { + "id_number": card_number, + "success": True, + "method": "rfid", + } + ) + return True + return _lookup_ldap(card_number) -def lookup_pin(input_value): +def lookup_pin(input_value: str) -> bool: for pin in ACCESS_PINS: if input_value == str(pin): app.logger.info(f"Access granted by PIN for {ACCESS_PINS[pin]}") @@ -145,15 +176,6 @@ def auth(): app.logger.info(f"Got keypad input: {input_value}") success = lookup_pin(input_value) if success: - # Notify the websocket client to send a door_access event - if input_type == fanvil.CARD_ID: - door_access_queue.put( - { - "id_number": input_value, - "success": True, - "method": "rfid", - } - ) return return_code_template(200), 200 return return_code_template(401), 401 @@ -161,5 +183,6 @@ def auth(): @app.route("/fanvil/doorbell", methods=["GET"]) @returns_xml def doorbell(): + """Historical function to work around webhook limitations in Home Assistant""" requests.post(DOORBELL_WEBHOOK) return return_code_template(200), 200 diff --git a/doorman/fanvil.py b/doorman/fanvil.py index 74342f6..c751b19 100644 --- a/doorman/fanvil.py +++ b/doorman/fanvil.py @@ -22,13 +22,13 @@ class CommandParseError(FanvilParseError): pass -def parse_uri(uri): +def parse_uri(uri: str) -> tuple[str, str, str]: command, attr = uri.split(":") key, val = attr.split("=") return command, key, val.rstrip("@1") -def parse_command(xml): +def parse_command(xml: bytes) -> tuple[str, str]: """ Example card read POST payload: @@ -44,6 +44,8 @@ def parse_command(xml): """ root = ET.fromstring(xml) ei = root.find("ExecuteItem") + if ei is None or ei.text is None: + raise FanvilParseError("Missing ExecuteItem element or text") uri = ei.text.split('"')[1] try: diff --git a/envrc.example b/envrc.example index c871dc6..255eeff 100644 --- a/envrc.example +++ b/envrc.example @@ -17,6 +17,10 @@ DOORMAN_LDAP_USE_SSL=true DOORMAN_LDAP_USER_DN="uid=doorman,cn=sysaccounts,cn=etc,dc=pawprint,dc=space" DOORMAN_LDAP_PASS=foobar +# Optional notification for LDAP lookup. Leave unset to disable. +# https://appriseit.com/url-builder/ +#DOORMAN_LDAP_APPRISE_URL= + DOORMAN_FANVIL_URL=https://fanvil.local DOORMAN_FANVIL_USER=admin DOORMAN_FANVIL_PASS=admin diff --git a/main.py b/main.py index 01a5428..a195f7c 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,7 @@ from websocket_client import DEBUG, MMAccessClient -def start_websocket_client(): +def start_websocket_client() -> None: client = MMAccessClient(debug=DEBUG) client.run() diff --git a/requirements.txt b/requirements.txt index 505775f..4f6253b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ Flask>=3.1.2 ldap3>=2.9.1 requests~=2.32.5 +apprise>=1.9.0 #json_logging>=1.2 websocket-client~=1.9.0 rel~=0.4.9.21 diff --git a/websocket_client.py b/websocket_client.py index 702c4a8..9cbc8da 100644 --- a/websocket_client.py +++ b/websocket_client.py @@ -53,7 +53,7 @@ def __init__(self, *args, **kwargs): on_close=self.on_close, ) - def run(self): + def run(self) -> None: self.run_forever( dispatcher=rel, reconnect=15 ) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly @@ -62,7 +62,7 @@ def run(self): rel.signal(2, rel.abort) # Keyboard Interrupt rel.dispatch() - def _poll_door_access_queue(self): + def _poll_door_access_queue(self) -> bool: """Check the shared queue for door_access events and send them.""" try: while True: @@ -77,7 +77,9 @@ def _poll_door_access_queue(self): # Re-schedule ourselves — returning True keeps the rel timeout alive return True - def send_door_access(self, id_number: str, success: bool, method: str = "rfid"): + def send_door_access( + self, id_number: str, success: bool, method: str = "rfid" + ) -> None: """Send a door_access command to the MemberMatters server.""" packet = { "command": "door_access", @@ -93,7 +95,7 @@ def send_door_access(self, id_number: str, success: bool, method: str = "rfid"): ) self.send(json.dumps(packet)) - def save_tags(self, data): + def save_tags(self, data: dict) -> None: with open(MM_DATA_FILE, "w") as tags_file: tags_doc = { "tags": data["tags"], @@ -102,7 +104,7 @@ def save_tags(self, data): } json.dump(tags_doc, tags_file) - def load_tags(self): + def load_tags(self) -> dict: with open(MM_DATA_FILE) as tags_file: data = json.load(tags_file) self.locked_out = data.get("locked_out", False) @@ -114,10 +116,10 @@ def update_device_locked_out(self, locked_out: bool) -> None: self.locked_out = locked_out self.save_tags(tags_doc) - def parse_command(self, data): + def parse_command(self, data: dict) -> None: command = data.get("command") if command == "ping": - websocket.send(json.dumps({"command": "pong"})) + self.send(json.dumps({"command": "pong"})) elif command == "reboot": # Not implemented (lol) @@ -134,7 +136,7 @@ def parse_command(self, data): elif command == "update_device_locked_out": self.update_device_locked_out(data.get("locked_out", False)) - def on_message(self, ws, message): + def on_message(self, ws, message: str) -> None: logger.debug(f"> {message}") try: data = json.loads(message) @@ -143,14 +145,14 @@ def on_message(self, ws, message): logger.exception(e) return - def on_error(self, ws, error): + def on_error(self, ws, error: Exception) -> None: logger.error(f"[SOCKET ERROR]: {error}") - def on_close(self, ws, close_status_code, close_msg): + def on_close(self, ws, close_status_code: int, close_msg: str) -> None: logger.info("### closed ###") logger.info(f"Status: {close_status_code}, Message: {close_msg}") - def on_open(self, data): + def on_open(self, data) -> None: logger.info(f"Opened connection to {self.url}, sending auth.") # Send auth auth_packet = { From 478733ea1d56bda534d30b45a72f28087bb7bbaa Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Wed, 20 May 2026 14:07:08 -0700 Subject: [PATCH 09/14] Rewrite as FastAPI app, add Sentry instrumentation Replace Flask/requests/gunicorn with FastAPI/httpx/uvicorn. The MemberMatters websocket client now runs in a daemon thread started by the FastAPI lifespan, with a separate pump thread forwarding door_access events to the server (drops the rel dispatcher, which can't install signal handlers off the main thread). Other fixes: - Wrap blocking ldap3, file, and apprise calls in asyncio.to_thread so they don't stall the event loop. - Catch FanvilParseError in auth() and add a global exception handler that ensures we always return the delicious XML it likes. - Share a single httpx.AsyncClient on app.state with explicit timeouts for success/doorbell webhooks; guard against None DOORBELL_WEBHOOK. - open_door() is synchronous so the websocket worker thread doesn't spin up a fresh event loop and httpx pool on every bump. - Logging configuration is really pretty in a terminal now. --- .isort.cfg | 2 +- Dockerfile | 28 +++-- doorman/app.py | 264 +++++++++++++++++++++++++++++++------------- doorman/ipc.py | 8 +- envrc.example | 11 +- main.py | 25 +---- requirements.txt | 8 +- websocket_client.py | 67 ++++++----- 8 files changed, 271 insertions(+), 142 deletions(-) diff --git a/.isort.cfg b/.isort.cfg index e4272d7..4ec151a 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,3 +1,3 @@ [settings] profile = black -known_third_party = apprise,flask,ldap3,rel,requests,rich,websocket +known_third_party = apprise,fastapi,httpx,ldap3,rich,sentry_sdk,websocket diff --git a/Dockerfile b/Dockerfile index 633cb87..269c74a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,18 @@ -FROM tiangolo/meinheld-gunicorn-flask:python3.9 -COPY ./doorman /app/doorman -COPY ./websocket_client.py /app -COPY ./main.py /app -COPY requirements.txt /app -RUN pip install --upgrade pip -RUN pip install -r /app/requirements.txt - -ENV MODULE_NAME=main -ENV WEB_CONCURRENCY=1 +FROM python:3.14-alpine3.23 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +COPY ./doorman ./doorman +COPY ./websocket_client.py . +COPY ./main.py . + +EXPOSE 5000 + +# Keep --workers at 1 (uvicorn's default). Each worker spawns its own +# MemberMatters websocket client; multiple workers would produce duplicate +# door_access events. +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"] diff --git a/doorman/app.py b/doorman/app.py index 943b1b4..7eedb92 100644 --- a/doorman/app.py +++ b/doorman/app.py @@ -1,18 +1,47 @@ #!/usr/bin/env python +import asyncio import json +import logging import os -from functools import wraps +from contextlib import asynccontextmanager +from typing import AsyncIterator import apprise +import httpx import ldap3 -import requests -from flask import Flask, Response, request -from requests.auth import HTTPBasicAuth +import sentry_sdk +from fastapi import FastAPI, Request +from fastapi.responses import Response +from rich.logging import RichHandler from . import fanvil from .ipc import door_access_queue -app = Flask(__name__) +logger = logging.getLogger("doorman") + + +def _init_sentry() -> None: + """Initialize Sentry if DOORMAN_SENTRY_DSN is set; no-op otherwise. + + The FastAPI integration is auto-enabled when sentry-sdk sees fastapi + installed, so we don't need to pass integrations= explicitly. This must + run before `app = FastAPI(...)` is constructed. + """ + dsn = os.environ.get("DOORMAN_SENTRY_DSN") + if not dsn: + return + sentry_sdk.init( + dsn=dsn, + environment=os.environ.get("DOORMAN_SENTRY_ENVIRONMENT"), + release=os.environ.get("DOORMAN_SENTRY_RELEASE"), + traces_sample_rate=float( + os.environ.get("DOORMAN_SENTRY_TRACES_SAMPLE_RATE", "0.1") + ), + send_default_pii=True, + ) + + +_init_sentry() def env_bool(s) -> bool: @@ -40,49 +69,117 @@ def env_bool(s) -> bool: ACCESS_PINS = json.loads(os.environ.get("DOORMAN_ACCESS_PINS", "{}")) SUCCESS_WEBHOOK = os.environ.get("DOORMAN_SUCCESS_WEBHOOK") LDAP_APPRISE_URL = os.environ.get("DOORMAN_LDAP_APPRISE_URL") +DEBUG = env_bool(os.environ.get("DEBUG", False)) + +HTTP_TIMEOUT = httpx.Timeout(5.0) + + +def setup_logging() -> None: + """Attach a RichHandler to the doorman + doorman_client loggers. + + Called from the FastAPI lifespan and from websocket_client when run + standalone, so logs appear regardless of which entrypoint is hosting us. + """ + handler = RichHandler() + handler.setFormatter(logging.Formatter("%(message)s", datefmt="[%X]")) + level = logging.DEBUG if DEBUG else logging.INFO + for name in ("doorman", "doorman_client"): + log = logging.getLogger(name) + if not any(isinstance(h, RichHandler) for h in log.handlers): + log.addHandler(handler) + log.setLevel(level) + log.propagate = False + + +def xml_response(status: int) -> Response: + body = f'{status}' + return Response( + content=body, + media_type="application/xml; charset=utf-8", + status_code=status, + ) -def returns_xml(f): - @wraps(f) - def decorated_function(*args, **kwargs) -> Response: - r, status = f(*args, **kwargs) - return Response(r, content_type="application/xml; charset=utf-8", status=status) +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + setup_logging() - return decorated_function + # Single shared HTTP client with explicit timeouts for outbound webhooks. + app.state.http = httpx.AsyncClient(timeout=HTTP_TIMEOUT) + import threading -def return_code_template(status: int) -> str: - return f"""{status}""" + from websocket_client import MMAccessClient + ws_client: dict = {} + + def start_websocket_client() -> None: + try: + client = MMAccessClient(debug=DEBUG) + ws_client["instance"] = client + client.run() + except Exception as e: + logger.exception("MemberMatters websocket client crashed") + sentry_sdk.capture_exception(e) + + ws_thread = threading.Thread( + target=start_websocket_client, daemon=True, name="mm-ws-client" + ) + ws_thread.start() + + try: + yield + finally: + # Close the websocket so run_forever() returns and the pump thread exits. + client = ws_client.get("instance") + if client is not None: + try: + client.close() + except Exception: + logger.exception("Error closing websocket client") + await app.state.http.aclose() -def open_door(url: str = FANVIL_URL) -> None: - # Change code= to match the value in EGS settings > features > calling - # password (* by default) - url = f"{FANVIL_URL}/cgi-bin/ConfigManApp.com?Key=F_LOCK&code=*" - auth = HTTPBasicAuth(FANVIL_USER, FANVIL_PASS) - requests.get(url, auth=auth, verify=FANVIL_VERIFY_CA) +app = FastAPI(lifespan=lifespan) + + +@app.exception_handler(Exception) +async def xml_unhandled_exception_handler(request: Request, exc: Exception) -> Response: + """Fanvil expects XML for every response; never let a JSON 500 leak out.""" + logger.exception("Unhandled exception", exc_info=exc) + sentry_sdk.capture_exception(exc) + return xml_response(500) + + +def open_door() -> None: + """Trigger the Fanvil door relay via its HTTP API. + + Synchronous on purpose: it is called from the websocket worker thread, + which has no event loop. FastAPI handlers should call via asyncio.to_thread. + """ + url = f"{FANVIL_URL}/cgi-bin/ConfigManApp.com?Key=F_LOCK&code=*" + verify = FANVIL_VERIFY_CA if FANVIL_VERIFY_CA else False + with httpx.Client(verify=verify, timeout=HTTP_TIMEOUT) as client: + client.get(url, auth=(FANVIL_USER, FANVIL_PASS)) def _lookup_mm(card_number: str) -> bool: with open(MM_DATA_FILE) as f: mm_data = json.load(f) authorized_tags = mm_data.get("tags", []) - app.logger.debug( + logger.debug( f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}" ) locked_out = mm_data.get("locked_out", False) if locked_out: - app.logger.info( - "This MemberMatters device was set to locked_out by the server!" - ) + logger.info("This MemberMatters device was set to locked_out by the server!") return False if card_number in authorized_tags: - app.logger.info(f"card_number: {card_number} is authorized by MemberMatters") + logger.info(f"card_number: {card_number} is authorized by MemberMatters") return True - app.logger.debug(f"card_number: {card_number} not authorized by MemberMatters") + logger.debug(f"card_number: {card_number} not authorized by MemberMatters") return False @@ -102,40 +199,45 @@ def _notify_ldap(card_number: str, attributes: dict) -> None: ) -def _lookup_ldap(card_number: str) -> bool: +def _ldap_search(card_number: str) -> list: + """Synchronous LDAP search; call via asyncio.to_thread from async code.""" ldap_server = ldap3.Server(LDAP_SERVER, use_ssl=LDAP_USE_SSL) with ldap3.Connection(ldap_server, LDAP_USER_DN, LDAP_PASS, auto_bind=True) as conn: - app.logger.info(conn) + logger.info(str(conn)) search_filter = f"(&(rfidbadge={card_number})(!(nsAccountLock=TRUE)))" - app.logger.debug(f"SearchDN: {LDAP_BASE_DN} Search filter: {search_filter}") + logger.debug(f"SearchDN: {LDAP_BASE_DN} Search filter: {search_filter}") conn.search( LDAP_BASE_DN, search_filter, attributes=["cn", "uid"], ) - app.logger.info(f"Response: {conn.response}") - - if len(conn.response) == 1: - app.logger.info(f"Card found: {conn.response[0]['attributes']}") - # TODO: write cn to audit log (influxdb) - if LDAP_APPRISE_URL: - _notify_ldap(card_number, conn.response[0]["attributes"]) - if SUCCESS_WEBHOOK: - webhook_data = {"_type": "CARD", "card_number": card_number} - webhook_data.update(conn.response[0]["attributes"]) - try: - requests.post(SUCCESS_WEBHOOK, webhook_data) - except requests.RequestException as e: - app.logger.error("Exception while trying to send success webhook:") - app.logger.exception(e) - return True - else: - app.logger.info("Card not found") - return False + logger.info(f"Response: {conn.response}") + return list(conn.response) -def lookup_card(card_number: str) -> bool: - mm_authorized = _lookup_mm(card_number) +async def _lookup_ldap(request: Request, card_number: str) -> bool: + response = await asyncio.to_thread(_ldap_search, card_number) + if len(response) != 1: + logger.info("Card not found") + return False + attributes = response[0]["attributes"] + logger.info(f"Card found: {attributes}") + if LDAP_APPRISE_URL: + await asyncio.to_thread(_notify_ldap, card_number, attributes) + if SUCCESS_WEBHOOK: + webhook_data = {"_type": "CARD", "card_number": card_number} + webhook_data.update(attributes) + try: + await request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data) + except httpx.HTTPError as e: + logger.error("Exception while trying to send success webhook:") + logger.exception(e) + sentry_sdk.capture_exception(e) + return True + + +async def lookup_card(request: Request, card_number: str) -> bool: + mm_authorized = await asyncio.to_thread(_lookup_mm, card_number) if mm_authorized: # Only notify MemberMatters of access if the card was in its own tag list door_access_queue.put( @@ -146,43 +248,57 @@ def lookup_card(card_number: str) -> bool: } ) return True - return _lookup_ldap(card_number) + return await _lookup_ldap(request, card_number) -def lookup_pin(input_value: str) -> bool: +async def lookup_pin(request: Request, input_value: str) -> bool: for pin in ACCESS_PINS: if input_value == str(pin): - app.logger.info(f"Access granted by PIN for {ACCESS_PINS[pin]}") + logger.info(f"Access granted by PIN for {ACCESS_PINS[pin]}") if SUCCESS_WEBHOOK: webhook_data = {"_type": "PIN", "cn": ACCESS_PINS[pin]} try: - requests.post(SUCCESS_WEBHOOK, webhook_data) - except requests.RequestException as e: - app.logger.error("Exception while trying to send success webhook:") - app.logger.exception(e) + await request.app.state.http.post( + SUCCESS_WEBHOOK, data=webhook_data + ) + except httpx.HTTPError as e: + logger.error("Exception while trying to send success webhook:") + logger.exception(e) + sentry_sdk.capture_exception(e) return True return False -@app.route("/", methods=["GET", "POST"]) -@returns_xml -def auth(): +@app.api_route("/", methods=["GET", "POST"]) +async def auth(request: Request) -> Response: if request.method == "POST": - input_type, input_value = fanvil.parse_command(request.get_data()) + body = await request.body() + try: + input_type, input_value = fanvil.parse_command(body) + except fanvil.FanvilParseError as e: + logger.warning(f"Failed to parse Fanvil command: {e}") + return xml_response(401) + success = False if input_type == fanvil.CARD_ID: - app.logger.info(f"Got card: {input_value}") - success = lookup_card(input_value) + logger.info(f"Got card: {input_value}") + success = await lookup_card(request, input_value) elif input_type == fanvil.KEYPAD_INPUT: - app.logger.info(f"Got keypad input: {input_value}") - success = lookup_pin(input_value) + logger.info(f"Got keypad input: {input_value}") + success = await lookup_pin(request, input_value) if success: - return return_code_template(200), 200 - return return_code_template(401), 401 - - -@app.route("/fanvil/doorbell", methods=["GET"]) -@returns_xml -def doorbell(): - """Historical function to work around webhook limitations in Home Assistant""" - requests.post(DOORBELL_WEBHOOK) - return return_code_template(200), 200 + return xml_response(200) + return xml_response(401) + + +@app.get("/fanvil/doorbell") +async def doorbell(request: Request) -> Response: + """Historical function to work around webhook limitations in Home Assistant.""" + if not DOORBELL_WEBHOOK: + return xml_response(200) + try: + await request.app.state.http.post(DOORBELL_WEBHOOK) + except httpx.HTTPError as e: + logger.error("Exception while sending doorbell webhook:") + logger.exception(e) + sentry_sdk.capture_exception(e) + return xml_response(200) diff --git a/doorman/ipc.py b/doorman/ipc.py index 5998606..a108435 100644 --- a/doorman/ipc.py +++ b/doorman/ipc.py @@ -1,10 +1,12 @@ """ -Inter-process communication between the Flask server and the websocket client. +Inter-process communication between the FastAPI server and the websocket client. -Uses a threading.Queue since both components run in the same process. +Uses a threading.Queue since the websocket client runs in a separate thread. +Both asyncio (FastAPI) and synchronous (websocket client) code can safely +interact with threading.Queue. """ import queue -# Flask pushes door_access events here; the websocket client consumes them. +# FastAPI pushes door_access events here; the websocket client consumes them. door_access_queue: queue.Queue = queue.Queue() diff --git a/envrc.example b/envrc.example index 255eeff..1268c06 100644 --- a/envrc.example +++ b/envrc.example @@ -1,6 +1,4 @@ -FLASK_HOST=0.0.0.0 -FLASK_ENV=development -FLASK_APP=doorman.app +# Run with: uvicorn main:app --host 0.0.0.0 --port 5000 # Websocket connection to Member Matters DOORMAN_MM_ENABLE=true @@ -27,8 +25,13 @@ DOORMAN_FANVIL_PASS=admin # Unset to skip SSL verification DOORMAN_FANVIL_CA=pawprint-ca.crt +# Sentry error reporting. Leave DSN unset to disable. +#DOORMAN_SENTRY_DSN= +#DOORMAN_SENTRY_ENVIRONMENT=production +#DOORMAN_SENTRY_RELEASE= +#DOORMAN_SENTRY_TRACES_SAMPLE_RATE=0.1 + # Uncomment for .envrc use: -#export FLASK_HOST FLASK_ENV FLASK_APP #export DOORMAN_MM_ENABLE DOORMAN_MM_ACCESS_URL DOORMAN_MM_DEVICE_NAME DOORMAN_MM_API_KEY #export DOORMAN_LDAP_ENABLE DOORMAN_LDAP_SERVER DOORMAN_LDAP_USE_SSL DOORMAN_LDAP_USER_DN DOORMAN_LDAP_PASS #export DOORMAN_FANVIL_URL DOORMAN_FANVIL_PASS DOORMAN_FANVIL_CA diff --git a/main.py b/main.py index a195f7c..ae5ed15 100644 --- a/main.py +++ b/main.py @@ -1,24 +1,11 @@ """ -Unified entrypoint that runs the Flask server and the MemberMatters +Unified entrypoint that runs the FastAPI server and the MemberMatters websocket client in the same process. -The Flask app is exposed as `app` for gunicorn/meinheld to pick up. -The websocket client runs in a daemon thread so it dies with the process. -""" - -import threading - -from doorman.app import app # noqa: F401 — gunicorn imports `app` from this module -from websocket_client import DEBUG, MMAccessClient - - -def start_websocket_client() -> None: - client = MMAccessClient(debug=DEBUG) - client.run() +Run with: + uvicorn main:app --host 0.0.0.0 --port 5000 +The websocket client is started via the FastAPI lifespan handler. +""" -# Start the websocket client in a daemon thread -ws_thread = threading.Thread( - target=start_websocket_client, daemon=True, name="mm-ws-client" -) -ws_thread.start() +from doorman.app import app # noqa: F401 — uvicorn imports `app` from this module diff --git a/requirements.txt b/requirements.txt index 4f6253b..1189f72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -Flask>=3.1.2 +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 ldap3>=2.9.1 -requests~=2.32.5 +httpx>=0.27.0 apprise>=1.9.0 -#json_logging>=1.2 websocket-client~=1.9.0 -rel~=0.4.9.21 rich~=14.2.0 +sentry-sdk>=2.0 diff --git a/websocket_client.py b/websocket_client.py index 9cbc8da..a07eee7 100644 --- a/websocket_client.py +++ b/websocket_client.py @@ -2,12 +2,12 @@ import logging import os import queue +import threading import time from enum import Enum -import rel +import sentry_sdk import websocket -from rich.logging import RichHandler from doorman.app import env_bool, open_door from doorman.ipc import door_access_queue @@ -32,13 +32,7 @@ def __str__(self): MM_API_KEY = os.environ.get("DOORMAN_MM_API_KEY", "unset") DEBUG = env_bool(os.environ.get("DEBUG", False)) -logging.basicConfig( - level=logging.NOTSET, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()] -) logger = logging.getLogger("doorman_client") -logger.setLevel(logging.INFO) -if DEBUG: - logger.setLevel(logging.DEBUG) class MMAccessClient(websocket.WebSocketApp): @@ -54,28 +48,37 @@ def __init__(self, *args, **kwargs): ) def run(self) -> None: - self.run_forever( - dispatcher=rel, reconnect=15 - ) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly - # Poll the door_access queue every 0.5s and forward events to the server - rel.timeout(0.5, self._poll_door_access_queue) - rel.signal(2, rel.abort) # Keyboard Interrupt - rel.dispatch() - - def _poll_door_access_queue(self) -> bool: - """Check the shared queue for door_access events and send them.""" + # rel's dispatcher installs a SIGINT handler at construction time, + # which fails off the main thread. Use run_forever()'s default select + # loop and pump the door_access queue from a separate daemon thread. + self._pump_stop = threading.Event() + pump = threading.Thread( + target=self._pump_door_access_queue, + daemon=True, + name="mm-ws-queue-pump", + ) + pump.start() try: - while True: - event = door_access_queue.get_nowait() + self.run_forever(reconnect=15) + finally: + self._pump_stop.set() + + def _pump_door_access_queue(self) -> None: + """Forward queued door_access events to the server until stopped.""" + while not self._pump_stop.is_set(): + try: + event = door_access_queue.get(timeout=0.5) + except queue.Empty: + continue + try: self.send_door_access( id_number=event["id_number"], success=event["success"], method=event.get("method", "rfid"), ) - except queue.Empty: - pass - # Re-schedule ourselves — returning True keeps the rel timeout alive - return True + except Exception as e: + logger.exception("Error sending door_access event") + sentry_sdk.capture_exception(e) def send_door_access( self, id_number: str, success: bool, method: str = "rfid" @@ -126,8 +129,13 @@ def parse_command(self, data: dict) -> None: pass elif command == "bump": - # Bump the door open - open_door() + # Bump the door open. open_door is synchronous so we don't need + # to spin up an event loop in this websocket worker thread. + try: + open_door() + except Exception as e: + logger.exception("Error opening door from bump command") + sentry_sdk.capture_exception(e) elif command == "sync": # Save synced tags to a file @@ -143,10 +151,12 @@ def on_message(self, ws, message: str) -> None: self.parse_command(data) except json.decoder.JSONDecodeError as e: logger.exception(e) + sentry_sdk.capture_exception(e) return def on_error(self, ws, error: Exception) -> None: logger.error(f"[SOCKET ERROR]: {error}") + sentry_sdk.capture_exception(error) def on_close(self, ws, close_status_code: int, close_msg: str) -> None: logger.info("### closed ###") @@ -163,6 +173,9 @@ def on_open(self, data) -> None: if __name__ == "__main__": - # Standalone mode: run without Flask (useful for local testing) + # Standalone mode: run without FastAPI (useful for local testing). + from doorman.app import setup_logging + + setup_logging() client = MMAccessClient(debug=DEBUG) client.run() From 580c3964fcdeac46e2940595919cb4fe38b9388d Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Wed, 20 May 2026 20:25:50 -0700 Subject: [PATCH 10/14] add server ping timeout and cron to send keep-alives --- envrc.example | 5 ++++- websocket_client.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/envrc.example b/envrc.example index 1268c06..9454ef3 100644 --- a/envrc.example +++ b/envrc.example @@ -6,6 +6,9 @@ DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access DOORMAN_MM_DEVICE_NAME=frontdoor_test DOORMAN_MM_API_KEY=super.secret +# Keep-alive ping interval (seconds). Pong timeout = 3× this value. +#DOORMAN_MM_PING_INTERVAL=30 + # LDAP Configuration DOORMAN_LDAP_ENABLE=true DOORMAN_LDAP_SERVER=ipa.pawprint.space @@ -32,6 +35,6 @@ DOORMAN_FANVIL_CA=pawprint-ca.crt #DOORMAN_SENTRY_TRACES_SAMPLE_RATE=0.1 # Uncomment for .envrc use: -#export DOORMAN_MM_ENABLE DOORMAN_MM_ACCESS_URL DOORMAN_MM_DEVICE_NAME DOORMAN_MM_API_KEY +#export DOORMAN_MM_ENABLE DOORMAN_MM_ACCESS_URL DOORMAN_MM_DEVICE_NAME DOORMAN_MM_API_KEY DOORMAN_MM_PING_INTERVAL #export DOORMAN_LDAP_ENABLE DOORMAN_LDAP_SERVER DOORMAN_LDAP_USE_SSL DOORMAN_LDAP_USER_DN DOORMAN_LDAP_PASS #export DOORMAN_FANVIL_URL DOORMAN_FANVIL_PASS DOORMAN_FANVIL_CA diff --git a/websocket_client.py b/websocket_client.py index a07eee7..cb6460c 100644 --- a/websocket_client.py +++ b/websocket_client.py @@ -30,6 +30,7 @@ def __str__(self): ] MM_DATA_FILE = os.environ.get("DOORMAN_MM_DATA_FILE", "/tmp/mm-doorman.json") MM_API_KEY = os.environ.get("DOORMAN_MM_API_KEY", "unset") +MM_PING_INTERVAL = int(os.environ.get("DOORMAN_MM_PING_INTERVAL", "30")) DEBUG = env_bool(os.environ.get("DEBUG", False)) logger = logging.getLogger("doorman_client") @@ -50,14 +51,21 @@ def __init__(self, *args, **kwargs): def run(self) -> None: # rel's dispatcher installs a SIGINT handler at construction time, # which fails off the main thread. Use run_forever()'s default select - # loop and pump the door_access queue from a separate daemon thread. + # loop and pump work from daemon threads. self._pump_stop = threading.Event() + self._last_pong = time.monotonic() pump = threading.Thread( target=self._pump_door_access_queue, daemon=True, name="mm-ws-queue-pump", ) + keepalive = threading.Thread( + target=self._pump_keepalive, + daemon=True, + name="mm-ws-keepalive", + ) pump.start() + keepalive.start() try: self.run_forever(reconnect=15) finally: @@ -80,6 +88,36 @@ def _pump_door_access_queue(self) -> None: logger.exception("Error sending door_access event") sentry_sdk.capture_exception(e) + def _pump_keepalive(self) -> None: + """Send periodic client→server pings; force reconnect on pong timeout.""" + timeout_seconds = MM_PING_INTERVAL * 3 + while not self._pump_stop.wait(MM_PING_INTERVAL): + sock = self.sock + if sock is None or not sock.connected: + # Reset so a freshly reopened socket isn't immediately stale. + self._last_pong = time.monotonic() + continue + + if time.monotonic() - self._last_pong > timeout_seconds: + logger.warning( + "MemberMatters pong timeout (%ss), forcing reconnect", + timeout_seconds, + ) + self._last_pong = time.monotonic() + try: + self.close() + except Exception as e: + logger.exception("Error closing socket on pong timeout") + sentry_sdk.capture_exception(e) + continue + + try: + logger.debug("sending keep-alive ping") + self.send(json.dumps({"command": "ping"})) + except Exception as e: + logger.exception("Error sending keep-alive ping") + sentry_sdk.capture_exception(e) + def send_door_access( self, id_number: str, success: bool, method: str = "rfid" ) -> None: @@ -124,6 +162,9 @@ def parse_command(self, data: dict) -> None: if command == "ping": self.send(json.dumps({"command": "pong"})) + elif command == "pong": + self._last_pong = time.monotonic() + elif command == "reboot": # Not implemented (lol) pass @@ -163,6 +204,7 @@ def on_close(self, ws, close_status_code: int, close_msg: str) -> None: logger.info(f"Status: {close_status_code}, Message: {close_msg}") def on_open(self, data) -> None: + self._last_pong = time.monotonic() logger.info(f"Opened connection to {self.url}, sending auth.") # Send auth auth_packet = { From 7a5fc60e2a0a64d2a6d65e98c89df5296906e600 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Wed, 20 May 2026 20:27:08 -0700 Subject: [PATCH 11/14] Glitchtip-specific sentry config --- doorman/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doorman/app.py b/doorman/app.py index 7eedb92..a4ca1b8 100644 --- a/doorman/app.py +++ b/doorman/app.py @@ -38,6 +38,8 @@ def _init_sentry() -> None: os.environ.get("DOORMAN_SENTRY_TRACES_SAMPLE_RATE", "0.1") ), send_default_pii=True, + auto_session_tracking=False, # GlitchTip does not support sessions + # enable_logs=True, # Opt-in: send logs to GlitchTip (uses disk space) ) From 40b78fb7f993eabdfae8f5657f332ab07a9ef205 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Wed, 20 May 2026 20:46:58 -0700 Subject: [PATCH 12/14] Webhook and telegram notifications to best-effort --- doorman/app.py | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/doorman/app.py b/doorman/app.py index a4ca1b8..048a228 100644 --- a/doorman/app.py +++ b/doorman/app.py @@ -102,6 +102,24 @@ def xml_response(status: int) -> Response: ) +_background_tasks: set[asyncio.Task] = set() + + +def _fire_and_forget(coro) -> None: + """Schedule a coroutine as a background task with error logging.""" + + async def _wrapper(): + try: + await coro + except Exception as e: + logger.error(f"Background task failed: {e}") + sentry_sdk.capture_exception(e) + + task = asyncio.create_task(_wrapper()) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: setup_logging() @@ -225,16 +243,13 @@ async def _lookup_ldap(request: Request, card_number: str) -> bool: attributes = response[0]["attributes"] logger.info(f"Card found: {attributes}") if LDAP_APPRISE_URL: - await asyncio.to_thread(_notify_ldap, card_number, attributes) + _fire_and_forget(asyncio.to_thread(_notify_ldap, card_number, attributes)) if SUCCESS_WEBHOOK: webhook_data = {"_type": "CARD", "card_number": card_number} webhook_data.update(attributes) - try: - await request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data) - except httpx.HTTPError as e: - logger.error("Exception while trying to send success webhook:") - logger.exception(e) - sentry_sdk.capture_exception(e) + _fire_and_forget( + request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data) + ) return True @@ -259,14 +274,9 @@ async def lookup_pin(request: Request, input_value: str) -> bool: logger.info(f"Access granted by PIN for {ACCESS_PINS[pin]}") if SUCCESS_WEBHOOK: webhook_data = {"_type": "PIN", "cn": ACCESS_PINS[pin]} - try: - await request.app.state.http.post( - SUCCESS_WEBHOOK, data=webhook_data - ) - except httpx.HTTPError as e: - logger.error("Exception while trying to send success webhook:") - logger.exception(e) - sentry_sdk.capture_exception(e) + _fire_and_forget( + request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data) + ) return True return False From 51703f19c9149ef05a058b91cfa76905b63d6e08 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Thu, 21 May 2026 10:44:29 -0700 Subject: [PATCH 13/14] Update mypy dependencies list --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2e14d1..291f70e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,10 +36,10 @@ repos: hooks: - id: mypy additional_dependencies: - - flask - - types-requests + - fastapi + - httpx - apprise - ldap3 - websocket-client - - rel - rich + - sentry-sdk From a8f75ab7bda0a474f21d6dd4d5a0161fa9ab766a Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Sun, 24 May 2026 21:43:56 -0700 Subject: [PATCH 14/14] Add GHA to build docker container --- .github/workflows/build-image.yml | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/build-image.yml diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..c47780b --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,50 @@ +name: Build and Push Docker Image + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }}