From 401bea892704de599657d67d5509a6f05e3ceb91 Mon Sep 17 00:00:00 2001 From: dario Date: Mon, 18 May 2026 20:32:12 +0200 Subject: [PATCH 01/46] app: add cat language --- app/src/i18n/ui.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/i18n/ui.ts b/app/src/i18n/ui.ts index 3398564..9947211 100644 --- a/app/src/i18n/ui.ts +++ b/app/src/i18n/ui.ts @@ -96,4 +96,31 @@ export const ui = { "chat.dooris": "dOwOris", "chat.user": "my fren :3", }, -} as const \ No newline at end of file + meow: { + "dooris": "meoowris 🐈️ (DOORIS)", + "unauthorized.title": "rawwwwr" πŸ˜Ύβ›”οΈ", + "unauthorized.description": `meow mreoow: 🌐 meow`, + "unauthenticated.title": "meow? 🫴🐈️", + "unauthenticated.description": `meow mreoow: 🌐 meow`, + "state.unlocked": "mrrp πŸ±πŸ”“οΈ", + "state.locked": "mreoww πŸ±πŸ”’οΈ", + "state.unknown": "meoooow????? πŸ™€", + "state.unlocking": "meeeow πŸ˜ΊπŸ”πŸ”“οΈ", + "state.locking": "mreowww πŸ˜ΏπŸ”πŸ”’οΈ", + "lock.batteryLow": "mrrrrrp flop πŸͺ«", + "lock.unreachable": "meooww?? πŸ™€πŸš«β“οΈπŸšͺ", + "lock.jammed": "mew πŸšͺπŸ”πŸš«", + "button.open": "🐈️ πŸ‘‰πŸ‘ˆπŸ”“οΈ", + "button.close": "🐈️ πŸ”’οΈ", + "login": "meow? 🫴🐈️", + "loggedOut.title": "mew mew 😿", + "loggedOut.description": `meow πŸ±βŒ›οΈ`, + "serverError.title": "πŸ–₯️⛓️‍πŸ’₯", + "serverError.description": `mew mew ⌚️`, + "networkError.title": "meww πŸ›œπŸš«", + "networkError.description": `πŸ›œπŸ˜Ί`, + "loadingDoors": 'πŸ”', + "chat.dooris": "meoowris", + "chat.user": "😻", + }, +} as const From d46d8667059d05e2dacdc0e55cc93553747fba5c Mon Sep 17 00:00:00 2001 From: dario Date: Mon, 18 May 2026 20:38:37 +0200 Subject: [PATCH 02/46] app: fix syntax error in cat language --- app/src/i18n/ui.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/i18n/ui.ts b/app/src/i18n/ui.ts index 9947211..fc03078 100644 --- a/app/src/i18n/ui.ts +++ b/app/src/i18n/ui.ts @@ -98,7 +98,7 @@ export const ui = { }, meow: { "dooris": "meoowris 🐈️ (DOORIS)", - "unauthorized.title": "rawwwwr" πŸ˜Ύβ›”οΈ", + "unauthorized.title": "rawwwwr πŸ˜Ύβ›”οΈ", "unauthorized.description": `meow mreoow: 🌐 meow`, "unauthenticated.title": "meow? 🫴🐈️", "unauthenticated.description": `meow mreoow: 🌐 meow`, From 1a50d67df68e62b3b018aeddd4980a4dc839137b Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 09:34:51 +0200 Subject: [PATCH 03/46] api: implement abstraction for connecting to CCUJACK MQTT broker --- api/pyproject.toml | 1 + api/src/dooris_api/mqtt_client.py | 92 +++++++++++++++++++++++++++++++ api/uv.lock | 11 ++++ 3 files changed, 104 insertions(+) create mode 100644 api/src/dooris_api/mqtt_client.py diff --git a/api/pyproject.toml b/api/pyproject.toml index 1b0cc5f..4e42dd1 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.12" dependencies = [ "aiohttp>=3.13.5", "fastapi>=0.136.1", + "paho-mqtt>=2.1.0", "simple-openid-connect>=2.4.0", "uvicorn>=0.46.0", ] diff --git a/api/src/dooris_api/mqtt_client.py b/api/src/dooris_api/mqtt_client.py new file mode 100644 index 0000000..f7cb22b --- /dev/null +++ b/api/src/dooris_api/mqtt_client.py @@ -0,0 +1,92 @@ +# +# This whole implementation is adapted from the upstream GitHub example +# https://github.com/eclipse-paho/paho.mqtt.python/blob/master/examples/loop_asyncio.py +# + +from typing import Any +import logging +import asyncio +import socket + +import paho.mqtt.client as mqtt + + +logger = logging.getLogger(__name__) + + +class AsyncLooper: + """ + Helper class to implement loopgin with asyncio for the underlying mqtt IO + """ + + def __init__(self, loop: asyncio.AbstractEventLoop, client: mqtt.Client): + self.loop = loop + + self.client = client + self.client.on_socket_open = self.on_socket_open + self.client.on_socket_close = self.on_socket_close + self.client.on_socket_register_write = self.on_socket_register_write + self.client.on_socket_unregister_write = self.on_socket_unregister_write + + def on_socket_open(self, client, userdata, sock): + def cb(): + client.loop_read() + + self.loop.add_reader(sock, cb) + self.task_misc = self.loop.create_task(self.misc_loop()) + + def on_socket_close(self, client, userdata, sock): + self.loop.remove_reader(sock) + self.task_misc.cancel() + + def on_socket_register_write(self, client, userdata, sock): + def cb(): + client.loop_write() + + self.loop.add_writer(sock, cb) + + def on_socket_unregister_write(self, client, userdata, sock): + self.loop.remove_writer(sock) + + async def misc_loop(self): + while self.client.loop_misc() == mqtt.MQTT_ERR_SUCCESS: + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + break + + +class AsyncMqttClient: + loop: asyncio.AbstractEventLoop + + def __init__(self, connection_string: str, username: str, password: str): + self.connection_string = connection_string + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="dooris") + self.client.username = username + self.client.password = password + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + self.client.on_disconnect = self.on_disconnect + + def on_connect(self, client: mqtt.Client, userdata: Any, flags: mqtt.ConnectFlags, reason_code, properties: mqtt.Properties): + logger.debug(f"mqtt client connected with message '{reason_code}'") + + def on_disconnect(self, client: mqtt.Client, userdata: Any, flags: mqtt.DisconnectFlags, reason_code, properties: mqtt.Properties): + logger.debug("mqtt client disconnected") + print("flags", type(flags), flags) + print("reason_code", type(reason_code), reason_code) + print("properties", type(properties), properties) + + def on_message(self, client: mqtt.Client, userdata: Any, msg): + logger.debug("mqtt client got message") + print("msg", type(msg), msg) + + async def connect(self): + server_host, server_port = self.connection_string.rsplit(":", maxsplit=1) + + looper = AsyncLooper(asyncio.get_running_loop(), self.client) + + logger.info("Connecting to mqtt server at %s:%s", server_host, server_port) + self.client.connect(server_host, int(server_port)) + self.client.socket().setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2048) + diff --git a/api/uv.lock b/api/uv.lock index 9e7f71a..6b6a649 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -400,6 +400,7 @@ source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "fastapi" }, + { name = "paho-mqtt" }, { name = "simple-openid-connect" }, { name = "uvicorn" }, ] @@ -413,6 +414,7 @@ dev = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.13.5" }, { name = "fastapi", specifier = ">=0.136.1" }, + { name = "paho-mqtt", specifier = ">=2.1.0" }, { name = "simple-openid-connect", specifier = ">=2.4.0" }, { name = "uvicorn", specifier = ">=0.46.0" }, ] @@ -734,6 +736,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/6c/d8a02ffb24876b5f51fbd781f479fc6525a518553a4196bd0433dae9ff8e/orderedmultidict-1.0.2-py2.py3-none-any.whl", hash = "sha256:ab5044c1dca4226ae4c28524cfc5cc4c939f0b49e978efa46a6ad6468049f79b", size = 11897, upload-time = "2025-11-18T08:00:41.44Z" }, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, +] + [[package]] name = "parso" version = "0.8.7" From 0331dd6406f9e6f06d1fcb741e89fdc2862a3f70 Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 09:34:51 +0200 Subject: [PATCH 04/46] api: connect to CCUJACK MQTT broker on startup --- api/src/dooris_api/__init__.py | 6 ++++++ api/src/dooris_api/app.py | 5 ++++- api/src/dooris_api/ccujack.py | 8 +++++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py index cd488a6..8ee8809 100644 --- a/api/src/dooris_api/__init__.py +++ b/api/src/dooris_api/__init__.py @@ -52,6 +52,12 @@ def main(): default=os.environ.get("DOORIS_CCUJACK_URL", "https://hmdooris-ccu.ccchh.net:2122"), help="The URL under which a CCUJACK instance is hosted that actually operates the locks", ) + argp.add_argument( + "--ccujack-mqtt", + required=False, + default=os.environ.get("DOORIS_CCUJACK_MQTT", "hmdooris-ccu.ccchh.net:1883"), + help="The $HOSTNAME:$PORT of the CCUJack embedded MQTT server", + ) argp.add_argument( "--ccujack-user", required="DOORIS_CCUJACK_USER" not in os.environ, diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index 3bcd848..3b53d35 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -36,11 +36,14 @@ async def lifespan(app: FastAPI): scope=app_cfg.openid_scope, ) + # TODO: regularly re-query CCUJACK to discover new locks app.extra["ccujack"] = CCUJackClient( base_uri=app_cfg.ccujack_url, - auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password) + auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password), + mqtt_conn=app_cfg.ccujack_mqtt, ) await app.extra["ccujack"].find_locks() + await app.extra["ccujack"].connect_mqtt() yield diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index 5d5eca0..78c9e00 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -4,6 +4,8 @@ import logging import asyncio from pydantic import BaseModel, Field +from dooris_api.mqtt_client import AsyncMqttClient + logger = logging.getLogger(__name__) @@ -69,7 +71,7 @@ class CCUJackClient: base_uri: str locks: LockData - def __init__(self, base_uri: str, auth: BasicAuth): + def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str): self.http = ClientSession( base_url=base_uri, auth=auth, @@ -77,6 +79,10 @@ class CCUJackClient: connector=TCPConnector(ssl=False), ) self.locks = None + self.mqtt = AsyncMqttClient(mqtt_conn, auth.login, auth.password) + + async def connect_mqtt(self): + await self.mqtt.connect() async def find_locks(self): logger.debug("Inspecting lock devices present in CCUJack") From 44d484cfc196058bca136efbd38d874fba094327 Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 13:09:00 +0200 Subject: [PATCH 05/46] api: use proper connection shutdown for downstream services --- api/src/dooris_api/app.py | 13 ++--- api/src/dooris_api/ccujack.py | 16 ++++++ api/src/dooris_api/mqtt_client.py | 83 +++++++++++++++++++++++++------ 3 files changed, 92 insertions(+), 20 deletions(-) diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index 3b53d35..32d626d 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -2,7 +2,6 @@ from typing import Optional, List import logging import secrets import sys -import os from datetime import datetime, UTC from fastapi import FastAPI, Request, Response, status from fastapi.responses import RedirectResponse @@ -42,11 +41,13 @@ async def lifespan(app: FastAPI): auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password), mqtt_conn=app_cfg.ccujack_mqtt, ) - await app.extra["ccujack"].find_locks() await app.extra["ccujack"].connect_mqtt() + await app.extra["ccujack"].find_locks() yield + await app.extra["ccujack"].close_connections() + app = FastAPI( title="Dooris", @@ -71,9 +72,7 @@ async def get_user_info( ) -> models.UserStatus: return models.UserStatus( is_authorized=current_user.may_operate_locks, - guaranteed_session_until=datetime.fromtimestamp( - current_user.id_token.exp, UTC - ), + guaranteed_session_until=datetime.fromtimestamp(current_user.id_token.exp, UTC), username=current_user.id_token.preferred_username, ccchh_roles=current_user.ccchh_roles, ) @@ -122,7 +121,9 @@ async def login_init( response_class=RedirectResponse, status_code=302, ) -async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidClient) -> str: +async def login_callback( + req: Request, resp: Response, oidc_client: deps.OpenidClient +) -> str: # check that the user is currently in an authenticating state # these cookies are set by the login_init() view if ( diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index 78c9e00..ed1e038 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -84,8 +84,16 @@ class CCUJackClient: async def connect_mqtt(self): await self.mqtt.connect() + async def close_connections(self): + await asyncio.gather( + self.mqtt.disconnect(), + self.http.close() + ) + async def find_locks(self): logger.debug("Inspecting lock devices present in CCUJack") + + # iterate through the CCUJACK API to find all devices async with self.http.get("/device") as resp: devices = CCUDeviceList.model_validate(await resp.json()) @@ -99,6 +107,14 @@ class CCUJackClient: self.locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK] + # update active mqtt subscriptions + mqtt_topics = set() + for i_lock, lock_channels in self.locks: + for i_channel, channel_params in lock_channels: + for i_param in channel_params: + mqtt_topics.add(f"device/status/{i_lock.address}/{i_channel.index}/{i_param.id}") + # await self.mqtt.update_subscriptions(mqtt_topics) + async def query_param_value(self, address: str): logger.debug("Querying parameter value from '%s'", address) async with self.http.get(f"/device/{address}/~pv") as resp: diff --git a/api/src/dooris_api/mqtt_client.py b/api/src/dooris_api/mqtt_client.py index f7cb22b..3f6de6f 100644 --- a/api/src/dooris_api/mqtt_client.py +++ b/api/src/dooris_api/mqtt_client.py @@ -1,9 +1,9 @@ # # This whole implementation is adapted from the upstream GitHub example # https://github.com/eclipse-paho/paho.mqtt.python/blob/master/examples/loop_asyncio.py -# +# -from typing import Any +from typing import Any, List, Set, Iterable import logging import asyncio import socket @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) class AsyncLooper: """ - Helper class to implement loopgin with asyncio for the underlying mqtt IO + Helper class to implement loopgin with asyncio for the underlying mqtt IO """ def __init__(self, loop: asyncio.AbstractEventLoop, client: mqtt.Client): @@ -58,9 +58,14 @@ class AsyncLooper: class AsyncMqttClient: loop: asyncio.AbstractEventLoop - + connection_string: str + looper: AsyncLooper + client: mqtt.Client + active_subscriptions: Set[str] + def __init__(self, connection_string: str, username: str, password: str): self.connection_string = connection_string + self.active_subscriptions = set() self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="dooris") self.client.username = username self.client.password = password @@ -68,25 +73,75 @@ class AsyncMqttClient: self.client.on_message = self.on_message self.client.on_disconnect = self.on_disconnect - def on_connect(self, client: mqtt.Client, userdata: Any, flags: mqtt.ConnectFlags, reason_code, properties: mqtt.Properties): + def on_connect( + self, + client: mqtt.Client, + userdata: Any, + flags: mqtt.ConnectFlags, + reason_code, + properties: mqtt.Properties, + ): logger.debug(f"mqtt client connected with message '{reason_code}'") + self.fut_connected.set_result(None) - def on_disconnect(self, client: mqtt.Client, userdata: Any, flags: mqtt.DisconnectFlags, reason_code, properties: mqtt.Properties): - logger.debug("mqtt client disconnected") - print("flags", type(flags), flags) - print("reason_code", type(reason_code), reason_code) - print("properties", type(properties), properties) + def on_disconnect( + self, + client: mqtt.Client, + userdata: Any, + flags: mqtt.DisconnectFlags, + reason_code, + properties: mqtt.Properties, + ): + logger.debug(f"mqtt client disconnected with message '{reason_code}'") + if self.fut_disconnect: + self.fut_disconnect.set_result(None) - def on_message(self, client: mqtt.Client, userdata: Any, msg): + def on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage): logger.debug("mqtt client got message") print("msg", type(msg), msg) + def on_subscribe(self, client, userdata, mid, reason_code, properties): + logger.debug(f"mqtt client subscribed to topics with message '{reason_code}'") + self.fut_subscribe.set_result(None) + + def on_unsubscribe(self, client, userdata, mid, reason_code, properties): + logger.debug(f"mqtt client unsubscribed from topics with message '{reason_code}'") + self.fut_unsubscribe.set_result(None) + + async def update_subscriptions(self, topics: Iterable[str]): + """ + Update MQTT subscriptions so that the client is subscribed to exactly the given list of topics + """ + + to_add = topics.difference(self.active_subscriptions) + if to_add: + logger.info(f"mqtt client subscribing to topics {', '.join(to_add)}") + qos = 2 + self.fut_subscribe = asyncio.get_running_loop().create_future() + self.client.subscribe([(i, qos) for i in to_add]) + await self.fut_subscribe + + to_remove = self.active_subscriptions.difference(topics) + if to_remove: + logger.info(f"mqtt client unsubscribing from topics {','.join(to_remove)}") + self.fut_unsubscribe = asyncio.get_running_loop().create_future() + self.client.unsubscribe(list(to_remove)) + await self.fut_unsubscribe + async def connect(self): server_host, server_port = self.connection_string.rsplit(":", maxsplit=1) - - looper = AsyncLooper(asyncio.get_running_loop(), self.client) + + self.looper = AsyncLooper(asyncio.get_running_loop(), self.client) logger.info("Connecting to mqtt server at %s:%s", server_host, server_port) + self.fut_connected = asyncio.get_running_loop().create_future() self.client.connect(server_host, int(server_port)) - self.client.socket().setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2048) + self.client.socket().setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2048) + await self.fut_connected + + async def disconnect(self): + logger.info("Disconnecting mqtt client from broker") + self.fut_disconnect = asyncio.get_running_loop().create_future() + self.client.disconnect() + await self.fut_disconnect From 4103c0ca5f6c12518bbd8efb43eb975e508de837 Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 14:10:47 +0200 Subject: [PATCH 06/46] api: process mqtt messages to keep a current local lock state --- api/src/dooris_api/ccujack.py | 44 ++++++++++++++++++++++++------- api/src/dooris_api/mqtt_client.py | 24 ++++++++++++----- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index ed1e038..112fbc1 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -1,4 +1,4 @@ -from typing import List, Tuple, Optional, Any +from typing import List, Tuple, Optional, Any, Dict from aiohttp import ClientSession, BasicAuth, TCPConnector import logging import asyncio @@ -70,6 +70,8 @@ LockData = List[Tuple[CCUDeviceInfo, List[Tuple[CCUChannelInfo, List[CCUParamInf class CCUJackClient: base_uri: str locks: LockData + param_values: Dict[str, Any] + task_process_messages: asyncio.Task def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str): self.http = ClientSession( @@ -78,17 +80,21 @@ class CCUJackClient: raise_for_status=True, connector=TCPConnector(ssl=False), ) - self.locks = None self.mqtt = AsyncMqttClient(mqtt_conn, auth.login, auth.password) + self.locks = None + self.param_values = dict() + self.task_process_messages = None async def connect_mqtt(self): await self.mqtt.connect() + self.task_process_messages = asyncio.get_running_loop().create_task( + self.process_mqt_messages(), name="process-mqtt-messages" + ) async def close_connections(self): - await asyncio.gather( - self.mqtt.disconnect(), - self.http.close() - ) + await asyncio.gather(self.mqtt.disconnect(), self.http.close()) + self.task_process_messages.cancel() + self.task_process_messages = None async def find_locks(self): logger.debug("Inspecting lock devices present in CCUJack") @@ -112,10 +118,30 @@ class CCUJackClient: for i_lock, lock_channels in self.locks: for i_channel, channel_params in lock_channels: for i_param in channel_params: - mqtt_topics.add(f"device/status/{i_lock.address}/{i_channel.index}/{i_param.id}") - # await self.mqtt.update_subscriptions(mqtt_topics) + mqtt_topics.add( + f"device/status/{i_lock.address}/{i_channel.index}/{i_param.id}" + ) + await self.mqtt.update_subscriptions(mqtt_topics) + + async def process_mqt_messages(self): + while True: + try: + msg = await self.mqtt.messages.get() + + param_name = msg.topic.removeprefix("device/status/") + param_value = CCUValue.model_validate_json(msg.payload) + logger.debug( + f"Got new value from MQTT for parameter {param_name}: {param_value}" + ) + self.param_values[param_name] = param_value + + except Exception as e: + logger.exception(f"could not process incoming mqtt message: {e}") + + async def query_param_value(self, address: str) -> CCUValue: + if address in self.param_values: + return self.param_values[address] - async def query_param_value(self, address: str): logger.debug("Querying parameter value from '%s'", address) async with self.http.get(f"/device/{address}/~pv") as resp: return CCUValue.model_validate(await resp.json()) diff --git a/api/src/dooris_api/mqtt_client.py b/api/src/dooris_api/mqtt_client.py index 3f6de6f..1548e66 100644 --- a/api/src/dooris_api/mqtt_client.py +++ b/api/src/dooris_api/mqtt_client.py @@ -62,16 +62,22 @@ class AsyncMqttClient: looper: AsyncLooper client: mqtt.Client active_subscriptions: Set[str] + messages: asyncio.Queue def __init__(self, connection_string: str, username: str, password: str): self.connection_string = connection_string self.active_subscriptions = set() + self.messages = asyncio.Queue() self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="dooris") self.client.username = username self.client.password = password self.client.on_connect = self.on_connect + self.client.on_connect_fail = self.on_connect_fail self.client.on_message = self.on_message self.client.on_disconnect = self.on_disconnect + self.client.on_subscribe = self.on_subscribe + self.client.on_unsubscribe = self.on_unsubscribe + self.client.on_disconnect = self.on_disconnect def on_connect( self, @@ -84,6 +90,12 @@ class AsyncMqttClient: logger.debug(f"mqtt client connected with message '{reason_code}'") self.fut_connected.set_result(None) + def on_connect_fail(self, client, userdata): + logger.error("mqtt client could not connect to broker") + self.fut_connected.set_exception( + Exception("mqtt client could not connect to broker") + ) + def on_disconnect( self, client: mqtt.Client, @@ -97,26 +109,26 @@ class AsyncMqttClient: self.fut_disconnect.set_result(None) def on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage): - logger.debug("mqtt client got message") - print("msg", type(msg), msg) + self.messages.put_nowait(msg) def on_subscribe(self, client, userdata, mid, reason_code, properties): logger.debug(f"mqtt client subscribed to topics with message '{reason_code}'") self.fut_subscribe.set_result(None) def on_unsubscribe(self, client, userdata, mid, reason_code, properties): - logger.debug(f"mqtt client unsubscribed from topics with message '{reason_code}'") + logger.debug( + f"mqtt client unsubscribed from topics with message '{reason_code}'" + ) self.fut_unsubscribe.set_result(None) - async def update_subscriptions(self, topics: Iterable[str]): + async def update_subscriptions(self, topics: Iterable[str], qos: int = 1): """ - Update MQTT subscriptions so that the client is subscribed to exactly the given list of topics + Update MQTT subscriptions so that the client is subscribed to exactly the given list of topics """ to_add = topics.difference(self.active_subscriptions) if to_add: logger.info(f"mqtt client subscribing to topics {', '.join(to_add)}") - qos = 2 self.fut_subscribe = asyncio.get_running_loop().create_future() self.client.subscribe([(i, qos) for i in to_add]) await self.fut_subscribe From 7ac0a4106cdc621f7b62dab7317d7c0c6317fb9b Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 14:37:55 +0200 Subject: [PATCH 07/46] api: implement Server-Sent-Events on top of mqtt parameters --- api/src/dooris_api/app.py | 15 ++++++++++++++- api/src/dooris_api/ccujack.py | 34 ++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index 32d626d..edb338f 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -1,10 +1,11 @@ -from typing import Optional, List +from typing import Optional, List, AsyncIterable import logging import secrets import sys from datetime import datetime, UTC from fastapi import FastAPI, Request, Response, status from fastapi.responses import RedirectResponse +from fastapi.sse import EventSourceResponse from contextlib import asynccontextmanager from simple_openid_connect.client import OpenidClient from simple_openid_connect.data import TokenSuccessResponse, RpInitiatedLogoutRequest @@ -241,6 +242,18 @@ async def list_locks(ccujack: deps.CCUJackClient) -> List[models.Lock]: return result +@app.get( + "/api/locks/stream", + tags=["locks"], + responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}}, + response_class=EventSourceResponse, +) +async def watch_locks(ccujack: deps.CCUJackClient) -> AsyncIterable[List[models.Lock]]: + while True: + yield await list_locks(ccujack) + await ccujack.data_updated.wait() + + @app.patch( "/api/locks/{lock_id}", tags=["locks"], diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index 112fbc1..1132e63 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -72,6 +72,7 @@ class CCUJackClient: locks: LockData param_values: Dict[str, Any] task_process_messages: asyncio.Task + data_updated: asyncio.Event def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str): self.http = ClientSession( @@ -84,6 +85,7 @@ class CCUJackClient: self.locks = None self.param_values = dict() self.task_process_messages = None + self.data_updated = asyncio.Event() async def connect_mqtt(self): await self.mqtt.connect() @@ -99,10 +101,10 @@ class CCUJackClient: async def find_locks(self): logger.debug("Inspecting lock devices present in CCUJack") - # iterate through the CCUJACK API to find all devices async with self.http.get("/device") as resp: devices = CCUDeviceList.model_validate(await resp.json()) + # inspect CCUJACK for locks device_infos = await asyncio.gather( *[ self._inspect_ccu_device(i) @@ -111,17 +113,22 @@ class CCUJackClient: ] ) - self.locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK] - - # update active mqtt subscriptions - mqtt_topics = set() - for i_lock, lock_channels in self.locks: - for i_channel, channel_params in lock_channels: - for i_param in channel_params: - mqtt_topics.add( - f"device/status/{i_lock.address}/{i_channel.index}/{i_param.id}" - ) - await self.mqtt.update_subscriptions(mqtt_topics) + # save the result + new_locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK] + if new_locks != self.locks: + self.locks = new_locks + self.data_updated.set() + self.data_updated.clear() + + # update active mqtt subscriptions based on newly discovered devices + mqtt_topics = set() + for i_lock, lock_channels in self.locks: + for i_channel, channel_params in lock_channels: + for i_param in channel_params: + mqtt_topics.add( + f"device/status/{i_lock.address}/{i_channel.index}/{i_param.id}" + ) + await self.mqtt.update_subscriptions(mqtt_topics) async def process_mqt_messages(self): while True: @@ -134,9 +141,12 @@ class CCUJackClient: f"Got new value from MQTT for parameter {param_name}: {param_value}" ) self.param_values[param_name] = param_value + self.data_updated.set() except Exception as e: logger.exception(f"could not process incoming mqtt message: {e}") + finally: + self.data_updated.clear() async def query_param_value(self, address: str) -> CCUValue: if address in self.param_values: From b1b624a7b3115e917b138785103fda84716f68ad Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 14:57:28 +0200 Subject: [PATCH 08/46] api: simplify logging setup --- api/src/dooris_api/__init__.py | 7 +++++++ api/src/dooris_api/app.py | 6 ------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py index 8ee8809..139ee17 100644 --- a/api/src/dooris_api/__init__.py +++ b/api/src/dooris_api/__init__.py @@ -1,4 +1,6 @@ import os +import sys +import logging from contextvars import ContextVar from argparse import ArgumentParser, Namespace @@ -72,6 +74,10 @@ def main(): ) args = argp.parse_args() + # setup logging + logging.basicConfig(level=logging.DEBUG, format="[%(levelname)s] %(filename)s: %(message)s") + + # setup app app_config.set(args) import uvicorn from dooris_api.app import app @@ -80,6 +86,7 @@ def main(): from fastapi.staticfiles import StaticFiles app.mount("/", StaticFiles(directory=args.serve_static, html=True), name="static") + # start webserver config = uvicorn.Config(app, port=8000, log_level="debug") server = uvicorn.Server(config) server.run() diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index edb338f..7e2a948 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -22,12 +22,6 @@ logger = logging.getLogger(__name__) async def lifespan(app: FastAPI): app_cfg = app_config.get() - root_logger = logging.getLogger("") - root_logger.setLevel(logging.INFO) - root_logger.addHandler(logging.StreamHandler(sys.stderr)) - app_logger = logging.getLogger("dooris_api") - app_logger.setLevel(logging.DEBUG) - app.extra["oidc_client"] = OpenidClient.from_issuer_url( url=app_cfg.openid_issuer, authentication_redirect_uri=f"{app_cfg.base_url}/auth/login-callback", From 2349e58924b010b01f6e33160053bd34027beab8 Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 15:03:26 +0200 Subject: [PATCH 09/46] api: automatically rediscover locks from CCUJack closes CCCHH/dooris#5 --- api/src/dooris_api/app.py | 4 +--- api/src/dooris_api/ccujack.py | 21 ++++++++++++++++++++- api/src/dooris_api/mqtt_client.py | 4 +++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index 7e2a948..54f97f1 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -1,7 +1,6 @@ from typing import Optional, List, AsyncIterable import logging import secrets -import sys from datetime import datetime, UTC from fastapi import FastAPI, Request, Response, status from fastapi.responses import RedirectResponse @@ -36,8 +35,7 @@ async def lifespan(app: FastAPI): auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password), mqtt_conn=app_cfg.ccujack_mqtt, ) - await app.extra["ccujack"].connect_mqtt() - await app.extra["ccujack"].find_locks() + await app.extra["ccujack"].start() yield diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index 1132e63..9edbcdb 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -72,6 +72,7 @@ class CCUJackClient: locks: LockData param_values: Dict[str, Any] task_process_messages: asyncio.Task + task_find_locks: asyncio.Task data_updated: asyncio.Event def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str): @@ -85,10 +86,16 @@ class CCUJackClient: self.locks = None self.param_values = dict() self.task_process_messages = None + self.task_find_locks = None self.data_updated = asyncio.Event() - async def connect_mqtt(self): + async def start(self): await self.mqtt.connect() + await self.find_locks() + + self.task_find_locks = asyncio.get_running_loop().create_task( + self.cron(), name="ccujack-cron" + ) self.task_process_messages = asyncio.get_running_loop().create_task( self.process_mqt_messages(), name="process-mqtt-messages" ) @@ -116,6 +123,7 @@ class CCUJackClient: # save the result new_locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK] if new_locks != self.locks: + logger.info("Found new locks, updating state") self.locks = new_locks self.data_updated.set() self.data_updated.clear() @@ -148,6 +156,17 @@ class CCUJackClient: finally: self.data_updated.clear() + async def cron(self): + while True: + try: + + await asyncio.sleep(60 * 60) # 1 hour + logger.info("Running CCUJack cron") + await self.find_locks() + + except Exception as e: + logger.exception(f"Error in CCUJack cron task: {e}") + async def query_param_value(self, address: str) -> CCUValue: if address in self.param_values: return self.param_values[address] diff --git a/api/src/dooris_api/mqtt_client.py b/api/src/dooris_api/mqtt_client.py index 1548e66..03db2cc 100644 --- a/api/src/dooris_api/mqtt_client.py +++ b/api/src/dooris_api/mqtt_client.py @@ -3,7 +3,7 @@ # https://github.com/eclipse-paho/paho.mqtt.python/blob/master/examples/loop_asyncio.py # -from typing import Any, List, Set, Iterable +from typing import Any, Set, Iterable import logging import asyncio import socket @@ -132,6 +132,7 @@ class AsyncMqttClient: self.fut_subscribe = asyncio.get_running_loop().create_future() self.client.subscribe([(i, qos) for i in to_add]) await self.fut_subscribe + self.active_subscriptions.update(to_add) to_remove = self.active_subscriptions.difference(topics) if to_remove: @@ -139,6 +140,7 @@ class AsyncMqttClient: self.fut_unsubscribe = asyncio.get_running_loop().create_future() self.client.unsubscribe(list(to_remove)) await self.fut_unsubscribe + self.active_subscriptions.difference_update(to_remove) async def connect(self): server_host, server_port = self.connection_string.rsplit(":", maxsplit=1) From 41fd939d30b123060b9804258cd264ed410cd97a Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 15:17:41 +0200 Subject: [PATCH 10/46] api: fix ccujack cron task not being properly stopped --- api/src/dooris_api/app.py | 1 - api/src/dooris_api/ccujack.py | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index 54f97f1..24cc708 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -29,7 +29,6 @@ async def lifespan(app: FastAPI): scope=app_cfg.openid_scope, ) - # TODO: regularly re-query CCUJACK to discover new locks app.extra["ccujack"] = CCUJackClient( base_uri=app_cfg.ccujack_url, auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password), diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index 9edbcdb..1a6120a 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -104,6 +104,8 @@ class CCUJackClient: await asyncio.gather(self.mqtt.disconnect(), self.http.close()) self.task_process_messages.cancel() self.task_process_messages = None + self.task.cron.cancel() + self.task.cron = None async def find_locks(self): logger.debug("Inspecting lock devices present in CCUJack") From 5bdf04cbb6f4602119a49dabdb9999b1b94ca40e Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 16:34:51 +0200 Subject: [PATCH 11/46] api: tentatively make auth_nonce optional after token refresh --- api/src/dooris_api/deps.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/api/src/dooris_api/deps.py b/api/src/dooris_api/deps.py index 949fd13..7c5e26b 100644 --- a/api/src/dooris_api/deps.py +++ b/api/src/dooris_api/deps.py @@ -4,6 +4,7 @@ from datetime import datetime, UTC, timedelta from fastapi import Request, Depends, Response from simple_openid_connect.data import TokenSuccessResponse from simple_openid_connect.client import OpenidClient +from simple_openid_connect.exceptions import ValidationError from dooris_api import models, exceptions from dooris_api.ccujack import CCUJackClient @@ -24,19 +25,19 @@ async def get_current_user( ) -> Optional[models.CurrentUser]: # easiest case: we still have an access token (which is the most fleeting component) # everything else should still be valid so we can just use it - if all(i in req.cookies for i in ("access_token", "id_token", "auth_nonce")): + if all(i in req.cookies for i in ("access_token", "id_token")): logger.debug( "user is fully authenticated, returning current user from existing id_token" ) id_token = oidc_client.decode_id_token( - req.cookies["id_token"], nonce=req.cookies["auth_nonce"] + req.cookies["id_token"], nonce=req.cookies.get("auth_nonce", None), ) return models.CurrentUser( id_token=id_token, raw_id_token=req.cookies["id_token"] ) # if we have a refresh token, try to get new tokens - elif all(i in req.cookies for i in ("refresh_token", "auth_nonce")): + elif all(i in req.cookies for i in ("refresh_token",)): logger.debug( "user has been previously authenticated, trying to recover with refresh_token" ) @@ -44,7 +45,7 @@ async def get_current_user( token_resp = oidc_client.exchange_refresh_token(req.cookies["refresh_token"]) if isinstance(token_resp, TokenSuccessResponse): logger.debug("successfully got new tokens from refresh token") - persist_auth_state(oidc_client, resp, token_resp, auth_start_time, req.cookies["auth_nonce"]) + persist_auth_state(oidc_client, resp, token_resp, auth_start_time, None) # return the newly gotten info id_token = oidc_client.decode_id_token(token_resp.id_token) From c0d6bd454890518d3769b7bb054ad3f591107ad9 Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 16:44:18 +0200 Subject: [PATCH 12/46] restructure dockerfile to not have npm dependencies in final image --- Containerfile | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/Containerfile b/Containerfile index 1a1812f..2c73422 100644 --- a/Containerfile +++ b/Containerfile @@ -1,3 +1,16 @@ +FROM docker.io/alpine:3.22 AS build-frontend +ENV PNPM_HOME=/usr/local/share/dooris/pnpm/ +WORKDIR /usr/local/src/dooris/ +RUN apk add --no-cache pnpm + +ADD --link app/package.json app/pnpm-lock.yaml app/ +RUN pnpm --dir=app/ install --frozen-lockfile --package-import-method=copy + +ADD --link . /usr/local/src/dooris/ +RUN pnpm --dir=app/ run build + + + FROM docker.io/alpine:3.22 AS base ARG APP_UID=10000 @@ -9,12 +22,11 @@ ENV UV_LINK_MODE=copy ENV UV_CACHE_DIR=/var/cache/dooris/uv/ ENV UV_NO_MANAGED_PYTHON=true ENV VIRTUAL_ENV=/usr/local/share/dooris/venv/ -ENV PNPM_HOME=/usr/local/share/dooris/pnpm/ -ENV PATH=$PNPM_HOME:$VIRTUAL_ENV/bin:$PATH +ENV PATH=$VIRTUAL_ENV/bin:$PATH ENV DOORIS_SERVE_STATIC=/var/www/dooris/static/ WORKDIR /usr/local/src/dooris/ -RUN apk add --no-cache uv python3 pnpm +RUN apk add --no-cache uv python3 RUN addgroup -g $APP_GID dooris &&\ adduser -h /usr/local/src/dooris -u $APP_UID -G dooris -D dooris &&\ mkdir -p /var/www/dooris/ /usr/local/share/dooris/ /usr/local/src/dooris/ /var/cache/dooris/ &&\ @@ -25,16 +37,14 @@ RUN addgroup -g $APP_GID dooris &&\ FROM base AS deps USER dooris ADD --link --chown=dooris:dooris api/pyproject.toml api/uv.lock api/ -ADD --link --chown=dooris:dooris app/package.json app/pnpm-lock.yaml app/ RUN uv venv $VIRTUAL_ENV &&\ uv sync --active --frozen --no-install-project --no-editable -RUN pnpm --dir=app/ install --frozen-lockfile --package-import-method=copy FROM deps AS final ADD --chown=dooris:dooris --link . /usr/local/src/dooris/ -RUN pnpm --dir=app/ run build --outDir=$DOORIS_SERVE_STATIC +COPY --chown=dooris:dooris --from=build-frontend --link /usr/local/src/dooris/app/dist/ $DOORIS_SERVE_STATIC RUN --mount=type=cache,uid=$APP_UID,gid=$APP_GID,target=$UV_CACHE_DIR \ uv sync --active --frozen From 8bc4e7f28edc21bcc9dae43d2f6fd654fbb1467d Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 17:02:59 +0200 Subject: [PATCH 13/46] api: debounce multiple mqtt parameter updates for frontend --- api/src/dooris_api/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index 24cc708..f862d61 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -1,6 +1,7 @@ from typing import Optional, List, AsyncIterable import logging import secrets +import asyncio from datetime import datetime, UTC from fastapi import FastAPI, Request, Response, status from fastapi.responses import RedirectResponse @@ -243,6 +244,7 @@ async def watch_locks(ccujack: deps.CCUJackClient) -> AsyncIterable[List[models. while True: yield await list_locks(ccujack) await ccujack.data_updated.wait() + await asyncio.sleep(0.1) # debounce multiple mqtt parameter updates @app.patch( From 82818482152f6f801dfa4e5d150b3da849dad3f2 Mon Sep 17 00:00:00 2001 From: kritzl Date: Tue, 19 May 2026 17:09:02 +0200 Subject: [PATCH 14/46] use server sent events instead of polling --- app/src/api/schema.ts | 70 ++++++++++++++++++++++++++++++++++++++---- app/src/assets/main.ts | 66 ++++++++++++++++----------------------- 2 files changed, 90 insertions(+), 46 deletions(-) diff --git a/app/src/api/schema.ts b/app/src/api/schema.ts index c9b7069..4f4b938 100644 --- a/app/src/api/schema.ts +++ b/app/src/api/schema.ts @@ -89,6 +89,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/locks/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Watch Locks */ + get: operations["watch_locks_api_locks_stream_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/locks/{lock_id}": { parameters: { query?: never; @@ -135,7 +152,7 @@ export interface components { * @description Statically known HTTP problem types using the [type URI scheme](https://datatracker.ietf.org/doc/rfc4151/) * @enum {string} */ - HttpProblemType: "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" | "type:noc@hamburg.ccc.de,2026:LOCK_NOT_FOUND"; + HttpProblemType: "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" | "type:noc@hamburg.ccc.de,2026,FORBIDDEN_TO_OPERATE" | "type:noc@hamburg.ccc.de,2026:LOCK_NOT_FOUND"; /** Lock */ Lock: { /** Name */ @@ -178,14 +195,17 @@ export interface components { }; /** UserStatus */ UserStatus: { - /** Is Logged In */ - is_logged_in: boolean; /** Is Authorized */ is_authorized: boolean; - /** Guaranteed Session Until */ - guaranteed_session_until: string | null; + /** + * Guaranteed Session Until + * Format: date-time + */ + guaranteed_session_until: string; /** Username */ - username: string | null; + username: string; + /** Ccchh Roles */ + ccchh_roles: string[]; }; /** ValidationError */ ValidationError: { @@ -332,6 +352,35 @@ export interface operations { }; }; }; + watch_locks_api_locks_stream_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": components["schemas"]["HttpProblemDetail"]; + }; + }; + }; + }; operate_lock_api_locks__lock_id__patch: { parameters: { query?: never; @@ -365,6 +414,15 @@ export interface operations { "application/json": components["schemas"]["HttpProblemDetail"]; }; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HttpProblemDetail"]; + }; + }; /** @description Not Found */ 404: { headers: { diff --git a/app/src/assets/main.ts b/app/src/assets/main.ts index 4f61428..205f64f 100644 --- a/app/src/assets/main.ts +++ b/app/src/assets/main.ts @@ -1,5 +1,5 @@ import {Fetcher} from "openapi-typescript-fetch" -import type {paths} from "../api/schema" +import type {components, paths} from "../api/schema" import type {ui} from "../i18n/ui.ts" const fetcher = Fetcher.for() @@ -29,7 +29,7 @@ declare global { lang: keyof typeof ui; doors: Array; auth: AuthType; - doorAction: (action: 'unlock' | 'lock', doorId: string) => void; + doorAction: (action: "unlock" | "lock", doorId: string) => void; } } @@ -105,18 +105,18 @@ async function checkUser() { if (e instanceof getUserInfo.Error) { const error = e.getActualType() - if (error.status === 401) { - if (!auth.recentLogout) - auth.recentLogout = auth.authenticated // set recentLogout true, if user was logged in before - auth.authenticated = false - auth.authorized = false - auth.until = null - auth.username = "" - } else if (error.status >= 500 && error.status < 600) { + + if (error.status >= 500 && error.status < 600) { apiError.current = "serverError" - } else { - console.error("unknown error:", error) } + + if (!auth.recentLogout) + auth.recentLogout = auth.authenticated // set recentLogout true, if user was logged in before + auth.authenticated = false + auth.authorized = false + auth.until = null + auth.username = "" + } } finally { localStorage.setItem("auth", JSON.stringify(auth)) @@ -125,15 +125,24 @@ async function checkUser() { } } -async function fetchDoors() { +async function subscribeDoorEvents() { if (doors.length === 0) { loading.doors = true } refresh() - const getDoors = fetcher.path("/api/locks/").method("get").create() - try { - const {data: doorInfo} = await getDoors({}) + const evtSource = new EventSource("/api/locks/stream") + + evtSource.onerror = () => { + if (!window.navigator.onLine) { + apiError.current = "networkError" + } else { + apiError.current = "serverError" + } + } + + evtSource.onmessage = (event) => { + const doorInfo: Array = JSON.parse(event.data) apiError.current = null while (doors.length) { @@ -164,29 +173,6 @@ async function fetchDoors() { loading.doors = false refresh() - } catch (e) { - // check which operation threw the exception - if (e instanceof getDoors.Error) { - const error = e.getActualType() - - if (error.status === 401) { - console.log("unauthorized") - loading.doors = false - refresh() - } else if (error.status >= 500 && error.status < 600) { - apiError.current = "serverError" - clearInterval(doorsInterval) - } else { - console.error("unknown error:", error) - } - } - - if (e instanceof Error) { - switch (e.name) { - case "TypeError": - apiError.current = "networkError" - } - } } } @@ -295,7 +281,7 @@ function refresh() { loadAuthFromLocalStorage() -const doorsInterval = setInterval(fetchDoors, 250) // TODO: replace with SSE +subscribeDoorEvents() checkUser() document.addEventListener("loadeddata", () => { From 319670b6aea3fcf0945fa03e2f29b1ccc47d7f8e Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 17:12:59 +0200 Subject: [PATCH 15/46] api: fix variable misnomer failing at program shutdown --- api/src/dooris_api/ccujack.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index 1a6120a..8add7ef 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -93,7 +93,7 @@ class CCUJackClient: await self.mqtt.connect() await self.find_locks() - self.task_find_locks = asyncio.get_running_loop().create_task( + self.task_cron= asyncio.get_running_loop().create_task( self.cron(), name="ccujack-cron" ) self.task_process_messages = asyncio.get_running_loop().create_task( @@ -104,8 +104,8 @@ class CCUJackClient: await asyncio.gather(self.mqtt.disconnect(), self.http.close()) self.task_process_messages.cancel() self.task_process_messages = None - self.task.cron.cancel() - self.task.cron = None + self.task_cron.cancel() + self.task_cron = None async def find_locks(self): logger.debug("Inspecting lock devices present in CCUJack") From 6bbfc5dc689624f91d11e9d25be2acc63189ecf0 Mon Sep 17 00:00:00 2001 From: lilly Date: Thu, 28 May 2026 16:23:03 +0200 Subject: [PATCH 16/46] api: implement automatic mqtt reconnect in ccujack cron --- api/src/dooris_api/ccujack.py | 5 ++++- api/src/dooris_api/mqtt_client.py | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index 8add7ef..03d0480 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -162,9 +162,12 @@ class CCUJackClient: while True: try: - await asyncio.sleep(60 * 60) # 1 hour + await asyncio.sleep(15 * 60) # 15 minutes logger.info("Running CCUJack cron") await self.find_locks() + if not self.mqtt.is_connected(): + logger.warning("MQTT client was discovered to be disconnected; reconnecting now") + await self.mqtt.connect() except Exception as e: logger.exception(f"Error in CCUJack cron task: {e}") diff --git a/api/src/dooris_api/mqtt_client.py b/api/src/dooris_api/mqtt_client.py index 03db2cc..2660951 100644 --- a/api/src/dooris_api/mqtt_client.py +++ b/api/src/dooris_api/mqtt_client.py @@ -154,8 +154,16 @@ class AsyncMqttClient: await self.fut_connected + # re-establish all supposed mqtt subscriptions + if len(self.active_subscriptions > 0): + qos = 1 + await self.client.subscribe((i, qos) for i in self.active_subscriptions) + async def disconnect(self): logger.info("Disconnecting mqtt client from broker") self.fut_disconnect = asyncio.get_running_loop().create_future() self.client.disconnect() await self.fut_disconnect + + def is_connected(self) -> bool: + return self.client.is_connected() From e5b880d038ce7e3055538d1f12c18a2153e38212 Mon Sep 17 00:00:00 2001 From: lilly Date: Thu, 28 May 2026 16:27:20 +0200 Subject: [PATCH 17/46] api: fix syntax error in mqtt connector --- api/src/dooris_api/mqtt_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/dooris_api/mqtt_client.py b/api/src/dooris_api/mqtt_client.py index 2660951..3df1f19 100644 --- a/api/src/dooris_api/mqtt_client.py +++ b/api/src/dooris_api/mqtt_client.py @@ -155,7 +155,7 @@ class AsyncMqttClient: await self.fut_connected # re-establish all supposed mqtt subscriptions - if len(self.active_subscriptions > 0): + if len(self.active_subscriptions) > 0: qos = 1 await self.client.subscribe((i, qos) for i in self.active_subscriptions) From 63a9485209ec291402337ea0aa7d089960ffc78f Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 19 May 2026 19:28:12 +0200 Subject: [PATCH 18/46] api: restructure user authentication modelling to support other user backends --- api/src/dooris_api/__init__.py | 25 ++++++++++--- api/src/dooris_api/app.py | 22 ++++------- api/src/dooris_api/deps.py | 67 +++++++++++++++++++++++++--------- api/src/dooris_api/models.py | 43 ++++++++++++---------- 4 files changed, 99 insertions(+), 58 deletions(-) diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py index 139ee17..618ce97 100644 --- a/api/src/dooris_api/__init__.py +++ b/api/src/dooris_api/__init__.py @@ -1,5 +1,4 @@ import os -import sys import logging from contextvars import ContextVar from argparse import ArgumentParser, Namespace @@ -51,7 +50,9 @@ def main(): argp.add_argument( "--ccujack-url", required=False, - default=os.environ.get("DOORIS_CCUJACK_URL", "https://hmdooris-ccu.ccchh.net:2122"), + default=os.environ.get( + "DOORIS_CCUJACK_URL", "https://hmdooris-ccu.ccchh.net:2122" + ), help="The URL under which a CCUJACK instance is hosted that actually operates the locks", ) argp.add_argument( @@ -70,12 +71,21 @@ def main(): "--ccujack-password", required="DOORIS_CCUJACK_PASSWORD" not in os.environ, default=os.environ.get("DOORIS_CCUJACK_PASSWORD", None), - help="The password used to authenticate against the CCUJACK" + help="The password used to authenticate against the CCUJACK", + ) + argp.add_argument( + "--static-api-tokens", + required=False, + nargs=1, + action="append", + default=[], ) args = argp.parse_args() # setup logging - logging.basicConfig(level=logging.DEBUG, format="[%(levelname)s] %(filename)s: %(message)s") + logging.basicConfig( + level=logging.DEBUG, format="[%(levelname)s] %(filename)s: %(message)s" + ) # setup app app_config.set(args) @@ -84,8 +94,11 @@ def main(): if args.serve_static: from fastapi.staticfiles import StaticFiles - app.mount("/", StaticFiles(directory=args.serve_static, html=True), name="static") - + + app.mount( + "/", StaticFiles(directory=args.serve_static, html=True), name="static" + ) + # start webserver config = uvicorn.Config(app, port=8000, log_level="debug") server = uvicorn.Server(config) diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index f862d61..0a33704 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -58,17 +58,9 @@ app.add_exception_handler( "/api/user-info/", name="get-user-info", tags=["auth"], - responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}}, ) -async def get_user_info( - req: Request, current_user: deps.CurrentUser -) -> models.UserStatus: - return models.UserStatus( - is_authorized=current_user.may_operate_locks, - guaranteed_session_until=datetime.fromtimestamp(current_user.id_token.exp, UTC), - username=current_user.id_token.preferred_username, - ccchh_roles=current_user.ccchh_roles, - ) +async def get_user_info(req: Request, current_user: deps.ApiUser) -> models.ApiUser: + return current_user @app.get("/auth/login", tags=["auth"], response_class=RedirectResponse, status_code=302) @@ -143,7 +135,7 @@ async def login_callback( auth_start_time = datetime.fromtimestamp( float(req.cookies["auth_start_time"]), UTC ) - deps.persist_auth_state( + deps.persist_oidc_auth_state( oidc_client, resp, auth_result, auth_start_time, req.cookies["auth_nonce"] ) logger.debug("successfully authenticated user") @@ -165,9 +157,9 @@ async def login_callback( "/auth/logout", tags=["auth"], response_class=RedirectResponse, status_code=302 ) async def logout( - resp: Response, oidc_client: deps.OpenidClient, current_user: deps.CurrentUser + resp: Response, oidc_client: deps.OpenidClient, current_user: deps.AuthenticatedUser ) -> str: - deps.clear_auth_state(resp) + deps.clear_oidc_auth_state(resp) return oidc_client.initiate_logout( RpInitiatedLogoutRequest( id_token_hint=current_user.raw_id_token, @@ -244,7 +236,7 @@ async def watch_locks(ccujack: deps.CCUJackClient) -> AsyncIterable[List[models. while True: yield await list_locks(ccujack) await ccujack.data_updated.wait() - await asyncio.sleep(0.1) # debounce multiple mqtt parameter updates + await asyncio.sleep(0.1) # debounce multiple mqtt parameter updates @app.patch( @@ -261,7 +253,7 @@ async def operate_lock( lock_id: str, requested_op: models.LockOperation, ccujack: deps.CCUJackClient, - current_user: deps.CurrentUser, + current_user: deps.AuthenticatedUser, ) -> None: if not current_user.may_operate_locks: raise exceptions.HttpProblemException.forbidden_to_operate(req.url) diff --git a/api/src/dooris_api/deps.py b/api/src/dooris_api/deps.py index 7c5e26b..c611b5d 100644 --- a/api/src/dooris_api/deps.py +++ b/api/src/dooris_api/deps.py @@ -1,4 +1,4 @@ -from typing import Annotated, Optional +from typing import Annotated, Optional, Tuple import logging from datetime import datetime, UTC, timedelta from fastapi import Request, Depends, Response @@ -20,9 +20,9 @@ async def get_oidc_client(req: Request) -> OpenidClient: OpenidClient = Annotated[OpenidClient, Depends(get_oidc_client)] -async def get_current_user( +async def get_logged_in_oidc_user( req: Request, resp: Response, oidc_client: OpenidClient -) -> Optional[models.CurrentUser]: +) -> Optional[models.ApiUser]: # easiest case: we still have an access token (which is the most fleeting component) # everything else should still be valid so we can just use it if all(i in req.cookies for i in ("access_token", "id_token")): @@ -30,11 +30,10 @@ async def get_current_user( "user is fully authenticated, returning current user from existing id_token" ) id_token = oidc_client.decode_id_token( - req.cookies["id_token"], nonce=req.cookies.get("auth_nonce", None), - ) - return models.CurrentUser( - id_token=id_token, raw_id_token=req.cookies["id_token"] + req.cookies["id_token"], + nonce=req.cookies.get("auth_nonce", None), ) + return models.ApiUser.from_id_token(id_token, req.cookies["id_token"]) # if we have a refresh token, try to get new tokens elif all(i in req.cookies for i in ("refresh_token",)): @@ -45,24 +44,26 @@ async def get_current_user( token_resp = oidc_client.exchange_refresh_token(req.cookies["refresh_token"]) if isinstance(token_resp, TokenSuccessResponse): logger.debug("successfully got new tokens from refresh token") - persist_auth_state(oidc_client, resp, token_resp, auth_start_time, None) + persist_oidc_auth_state( + oidc_client, resp, token_resp, auth_start_time, None + ) # return the newly gotten info id_token = oidc_client.decode_id_token(token_resp.id_token) - return models.CurrentUser( - id_token=id_token, raw_id_token=token_resp.id_token - ) + return models.ApiUser.from_id_token(id_token, token_resp.id_token) else: - logger.debug("failed to exchange refresh token for new access token: %s", token_resp) + logger.debug( + "failed to exchange refresh token for new access token: %s", token_resp + ) # otherwise we can't meaningfully recover any user information or the user is simply not authenticated else: - logger.debug("no currently authenticated user") + logger.debug("no currently authenticated oidc user") - raise exceptions.HttpProblemException.unauthorized(req.url) + return None -def persist_auth_state( +def persist_oidc_auth_state( oidc_client: OpenidClient, resp: Response, tokens: TokenSuccessResponse, @@ -118,7 +119,7 @@ def persist_auth_state( ) -def clear_auth_state(resp: Response): +def clear_oidc_auth_state(resp: Response): resp.set_cookie("access_token", "", max_age=0) resp.set_cookie("refresh_token", "", max_age=0) resp.set_cookie("id_token", "", max_age=0) @@ -128,7 +129,39 @@ def clear_auth_state(resp: Response): resp.set_cookie("auth_start_time", "", max_age=0) -CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)] +async def get_api_user( + req: Request, resp: Response, oidc_client: OpenidClient +) -> models.ApiUser: + oidc_user = await get_logged_in_oidc_user(req, resp, oidc_client) + # TODO: Implement API user based on static tokens + if oidc_user is not None: + return oidc_user + else: + return models.ApiUser( + is_anonymous=True, + is_ccchh_user=False, + is_token_user=False, + may_operate_locks=False, + username="anonymous", + guaranteed_session_until=None, + raw_id_token=None, + ) + + +ApiUser = Annotated[models.ApiUser, Depends(get_api_user)] + + +async def get_authenticated_user( + req: Request, resp: Response, oidc_client: OpenidClient +) -> models.ApiUser: + user = await get_api_user(req, resp, oidc_client) + if user.is_anonymous: + raise exceptions.HttpProblemException.unauthorized(req.url) + else: + return user + + +AuthenticatedUser = Annotated[models.ApiUser, Depends(get_authenticated_user)] def get_ccujack(req: Request) -> CCUJackClient: diff --git a/api/src/dooris_api/models.py b/api/src/dooris_api/models.py index d1574c4..bfb741c 100644 --- a/api/src/dooris_api/models.py +++ b/api/src/dooris_api/models.py @@ -1,6 +1,6 @@ -from typing import Optional, Literal, List -from datetime import datetime -from pydantic import BaseModel, HttpUrl +from typing import Optional, Literal, Self +from datetime import datetime, UTC +from pydantic import BaseModel, HttpUrl, Field from enum import Enum from simple_openid_connect.data import IdToken @@ -27,24 +27,27 @@ class HttpProblemDetail(BaseModel): instance: Optional[HttpUrl] -class CurrentUser(BaseModel): - id_token: IdToken - raw_id_token: str - - @property - def ccchh_roles(self) -> List[str]: - return getattr(self.id_token, "ccchh-roles", []) - - @property - def may_operate_locks(self) -> bool: - return "intern@" in self.ccchh_roles - - -class UserStatus(BaseModel): - is_authorized: bool - guaranteed_session_until: datetime +class ApiUser(BaseModel): + is_anonymous: bool + is_ccchh_user: bool + is_token_user: bool + may_operate_locks: bool username: str - ccchh_roles: List[str] + guaranteed_session_until: Optional[datetime] + + raw_id_token: Optional[str] = Field(exclude=True) + + @classmethod + def from_id_token(cls, id_token: IdToken, raw_id_token: str) -> Self: + return cls( + is_anonymous=False, + is_ccchh_user=True, + is_token_user=False, + may_operate_locks="intern@" in getattr(id_token, "ccchh-roles", []), + username=id_token.preferred_username, + guaranteed_session_until=datetime.fromtimestamp(id_token.exp, UTC), + raw_id_token=raw_id_token, + ) class LockStatus(BaseModel): From 186ab662fb0913a3bf1be33b953da3954431896a Mon Sep 17 00:00:00 2001 From: lilly Date: Thu, 28 May 2026 17:31:06 +0200 Subject: [PATCH 19/46] api: implement static dooris tokens --- api/src/dooris_api/__init__.py | 3 ++- api/src/dooris_api/deps.py | 40 +++++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py index 618ce97..cdf4d67 100644 --- a/api/src/dooris_api/__init__.py +++ b/api/src/dooris_api/__init__.py @@ -78,9 +78,10 @@ def main(): required=False, nargs=1, action="append", - default=[], + default=[i for i in os.environ.get("DOORIS_STATIC_API_TOKENS", "").split(",") if bool(i)], ) args = argp.parse_args() + print(args.static_api_tokens) # setup logging logging.basicConfig( diff --git a/api/src/dooris_api/deps.py b/api/src/dooris_api/deps.py index c611b5d..b82ae05 100644 --- a/api/src/dooris_api/deps.py +++ b/api/src/dooris_api/deps.py @@ -1,11 +1,12 @@ -from typing import Annotated, Optional, Tuple +from typing import Annotated, Optional import logging from datetime import datetime, UTC, timedelta -from fastapi import Request, Depends, Response +from fastapi import Request, Depends, Response, Header +from fastapi.security import APIKeyHeader from simple_openid_connect.data import TokenSuccessResponse from simple_openid_connect.client import OpenidClient -from simple_openid_connect.exceptions import ValidationError +from dooris_api import app_config from dooris_api import models, exceptions from dooris_api.ccujack import CCUJackClient @@ -13,6 +14,9 @@ from dooris_api.ccujack import CCUJackClient logger = logging.getLogger(__name__) +api_key_security_scheme = APIKeyHeader(name="Authorization", scheme_name="Static-Token", auto_error=False) + + async def get_oidc_client(req: Request) -> OpenidClient: return req.app.extra["oidc_client"] @@ -129,13 +133,39 @@ def clear_oidc_auth_state(resp: Response): resp.set_cookie("auth_start_time", "", max_age=0) +def get_logged_in_token_user(req: Request, token: Optional[str]): + if not token or not token.startswith("Static-Token "): + logger.debug("No valid API-Token was provided") + return None + + token = token.removeprefix("Static-Token ") + valid_tokens = app_config.get().static_api_tokens + + if any((i == token for i in valid_tokens)): + logger.debug("Successfully authenticated a static API-Token") + return models.ApiUser( + is_anonymous=False, + is_ccchh_user=False, + is_token_user=True, + may_operate_locks=True, + username="static-token", + guaranteed_session_until=None, + raw_id_token=None, + ) + + return None + + async def get_api_user( - req: Request, resp: Response, oidc_client: OpenidClient + req: Request, resp: Response, oidc_client: OpenidClient, token: Annotated[Optional[str], Depends(api_key_security_scheme)] = None ) -> models.ApiUser: oidc_user = await get_logged_in_oidc_user(req, resp, oidc_client) - # TODO: Implement API user based on static tokens + token_user = get_logged_in_token_user(req, token) + if oidc_user is not None: return oidc_user + elif token_user is not None: + return token_user else: return models.ApiUser( is_anonymous=True, From 99668e2a33f9188dd2284424ad1c8ee7b261e98f Mon Sep 17 00:00:00 2001 From: lilly Date: Thu, 28 May 2026 17:53:15 +0200 Subject: [PATCH 20/46] document current api parameters in README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f83e55d..7cf569f 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,10 @@ THe following table lists all available configuration parameters: | `--base-url` | `DOORIS_BASE_URL` | Yes | *None* | | `--serve-static` | `DOORIS_SERVE_STATIC` | No | *None* | | `--ccujack-url` | `DOORIS_CCUJACK_URL` | No | `https://hmdooris-ccu.ccchh.net:2122` | +| `--ccujack-mqtt` | `DOORIS_CCUJACK_MQTT` | No | `hmdooris-ccu.ccchh.net:1883` | | `--ccujack-user` | `DOORIS_CCUJACK_USER` | Yes | *None* | | `--ccujack-password` | `DOORIS_CCUJACK_PASSWORD` | Yes | *None* | +| `--static-api-tokens` | `DOORIS_STATIC_API_TOKENS` | No | *None* | ## API Development From 2e1742279d919400bfbd976ce04f3642db7ab9aa Mon Sep 17 00:00:00 2001 From: kritzl Date: Fri, 29 May 2026 09:57:04 +0200 Subject: [PATCH 21/46] update frontend to match new user-info endpoint --- app/src/api/README.md | 2 +- app/src/api/schema.ts | 40 ++++++++++++++++------------------------ app/src/assets/main.ts | 8 +++++--- 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/app/src/api/README.md b/app/src/api/README.md index 256986b..6dc22f0 100644 --- a/app/src/api/README.md +++ b/app/src/api/README.md @@ -1,3 +1,3 @@ # Update schema -`pnpm dlx openapi-typescript http://localhost:8000/openapi.json -o ./schema.ts` +`pnpm dlx openapi-typescript http://localhost:8000/api/openapi.json -o ./schema.ts` diff --git a/app/src/api/schema.ts b/app/src/api/schema.ts index 4f4b938..50495c9 100644 --- a/app/src/api/schema.ts +++ b/app/src/api/schema.ts @@ -127,6 +127,21 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** ApiUser */ + ApiUser: { + /** Is Anonymous */ + is_anonymous: boolean; + /** Is Ccchh User */ + is_ccchh_user: boolean; + /** Is Token User */ + is_token_user: boolean; + /** May Operate Locks */ + may_operate_locks: boolean; + /** Username */ + username: string; + /** Guaranteed Session Until */ + guaranteed_session_until: string | null; + }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -193,20 +208,6 @@ export interface components { */ activity_state: "unknown" | "locking" | "unlocking" | "stable"; }; - /** UserStatus */ - UserStatus: { - /** Is Authorized */ - is_authorized: boolean; - /** - * Guaranteed Session Until - * Format: date-time - */ - guaranteed_session_until: string; - /** Username */ - username: string; - /** Ccchh Roles */ - ccchh_roles: string[]; - }; /** ValidationError */ ValidationError: { /** Location */ @@ -244,16 +245,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserStatus"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HttpProblemDetail"]; + "application/json": components["schemas"]["ApiUser"]; }; }; }; diff --git a/app/src/assets/main.ts b/app/src/assets/main.ts index 205f64f..82594c6 100644 --- a/app/src/assets/main.ts +++ b/app/src/assets/main.ts @@ -93,13 +93,15 @@ async function checkUser() { const {data: userInfo} = await getUserInfo({}) apiError.current = null - auth.authenticated = true - auth.authorized = userInfo.is_authorized + auth.authenticated = !userInfo.is_anonymous + auth.authorized = userInfo.may_operate_locks auth.until = userInfo.guaranteed_session_until ? new Date(userInfo.guaranteed_session_until) : null auth.username = userInfo.username ?? "" auth.recentLogout = false - triggerAuthTimeout() + if (auth.authenticated) { + triggerAuthTimeout() + } } catch (e) { // check which operation threw the exception if (e instanceof getUserInfo.Error) { From b75288881283c679ab593f9d3c7f6001e41dae95 Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 31 May 2026 20:53:14 +0200 Subject: [PATCH 22/46] api: fix mqtt reconnect logic errors --- api/src/dooris_api/ccujack.py | 9 ++++++--- api/src/dooris_api/mqtt_client.py | 26 +++++++++++++++++--------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index 03d0480..a6b0640 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -162,15 +162,18 @@ class CCUJackClient: while True: try: - await asyncio.sleep(15 * 60) # 15 minutes logger.info("Running CCUJack cron") - await self.find_locks() - if not self.mqtt.is_connected(): + if not self.mqtt.is_connected: logger.warning("MQTT client was discovered to be disconnected; reconnecting now") await self.mqtt.connect() + await self.find_locks() + except Exception as e: logger.exception(f"Error in CCUJack cron task: {e}") + finally: + await asyncio.sleep(15 * 60) # 15 minutes + async def query_param_value(self, address: str) -> CCUValue: if address in self.param_values: diff --git a/api/src/dooris_api/mqtt_client.py b/api/src/dooris_api/mqtt_client.py index 3df1f19..a090c5f 100644 --- a/api/src/dooris_api/mqtt_client.py +++ b/api/src/dooris_api/mqtt_client.py @@ -128,19 +128,21 @@ class AsyncMqttClient: to_add = topics.difference(self.active_subscriptions) if to_add: - logger.info(f"mqtt client subscribing to topics {', '.join(to_add)}") - self.fut_subscribe = asyncio.get_running_loop().create_future() - self.client.subscribe([(i, qos) for i in to_add]) - await self.fut_subscribe self.active_subscriptions.update(to_add) + if self.is_connected: + logger.info(f"mqtt client subscribing to topics {', '.join(to_add)}") + self.fut_subscribe = asyncio.get_running_loop().create_future() + self.client.subscribe([(i, qos) for i in to_add]) + await self.fut_subscribe to_remove = self.active_subscriptions.difference(topics) if to_remove: - logger.info(f"mqtt client unsubscribing from topics {','.join(to_remove)}") - self.fut_unsubscribe = asyncio.get_running_loop().create_future() - self.client.unsubscribe(list(to_remove)) - await self.fut_unsubscribe self.active_subscriptions.difference_update(to_remove) + if self.is_connected: + logger.info(f"mqtt client unsubscribing from topics {','.join(to_remove)}") + self.fut_unsubscribe = asyncio.get_running_loop().create_future() + self.client.unsubscribe(list(to_remove)) + await self.fut_unsubscribe async def connect(self): server_host, server_port = self.connection_string.rsplit(":", maxsplit=1) @@ -157,13 +159,19 @@ class AsyncMqttClient: # re-establish all supposed mqtt subscriptions if len(self.active_subscriptions) > 0: qos = 1 - await self.client.subscribe((i, qos) for i in self.active_subscriptions) + self.fut_subscribe = asyncio.get_running_loop().create_future() + self.client.subscribe([(i, qos) for i in self.active_subscriptions]) + await self.fut_subscribe async def disconnect(self): + if not self.is_connected: + return + logger.info("Disconnecting mqtt client from broker") self.fut_disconnect = asyncio.get_running_loop().create_future() self.client.disconnect() await self.fut_disconnect + @property def is_connected(self) -> bool: return self.client.is_connected() From 64ab73e9c30fce5c82c8599c407605fdad223ade Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 31 May 2026 20:53:14 +0200 Subject: [PATCH 23/46] api: initialize ccujack asynchonously via immedietaly running cron --- api/src/dooris_api/ccujack.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index a6b0640..de08bbf 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -90,15 +90,12 @@ class CCUJackClient: self.data_updated = asyncio.Event() async def start(self): - await self.mqtt.connect() - await self.find_locks() - - self.task_cron= asyncio.get_running_loop().create_task( - self.cron(), name="ccujack-cron" - ) self.task_process_messages = asyncio.get_running_loop().create_task( self.process_mqt_messages(), name="process-mqtt-messages" ) + self.task_cron= asyncio.get_running_loop().create_task( + self.cron(), name="ccujack-cron" + ) async def close_connections(self): await asyncio.gather(self.mqtt.disconnect(), self.http.close()) From 279a45a9e8259b28c3fe6dff546ee613771426dc Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 31 May 2026 20:53:14 +0200 Subject: [PATCH 24/46] api: don't print static tokens on startup (was only for debugging) --- api/src/dooris_api/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py index cdf4d67..203d379 100644 --- a/api/src/dooris_api/__init__.py +++ b/api/src/dooris_api/__init__.py @@ -81,7 +81,6 @@ def main(): default=[i for i in os.environ.get("DOORIS_STATIC_API_TOKENS", "").split(",") if bool(i)], ) args = argp.parse_args() - print(args.static_api_tokens) # setup logging logging.basicConfig( From 914a4497c7f48e88aaec34e1cedde61e860d1d1c Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 31 May 2026 22:02:39 +0200 Subject: [PATCH 25/46] api: implement automatic ssh key fetching from keycloak --- .dev.env | 1 + .gitignore | 1 + api/src/dooris_api/__init__.py | 13 ++++++ api/src/dooris_api/app.py | 10 ++++ api/src/dooris_api/ccujack.py | 3 ++ api/src/dooris_api/keycloak.py | 83 ++++++++++++++++++++++++++++++++++ 6 files changed, 111 insertions(+) create mode 100644 api/src/dooris_api/keycloak.py diff --git a/.dev.env b/.dev.env index eb77b35..59f4383 100644 --- a/.dev.env +++ b/.dev.env @@ -3,3 +3,4 @@ DOORIS_OPENID_CLIENT_ID=dooris DOORIS_OPENID_CLIENT_SECRET=dp9HhnvUhAtKm3pRnxfGA7q8Nwrd1td8 DOORIS_BASE_URL=http://localhost:8000 DOORIS_CCUJACK_USER=dooris +DOORIS_AUTHORIZED_KEYS_FILE=./authorized_keys diff --git a/.gitignore b/.gitignore index 4ad5c33..7b869c5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .env **/__pycache__ +**/authorized_keys api/dist diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py index 203d379..10d1ff5 100644 --- a/api/src/dooris_api/__init__.py +++ b/api/src/dooris_api/__init__.py @@ -1,5 +1,6 @@ import os import logging +from pathlib import Path from contextvars import ContextVar from argparse import ArgumentParser, Namespace @@ -80,6 +81,18 @@ def main(): action="append", default=[i for i in os.environ.get("DOORIS_STATIC_API_TOKENS", "").split(",") if bool(i)], ) + argp.add_argument( + "--kc-ssh-attr-group", + required=False, + default=os.environ.get("DOORIS_KC_SSH_ATTR_GROUP", "dooris-ssh-keys"), + ) + argp.add_argument( + "--authorized-keys-file", + required="DOORIS_AUTHORIZED_KEYS_FILE" not in os.environ, + default=os.environ.get("DOORIS_AUTHORIZED_KEYS_FILE", None), + type=Path, + help="A file to which ssh authorized keys fetched from keycloak will be written", + ) args = argp.parse_args() # setup logging diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index 0a33704..73cab3d 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -13,6 +13,7 @@ from aiohttp import BasicAuth from dooris_api import deps, models, exceptions, app_config from dooris_api.ccujack import CCUJackClient +from dooris_api.keycloak import KeycloakClient logger = logging.getLogger(__name__) @@ -37,9 +38,18 @@ async def lifespan(app: FastAPI): ) await app.extra["ccujack"].start() + app.extra["keycloak"] = KeycloakClient( + base_uri=app_cfg.openid_issuer, + oidc_client=app.extra["oidc_client"], + keycloak_attribute_group=app_cfg.kc_ssh_attr_group, + authorized_keys_file=app_cfg.authorized_keys_file, + ) + app.extra["keycloak"].start() + yield await app.extra["ccujack"].close_connections() + await app.extra["keycloak"].stop() app = FastAPI( diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index de08bbf..8cf767f 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -166,6 +166,9 @@ class CCUJackClient: await self.find_locks() + except asyncio.CancelledError: + logger.info("CCUJack task cron stopped") + raise except Exception as e: logger.exception(f"Error in CCUJack cron task: {e}") finally: diff --git a/api/src/dooris_api/keycloak.py b/api/src/dooris_api/keycloak.py new file mode 100644 index 0000000..ea614d1 --- /dev/null +++ b/api/src/dooris_api/keycloak.py @@ -0,0 +1,83 @@ +from typing import List +from aiohttp import ClientSession, ClientRequest, ClientHandlerType, ClientResponse +import asyncio +import logging +from pathlib import Path +from simple_openid_connect.client import OpenidClient +from simple_openid_connect.data import TokenErrorResponse + + +logger = logging.getLogger(__name__) + + +class ClientCredentialsAuth: + def __init__(self, oidc_client: OpenidClient): + self.oidc_client = oidc_client + + async def __call__(self, req: ClientRequest, handler: ClientHandlerType) -> ClientResponse: + resp = self.oidc_client.client_credentials_grant.authenticate() + if isinstance(resp, TokenErrorResponse): + raise Exception(f"Could not authenticate against keycloak with client-credentials-grant: {resp.error} ({resp.error_description})") + + req.headers["Authorization"] = f"Bearer {resp.access_token}" + return await handler(req) + + +class KeycloakClient: + keycloak_attribute_group: str + authorized_keys_file: Path + ssh_keys: List[str] + data_updated: asyncio.Event + task_cron = asyncio.Task + + def __init__(self, base_uri: str, oidc_client: OpenidClient, keycloak_attribute_group: str, authorized_keys_file: Path): + self.base_uri: str + self.keycloak_attribute_group = keycloak_attribute_group + self.ssh_keys = [] + self.data_updated = asyncio.Event() + self.authorized_keys_file = authorized_keys_file + self.http = ClientSession( + base_url=base_uri, + middlewares=[ClientCredentialsAuth(oidc_client)], + raise_for_status=True, + ) + + def start(self): + self.task_cron = asyncio.get_running_loop().create_task(self.cron(), name="keycloak-cron") + + async def stop(self): + await self.http.close() + self.task_cron.cancel() + self.task_cron = None + + async def cron(self): + while True: + try: + + logger.info("Running keycloak cron") + await self.fetch_ssh_keys() + await self.write_authorized_keys() + + except asyncio.CancelledError: + logger.info("keycloak cron stopped") + raise + except Exception as e: + logger.exception(f"Error in Keycloak cron task: {e}") + finally: + await asyncio.sleep(15 * 60) # 15 minutes + + async def fetch_ssh_keys(self): + logger.info("Fetching ssh keys from keycloak") + async with self.http.get(f"./attribute-endpoints-provider/export/{self.keycloak_attribute_group}") as resp: + keys = await resp.json() + assert isinstance(keys, list) + self.ssh_keys = keys + self.data_updated.set() + self.data_updated.clear() + + async def write_authorized_keys(self): + logger.info(f"Writing authorized keys to {self.authorized_keys_file.absolute()}") + with self.authorized_keys_file.open(mode="wt", encoding="UTF-8") as f: + f.writelines(self.ssh_keys) + + From ae8324c5c6d6668225026be810e271220c054af7 Mon Sep 17 00:00:00 2001 From: lilly Date: Sat, 18 Jul 2026 20:43:47 +0200 Subject: [PATCH 26/46] init locks as empty list to avoid internal server errors --- api/src/dooris_api/ccujack.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index 8cf767f..6856128 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -83,7 +83,7 @@ class CCUJackClient: connector=TCPConnector(ssl=False), ) self.mqtt = AsyncMqttClient(mqtt_conn, auth.login, auth.password) - self.locks = None + self.locks = [] self.param_values = dict() self.task_process_messages = None self.task_find_locks = None From ee9edfcc70635e07913fbd8f2ab10aae41bc4fa6 Mon Sep 17 00:00:00 2001 From: lilly Date: Sat, 18 Jul 2026 20:49:11 +0200 Subject: [PATCH 27/46] clear up static api token error message --- api/src/dooris_api/deps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/dooris_api/deps.py b/api/src/dooris_api/deps.py index b82ae05..9ca5139 100644 --- a/api/src/dooris_api/deps.py +++ b/api/src/dooris_api/deps.py @@ -135,7 +135,7 @@ def clear_oidc_auth_state(resp: Response): def get_logged_in_token_user(req: Request, token: Optional[str]): if not token or not token.startswith("Static-Token "): - logger.debug("No valid API-Token was provided") + logger.debug("No static API-Token was part of the request") return None token = token.removeprefix("Static-Token ") From ba404112d7e688b88ec62e126af4e28f01411f07 Mon Sep 17 00:00:00 2001 From: lilly Date: Sat, 18 Jul 2026 20:50:29 +0200 Subject: [PATCH 28/46] add debug message about fetched keycloak keys --- api/src/dooris_api/keycloak.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/dooris_api/keycloak.py b/api/src/dooris_api/keycloak.py index ea614d1..3365b81 100644 --- a/api/src/dooris_api/keycloak.py +++ b/api/src/dooris_api/keycloak.py @@ -71,6 +71,7 @@ class KeycloakClient: async with self.http.get(f"./attribute-endpoints-provider/export/{self.keycloak_attribute_group}") as resp: keys = await resp.json() assert isinstance(keys, list) + logger.info(f"Successfully fetched {len(keys)} from Keycloak") self.ssh_keys = keys self.data_updated.set() self.data_updated.clear() From f904aa2831c7b340839efce256d54a048d2ca151 Mon Sep 17 00:00:00 2001 From: lilly Date: Sat, 18 Jul 2026 20:58:58 +0200 Subject: [PATCH 29/46] fix typo in keycloak ssh key count debug message --- api/src/dooris_api/keycloak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/dooris_api/keycloak.py b/api/src/dooris_api/keycloak.py index 3365b81..ab792aa 100644 --- a/api/src/dooris_api/keycloak.py +++ b/api/src/dooris_api/keycloak.py @@ -71,7 +71,7 @@ class KeycloakClient: async with self.http.get(f"./attribute-endpoints-provider/export/{self.keycloak_attribute_group}") as resp: keys = await resp.json() assert isinstance(keys, list) - logger.info(f"Successfully fetched {len(keys)} from Keycloak") + logger.info(f"Successfully fetched {len(keys)} ssh keys from Keycloak") self.ssh_keys = keys self.data_updated.set() self.data_updated.clear() From 58f5a8cd43fae31f7c92333a9d75f0d7f974e766 Mon Sep 17 00:00:00 2001 From: lilly Date: Sat, 18 Jul 2026 21:09:45 +0200 Subject: [PATCH 30/46] init nix based devshell --- .envrc.dist | 3 +++ flake.lock | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 34 +++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc.dist b/.envrc.dist index 09c6b28..665b4c9 100644 --- a/.envrc.dist +++ b/.envrc.dist @@ -1,4 +1,7 @@ # integrate this into your own .envrc file with `source_env .envrc.dist` +if has nix; then + use flake +fi watch_file api/pyproject.toml \ api/uv.lock diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c9db7fc --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1784356753, + "narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "61b7c44c4073f0b827768aff0049561b5110ea5a", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..89d8b8c --- /dev/null +++ b/flake.nix @@ -0,0 +1,34 @@ +{ + description = "dooris"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { system = system; }; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + uv + pre-commit + ruff + python3 + pnpm + rustc + cargo + ]; + }; + } + ); +} From a7692dc89484cae29e051d07f5279c1e04cc929a Mon Sep 17 00:00:00 2001 From: lilly Date: Sat, 18 Jul 2026 21:10:32 +0200 Subject: [PATCH 31/46] fix bug in API static tokens where not actually extracted from request --- api/src/dooris_api/deps.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/api/src/dooris_api/deps.py b/api/src/dooris_api/deps.py index 9ca5139..8f65df2 100644 --- a/api/src/dooris_api/deps.py +++ b/api/src/dooris_api/deps.py @@ -1,7 +1,7 @@ from typing import Annotated, Optional import logging from datetime import datetime, UTC, timedelta -from fastapi import Request, Depends, Response, Header +from fastapi import Request, Depends, Response from fastapi.security import APIKeyHeader from simple_openid_connect.data import TokenSuccessResponse from simple_openid_connect.client import OpenidClient @@ -14,7 +14,12 @@ from dooris_api.ccujack import CCUJackClient logger = logging.getLogger(__name__) -api_key_security_scheme = APIKeyHeader(name="Authorization", scheme_name="Static-Token", auto_error=False) +api_key_security_scheme = APIKeyHeader( + name="authorization", + scheme_name="Static-Token", + description="Set the Authorization header to 'Static-Token foobar123' with a token that is statically configured at application parameter", + auto_error=False, +) async def get_oidc_client(req: Request) -> OpenidClient: @@ -134,14 +139,17 @@ def clear_oidc_auth_state(resp: Response): def get_logged_in_token_user(req: Request, token: Optional[str]): + print(req.headers) + print(token) + if not token or not token.startswith("Static-Token "): logger.debug("No static API-Token was part of the request") return None - + token = token.removeprefix("Static-Token ") valid_tokens = app_config.get().static_api_tokens - if any((i == token for i in valid_tokens)): + if any((i == token for i in valid_tokens)): logger.debug("Successfully authenticated a static API-Token") return models.ApiUser( is_anonymous=False, @@ -157,7 +165,10 @@ def get_logged_in_token_user(req: Request, token: Optional[str]): async def get_api_user( - req: Request, resp: Response, oidc_client: OpenidClient, token: Annotated[Optional[str], Depends(api_key_security_scheme)] = None + req: Request, + resp: Response, + oidc_client: OpenidClient, + token: Annotated[Optional[str], Depends(api_key_security_scheme)] = None, ) -> models.ApiUser: oidc_user = await get_logged_in_oidc_user(req, resp, oidc_client) token_user = get_logged_in_token_user(req, token) @@ -182,9 +193,12 @@ ApiUser = Annotated[models.ApiUser, Depends(get_api_user)] async def get_authenticated_user( - req: Request, resp: Response, oidc_client: OpenidClient + req: Request, + resp: Response, + oidc_client: OpenidClient, + token: Annotated[Optional[str], Depends(api_key_security_scheme)] = None, ) -> models.ApiUser: - user = await get_api_user(req, resp, oidc_client) + user = await get_api_user(req, resp, oidc_client, token) if user.is_anonymous: raise exceptions.HttpProblemException.unauthorized(req.url) else: From 504d64ea5763ddc120f6629d4ff3e8e68a00d004 Mon Sep 17 00:00:00 2001 From: lilly Date: Sat, 18 Jul 2026 21:10:32 +0200 Subject: [PATCH 32/46] init small ssh-cli to accept commands over SSH and operate local locks --- flake.nix | 2 + ssh-cli/Cargo.lock | 1863 +++++++++++++++++++++++++++++++++++++ ssh-cli/Cargo.toml | 12 + ssh-cli/src/api_models.rs | 63 ++ ssh-cli/src/main.rs | 122 +++ 5 files changed, 2062 insertions(+) create mode 100644 ssh-cli/Cargo.lock create mode 100644 ssh-cli/Cargo.toml create mode 100644 ssh-cli/src/api_models.rs create mode 100644 ssh-cli/src/main.rs diff --git a/flake.nix b/flake.nix index 89d8b8c..a7c59ee 100644 --- a/flake.nix +++ b/flake.nix @@ -27,6 +27,8 @@ pnpm rustc cargo + rust-analyzer + rustfmt ]; }; } diff --git a/ssh-cli/Cargo.lock b/ssh-cli/Cargo.lock new file mode 100644 index 0000000..228d277 --- /dev/null +++ b/ssh-cli/Cargo.lock @@ -0,0 +1,1863 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ssh-cli" +version = "0.1.0" +dependencies = [ + "color-eyre", + "eyre", + "reqwest", + "serde", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/ssh-cli/Cargo.toml b/ssh-cli/Cargo.toml new file mode 100644 index 0000000..15136fe --- /dev/null +++ b/ssh-cli/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ssh-cli" +version = "0.1.0" +edition = "2024" + +[dependencies] +color-eyre = "0.6.5" +eyre = "0.6.12" +reqwest = { version = "0.13.4", features = ["blocking", "json"] } +serde = { version = "1.0.228", features = ["derive"] } +tracing = "0.1.44" +tracing-subscriber = "0.3.23" diff --git a/ssh-cli/src/api_models.rs b/ssh-cli/src/api_models.rs new file mode 100644 index 0000000..b7ad45d --- /dev/null +++ b/ssh-cli/src/api_models.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; + +#[allow(unused)] +#[derive(Debug, Eq, PartialEq, Hash, Deserialize)] +pub struct ApiLock { + pub id: String, + pub name: String, + pub status: ApiLockStatus, +} + +#[allow(unused)] +#[derive(Debug, Eq, PartialEq, Hash, Deserialize)] +pub struct ApiLockStatus { + pub is_unreachable: bool, + #[serde(default)] + pub is_low_batteray: bool, + pub is_error_jammed: bool, + pub lock_target_level: String, + pub lock_state: String, + pub activity_state: String, +} + +#[allow(unused)] +#[derive(Debug, Eq, PartialEq, Hash, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LockTargetLevel { + LOCKED, + UNLOCKED, + OPEN, +} + +#[allow(unused)] +#[derive(Debug, Eq, PartialEq, Hash, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LockState { + UNKNOWN, + LOCKED, + UNLOCKED, +} + +#[allow(unused)] +#[derive(Debug, Eq, PartialEq, Hash, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LockActivityState { + UNKNOWN, + LOCKING, + UNLOCKING, + STABLE, +} + +#[allow(unused)] +#[derive(Debug, Eq, PartialEq, Hash, Serialize)] +pub struct LockOperationRequest { + pub desired_state: DesiredLockState, +} + +#[allow(unused)] +#[derive(Debug, Eq, PartialEq, Hash, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DesiredLockState { + OPEN, + CLOSED, +} diff --git a/ssh-cli/src/main.rs b/ssh-cli/src/main.rs new file mode 100644 index 0000000..9c8ed62 --- /dev/null +++ b/ssh-cli/src/main.rs @@ -0,0 +1,122 @@ +//! This is a small CLI intended to be run on a host that accepts dooris commands via SSH +//! +//! The SSH server should be configured to force execution of this command via the following server config snippet +//! +//! ```sshd_config +//! Match User dooris +//! AuthorizedKeysFile /path/to/api/state/authorized_keys +//! ForceCommand /path/to/ssh-cli +//! SetEnv DOORIS_SERVER_URL=https://dooris.ccchh.net/ DOORIS_API_TOKEN=foobar123 +//! ``` + +mod api_models; + +use eyre::{Context, OptionExt, eyre}; +use reqwest::{Url, header::HeaderValue}; +use std::{env::VarError, process::exit}; + +use crate::api_models::LockOperationRequest; + +fn main() -> eyre::Result<()> { + tracing_subscriber::fmt::init(); + color_eyre::install()?; + + let doors = fetch_doors()?; + let args = get_args()?; + let args = args.split(" ").collect::>(); + + if args.len() == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "" { + println!("Help is not yet available. Come back later >~<"); + exit(3); + } else if args[0] == "lock" || args[0] == "unlock" { + let cli_target_door = args + .get(1) + .ok_or_eyre("'unlock' and 'lock' cli requires the door name as second argument")?; + + let api_target_door = doors + .iter() + .find(|i_door| i_door.name.eq_ignore_ascii_case(cli_target_door)) + .ok_or({ + let door_names = doors + .iter() + .map(|i_door| i_door.name.to_owned()) + .collect::>(); + eyre!( + "No door named {cli_target_door:?} exists. Available doors are {door_names:?}.", + ) + })?; + + println!( + "Lock {} ({:?}) identified!", + api_target_door.id, api_target_door.name + ); + operate_lock( + &api_target_door.id, + match args[0] { + "lock" => api_models::DesiredLockState::CLOSED, + "unlock" => api_models::DesiredLockState::OPEN, + _ => unreachable!(), + }, + )?; + println!("Lock has been turned. Enjoy :3"); + return Ok(()); + } else { + println!("Unknown commandline. See --help for more info."); + exit(2); + } +} + +/// Get passed arguments from SSH_ORIGINAL_COMMAND environment variable but falling back to direct CLI args +fn get_args() -> eyre::Result { + match std::env::var("SSH_ORIGINAL_COMMAND") { + Ok(original_command) => Ok(original_command), + Err(VarError::NotUnicode(_)) => { + Err(eyre!("Your command is not very unicoded. Refusing >~<")) + } + Err(VarError::NotPresent) => Ok(std::env::args().skip(1).collect::>().join(" ")), + } +} + +fn http_client() -> eyre::Result { + return reqwest::blocking::ClientBuilder::new() + .user_agent("dooris-ssh-cli") + .redirect(reqwest::redirect::Policy::none()) + .http1_title_case_headers() + .build() + .context("Could not construct http client even though all values are statically known to be valid"); +} + +fn api_token() -> eyre::Result { + let server_token = std::env::var("DOORIS_API_TOKEN").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_API_TOKEN environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?; + format!("Static-Token {server_token}") + .try_into() + .context("Could not convert HTTP API Token to a valid HTTP header") +} + +fn server_url() -> eyre::Result { + let server_url = std::env::var("DOORIS_SERVER_URL").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_SERVER_URL environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?; + Url::parse(&server_url).context("Configured DOORIS_SERVER_URL is not a valid URL. Contact your nearest server maid to fix the dooris setup") +} + +fn fetch_doors() -> eyre::Result> { + http_client()? + .get(server_url()?.join("api/locks/")?) + .header(reqwest::header::AUTHORIZATION, api_token()?) + .send()? + .error_for_status() + .context("Could not get list of locks from the dooris API")? + .json() + .context("Could not parse list-locks response from dooris API") +} + +fn operate_lock(lock_id: &str, desired_state: api_models::DesiredLockState) -> eyre::Result<()> { + println!("Turning Lock, please stand by…"); + http_client()? + .patch(server_url()?.join(&format!("api/locks/{lock_id}"))?) + .header(reqwest::header::AUTHORIZATION, api_token()?) + .json(&LockOperationRequest { desired_state }) + .send()? + .error_for_status() + .context("Could not operate lock")?; + Ok(()) +} From f5c69ae5e9beb388e3385b3345f33c96b1ad6225 Mon Sep 17 00:00:00 2001 From: lilly Date: Sat, 18 Jul 2026 21:10:32 +0200 Subject: [PATCH 33/46] set authorized_keys file mode automatically from api process --- api/src/dooris_api/keycloak.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/src/dooris_api/keycloak.py b/api/src/dooris_api/keycloak.py index ab792aa..de99b92 100644 --- a/api/src/dooris_api/keycloak.py +++ b/api/src/dooris_api/keycloak.py @@ -2,6 +2,7 @@ from typing import List from aiohttp import ClientSession, ClientRequest, ClientHandlerType, ClientResponse import asyncio import logging +import stat from pathlib import Path from simple_openid_connect.client import OpenidClient from simple_openid_connect.data import TokenErrorResponse @@ -81,4 +82,6 @@ class KeycloakClient: with self.authorized_keys_file.open(mode="wt", encoding="UTF-8") as f: f.writelines(self.ssh_keys) + self.authorized_keys_file.chmod(stat.S_IWUSR | stat.S_IRUSR) + From a6949ed0662ecb555111a6439d9cfdf20f050352 Mon Sep 17 00:00:00 2001 From: lilly Date: Sat, 18 Jul 2026 21:10:32 +0200 Subject: [PATCH 34/46] ship ssh-cli in docker container --- .gitignore | 1 + Containerfile | 15 +++++++++++++-- oci/entrypoint.sh | 8 ++++++++ ssh-cli/test_id_ed25519 | 7 +++++++ ssh-cli/test_id_ed25519.pub | 1 + 5 files changed, 30 insertions(+), 2 deletions(-) create mode 100755 oci/entrypoint.sh create mode 100644 ssh-cli/test_id_ed25519 create mode 100644 ssh-cli/test_id_ed25519.pub diff --git a/.gitignore b/.gitignore index 7b869c5..984e24a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ **/__pycache__ **/authorized_keys api/dist +**/target diff --git a/Containerfile b/Containerfile index 2c73422..0cc80f3 100644 --- a/Containerfile +++ b/Containerfile @@ -1,3 +1,10 @@ +FROM docker.io/rust:alpine3.22 AS build-ssh-cli +WORKDIR /usr/local/src/dooris/ssh-cli/ +ADD --link ./ssh-cli/ /usr/local/src/dooris/ssh-cli/ +RUN cargo build --release + + + FROM docker.io/alpine:3.22 AS build-frontend ENV PNPM_HOME=/usr/local/share/dooris/pnpm/ WORKDIR /usr/local/src/dooris/ @@ -6,7 +13,7 @@ RUN apk add --no-cache pnpm ADD --link app/package.json app/pnpm-lock.yaml app/ RUN pnpm --dir=app/ install --frozen-lockfile --package-import-method=copy -ADD --link . /usr/local/src/dooris/ +ADD --link ./app/ /usr/local/src/dooris/app/ RUN pnpm --dir=app/ run build @@ -45,9 +52,13 @@ RUN uv venv $VIRTUAL_ENV &&\ FROM deps AS final ADD --chown=dooris:dooris --link . /usr/local/src/dooris/ COPY --chown=dooris:dooris --from=build-frontend --link /usr/local/src/dooris/app/dist/ $DOORIS_SERVE_STATIC +COPY --chown=dooris:dooris --from=build-ssh-cli --link /usr/local/src/dooris/ssh-cli/target/release/ssh-cli /usr/local/bin/ssh-cli +COPY oci/entrypoint.sh /usr/local/bin/entrypoint.sh RUN --mount=type=cache,uid=$APP_UID,gid=$APP_GID,target=$UV_CACHE_DIR \ uv sync --active --frozen -ENTRYPOINT [ "uv", "run", "--active", "dooris-api" ] +ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ] +CMD [ "uv", "run", "--active", "dooris-api" ] EXPOSE 8000/tcp +VOLUME /srv/state/ diff --git a/oci/entrypoint.sh b/oci/entrypoint.sh new file mode 100755 index 0000000..b598107 --- /dev/null +++ b/oci/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +echo "Copying ssh-cli into state directory" +cp -v /usr/local/bin/ssh-cli /srv/state/ssh-cli + +echo "Running user script" +exec $@ + diff --git a/ssh-cli/test_id_ed25519 b/ssh-cli/test_id_ed25519 new file mode 100644 index 0000000..7fbba02 --- /dev/null +++ b/ssh-cli/test_id_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACDTjbowlWL4JrOcqQrlpWg9MBwejsfUvEEnTjkFekOsmwAAAIhz+nzqc/p8 +6gAAAAtzc2gtZWQyNTUxOQAAACDTjbowlWL4JrOcqQrlpWg9MBwejsfUvEEnTjkFekOsmw +AAAEDHkXZZNymkxkR3QMacM0R8MzCWg0+JPkL5GsFdP6Jzs9ONujCVYvgms5ypCuWlaD0w +HB6Ox9S8QSdOOQV6Q6ybAAAABHRlc3QB +-----END OPENSSH PRIVATE KEY----- diff --git a/ssh-cli/test_id_ed25519.pub b/ssh-cli/test_id_ed25519.pub new file mode 100644 index 0000000..db1ae93 --- /dev/null +++ b/ssh-cli/test_id_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINONujCVYvgms5ypCuWlaD0wHB6Ox9S8QSdOOQV6Q6yb test From fb19ca38401b3a795844ef9c68782816d945c2ab Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 19 Jul 2026 17:25:27 +0200 Subject: [PATCH 35/46] document how different dooris components work together --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7cf569f..03e970d 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,51 @@ Based on prior work of [hmdooris](https://git.hamburg.ccc.de/CCCHH/hmdooris). Project structure: ``` -β”œβ”€β”€ api # Python application interacting with HomeMatic and providing the API. -└── app # Web UI +β”œβ”€β”€ api # Python application interacting with HomeMatic and providing the API. +β”œβ”€β”€ ssh-cli # CLI application that allows door management over SSH +└── app # Web UI ``` +## Architecture Explanation + +The deployment of dooris is a little bit involved but I hope this paragraph can give some clarity about the required details. + +This dooris implementation works together with an existing HomeMatic installation that does the actual work of turning locks. Dooris is in principal just the access layer as well as integration layer into other CCH services. +For example, dooris enables us to bind guard access to the underlying HomeMatic to our Keycloak roles as well as enabling new access methods like dooris-over-ssh. + +The appliction is implemented using the following different components: + +- An **API** (or backend). + This python application is intended to be accessible over simple HTTP(S) connections and serves as the central point of managing everything. + It interacts directly with HomeMatic by automatically querying its API for locks and connecting to its integrated MQTT server for state updates. + All other components only every talk to this API and the API is in charge of managing all the different integrations. + +- An astro **frontend** served at the same HTTP(S) host as the API. + This is the user facing part of dooris and what you see when you navigate to said url. + In the background, this frontend simply talks to the API for available doors, state updates, lock interactions and whatelse. + Since the frontend is also the user facing part of dooris, it is the intended place for catching common error conditions and displaying helpful hints. + +- The **ssh-cli** rust application whose setup is a bit contrived but which serves as an alternative access layer to the HTTP(S) frontend. + Even though dooris is mainly packaged as a docker container, using this part of dooris requires configuration on the underlying linux host. + + The way the integration works is as follows: + - On the underlying docker host, a local `dooris` user is created with a home directory configured that is shared with the running dooris container. User ID, group ID and permissions must also match between the host user and the container user (`10000` for uid and gid by default). + - The container has an entrypoint script which copies the bundled `ssh-cli` binary into said shared directory. Afterwards, the web service is started normally. + - The web service must be configured with a static API token (either via environment variables or cli parameters) to allow the ssh-cli access. + - The keycloak instance backing dooris must have the [attribute-endpoints-provider plugin](https://git.hamburg.ccc.de/CCCHH/keycloak-attribute-endpoints-provider) installed and configured to expose an endpoint containing a list of ssh public keys and accessible by the dooris clients service account. That endpoint must be configured in the dooris backend instance. + - The dooris backend regularly queries the configured keycloak endpoint for a list of ssh keys and writes them into the host-container shared directory as an authorized_keys file. + - Finally, the ssh server on the host must have a specific configuration section for the local dooris user to use the automatically written authorized_keys file as well as force the command to be the placed `ssh-cli` binary. The binary must also be configured via environment variables to connect to the running backend (including authorization). + + This can be done, for example, with the following config section: + ```sshd_config + Match User dooris + AuthorizedKeysFile /my/shared/directory/dooris_authorized_keys + ForceCommand /my/shared/directory/ssh-cli + SetEnv DOORIS_SERVER_URL=https://dooris.ccchh.net/ DOORIS_API_TOKEN=foobar123 + ``` + - When a user logs in with the `dooris` user now, they are always dropped into the `ssh-cli` binary. This works in cases where a command is specified on the users cli or where it isn't. + In any case, the `ssh-cli` binary interprets the given command (via the `SSH_ORIGINAL_COMMAND` environment variable), figures out what to do, and contacts the running API with the configured credentials to do it. + ## Configuration The final application can be configured either via CLI arguments or via environment variables. From bc0c1b85ca29fea38653df2c01b4c96c21dd15a4 Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 19 Jul 2026 20:19:43 +0200 Subject: [PATCH 36/46] improve ssh-cli interaction surface - better CLI parameter parsing - an actual help message - a new command to list currently known locks --- ssh-cli/Cargo.lock | 140 +++++-------------------- ssh-cli/Cargo.toml | 5 +- ssh-cli/src/api_client.rs | 78 ++++++++++++++ ssh-cli/src/cli.rs | 104 ++++++++++++++++++ ssh-cli/src/main.rs | 214 ++++++++++++++++++++------------------ 5 files changed, 323 insertions(+), 218 deletions(-) create mode 100644 ssh-cli/src/api_client.rs create mode 100644 ssh-cli/src/cli.rs diff --git a/ssh-cli/Cargo.lock b/ssh-cli/Cargo.lock index 228d277..1ccaee5 100644 --- a/ssh-cli/Cargo.lock +++ b/ssh-cli/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -46,21 +31,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - [[package]] name = "base64" version = "0.22.1" @@ -129,33 +99,6 @@ dependencies = [ "cc", ] -[[package]] -name = "color-eyre" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" -dependencies = [ - "backtrace", - "color-spantrace", - "eyre", - "indenter", - "once_cell", - "owo-colors", - "tracing-error", -] - -[[package]] -name = "color-spantrace" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" -dependencies = [ - "once_cell", - "owo-colors", - "tracing-core", - "tracing-error", -] - [[package]] name = "combine" version = "4.6.7" @@ -277,7 +220,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -286,12 +228,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" -[[package]] -name = "futures-io" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" - [[package]] name = "futures-sink" version = "0.3.33" @@ -311,10 +247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", - "futures-io", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -346,12 +279,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "h2" version = "0.4.15" @@ -720,15 +647,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - [[package]] name = "mio" version = "1.2.2" @@ -749,15 +667,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -770,12 +679,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -919,9 +822,7 @@ dependencies = [ "base64", "bytes", "encoding_rs", - "futures-channel", "futures-core", - "futures-util", "h2", "http", "http-body", @@ -966,12 +867,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - [[package]] name = "rustc-hash" version = "2.1.3" @@ -1189,6 +1084,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple-eyre" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b561532e8ffe7ecf09108c4f662896a9ec3eac4999eba84015ec3dcb8cc630a" +dependencies = [ + "eyre", + "indenter", +] + [[package]] name = "slab" version = "0.4.12" @@ -1215,10 +1120,11 @@ dependencies = [ name = "ssh-cli" version = "0.1.0" dependencies = [ - "color-eyre", "eyre", "reqwest", "serde", + "simple-eyre", + "tokio", "tracing", "tracing-subscriber", ] @@ -1352,9 +1258,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -1455,16 +1373,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-error" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" -dependencies = [ - "tracing", - "tracing-subscriber", -] - [[package]] name = "tracing-log" version = "0.2.0" diff --git a/ssh-cli/Cargo.toml b/ssh-cli/Cargo.toml index 15136fe..8c6edfc 100644 --- a/ssh-cli/Cargo.toml +++ b/ssh-cli/Cargo.toml @@ -4,9 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] -color-eyre = "0.6.5" eyre = "0.6.12" -reqwest = { version = "0.13.4", features = ["blocking", "json"] } +reqwest = { version = "0.13.4", features = ["json"] } serde = { version = "1.0.228", features = ["derive"] } +simple-eyre = "0.3.1" +tokio = { version = "1.53.0", features = ["macros", "rt"] } tracing = "0.1.44" tracing-subscriber = "0.3.23" diff --git a/ssh-cli/src/api_client.rs b/ssh-cli/src/api_client.rs new file mode 100644 index 0000000..5e118fc --- /dev/null +++ b/ssh-cli/src/api_client.rs @@ -0,0 +1,78 @@ +use eyre::Context; +use eyre::eyre; +use reqwest::Url; +use reqwest::header::HeaderValue; + +use crate::api_models::ApiLock; +use crate::api_models::DesiredLockState; +use crate::api_models::LockOperationRequest; + +/// Client for interacting with the dooris API +pub struct ApiClient { + client: reqwest::Client, + authorization: HeaderValue, + server_url: Url, +} + +impl ApiClient { + pub fn new_from_env() -> eyre::Result { + let client = reqwest::Client::builder() + .user_agent("dooris-ssh-cli") + .redirect(reqwest::redirect::Policy::none()) + .http1_title_case_headers() + .build() + .expect("Could not construct http client even though all values are statically known to be valid"); + + let api_token = std::env::var("DOORIS_API_TOKEN").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_API_TOKEN environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?; + let api_token = format!("Static-Token {api_token}") + .try_into() + .context("Could not convert HTTP API Token to a valid HTTP header")?; + + let server_url = std::env::var("DOORIS_SERVER_URL").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_SERVER_URL environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?; + let server_url = Url::parse(&server_url).context("Configured DOORIS_SERVER_URL is not a valid URL. Contact your nearest server maid to fix the dooris setup")?; + + tracing::debug!("Client is connected to dooris at {server_url}"); + Ok(Self { + client, + authorization: api_token, + server_url, + }) + } + + /// Fetch the current list of locks along with their state from the API + pub async fn fetch_locks(&self) -> eyre::Result> { + tracing::debug!("Fetching locks from dooris API"); + let url = self.server_url.join("api/locks/")?; + self.client + .get(url) + .header(reqwest::header::AUTHORIZATION, self.authorization.clone()) + .send() + .await? + .error_for_status() + .context("Could not get list of locks from the dooris API")? + .json() + .await + .context("Could not parse list-locks response from dooris API") + } + + /// Put the lock identified by `lock_id` into the given desired state. + /// + /// Returns as soon as the operation has been handed of to the backend and does not wait for physical lock movements. + pub async fn operate_lock( + &self, + lock_id: &str, + desired_state: DesiredLockState, + ) -> eyre::Result<()> { + tracing::debug!("Calling API to put {lock_id:?} into state {desired_state:?}"); + let url = self.server_url.join(&format!("api/locks/{lock_id}"))?; + self.client + .patch(url) + .header(reqwest::header::AUTHORIZATION, self.authorization.clone()) + .json(&LockOperationRequest { desired_state }) + .send() + .await? + .error_for_status() + .context("Could not operate lock")?; + Ok(()) + } +} diff --git a/ssh-cli/src/cli.rs b/ssh-cli/src/cli.rs new file mode 100644 index 0000000..6a5b17b --- /dev/null +++ b/ssh-cli/src/cli.rs @@ -0,0 +1,104 @@ +use eyre::{OptionExt, eyre}; +use std::env::VarError; + +/// CLI parameters given to this program +#[derive(Debug, Eq, PartialEq)] +pub enum CliArgs { + /// The user requested a help message + Help, + /// The user requested to lock a specific lock + Lock { + /// The name of the lock that should be locked + lock_name: String, + /// Whether the CLI should wait until the lock operation is not just started but until it is completely finished + wait_until_done: bool, + }, + /// The user requested to unlock a specific lock + Unlock { + /// The name of the lock that should be unlocked + lock_name: String, + /// Whether the CLI should wait until the unlock operation is not just started but until it is completely finished + wait_until_done: bool, + }, + /// The user wants to see a list of currently connected locks as well as their status + ShowLocks, +} + +/// Get passed arguments from SSH_ORIGINAL_COMMAND environment variable but falling back to direct CLI args +fn get_arg_string() -> eyre::Result { + match std::env::var("SSH_ORIGINAL_COMMAND") { + Ok(original_command) => { + tracing::trace!( + SSH_ORIGINAL_COMMAND = ?original_command, + "Taking CLI arguments from SSH_ORIGINAL_COMMAND environment variable" + ); + Ok(original_command) + } + Err(VarError::NotUnicode(_)) => { + Err(eyre!("Your command is not very unicoded. Refusing >~<")) + } + Err(VarError::NotPresent) => { + let args = std::env::args().skip(1).collect::>().join(" "); + tracing::trace!( + argv = ?args, + "Taking CLI arguments from argv" + ); + Ok(args) + } + } +} + +impl CliArgs { + pub fn parse() -> eyre::Result { + let raw_args = get_arg_string()?; + let raw_args_list = raw_args.split_whitespace().collect::>(); + + if raw_args_list.len() == 0 + || raw_args_list[0] == "help" + || raw_args_list[0] == "-h" + || raw_args_list[0] == "--help" + { + Ok(CliArgs::Help) + } else if raw_args_list[0] == "unlock" { + let lock_name = raw_args_list.get(1).ok_or_eyre( + "Unlock cli needs a lock name as parameter. See \"help\" for usage information.", + )?; + let wait_until_done = match *raw_args_list.get(2).unwrap_or(&"wait") { + "wait" => true, + "nowait" => false, + _ => { + return Err(eyre!( + "Third unlock parameter needs to be either \"wait\" or \"nowait\". See \"help\" for usage information." + )); + } + }; + Ok(CliArgs::Unlock { + lock_name: lock_name.to_string(), + wait_until_done, + }) + } else if raw_args_list[0] == "lock" { + let lock_name = raw_args_list.get(1).ok_or_eyre( + "Lock cli needs a lock name as parameter. See \"help\" for usage information.", + )?; + let wait_until_done = match *raw_args_list.get(2).unwrap_or(&"wait") { + "wait" => true, + "nowait" => false, + _ => { + return Err(eyre!( + "Third lock parameter needs to be either \"wait\" or \"nowait\". See \"help\" for usage information." + )); + } + }; + Ok(CliArgs::Lock { + lock_name: lock_name.to_string(), + wait_until_done, + }) + } else if raw_args_list[0] == "show-locks" { + Ok(CliArgs::ShowLocks) + } else { + Err(eyre!( + "Unknown commandline {raw_args:?}. See \"help\" for usage information." + )) + } + } +} diff --git a/ssh-cli/src/main.rs b/ssh-cli/src/main.rs index 9c8ed62..b4690ee 100644 --- a/ssh-cli/src/main.rs +++ b/ssh-cli/src/main.rs @@ -9,114 +9,128 @@ //! SetEnv DOORIS_SERVER_URL=https://dooris.ccchh.net/ DOORIS_API_TOKEN=foobar123 //! ``` +mod api_client; mod api_models; +mod cli; -use eyre::{Context, OptionExt, eyre}; -use reqwest::{Url, header::HeaderValue}; -use std::{env::VarError, process::exit}; +use std::process::exit; -use crate::api_models::LockOperationRequest; +use eyre::eyre; -fn main() -> eyre::Result<()> { +use crate::{ + api_client::ApiClient, + api_models::{ApiLock, DesiredLockState}, +}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> eyre::Result<()> { tracing_subscriber::fmt::init(); - color_eyre::install()?; + simple_eyre::install()?; - let doors = fetch_doors()?; - let args = get_args()?; - let args = args.split(" ").collect::>(); + let args = cli::CliArgs::parse()?; + tracing::debug!(cli = ?args, "Parsed CLI parameters"); - if args.len() == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "" { - println!("Help is not yet available. Come back later >~<"); - exit(3); - } else if args[0] == "lock" || args[0] == "unlock" { - let cli_target_door = args - .get(1) - .ok_or_eyre("'unlock' and 'lock' cli requires the door name as second argument")?; - - let api_target_door = doors - .iter() - .find(|i_door| i_door.name.eq_ignore_ascii_case(cli_target_door)) - .ok_or({ - let door_names = doors - .iter() - .map(|i_door| i_door.name.to_owned()) - .collect::>(); - eyre!( - "No door named {cli_target_door:?} exists. Available doors are {door_names:?}.", - ) - })?; - - println!( - "Lock {} ({:?}) identified!", - api_target_door.id, api_target_door.name - ); - operate_lock( - &api_target_door.id, - match args[0] { - "lock" => api_models::DesiredLockState::CLOSED, - "unlock" => api_models::DesiredLockState::OPEN, - _ => unreachable!(), - }, - )?; - println!("Lock has been turned. Enjoy :3"); - return Ok(()); - } else { - println!("Unknown commandline. See --help for more info."); - exit(2); - } -} - -/// Get passed arguments from SSH_ORIGINAL_COMMAND environment variable but falling back to direct CLI args -fn get_args() -> eyre::Result { - match std::env::var("SSH_ORIGINAL_COMMAND") { - Ok(original_command) => Ok(original_command), - Err(VarError::NotUnicode(_)) => { - Err(eyre!("Your command is not very unicoded. Refusing >~<")) + match args { + cli::CliArgs::Help => { + println!( + "dooris-over-ssh: Your personal door assistant with an extra serving of chaos" + ); + println!( + "Usage: ssh dooris@dooris.ccchh.net help | lock [wait|nowait] | unlock [wait|nowait]" + ); + println!(); + println!("Commands:"); + println!(); + println!(" help -> The help command prints this message. Hello πŸ‘‹"); + println!(""); + println!(" lock [wait|nowait] -> Locks the door identified with ."); + println!( + " Also takes an additional parameter of \"wait\" or \"nowait\"which dictates" + ); + println!(" whether the CLI exit as soon as the lock operation is queued or whether"); + println!(" it waits until the lock status changes to open."); + println!(" Defaults to \"wait\" if not specified."); + println!(); + println!(" unlock [wait|nowait] -> Unlocks the door identified with ."); + println!( + " Also takes an additional parameter of \"wait\" or \"nowait\"which dictates" + ); + println!(" whether the CLI exit as soon as the lock operation is queued or whether"); + println!(" it waits until the lock status changes to closed."); + println!(" Defaults to \"wait\" if not specified."); + exit(0); } - Err(VarError::NotPresent) => Ok(std::env::args().skip(1).collect::>().join(" ")), - } + cli::CliArgs::Lock { + lock_name, + wait_until_done, + } => { + let api = ApiClient::new_from_env()?; + let lock = find_lock(&api, &lock_name).await?; + + println!("dooris at your service, locking {} now…", lock.name); + api.operate_lock(&lock.id, DesiredLockState::CLOSED).await?; + + if wait_until_done { + tracing::warn!( + "wait feature is not yet implemented. lock should be turning now >~<" + ); + } + + exit(0); + } + cli::CliArgs::Unlock { + lock_name, + wait_until_done, + } => { + let api = ApiClient::new_from_env()?; + let lock = find_lock(&api, &lock_name).await?; + + println!("dooris at your service, locking {} now…", lock.name); + api.operate_lock(&lock.id, DesiredLockState::OPEN).await?; + + if wait_until_done { + tracing::warn!( + "wait feature is not yet implemented. lock should be turning now >~<" + ); + } + + exit(0); + } + cli::CliArgs::ShowLocks => { + let api = ApiClient::new_from_env()?; + let locks = api.fetch_locks().await?; + + for i_lock in locks.iter() { + println!(">> {}", i_lock.name); + if i_lock.status.is_unreachable { + println!(" !!! Lock is currently unreachable !!!"); + } + if i_lock.status.is_low_batteray { + println!(" !!! Lock has low battery !!!"); + } + if i_lock.status.is_error_jammed { + println!(" !!! Lock is jammed !!!"); + } + println!(" State: {}", i_lock.status.lock_state); + println!(" Desired State: {}", i_lock.status.lock_target_level); + println!(" Acitivty: {}", i_lock.status.activity_state); + println!(); + } + + exit(0); + } + }; } -fn http_client() -> eyre::Result { - return reqwest::blocking::ClientBuilder::new() - .user_agent("dooris-ssh-cli") - .redirect(reqwest::redirect::Policy::none()) - .http1_title_case_headers() - .build() - .context("Could not construct http client even though all values are statically known to be valid"); -} - -fn api_token() -> eyre::Result { - let server_token = std::env::var("DOORIS_API_TOKEN").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_API_TOKEN environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?; - format!("Static-Token {server_token}") - .try_into() - .context("Could not convert HTTP API Token to a valid HTTP header") -} - -fn server_url() -> eyre::Result { - let server_url = std::env::var("DOORIS_SERVER_URL").map_err(|_| eyre!("This ssh-cli is not configured correctly and does not have it's DOORIS_SERVER_URL environment variable set correctly. Contact your nearest server maid to fix the dooris setup."))?; - Url::parse(&server_url).context("Configured DOORIS_SERVER_URL is not a valid URL. Contact your nearest server maid to fix the dooris setup") -} - -fn fetch_doors() -> eyre::Result> { - http_client()? - .get(server_url()?.join("api/locks/")?) - .header(reqwest::header::AUTHORIZATION, api_token()?) - .send()? - .error_for_status() - .context("Could not get list of locks from the dooris API")? - .json() - .context("Could not parse list-locks response from dooris API") -} - -fn operate_lock(lock_id: &str, desired_state: api_models::DesiredLockState) -> eyre::Result<()> { - println!("Turning Lock, please stand by…"); - http_client()? - .patch(server_url()?.join(&format!("api/locks/{lock_id}"))?) - .header(reqwest::header::AUTHORIZATION, api_token()?) - .json(&LockOperationRequest { desired_state }) - .send()? - .error_for_status() - .context("Could not operate lock")?; - Ok(()) +async fn find_lock(api: &ApiClient, name: &str) -> eyre::Result { + let locks = api.fetch_locks().await?; + tracing::debug!("Comparing locks from API for one with name {name:?}"); + let lock = locks + .into_iter() + .find(|i_lock| i_lock.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| { + eyre!("No lock named {name:?} exists. See \"list-locks\" for a list of valid locks.") + })?; + tracing::debug!(lock = ?lock,"Identified lock with name matching {name:?}"); + Ok(lock) } From 7c64d57eb55376419cbec94b9a3bf6f2def7417c Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 19 Jul 2026 20:47:27 +0200 Subject: [PATCH 37/46] implement wait feature in ssh-cli --- ssh-cli/Cargo.lock | 67 +++++++++++++++++++++++---------------- ssh-cli/Cargo.toml | 2 +- ssh-cli/src/api_models.rs | 6 ++-- ssh-cli/src/main.rs | 52 ++++++++++++++++++++++-------- 4 files changed, 82 insertions(+), 45 deletions(-) diff --git a/ssh-cli/Cargo.lock b/ssh-cli/Cargo.lock index 1ccaee5..d30972c 100644 --- a/ssh-cli/Cargo.lock +++ b/ssh-cli/Cargo.lock @@ -152,7 +152,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -562,7 +562,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -581,7 +581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -708,9 +708,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -774,9 +774,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1012,9 +1012,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1022,22 +1022,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.0", ] [[package]] @@ -1152,6 +1152,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1169,7 +1180,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1195,22 +1206,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.0", ] [[package]] @@ -1264,13 +1275,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1360,7 +1371,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1507,7 +1518,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -1700,7 +1711,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -1721,7 +1732,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -1761,7 +1772,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] diff --git a/ssh-cli/Cargo.toml b/ssh-cli/Cargo.toml index 8c6edfc..8e1b446 100644 --- a/ssh-cli/Cargo.toml +++ b/ssh-cli/Cargo.toml @@ -8,6 +8,6 @@ eyre = "0.6.12" reqwest = { version = "0.13.4", features = ["json"] } serde = { version = "1.0.228", features = ["derive"] } simple-eyre = "0.3.1" -tokio = { version = "1.53.0", features = ["macros", "rt"] } +tokio = { version = "1.53.0", features = ["macros", "rt", "time"] } tracing = "0.1.44" tracing-subscriber = "0.3.23" diff --git a/ssh-cli/src/api_models.rs b/ssh-cli/src/api_models.rs index b7ad45d..95c2a70 100644 --- a/ssh-cli/src/api_models.rs +++ b/ssh-cli/src/api_models.rs @@ -15,9 +15,9 @@ pub struct ApiLockStatus { #[serde(default)] pub is_low_batteray: bool, pub is_error_jammed: bool, - pub lock_target_level: String, - pub lock_state: String, - pub activity_state: String, + pub lock_target_level: LockTargetLevel, + pub lock_state: LockState, + pub activity_state: LockActivityState, } #[allow(unused)] diff --git a/ssh-cli/src/main.rs b/ssh-cli/src/main.rs index b4690ee..b76b2a0 100644 --- a/ssh-cli/src/main.rs +++ b/ssh-cli/src/main.rs @@ -13,13 +13,13 @@ mod api_client; mod api_models; mod cli; -use std::process::exit; +use std::{process::exit, time::Duration}; use eyre::eyre; use crate::{ api_client::ApiClient, - api_models::{ApiLock, DesiredLockState}, + api_models::{ApiLock, DesiredLockState, LockState}, }; #[tokio::main(flavor = "current_thread")] @@ -71,9 +71,10 @@ async fn main() -> eyre::Result<()> { api.operate_lock(&lock.id, DesiredLockState::CLOSED).await?; if wait_until_done { - tracing::warn!( - "wait feature is not yet implemented. lock should be turning now >~<" - ); + wait_for_lock_state(&api, &lock_name, LockState::LOCKED).await?; + println!("Lock is now closed. Enjoy <3"); + } else { + println!("Lock is now closing. Enjoy <3"); } exit(0); @@ -89,9 +90,10 @@ async fn main() -> eyre::Result<()> { api.operate_lock(&lock.id, DesiredLockState::OPEN).await?; if wait_until_done { - tracing::warn!( - "wait feature is not yet implemented. lock should be turning now >~<" - ); + wait_for_lock_state(&api, &lock_name, LockState::UNLOCKED).await?; + println!("Lock is now opened. Enjoy <3"); + } else { + println!("Lock is now opening. Enjoy <3"); } exit(0); @@ -111,9 +113,9 @@ async fn main() -> eyre::Result<()> { if i_lock.status.is_error_jammed { println!(" !!! Lock is jammed !!!"); } - println!(" State: {}", i_lock.status.lock_state); - println!(" Desired State: {}", i_lock.status.lock_target_level); - println!(" Acitivty: {}", i_lock.status.activity_state); + println!(" State: {:?}", i_lock.status.lock_state); + println!(" Desired State: {:?}", i_lock.status.lock_target_level); + println!(" Acitivty: {:?}", i_lock.status.activity_state); println!(); } @@ -124,13 +126,37 @@ async fn main() -> eyre::Result<()> { async fn find_lock(api: &ApiClient, name: &str) -> eyre::Result { let locks = api.fetch_locks().await?; - tracing::debug!("Comparing locks from API for one with name {name:?}"); + tracing::trace!("Comparing locks from API for one with name {name:?}"); let lock = locks .into_iter() .find(|i_lock| i_lock.name.eq_ignore_ascii_case(&name)) .ok_or_else(|| { eyre!("No lock named {name:?} exists. See \"list-locks\" for a list of valid locks.") })?; - tracing::debug!(lock = ?lock,"Identified lock with name matching {name:?}"); + tracing::trace!(lock = ?lock, "Identified lock with name matching {name:?}"); Ok(lock) } + +async fn wait_for_lock_state( + api: &ApiClient, + lock_name: &str, + desired_state: LockState, +) -> eyre::Result<()> { + tokio::time::sleep(Duration::from_millis(200)).await; + let lock = find_lock(&api, &lock_name).await?; + let mut interval = tokio::time::interval(Duration::from_millis(500)); + let mut last_status = lock.status; + while last_status.lock_state != desired_state { + interval.tick().await; + let lock = find_lock(&api, &lock_name).await?; + if lock.status != last_status { + println!( + "Lock state changed to {:?} (activity={:?})", + lock.status.lock_state, lock.status.activity_state + ); + last_status = lock.status; + } + } + + Ok(()) +} From 0d4c79697520c7406a97dcd681b2e93c07e4c60c Mon Sep 17 00:00:00 2001 From: lilly Date: Sun, 19 Jul 2026 21:11:34 +0200 Subject: [PATCH 38/46] add help text concerning show-locks command in ssh-cli --- ssh-cli/src/main.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ssh-cli/src/main.rs b/ssh-cli/src/main.rs index b76b2a0..2c044ef 100644 --- a/ssh-cli/src/main.rs +++ b/ssh-cli/src/main.rs @@ -43,7 +43,11 @@ async fn main() -> eyre::Result<()> { println!(); println!(" help -> The help command prints this message. Hello πŸ‘‹"); println!(""); - println!(" lock [wait|nowait] -> Locks the door identified with ."); + println!( + " show-locks -> Display a list of currently connected locks along with their status." + ); + println!(""); + println!(" lock [wait|nowait] -> Locks the door identified with ."); println!( " Also takes an additional parameter of \"wait\" or \"nowait\"which dictates" ); @@ -51,7 +55,7 @@ async fn main() -> eyre::Result<()> { println!(" it waits until the lock status changes to open."); println!(" Defaults to \"wait\" if not specified."); println!(); - println!(" unlock [wait|nowait] -> Unlocks the door identified with ."); + println!(" unlock [wait|nowait] -> Unlocks the lock identified with ."); println!( " Also takes an additional parameter of \"wait\" or \"nowait\"which dictates" ); From 14730ecb475a9db9ba2fc1320f901e2d01a9e7bf Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 21 Jul 2026 21:48:01 +0200 Subject: [PATCH 39/46] fix bug in keycloak cron not writing newline speparators to authorized_keys --- api/src/dooris_api/keycloak.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/src/dooris_api/keycloak.py b/api/src/dooris_api/keycloak.py index de99b92..79108fb 100644 --- a/api/src/dooris_api/keycloak.py +++ b/api/src/dooris_api/keycloak.py @@ -11,6 +11,9 @@ from simple_openid_connect.data import TokenErrorResponse logger = logging.getLogger(__name__) +CRON_INTERVAL_MIN = 13.37 + + class ClientCredentialsAuth: def __init__(self, oidc_client: OpenidClient): self.oidc_client = oidc_client @@ -65,7 +68,7 @@ class KeycloakClient: except Exception as e: logger.exception(f"Error in Keycloak cron task: {e}") finally: - await asyncio.sleep(15 * 60) # 15 minutes + await asyncio.sleep(CRON_INTERVAL_MIN * 60) async def fetch_ssh_keys(self): logger.info("Fetching ssh keys from keycloak") @@ -80,7 +83,7 @@ class KeycloakClient: async def write_authorized_keys(self): logger.info(f"Writing authorized keys to {self.authorized_keys_file.absolute()}") with self.authorized_keys_file.open(mode="wt", encoding="UTF-8") as f: - f.writelines(self.ssh_keys) + f.writelines([f"{i}\n" for i in self.ssh_keys]) self.authorized_keys_file.chmod(stat.S_IWUSR | stat.S_IRUSR) From a750a67d17de2aa89757971d80261a20b3fd7c4c Mon Sep 17 00:00:00 2001 From: lilly Date: Wed, 22 Jul 2026 19:47:36 +0200 Subject: [PATCH 40/46] add show-locks to usage string --- ssh-cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ssh-cli/src/main.rs b/ssh-cli/src/main.rs index 2c044ef..5257b49 100644 --- a/ssh-cli/src/main.rs +++ b/ssh-cli/src/main.rs @@ -36,7 +36,7 @@ async fn main() -> eyre::Result<()> { "dooris-over-ssh: Your personal door assistant with an extra serving of chaos" ); println!( - "Usage: ssh dooris@dooris.ccchh.net help | lock [wait|nowait] | unlock [wait|nowait]" + "Usage: ssh dooris@dooris.ccchh.net [wait|nowait] | unlock [wait|nowait]>" ); println!(); println!("Commands:"); From d0a865e85ac53135c587ffed67cd7a7659ab49b4 Mon Sep 17 00:00:00 2001 From: lilly Date: Thu, 23 Jul 2026 14:13:30 +0200 Subject: [PATCH 41/46] correct open/close wording which was incosistent --- ssh-cli/src/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ssh-cli/src/main.rs b/ssh-cli/src/main.rs index 5257b49..b0b2904 100644 --- a/ssh-cli/src/main.rs +++ b/ssh-cli/src/main.rs @@ -52,7 +52,7 @@ async fn main() -> eyre::Result<()> { " Also takes an additional parameter of \"wait\" or \"nowait\"which dictates" ); println!(" whether the CLI exit as soon as the lock operation is queued or whether"); - println!(" it waits until the lock status changes to open."); + println!(" it waits until the lock status changes to closed."); println!(" Defaults to \"wait\" if not specified."); println!(); println!(" unlock [wait|nowait] -> Unlocks the lock identified with ."); @@ -60,7 +60,7 @@ async fn main() -> eyre::Result<()> { " Also takes an additional parameter of \"wait\" or \"nowait\"which dictates" ); println!(" whether the CLI exit as soon as the lock operation is queued or whether"); - println!(" it waits until the lock status changes to closed."); + println!(" it waits until the lock status changes to open."); println!(" Defaults to \"wait\" if not specified."); exit(0); } @@ -90,7 +90,7 @@ async fn main() -> eyre::Result<()> { let api = ApiClient::new_from_env()?; let lock = find_lock(&api, &lock_name).await?; - println!("dooris at your service, locking {} now…", lock.name); + println!("dooris at your service, unlocking {} now…", lock.name); api.operate_lock(&lock.id, DesiredLockState::OPEN).await?; if wait_until_done { From 5850cff5367b0a46f2098aabddf8d51895aba524 Mon Sep 17 00:00:00 2001 From: lilly Date: Thu, 23 Jul 2026 14:19:29 +0200 Subject: [PATCH 42/46] display lock warnings on every operation not just show-locks --- ssh-cli/src/api_models.rs | 3 +-- ssh-cli/src/main.rs | 42 ++++++++++++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/ssh-cli/src/api_models.rs b/ssh-cli/src/api_models.rs index 95c2a70..2f90b7a 100644 --- a/ssh-cli/src/api_models.rs +++ b/ssh-cli/src/api_models.rs @@ -12,8 +12,7 @@ pub struct ApiLock { #[derive(Debug, Eq, PartialEq, Hash, Deserialize)] pub struct ApiLockStatus { pub is_unreachable: bool, - #[serde(default)] - pub is_low_batteray: bool, + pub is_low_battery: bool, pub is_error_jammed: bool, pub lock_target_level: LockTargetLevel, pub lock_state: LockState, diff --git a/ssh-cli/src/main.rs b/ssh-cli/src/main.rs index b0b2904..0f891cb 100644 --- a/ssh-cli/src/main.rs +++ b/ssh-cli/src/main.rs @@ -72,6 +72,7 @@ async fn main() -> eyre::Result<()> { let lock = find_lock(&api, &lock_name).await?; println!("dooris at your service, locking {} now…", lock.name); + print_lock_warnings(&lock); api.operate_lock(&lock.id, DesiredLockState::CLOSED).await?; if wait_until_done { @@ -91,6 +92,7 @@ async fn main() -> eyre::Result<()> { let lock = find_lock(&api, &lock_name).await?; println!("dooris at your service, unlocking {} now…", lock.name); + print_lock_warnings(&lock); api.operate_lock(&lock.id, DesiredLockState::OPEN).await?; if wait_until_done { @@ -108,15 +110,7 @@ async fn main() -> eyre::Result<()> { for i_lock in locks.iter() { println!(">> {}", i_lock.name); - if i_lock.status.is_unreachable { - println!(" !!! Lock is currently unreachable !!!"); - } - if i_lock.status.is_low_batteray { - println!(" !!! Lock has low battery !!!"); - } - if i_lock.status.is_error_jammed { - println!(" !!! Lock is jammed !!!"); - } + print_lock_warnings(&lock); println!(" State: {:?}", i_lock.status.lock_state); println!(" Desired State: {:?}", i_lock.status.lock_target_level); println!(" Acitivty: {:?}", i_lock.status.activity_state); @@ -164,3 +158,33 @@ async fn wait_for_lock_state( Ok(()) } + +/// Display big warnings if the lock is in an error state like jammed/disconnected/low battery +fn print_lock_warnings(lock: &ApiLock) { + let has_any_error = + lock.status.is_unreachable || lock.status.is_low_battery || lock.status.is_error_jammed; + + if has_any_error { + println!(" !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + } + + if lock.status.is_unreachable { + println!(" !!! !!!"); + println!(" !!! Lock is currently unreachable !!!"); + println!(" !!! !!!"); + } + if lock.status.is_low_battery { + println!(" !!! !!!"); + println!(" !!! Lock has low battery !!!"); + println!(" !!! !!!"); + } + if lock.status.is_error_jammed { + println!(" !!! !!!"); + println!(" !!! Lock is jammed !!!"); + println!(" !!! !!!"); + } + + if has_any_error { + println!(" !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + } +} From 38888fd58cb26827160c6e5622b8907fc5675919 Mon Sep 17 00:00:00 2001 From: lilly Date: Thu, 23 Jul 2026 14:23:49 +0200 Subject: [PATCH 43/46] fix compilation error in ssh-cli --- ssh-cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ssh-cli/src/main.rs b/ssh-cli/src/main.rs index 0f891cb..c51f61b 100644 --- a/ssh-cli/src/main.rs +++ b/ssh-cli/src/main.rs @@ -110,7 +110,7 @@ async fn main() -> eyre::Result<()> { for i_lock in locks.iter() { println!(">> {}", i_lock.name); - print_lock_warnings(&lock); + print_lock_warnings(&i_lock); println!(" State: {:?}", i_lock.status.lock_state); println!(" Desired State: {:?}", i_lock.status.lock_target_level); println!(" Acitivty: {:?}", i_lock.status.activity_state); From 394b423b8ec34698c0ac5399953a4b400a3a90d9 Mon Sep 17 00:00:00 2001 From: lilly Date: Thu, 23 Jul 2026 14:23:49 +0200 Subject: [PATCH 44/46] rename Containerfile build stages to be more clear and consistent --- Containerfile | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Containerfile b/Containerfile index 0cc80f3..9340bf9 100644 --- a/Containerfile +++ b/Containerfile @@ -18,7 +18,7 @@ RUN pnpm --dir=app/ run build -FROM docker.io/alpine:3.22 AS base +FROM docker.io/alpine:3.22 AS build-backend ARG APP_UID=10000 ARG APP_GID=10000 @@ -39,9 +39,6 @@ RUN addgroup -g $APP_GID dooris &&\ mkdir -p /var/www/dooris/ /usr/local/share/dooris/ /usr/local/src/dooris/ /var/cache/dooris/ &&\ chown -R dooris:dooris /var/www/dooris/ /usr/local/share/dooris/ /usr/local/src/dooris/ /var/cache/dooris/ - - -FROM base AS deps USER dooris ADD --link --chown=dooris:dooris api/pyproject.toml api/uv.lock api/ RUN uv venv $VIRTUAL_ENV &&\ @@ -49,7 +46,7 @@ RUN uv venv $VIRTUAL_ENV &&\ -FROM deps AS final +FROM build-backend AS final ADD --chown=dooris:dooris --link . /usr/local/src/dooris/ COPY --chown=dooris:dooris --from=build-frontend --link /usr/local/src/dooris/app/dist/ $DOORIS_SERVE_STATIC COPY --chown=dooris:dooris --from=build-ssh-cli --link /usr/local/src/dooris/ssh-cli/target/release/ssh-cli /usr/local/bin/ssh-cli From ecd6c2c48f9e5e099e9b1507341c4c889cfa163b Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 11 Aug 2026 16:04:11 +0200 Subject: [PATCH 45/46] implement spaceapid integration into API --- api/src/dooris_api/__init__.py | 33 +++++++++++++++++++++- api/src/dooris_api/app.py | 8 ++++++ api/src/dooris_api/ccujack.py | 51 +++++++++++++++++++++++++++++++++- api/src/dooris_api/deps.py | 10 +++++++ api/src/dooris_api/models.py | 1 + api/src/dooris_api/spaceapi.py | 28 +++++++++++++++++++ 6 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 api/src/dooris_api/spaceapi.py diff --git a/api/src/dooris_api/__init__.py b/api/src/dooris_api/__init__.py index 10d1ff5..6c39f39 100644 --- a/api/src/dooris_api/__init__.py +++ b/api/src/dooris_api/__init__.py @@ -79,7 +79,11 @@ def main(): required=False, nargs=1, action="append", - default=[i for i in os.environ.get("DOORIS_STATIC_API_TOKENS", "").split(",") if bool(i)], + default=[ + i + for i in os.environ.get("DOORIS_STATIC_API_TOKENS", "").split(",") + if bool(i) + ], ) argp.add_argument( "--kc-ssh-attr-group", @@ -93,6 +97,33 @@ def main(): type=Path, help="A file to which ssh authorized keys fetched from keycloak will be written", ) + argp.add_argument( + "--spaceapid-base-uri", + required=False, + default=os.environ.get( + "DOORIS_SPACEAPID_BASE_URI", "https://spaceapi.hamburg.ccc.de/" + ), + help="The URL under which a running spaceapid is reachable", + ) + argp.add_argument( + "--spaceapid-user", + required=False, + default=os.environ.get("DOORIS_SPACEAPID_USER", "dooris"), + help="The username used to authenticate against spaceapid", + ) + argp.add_argument( + "--spaceapid-password", + required="DOORIS_SPACEAPID_PASSWORD" not in os.environ, + default=os.environ.get("DOORIS_SPACEAPID_PASSWORD", None), + help="The password used to authenticate against spaceapid", + ) + argp.add_argument( + "--main-lock", + required=False, + default=os.environ.get("DOORIS_MAIN_DOOR", None), + help="Name or ID of the door which will be considered the main door for spaceapi. If none is set, integration to spaceapi is disabled", + ) + args = argp.parse_args() # setup logging diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index 73cab3d..9be8164 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -14,6 +14,7 @@ from aiohttp import BasicAuth from dooris_api import deps, models, exceptions, app_config from dooris_api.ccujack import CCUJackClient from dooris_api.keycloak import KeycloakClient +from dooris_api.spaceapi import SpaceApiClient logger = logging.getLogger(__name__) @@ -31,10 +32,17 @@ async def lifespan(app: FastAPI): scope=app_cfg.openid_scope, ) + app.extra["spaceapi"] = SpaceApiClient( + base_uri=app_cfg.spaceapid_base_uri, + auth=BasicAuth(app_cfg.spaceapid_user, app_cfg.spaceapid_password), + ) + app.extra["ccujack"] = CCUJackClient( base_uri=app_cfg.ccujack_url, auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password), mqtt_conn=app_cfg.ccujack_mqtt, + spaceapi_client=app.extra["spaceapi"], + main_lock=app_cfg.main_lock, ) await app.extra["ccujack"].start() diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index 6856128..c3a4d89 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -5,6 +5,7 @@ import asyncio from pydantic import BaseModel, Field from dooris_api.mqtt_client import AsyncMqttClient +from dooris_api.spaceapi import SpaceApiClient logger = logging.getLogger(__name__) @@ -74,8 +75,10 @@ class CCUJackClient: task_process_messages: asyncio.Task task_find_locks: asyncio.Task data_updated: asyncio.Event + spaceapi_client: SpaceApiClient + main_lock: Optional[str] - def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str): + def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str, spaceapi_client: SpaceApiClient, main_lock: Optional[str]): self.http = ClientSession( base_url=base_uri, auth=auth, @@ -88,6 +91,8 @@ class CCUJackClient: self.task_process_messages = None self.task_find_locks = None self.data_updated = asyncio.Event() + self.spaceapi_client = spaceapi_client + self.main_lock = main_lock async def start(self): self.task_process_messages = asyncio.get_running_loop().create_task( @@ -136,6 +141,7 @@ class CCUJackClient: f"device/status/{i_lock.address}/{i_channel.index}/{i_param.id}" ) await self.mqtt.update_subscriptions(mqtt_topics) + await self._update_spaceapi() async def process_mqt_messages(self): while True: @@ -150,6 +156,9 @@ class CCUJackClient: self.param_values[param_name] = param_value self.data_updated.set() + if param_name.endswith("/LOCK_STATE"): + await self._update_spaceapi() + except Exception as e: logger.exception(f"could not process incoming mqtt message: {e}") finally: @@ -187,6 +196,46 @@ class CCUJackClient: logger.debug("Writing parameter value '%s' to '%s'", value, address) await self.http.put(f"/device/{address}/~pv", json={"v": value}) + async def _update_spaceapi(self): + """ + Write the current lock state into spaceapid + """ + if not self.main_lock: + logger.debug("No main lock is configured, skipping spaceapid update") + return + + logger.info(f"Pushing current state of lock {self.main_lock} to spaceapid") + for (i_lock, lock_channels) in self.locks: + if i_lock.identifier.lower() == self.main_lock.lower() or i_lock.title.lower() == self.main_lock.lower(): + is_currently_open = False + + # find the correct parameter and extract it into a usable variable + for (i_channel, params) in lock_channels: + for i_param in params: + if i_param.id == "LOCK_STATE": + + # do a proper lookup and not just directly access the cache because the method + # could have been triggered by something other than MQTT in which case the value + # would not necessarily be available yet + value = await self.query_param_value( + f"{i_lock.address}/{i_channel.index}/{i_param.id}" + ) + match value.v: + case 0: # unknown + is_currently_open = False + case 1: # locked + is_currently_open = False + case 2: # unlocked + is_currently_open = True + + # actually post the update + await self.spaceapi_client.set_status(is_currently_open) + + break + + else: + logger.error(f"Could not push current lock state of lock {self.main_lock} to spaceapid because no such lock is known") + async def _inspect_ccu_device( self, device_ref: CCURef ) -> Tuple[CCUDeviceInfo, List[Tuple[CCUChannelInfo, List[CCUParamInfo]]]]: diff --git a/api/src/dooris_api/deps.py b/api/src/dooris_api/deps.py index 8f65df2..34550a3 100644 --- a/api/src/dooris_api/deps.py +++ b/api/src/dooris_api/deps.py @@ -9,6 +9,7 @@ from simple_openid_connect.client import OpenidClient from dooris_api import app_config from dooris_api import models, exceptions from dooris_api.ccujack import CCUJackClient +from dooris_api.spaceapi import SpaceApiClient logger = logging.getLogger(__name__) @@ -213,3 +214,12 @@ def get_ccujack(req: Request) -> CCUJackClient: CCUJackClient = Annotated[CCUJackClient, Depends(get_ccujack)] + + + +def get_spaceapi_client(req: Request) -> SpaceApiClient: + return req.app.extra["spaceapi"] + + +SpaceApiClient = Annotated[SpaceApiClient, Depends(get_spaceapi_client)] + diff --git a/api/src/dooris_api/models.py b/api/src/dooris_api/models.py index bfb741c..ee95233 100644 --- a/api/src/dooris_api/models.py +++ b/api/src/dooris_api/models.py @@ -54,6 +54,7 @@ class LockStatus(BaseModel): is_unreachable: bool is_low_battery: bool is_error_jammed: bool + is_main_lock: bool lock_target_level: Literal["locked", "unlocked", "open"] lock_state: Literal["unknown", "locked", "unlocked"] activity_state: Literal["unknown", "locking", "unlocking", "stable"] diff --git a/api/src/dooris_api/spaceapi.py b/api/src/dooris_api/spaceapi.py new file mode 100644 index 0000000..72d7fa9 --- /dev/null +++ b/api/src/dooris_api/spaceapi.py @@ -0,0 +1,28 @@ +import logging +from aiohttp import ClientSession, BasicAuth + + +logger = logging.getLogger(__name__) + + +class SpaceApiClient: + """ + Client for interacting with spaceapid + + ref: https://git.hamburg.ccc.de/CCCHH/spaceapid + """ + base_uri: str + http: ClientSession + + def __init__(self, base_uri: str, auth: BasicAuth): + self.base_uri = base_uri + self.http = ClientSession( + base_url=base_uri, + auth=auth, + raise_for_status=True, + ) + + async def set_status(self, is_open: bool): + logger.info(f"Setting club status to open={is_open} via spaceapid") + await self.http.put("/state/open", data=str(is_open).encode("ASCII")) + From 51c67c49ae83e38928c0e568d87f1e7eca3a3617 Mon Sep 17 00:00:00 2001 From: lilly Date: Tue, 11 Aug 2026 16:51:48 +0200 Subject: [PATCH 46/46] api: fix response generation with new "is_main_lock" field --- api/src/dooris_api/app.py | 6 ++++++ api/src/dooris_api/models.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/api/src/dooris_api/app.py b/api/src/dooris_api/app.py index 9be8164..f239fa5 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -233,11 +233,17 @@ async def list_locks(ccujack: deps.CCUJackClient) -> List[models.Lock]: case "UNREACH": status_data["is_unreachable"] = value.v + main_lock = app_config.get().main_lock or "" + is_main_lock = ( + i_lock.identifier.lower() == main_lock.lower() + or i_lock.title.lower() == main_lock.lower() + ) result.append( models.Lock( id=i_lock.identifier, name=i_lock.title, status=models.LockStatus(**status_data), + is_main_lock=is_main_lock, ) ) diff --git a/api/src/dooris_api/models.py b/api/src/dooris_api/models.py index ee95233..ae603c4 100644 --- a/api/src/dooris_api/models.py +++ b/api/src/dooris_api/models.py @@ -54,7 +54,6 @@ class LockStatus(BaseModel): is_unreachable: bool is_low_battery: bool is_error_jammed: bool - is_main_lock: bool lock_target_level: Literal["locked", "unlocked", "open"] lock_state: Literal["unknown", "locked", "unlocked"] activity_state: Literal["unknown", "locking", "unlocking", "stable"] @@ -63,6 +62,7 @@ class LockStatus(BaseModel): class Lock(BaseModel): name: str id: str + is_main_lock: bool status: LockStatus