diff --git a/.dev.env b/.dev.env index 59f4383..eb77b35 100644 --- a/.dev.env +++ b/.dev.env @@ -3,4 +3,3 @@ 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/.envrc.dist b/.envrc.dist index 665b4c9..09c6b28 100644 --- a/.envrc.dist +++ b/.envrc.dist @@ -1,7 +1,4 @@ # 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/.gitignore b/.gitignore index 984e24a..4ad5c33 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,4 @@ .env **/__pycache__ -**/authorized_keys api/dist -**/target diff --git a/Containerfile b/Containerfile index 9340bf9..1a1812f 100644 --- a/Containerfile +++ b/Containerfile @@ -1,24 +1,4 @@ -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/ -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 ./app/ /usr/local/src/dooris/app/ -RUN pnpm --dir=app/ run build - - - -FROM docker.io/alpine:3.22 AS build-backend +FROM docker.io/alpine:3.22 AS base ARG APP_UID=10000 ARG APP_GID=10000 @@ -29,33 +9,35 @@ 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 PATH=$VIRTUAL_ENV/bin:$PATH +ENV PNPM_HOME=/usr/local/share/dooris/pnpm/ +ENV PATH=$PNPM_HOME:$VIRTUAL_ENV/bin:$PATH ENV DOORIS_SERVE_STATIC=/var/www/dooris/static/ WORKDIR /usr/local/src/dooris/ -RUN apk add --no-cache uv python3 +RUN apk add --no-cache uv python3 pnpm 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/ &&\ 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/ +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 build-backend AS final +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 pnpm --dir=app/ run build --outDir=$DOORIS_SERVE_STATIC RUN --mount=type=cache,uid=$APP_UID,gid=$APP_GID,target=$UV_CACHE_DIR \ uv sync --active --frozen -ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ] -CMD [ "uv", "run", "--active", "dooris-api" ] +ENTRYPOINT [ "uv", "run", "--active", "dooris-api" ] EXPOSE 8000/tcp -VOLUME /srv/state/ diff --git a/README.md b/README.md index 03e970d..f83e55d 100644 --- a/README.md +++ b/README.md @@ -6,51 +6,10 @@ 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. -├── ssh-cli # CLI application that allows door management over SSH -└── app # Web UI +├── api # Python application interacting with HomeMatic and providing the API. +└── 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. @@ -67,10 +26,8 @@ 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 diff --git a/api/pyproject.toml b/api/pyproject.toml index 4e42dd1..1b0cc5f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -6,7 +6,6 @@ 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/__init__.py b/api/src/dooris_api/__init__.py index 6c39f39..cd488a6 100644 --- a/api/src/dooris_api/__init__.py +++ b/api/src/dooris_api/__init__.py @@ -1,6 +1,4 @@ import os -import logging -from pathlib import Path from contextvars import ContextVar from argparse import ArgumentParser, Namespace @@ -51,17 +49,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( - "--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, @@ -72,78 +62,18 @@ 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=[ - 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", - ) - 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 - 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 if args.serve_static: from fastapi.staticfiles import StaticFiles - - app.mount( - "/", StaticFiles(directory=args.serve_static, html=True), name="static" - ) - - # start webserver + app.mount("/", StaticFiles(directory=args.serve_static, html=True), name="static") + 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 f239fa5..3bcd848 100644 --- a/api/src/dooris_api/app.py +++ b/api/src/dooris_api/app.py @@ -1,11 +1,11 @@ -from typing import Optional, List, AsyncIterable +from typing import Optional, List import logging import secrets -import asyncio +import sys +import os 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 @@ -13,8 +13,6 @@ 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__) @@ -24,6 +22,12 @@ 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", @@ -32,33 +36,14 @@ 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, + auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password) ) - 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() + await app.extra["ccujack"].find_locks() yield - await app.extra["ccujack"].close_connections() - await app.extra["keycloak"].stop() - app = FastAPI( title="Dooris", @@ -76,9 +61,19 @@ 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.ApiUser) -> models.ApiUser: - return current_user +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, + ) @app.get("/auth/login", tags=["auth"], response_class=RedirectResponse, status_code=302) @@ -124,9 +119,7 @@ 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 ( @@ -153,7 +146,7 @@ async def login_callback( auth_start_time = datetime.fromtimestamp( float(req.cookies["auth_start_time"]), UTC ) - deps.persist_oidc_auth_state( + deps.persist_auth_state( oidc_client, resp, auth_result, auth_start_time, req.cookies["auth_nonce"] ) logger.debug("successfully authenticated user") @@ -175,9 +168,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.AuthenticatedUser + resp: Response, oidc_client: deps.OpenidClient, current_user: deps.CurrentUser ) -> str: - deps.clear_oidc_auth_state(resp) + deps.clear_auth_state(resp) return oidc_client.initiate_logout( RpInitiatedLogoutRequest( id_token_hint=current_user.raw_id_token, @@ -233,36 +226,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, ) ) 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() - await asyncio.sleep(0.1) # debounce multiple mqtt parameter updates - - @app.patch( "/api/locks/{lock_id}", tags=["locks"], @@ -277,7 +251,7 @@ async def operate_lock( lock_id: str, requested_op: models.LockOperation, ccujack: deps.CCUJackClient, - current_user: deps.AuthenticatedUser, + current_user: deps.CurrentUser, ) -> None: if not current_user.may_operate_locks: raise exceptions.HttpProblemException.forbidden_to_operate(req.url) diff --git a/api/src/dooris_api/ccujack.py b/api/src/dooris_api/ccujack.py index c3a4d89..5d5eca0 100644 --- a/api/src/dooris_api/ccujack.py +++ b/api/src/dooris_api/ccujack.py @@ -1,12 +1,9 @@ -from typing import List, Tuple, Optional, Any, Dict +from typing import List, Tuple, Optional, Any from aiohttp import ClientSession, BasicAuth, TCPConnector import logging import asyncio from pydantic import BaseModel, Field -from dooris_api.mqtt_client import AsyncMqttClient -from dooris_api.spaceapi import SpaceApiClient - logger = logging.getLogger(__name__) @@ -71,51 +68,21 @@ 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 - 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, spaceapi_client: SpaceApiClient, main_lock: Optional[str]): + def __init__(self, base_uri: str, auth: BasicAuth): self.http = ClientSession( base_url=base_uri, auth=auth, raise_for_status=True, connector=TCPConnector(ssl=False), ) - self.mqtt = AsyncMqttClient(mqtt_conn, auth.login, auth.password) - self.locks = [] - self.param_values = dict() - 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( - 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()) - self.task_process_messages.cancel() - self.task_process_messages = None - self.task_cron.cancel() - self.task_cron = None + self.locks = None async def find_locks(self): logger.debug("Inspecting lock devices present in CCUJack") - 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) @@ -124,70 +91,9 @@ 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() - - # 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) - await self._update_spaceapi() - - 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 - 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: - self.data_updated.clear() - - async def cron(self): - while True: - try: - - logger.info("Running CCUJack cron") - 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 asyncio.CancelledError: - logger.info("CCUJack task cron stopped") - raise - 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: - return self.param_values[address] + self.locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK] + 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()) @@ -196,46 +102,6 @@ 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 34550a3..949fd13 100644 --- a/api/src/dooris_api/deps.py +++ b/api/src/dooris_api/deps.py @@ -2,27 +2,16 @@ from typing import Annotated, Optional import logging from datetime import datetime, UTC, timedelta 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 -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__) -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: return req.app.extra["oidc_client"] @@ -30,23 +19,24 @@ async def get_oidc_client(req: Request) -> OpenidClient: OpenidClient = Annotated[OpenidClient, Depends(get_oidc_client)] -async def get_logged_in_oidc_user( +async def get_current_user( req: Request, resp: Response, oidc_client: OpenidClient -) -> Optional[models.ApiUser]: +) -> 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")): + if all(i in req.cookies for i in ("access_token", "id_token", "auth_nonce")): 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.get("auth_nonce", None), + req.cookies["id_token"], nonce=req.cookies["auth_nonce"] + ) + return models.CurrentUser( + id_token=id_token, raw_id_token=req.cookies["id_token"] ) - 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",)): + elif all(i in req.cookies for i in ("refresh_token", "auth_nonce")): logger.debug( "user has been previously authenticated, trying to recover with refresh_token" ) @@ -54,26 +44,24 @@ async def get_logged_in_oidc_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_oidc_auth_state( - oidc_client, resp, token_resp, auth_start_time, None - ) + persist_auth_state(oidc_client, resp, token_resp, auth_start_time, req.cookies["auth_nonce"]) # return the newly gotten info id_token = oidc_client.decode_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 + return models.CurrentUser( + id_token=id_token, raw_id_token=token_resp.id_token ) + else: + 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 oidc user") + logger.debug("no currently authenticated user") - return None + raise exceptions.HttpProblemException.unauthorized(req.url) -def persist_oidc_auth_state( +def persist_auth_state( oidc_client: OpenidClient, resp: Response, tokens: TokenSuccessResponse, @@ -129,7 +117,7 @@ def persist_oidc_auth_state( ) -def clear_oidc_auth_state(resp: Response): +def clear_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) @@ -139,74 +127,7 @@ 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]): - 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)): - 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, - 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) - - 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, - 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, - token: Annotated[Optional[str], Depends(api_key_security_scheme)] = None, -) -> models.ApiUser: - user = await get_api_user(req, resp, oidc_client, token) - if user.is_anonymous: - raise exceptions.HttpProblemException.unauthorized(req.url) - else: - return user - - -AuthenticatedUser = Annotated[models.ApiUser, Depends(get_authenticated_user)] +CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)] def get_ccujack(req: Request) -> CCUJackClient: @@ -214,12 +135,3 @@ 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/keycloak.py b/api/src/dooris_api/keycloak.py deleted file mode 100644 index 79108fb..0000000 --- a/api/src/dooris_api/keycloak.py +++ /dev/null @@ -1,90 +0,0 @@ -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 - - -logger = logging.getLogger(__name__) - - -CRON_INTERVAL_MIN = 13.37 - - -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(CRON_INTERVAL_MIN * 60) - - 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) - logger.info(f"Successfully fetched {len(keys)} ssh keys from Keycloak") - 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([f"{i}\n" for i in self.ssh_keys]) - - self.authorized_keys_file.chmod(stat.S_IWUSR | stat.S_IRUSR) - - diff --git a/api/src/dooris_api/models.py b/api/src/dooris_api/models.py index ae603c4..d1574c4 100644 --- a/api/src/dooris_api/models.py +++ b/api/src/dooris_api/models.py @@ -1,6 +1,6 @@ -from typing import Optional, Literal, Self -from datetime import datetime, UTC -from pydantic import BaseModel, HttpUrl, Field +from typing import Optional, Literal, List +from datetime import datetime +from pydantic import BaseModel, HttpUrl from enum import Enum from simple_openid_connect.data import IdToken @@ -27,27 +27,24 @@ class HttpProblemDetail(BaseModel): instance: Optional[HttpUrl] -class ApiUser(BaseModel): - is_anonymous: bool - is_ccchh_user: bool - is_token_user: bool - may_operate_locks: bool +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 username: 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, - ) + ccchh_roles: List[str] class LockStatus(BaseModel): @@ -62,7 +59,6 @@ class LockStatus(BaseModel): class Lock(BaseModel): name: str id: str - is_main_lock: bool status: LockStatus diff --git a/api/src/dooris_api/mqtt_client.py b/api/src/dooris_api/mqtt_client.py deleted file mode 100644 index a090c5f..0000000 --- a/api/src/dooris_api/mqtt_client.py +++ /dev/null @@ -1,177 +0,0 @@ -# -# 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, Set, Iterable -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 - connection_string: str - 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, - 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_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, - 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: mqtt.MQTTMessage): - 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}'" - ) - self.fut_unsubscribe.set_result(None) - - 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 - """ - - to_add = topics.difference(self.active_subscriptions) - if to_add: - 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: - 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) - - 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) - - await self.fut_connected - - # re-establish all supposed mqtt subscriptions - if len(self.active_subscriptions) > 0: - qos = 1 - 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() diff --git a/api/src/dooris_api/spaceapi.py b/api/src/dooris_api/spaceapi.py deleted file mode 100644 index 72d7fa9..0000000 --- a/api/src/dooris_api/spaceapi.py +++ /dev/null @@ -1,28 +0,0 @@ -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")) - diff --git a/api/uv.lock b/api/uv.lock index 6b6a649..9e7f71a 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -400,7 +400,6 @@ source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "fastapi" }, - { name = "paho-mqtt" }, { name = "simple-openid-connect" }, { name = "uvicorn" }, ] @@ -414,7 +413,6 @@ 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" }, ] @@ -736,15 +734,6 @@ 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" diff --git a/app/src/api/README.md b/app/src/api/README.md index 6dc22f0..256986b 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/api/openapi.json -o ./schema.ts` +`pnpm dlx openapi-typescript http://localhost:8000/openapi.json -o ./schema.ts` diff --git a/app/src/api/schema.ts b/app/src/api/schema.ts index 50495c9..c9b7069 100644 --- a/app/src/api/schema.ts +++ b/app/src/api/schema.ts @@ -89,23 +89,6 @@ 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; @@ -127,21 +110,6 @@ 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 */ @@ -167,7 +135,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,FORBIDDEN_TO_OPERATE" | "type:noc@hamburg.ccc.de,2026:LOCK_NOT_FOUND"; + HttpProblemType: "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" | "type:noc@hamburg.ccc.de,2026:LOCK_NOT_FOUND"; /** Lock */ Lock: { /** Name */ @@ -208,6 +176,17 @@ export interface components { */ activity_state: "unknown" | "locking" | "unlocking" | "stable"; }; + /** UserStatus */ + UserStatus: { + /** Is Logged In */ + is_logged_in: boolean; + /** Is Authorized */ + is_authorized: boolean; + /** Guaranteed Session Until */ + guaranteed_session_until: string | null; + /** Username */ + username: string | null; + }; /** ValidationError */ ValidationError: { /** Location */ @@ -245,7 +224,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ApiUser"]; + "application/json": components["schemas"]["UserStatus"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HttpProblemDetail"]; }; }; }; @@ -344,35 +332,6 @@ 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; @@ -406,15 +365,6 @@ 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 82594c6..4f61428 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 {components, paths} from "../api/schema" +import type {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; } } @@ -93,32 +93,30 @@ async function checkUser() { const {data: userInfo} = await getUserInfo({}) apiError.current = null - auth.authenticated = !userInfo.is_anonymous - auth.authorized = userInfo.may_operate_locks + auth.authenticated = true + auth.authorized = userInfo.is_authorized auth.until = userInfo.guaranteed_session_until ? new Date(userInfo.guaranteed_session_until) : null auth.username = userInfo.username ?? "" auth.recentLogout = false - if (auth.authenticated) { - triggerAuthTimeout() - } + triggerAuthTimeout() } catch (e) { // check which operation threw the exception if (e instanceof getUserInfo.Error) { const error = e.getActualType() - - if (error.status >= 500 && error.status < 600) { + 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) { 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)) @@ -127,24 +125,15 @@ async function checkUser() { } } -async function subscribeDoorEvents() { +async function fetchDoors() { if (doors.length === 0) { loading.doors = true } refresh() + const getDoors = fetcher.path("/api/locks/").method("get").create() - 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) + try { + const {data: doorInfo} = await getDoors({}) apiError.current = null while (doors.length) { @@ -175,6 +164,29 @@ async function subscribeDoorEvents() { 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" + } + } } } @@ -283,7 +295,7 @@ function refresh() { loadAuthFromLocalStorage() -subscribeDoorEvents() +const doorsInterval = setInterval(fetchDoors, 250) // TODO: replace with SSE checkUser() document.addEventListener("loadeddata", () => { diff --git a/app/src/i18n/ui.ts b/app/src/i18n/ui.ts index 3398564..fc03078 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 diff --git a/flake.lock b/flake.lock deleted file mode 100644 index c9db7fc..0000000 --- a/flake.lock +++ /dev/null @@ -1,61 +0,0 @@ -{ - "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 deleted file mode 100644 index a7c59ee..0000000 --- a/flake.nix +++ /dev/null @@ -1,36 +0,0 @@ -{ - 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 - rust-analyzer - rustfmt - ]; - }; - } - ); -} diff --git a/oci/entrypoint.sh b/oci/entrypoint.sh deleted file mode 100755 index b598107..0000000 --- a/oci/entrypoint.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/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/Cargo.lock b/ssh-cli/Cargo.lock deleted file mode 100644 index d30972c..0000000 --- a/ssh-cli/Cargo.lock +++ /dev/null @@ -1,1782 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[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 = "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 = "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 2.0.119", -] - -[[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", -] - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[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-task", - "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 = "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 2.0.119", -] - -[[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 2.0.119", -] - -[[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 = "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 = "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 = "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.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -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.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -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-core", - "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-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.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.0", -] - -[[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 = "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" -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 = [ - "eyre", - "reqwest", - "serde", - "simple-eyre", - "tokio", - "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 = "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" -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 2.0.119", -] - -[[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.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.0", -] - -[[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", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[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 2.0.119", -] - -[[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-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 2.0.119", - "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 2.0.119", - "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 2.0.119", - "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 2.0.119", -] - -[[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 deleted file mode 100644 index 8e1b446..0000000 --- a/ssh-cli/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "ssh-cli" -version = "0.1.0" -edition = "2024" - -[dependencies] -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", "time"] } -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 deleted file mode 100644 index 5e118fc..0000000 --- a/ssh-cli/src/api_client.rs +++ /dev/null @@ -1,78 +0,0 @@ -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/api_models.rs b/ssh-cli/src/api_models.rs deleted file mode 100644 index 2f90b7a..0000000 --- a/ssh-cli/src/api_models.rs +++ /dev/null @@ -1,62 +0,0 @@ -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, - pub is_low_battery: bool, - pub is_error_jammed: bool, - pub lock_target_level: LockTargetLevel, - pub lock_state: LockState, - pub activity_state: LockActivityState, -} - -#[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/cli.rs b/ssh-cli/src/cli.rs deleted file mode 100644 index 6a5b17b..0000000 --- a/ssh-cli/src/cli.rs +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index c51f61b..0000000 --- a/ssh-cli/src/main.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! 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_client; -mod api_models; -mod cli; - -use std::{process::exit, time::Duration}; - -use eyre::eyre; - -use crate::{ - api_client::ApiClient, - api_models::{ApiLock, DesiredLockState, LockState}, -}; - -#[tokio::main(flavor = "current_thread")] -async fn main() -> eyre::Result<()> { - tracing_subscriber::fmt::init(); - simple_eyre::install()?; - - let args = cli::CliArgs::parse()?; - tracing::debug!(cli = ?args, "Parsed CLI parameters"); - - 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 [wait|nowait] | unlock [wait|nowait]>" - ); - println!(); - println!("Commands:"); - println!(); - println!(" help -> The help command prints this message. Hello 👋"); - println!(""); - 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" - ); - 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."); - println!(); - println!(" unlock [wait|nowait] -> Unlocks the lock 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."); - exit(0); - } - 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); - print_lock_warnings(&lock); - api.operate_lock(&lock.id, DesiredLockState::CLOSED).await?; - - if wait_until_done { - 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); - } - 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, unlocking {} now…", lock.name); - print_lock_warnings(&lock); - api.operate_lock(&lock.id, DesiredLockState::OPEN).await?; - - if wait_until_done { - 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); - } - 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); - 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); - println!(); - } - - exit(0); - } - }; -} - -async fn find_lock(api: &ApiClient, name: &str) -> eyre::Result { - let locks = api.fetch_locks().await?; - 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::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(()) -} - -/// 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!(" !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - } -} diff --git a/ssh-cli/test_id_ed25519 b/ssh-cli/test_id_ed25519 deleted file mode 100644 index 7fbba02..0000000 --- a/ssh-cli/test_id_ed25519 +++ /dev/null @@ -1,7 +0,0 @@ ------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 deleted file mode 100644 index db1ae93..0000000 --- a/ssh-cli/test_id_ed25519.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINONujCVYvgms5ypCuWlaD0wHB6Ox9S8QSdOOQV6Q6yb test