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/.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 }} diff --git a/.gitignore b/.gitignore index a7207bb..6f0012c 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,5 @@ cython_debug/ .envrc .idea + +mm-doorman.json diff --git a/.isort.cfg b/.isort.cfg index 3b6e9ad..4ec151a 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,3 @@ [settings] -known_third_party = flask,ldap3,requests +profile = black +known_third_party = apprise,fastapi,httpx,ldap3,rich,sentry_sdk,websocket diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7539c68..291f70e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,29 +1,45 @@ -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: 9.0.0a3 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 - rev: v2.4.0 + - repo: https://github.com/pre-commit/pre-commit-hooks + 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: 19.10b0 + rev: 26.3.1 hooks: - id: black + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.16.0 + hooks: + - id: mypy + additional_dependencies: + - fastapi + - httpx + - apprise + - ldap3 + - websocket-client + - rich + - sentry-sdk diff --git a/Dockerfile b/Dockerfile index 4c30dca..269c74a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,18 @@ -FROM tiangolo/meinheld-gunicorn-flask:python3.9 -COPY ./doorman /app/doorman -COPY requirements.txt /app -RUN pip install --upgrade pip -RUN pip install -r /app/requirements.txt +FROM python:3.14-alpine3.23 -ENV MODULE_NAME=doorman.app +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/README.md b/README.md index 9c69732..fb106b8 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**. diff --git a/docs/mm-api-key.png b/docs/mm-api-key.png new file mode 100644 index 0000000..ceef572 Binary files /dev/null and b/docs/mm-api-key.png differ diff --git a/doorman/__init__.py b/doorman/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/doorman/app.py b/doorman/app.py index 8eaa120..048a228 100644 --- a/doorman/app.py +++ b/doorman/app.py @@ -1,27 +1,68 @@ #!/usr/bin/env python +import asyncio import json +import logging import os -import socket -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, + auto_session_tracking=False, # GlitchTip does not support sessions + # enable_logs=True, # Opt-in: send logs to GlitchTip (uses disk space) + ) + + +_init_sentry() + + +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)) +MM_DATA_FILE = os.environ.get("DOORMAN_MM_DATA_FILE", "/tmp/mm-doorman.json") LDAP_SERVER = os.environ.get("DOORMAN_LDAP_SERVER", "localhost") -LDAP_USE_SSL = os.environ.get("DOORMAN_LDAP_USE_SSL", False) +LDAP_USE_SSL = env_bool(os.environ.get("DOORMAN_LDAP_USE_SSL", False)) LDAP_BASE_DN = os.environ.get( "DOORMAN_LDAP_BASE_DN", "cn=users,cn=accounts,dc=pawprint,dc=space" ) 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") @@ -29,103 +70,247 @@ 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") +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 returns_xml(f): - @wraps(f) - def decorated_function(*args, **kwargs): - r, status = f(*args, **kwargs) - return Response(r, content_type="application/xml; charset=utf-8", status=status) - return decorated_function +def xml_response(status: int) -> Response: + body = f'{status}' + return Response( + content=body, + media_type="application/xml; charset=utf-8", + status_code=status, + ) -def return_code_template(status): - return f"""{status}""" +_background_tasks: set[asyncio.Task] = set() -def open_door(remote_addr): - if FANVIL_SSL: - protocol = "https" - else: - protocol = "http" +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() + + # Single shared HTTP client with explicit timeouts for outbound webhooks. + app.state.http = httpx.AsyncClient(timeout=HTTP_TIMEOUT) + + import threading + + 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: - 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 + 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() + - # 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=*" - auth = HTTPBasicAuth(FANVIL_USER, FANVIL_PASS) +app = FastAPI(lifespan=lifespan) - requests.get(url, auth=auth, verify=FANVIL_VERIFY_CA) +@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 lookup_card(card_number): + +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", []) + 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: + logger.info("This MemberMatters device was set to locked_out by the server!") + return False + + if card_number in authorized_tags: + logger.info(f"card_number: {card_number} is authorized by MemberMatters") + return True + + logger.debug(f"card_number: {card_number} not authorized by MemberMatters") + 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 _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"], + 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 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") + logger.info(f"Response: {conn.response}") + return list(conn.response) + + +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: + _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) + _fire_and_forget( + request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data) + ) + return True -def lookup_pin(input_value): +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( + { + "id_number": card_number, + "success": True, + "method": "rfid", + } + ) + return True + return await _lookup_ldap(request, card_number) + + +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) + _fire_and_forget( + request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data) + ) 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 + return xml_response(200) + return xml_response(401) -@app.route("/fanvil/doorbell", methods=["GET"]) -@returns_xml -def doorbell(): - requests.post(DOORBELL_WEBHOOK) - return return_code_template(200), 200 +@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/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/doorman/ipc.py b/doorman/ipc.py new file mode 100644 index 0000000..a108435 --- /dev/null +++ b/doorman/ipc.py @@ -0,0 +1,12 @@ +""" +Inter-process communication between the FastAPI server and the websocket client. + +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 + +# 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 5687c7c..9454ef3 100644 --- a/envrc.example +++ b/envrc.example @@ -1,18 +1,40 @@ -source venv/bin/activate +# Run with: uvicorn main:app --host 0.0.0.0 --port 5000 -export FLASK_HOST=0.0.0.0 -export FLASK_ENV=development -export FLASK_APP=doorman.app +# Websocket connection to Member Matters +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 -export DOORMAN_LDAP_SERVER=ipa.pawprint.space -export DOORMAN_LDAP_USE_SSL=1 +# 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 +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 + +# Optional notification for LDAP lookup. Leave unset to disable. +# https://appriseit.com/url-builder/ +#DOORMAN_LDAP_APPRISE_URL= -export DOORMAN_FANVIL_SSL=1 -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 + +# 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 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/main.py b/main.py new file mode 100644 index 0000000..ae5ed15 --- /dev/null +++ b/main.py @@ -0,0 +1,11 @@ +""" +Unified entrypoint that runs the FastAPI server and the MemberMatters +websocket client in the same process. + +Run with: + uvicorn main:app --host 0.0.0.0 --port 5000 + +The websocket client is started via the FastAPI lifespan handler. +""" + +from doorman.app import app # noqa: F401 — uvicorn imports `app` from this module diff --git a/requirements.txt b/requirements.txt index a5e71de..1189f72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ -Flask>=2.2.5 +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 ldap3>=2.9.1 -requests~=2.32.3 -#json_logging>=1.2 +httpx>=0.27.0 +apprise>=1.9.0 +websocket-client~=1.9.0 +rich~=14.2.0 +sentry-sdk>=2.0 diff --git a/websocket_client.py b/websocket_client.py new file mode 100644 index 0000000..cb6460c --- /dev/null +++ b/websocket_client.py @@ -0,0 +1,223 @@ +import json +import logging +import os +import queue +import threading +import time +from enum import Enum + +import sentry_sdk +import websocket + +from doorman.app import env_bool, open_door +from doorman.ipc import door_access_queue + + +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") +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") + + +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) -> 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 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: + 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 Exception as e: + 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: + """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: dict) -> None: + 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) -> dict: + 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: dict) -> None: + command = data.get("command") + if command == "ping": + self.send(json.dumps({"command": "pong"})) + + elif command == "pong": + self._last_pong = time.monotonic() + + elif command == "reboot": + # Not implemented (lol) + pass + + elif command == "bump": + # 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 + 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: str) -> None: + logger.debug(f"> {message}") + try: + data = json.loads(message) + 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 ###") + 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 = { + "command": "authenticate", + "secret_key": MM_API_KEY, + } + self.send(json.dumps(auth_packet)) + + +if __name__ == "__main__": + # Standalone mode: run without FastAPI (useful for local testing). + from doorman.app import setup_logging + + setup_logging() + client = MMAccessClient(debug=DEBUG) + client.run()