From 11e5b6e0233566cbaa96b69e81165965a774ae43 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Thu, 29 May 2025 14:56:08 +0200 Subject: [PATCH] Handle HTTPS to CCU Jack --- README.md | 58 +++++++++++++++++++++++++++++++++++++++ hmdooris/AppConfig.py | 27 ++++++++++-------- hmdooris/BottleHelpers.py | 55 +++++++++++++++++++++++++++++++++++++ hmdooris/__main__.py | 31 ++++----------------- hmdooris/ccujack.py | 15 ++++++---- 5 files changed, 144 insertions(+), 42 deletions(-) create mode 100644 hmdooris/BottleHelpers.py diff --git a/README.md b/README.md index 41fa2e8..5751428 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,63 @@ # hmdooris - Dooris via HomeMatic +## Configuration + +All configuration is handled through environment variables. + +| Name | Default | Description | +|---------------------------------|-------------------------------------------------------------------------|------------------------------------------------------------------------------------------| +| `HMDOORIS_URL` | `http://localhost:3000` | URL of the application, used to construct links to itself | +| `HMDOORIS_DISCOVERY_URL` | `http://localhost:8080/realms/testing/.well-known/openid-configuration` | OIDC configuration discovery URL | +| `HMDOORIS_CLIENT_ID` | `hmdooris` | OIDC client ID | +| `HMDOORIS_CLIENT_SECRET` | - | ODIC client secret for the confidential flow | +| `IDINVITE_OIDC_SCOPE` | `["openid", "email", "profile"]` | JSON list of OIDC scopes to request. The OIDC IDP will need to send the group attribute. | +| `IDINVITE_OIDC_USER_ATTR` | `email` | The attribute to use as the user ID | +| `HMDOORIS_REQUIRES_GROUP` | - | Set to require users to be a member of this groups. | +| `HMDOORIS_CCUJACK_URL` | `https://raspberrymatic:2122` | URL of the CCU Jack server | +| `HMDOORIS_CCU_CERTIFICATE_PATH` | - | File of a private certificate, or `false` | +| `HMDOORIS_CCUJACK_USERNAME` | - | Username in CCU Jack | +| `HMDOORIS_CCUJACK_PASSWORD` | - | Password in CCU Jack | + +### Required Group + +If you would like to restrict lock operations to members of a particular group, configure the OIDC client to add group +information to the ID token, and set `HMDOORIS_REQUIRES_GROUP` to the name of the group you would like to use. + +Otherwise, all users that can authenticate successfully can operate the locks. + +### TLS Certificate Configuration + +If you'd like to secure access to CCU Jack via TLS, you either need to install a publically trusted certificate on +RaspberryMatic. If you are using a private certificate, you will need to use `HMDOORIS_CCU_CERTIFICATE_PATH` to point +the HTTP client to a suitable CA certificate. Setting the variable to `false` will disable certificate verification. +Alternatively, you can use plain `http`. + +## Managing the CCU certificate + +If you want to talk to the RaspberryMatic/CCU-Jack and you are using a self-signed certificate (which is the default), +you will need to supply that certificate to `hmdooris`. + +1. Create a self-signed certificate: + +```shell +openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \ + -nodes -keyout hmdooris-ccu.ccchh.net.key -out hmdooris-ccu.ccchh.net.crt -subj "/CN=hmdooris-ccu.ccchh.net" \ + -addext "subjectAltName=DNS:hmdooris-ccu.ccchh.net" +cat hmdooris-ccu.ccchh.net.crt hmdooris-ccu.ccchh.net.key >hmdooris-ccu.ccchh.net.certkey.pem +``` + +2. Save the certificate to a file: + +```shell +echo | \ + openssl s_client -servername hmdooris-ccu.ccchh.net -connect hmdooris-ccu.ccchh.net:2122 | \ + openssl x509 -text >self-signed.cert +``` + +2. Start `hmdooris` and pass the path to the file in the environment variable `HMDOORIS_CCU_CERTIFICATE_PATH`. + +If you only want to use http, or your CCU has a public certificate (from for example Let's Encrypt), then you don't need +to do anything. ## Local Development Setup with Docker Compose diff --git a/hmdooris/AppConfig.py b/hmdooris/AppConfig.py index b38fd7b..d4380d2 100644 --- a/hmdooris/AppConfig.py +++ b/hmdooris/AppConfig.py @@ -1,6 +1,10 @@ import json +import logging +import os +import stat from json import JSONDecodeError from os import getenv, path +from pathlib import Path class AppConfig: @@ -12,9 +16,7 @@ class AppConfig: self.debug = getenv("DEBUG", None) self.staticpath = path.join(self.basepath, "static") self.templatepath = path.join(self.basepath, "templates") - self.app_email_base_uri = getenv('APP_EMAIL_BASE_URI', 'http://localhost:3000') - self.app_keycloak_base_uri = getenv('APP_KEYCLOAK_BASE_URI', 'http://localhost:3000') - self.url = getenv('IDINVITE_URL', 'http://localhost:3000') + self.url = getenv('HMDOORIS_URL', 'http://localhost:3000') self.discovery_url = getenv('HMDOORIS_DISCOVERY_URL', 'http://localhost:8080/realms/testing/.well-known/openid-configuration') self.client_id = getenv('HMDOORIS_CLIENT_ID', 'hmdooris') @@ -22,25 +24,28 @@ class AppConfig: self.oidc_scope = json.loads(getenv('IDINVITE_OIDC_SCOPE', '["openid", "email", "profile"]')) self.oidc_user_attr = getenv('IDINVITE_OIDC_USER_ATTR', 'email') self.requires_group = getenv('HMDOORIS_REQUIRES_GROUP', None) - self.ccujack_url = getenv('HMDOORIS_CCUJACK_URL', None) + self.ccujack_url = getenv('HMDOORIS_CCUJACK_URL', 'https://raspberrymatic:2122') + self.ccujack_certificate_path = getenv('HMDOORIS_CCU_CERTIFICATE_PATH', None) self.ccujack_username = getenv('HMDOORIS_CCUJACK_USERNAME', None) self.ccujack_password = getenv('HMDOORIS_CCUJACK_PASSWORD', None) + self.log = logging.getLogger(__name__) - if self.debug is not None and self.debug != "0" and self.debug.lower() != "false": + if self.debug is not None and self.debug.lower not in ('0', 'f', 'false'): self.debug = True else: self.debug = False - try: - self.ccujack_locks = json.loads(getenv('HMDOORIS_CCUJACK_LOCKS', '[]')) - except json.decoder.JSONDecodeError as e: - raise ValueError(f"Unable to decode HMDOORIS_CCUJACK_LOCKS=\"{getenv('HMDOORIS_CCUJACK_LOCKS', '{}')}\"", e) if self.client_secret is None or self.client_secret == '': raise ValueError('You need to provide HMDOORIS_CLIENT_SECRET') if self.ccujack_url is None or self.ccujack_url == '': raise ValueError('You need to provide HMDOORIS_CCUJACK_URL') - if len(self.ccujack_locks) == 0: - raise ValueError('You need to provide HMDOORIS_CCUJACK_LOCKS as a JSON object of locks') + if self.ccujack_certificate_path is not None: + if self.ccujack_certificate_path.lower() in ('0', 'f', 'false'): + self.ccujack_certificate_path = False + else: + p = Path(self.ccujack_certificate_path) + if not p.is_file(): + self.log.warning(f'Unable to read certificate file {self.ccujack_certificate_path}, certificate verification might not work') self.oidc = { 'client_id': self.client_id, diff --git a/hmdooris/BottleHelpers.py b/hmdooris/BottleHelpers.py new file mode 100644 index 0000000..92ad45c --- /dev/null +++ b/hmdooris/BottleHelpers.py @@ -0,0 +1,55 @@ +from ipaddress import ip_address, IPv4Address, ip_network +from typing import Callable, List + +from BottleOIDC import BottleOIDC +from BottleOIDC.bottle_utils import UnauthorizedError +from bottle import request + + +class BottleHelpers: + def __init__(self, auth: BottleOIDC, allowed=None, group=None): + if allowed is None: + self.allowed = [] + else: + self.allowed = [ip_network(a) for a in allowed] + self.auth = auth + self.group = group + + def require_login(self, func: Callable) -> Callable: + if self.group is not None: + return self.auth.require_login(auth.require_attribute('groups', self.group)(func)) + else: + return self.auth.require_login(func) + + def require_authz(self, func: Callable) -> Callable: + if self.group is not None: + return self.auth.require_attribute('groups', self.group)(func) + else: + def _outer_wrapper(f): + def _wrapper(*args, **kwargs): + if self.auth.my_username is not None: + return f(*args, **kwargs) + + return UnauthorizedError('Not Authorized') + + _wrapper.__name__ = f.__name__ + return _wrapper + + return _outer_wrapper(func) + + def require_sourceip(self, func: Callable) -> Callable: + if self.allowed is None or len(self.allowed) == 0: + return func + + def _outer_wrapper(f): + def _wrapper(*args, **kwargs): + addr = ip_network(request.remote_addr) + for allowed in self.allowed: + if addr.overlaps(allowed): + return f(*args, **kwargs) + return UnauthorizedError('Not Authorized') + + _wrapper.__name__ = f.__name__ + return _wrapper + + return _outer_wrapper(func) diff --git a/hmdooris/__main__.py b/hmdooris/__main__.py index c2cc22f..c0802d9 100644 --- a/hmdooris/__main__.py +++ b/hmdooris/__main__.py @@ -14,6 +14,7 @@ from bottle_websocket import websocket, GeventWebSocketServer from geventwebsocket.websocket import WebSocket from hmdooris.AppConfig import AppConfig +from hmdooris.BottleHelpers import BottleHelpers from hmdooris.ccujack import CCUJack from hmdooris.updatepoller import UpdatePoller from hmdooris.websocketcomm import WebSocketClients @@ -22,7 +23,7 @@ config = AppConfig() if config.debug: logging.basicConfig(level=logging.DEBUG) -ccujack = CCUJack(config.ccujack_url, config.ccujack_username, config.ccujack_password, config.ccujack_locks) +ccujack = CCUJack(config.ccujack_url, config.ccujack_certificate_path, config.ccujack_username, config.ccujack_password) app = Bottle() if config.debug: @@ -39,30 +40,9 @@ auth = BottleOIDC(app, config={ }) websocket_clients = WebSocketClients() +bottle_helpers = BottleHelpers(auth, config.requires_group) update_poller = UpdatePoller(websocket_clients, ccujack, 1 if config.debug else 0.1) -def require_login(func: Callable) -> Callable: - if config.requires_group is not None: - return auth.require_login(auth.require_attribute('groups', config.requires_group)(func)) - else: - return auth.require_login(func) - -def require_authz(func: Callable) -> Callable: - if config.requires_group is not None: - return auth.require_attribute('groups', config.requires_group)(func) - else: - def _outer_wrapper(f): - def _wrapper(*args, **kwargs): - if auth.my_username is not None: - return f(*args, **kwargs) - - return UnauthorizedError('Not Authorized') - - _wrapper.__name__ = f.__name__ - return _wrapper - - return _outer_wrapper(func) - @app.route("/static/") def server_static(filepath): @@ -71,11 +51,12 @@ def server_static(filepath): @app.get("/") @jinja2_view("home.html.j2") +@bottle_helpers.require_sourceip def root(): return {} @app.get("/operate") -@require_login +@bottle_helpers.require_login @jinja2_view("operate.html.j2") def root(): return {} @@ -103,7 +84,7 @@ def get_api_lock(id): return update_poller.get_lock(id) @app.post('/api/lock/') -@require_authz +@bottle_helpers.require_authz def post_api_lock(id): return ccujack.lock_unlock(id, request.json["locking"]) diff --git a/hmdooris/ccujack.py b/hmdooris/ccujack.py index 12ff91f..62da98e 100644 --- a/hmdooris/ccujack.py +++ b/hmdooris/ccujack.py @@ -1,5 +1,6 @@ import json import logging +import ssl from typing import List, Dict import requests @@ -35,18 +36,20 @@ class CCUJackHmIPDLD: class CCUJack: - def __init__(self, url: str, username: str, password: str, locks: Dict): + def __init__(self, url: str, certpath: str, username: str, password: str): self.log = logging.getLogger(__name__) self.url = url - self.username = username - self.password = password - self.auth = HTTPBasicAuth(username, password) + self.kvargs = {} + if username is not None and username != '': + self.kvargs['auth'] = HTTPBasicAuth(username, password) + if certpath is not None and certpath != '': + self.kvargs['verify'] = certpath self.locks : Dict[str, CCUJackHmIPDLD] = {} self.get_all_locks() def get_json(self, url: str): url = self.url + url - r = requests.get(url, auth=self.auth) + r = requests.get(url, **self.kvargs) if r.status_code != 200: raise AppException(f"Unable to talk to CCU at {url}: {r.status_code}") try: @@ -57,7 +60,7 @@ class CCUJack: def put_json(self, url: str, data: dict): url = self.url + url - r = requests.put(url, auth=self.auth, json=data) + r = requests.put(url, json=data, **self.kvargs) if r.status_code != 200: raise AppException(f"Unable to talk to CCU at {url}: {r.status_code}") try: