Compare commits

..
Author SHA1 Message Date
d46d866705
app: fix syntax error in cat language
All checks were successful
Build Container / Build Container (push) Successful in 1m32s
2026-05-18 20:38:37 +02:00
401bea8927
app: add cat language
Some checks failed
Build Container / Build Container (push) Failing after 1m17s
2026-05-18 20:32:12 +02:00
30 changed files with 183 additions and 3232 deletions

View file

@ -3,4 +3,3 @@ DOORIS_OPENID_CLIENT_ID=dooris
DOORIS_OPENID_CLIENT_SECRET=dp9HhnvUhAtKm3pRnxfGA7q8Nwrd1td8 DOORIS_OPENID_CLIENT_SECRET=dp9HhnvUhAtKm3pRnxfGA7q8Nwrd1td8
DOORIS_BASE_URL=http://localhost:8000 DOORIS_BASE_URL=http://localhost:8000
DOORIS_CCUJACK_USER=dooris DOORIS_CCUJACK_USER=dooris
DOORIS_AUTHORIZED_KEYS_FILE=./authorized_keys

View file

@ -1,7 +1,4 @@
# integrate this into your own .envrc file with `source_env .envrc.dist` # integrate this into your own .envrc file with `source_env .envrc.dist`
if has nix; then
use flake
fi
watch_file api/pyproject.toml \ watch_file api/pyproject.toml \
api/uv.lock api/uv.lock

2
.gitignore vendored
View file

@ -3,6 +3,4 @@
.env .env
**/__pycache__ **/__pycache__
**/authorized_keys
api/dist api/dist
**/target

View file

@ -1,24 +1,4 @@
FROM docker.io/rust:alpine3.22 AS build-ssh-cli FROM docker.io/alpine:3.22 AS base
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
ARG APP_UID=10000 ARG APP_UID=10000
ARG APP_GID=10000 ARG APP_GID=10000
@ -29,33 +9,35 @@ ENV UV_LINK_MODE=copy
ENV UV_CACHE_DIR=/var/cache/dooris/uv/ ENV UV_CACHE_DIR=/var/cache/dooris/uv/
ENV UV_NO_MANAGED_PYTHON=true ENV UV_NO_MANAGED_PYTHON=true
ENV VIRTUAL_ENV=/usr/local/share/dooris/venv/ 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/ ENV DOORIS_SERVE_STATIC=/var/www/dooris/static/
WORKDIR /usr/local/src/dooris/ 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 &&\ RUN addgroup -g $APP_GID dooris &&\
adduser -h /usr/local/src/dooris -u $APP_UID -G dooris -D 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/ &&\ 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/ chown -R dooris:dooris /var/www/dooris/ /usr/local/share/dooris/ /usr/local/src/dooris/ /var/cache/dooris/
FROM base AS deps
USER dooris USER dooris
ADD --link --chown=dooris:dooris api/pyproject.toml api/uv.lock api/ 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 &&\ RUN uv venv $VIRTUAL_ENV &&\
uv sync --active --frozen --no-install-project --no-editable 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/ 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 RUN pnpm --dir=app/ run build --outDir=$DOORIS_SERVE_STATIC
COPY --chown=dooris:dooris --from=build-ssh-cli --link /usr/local/src/dooris/ssh-cli/target/release/ssh-cli /usr/local/bin/ssh-cli
COPY oci/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN --mount=type=cache,uid=$APP_UID,gid=$APP_GID,target=$UV_CACHE_DIR \ RUN --mount=type=cache,uid=$APP_UID,gid=$APP_GID,target=$UV_CACHE_DIR \
uv sync --active --frozen uv sync --active --frozen
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ] ENTRYPOINT [ "uv", "run", "--active", "dooris-api" ]
CMD [ "uv", "run", "--active", "dooris-api" ]
EXPOSE 8000/tcp EXPOSE 8000/tcp
VOLUME /srv/state/

View file

@ -6,51 +6,10 @@ Based on prior work of [hmdooris](https://git.hamburg.ccc.de/CCCHH/hmdooris).
Project structure: Project structure:
``` ```
├── api # Python application interacting with HomeMatic and providing the API. ├── api # Python application interacting with HomeMatic and providing the API.
├── ssh-cli # CLI application that allows door management over SSH └── app # Web UI
└── 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 ## Configuration
The final application can be configured either via CLI arguments or via environment variables. 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* | | `--base-url` | `DOORIS_BASE_URL` | Yes | *None* |
| `--serve-static` | `DOORIS_SERVE_STATIC` | No | *None* | | `--serve-static` | `DOORIS_SERVE_STATIC` | No | *None* |
| `--ccujack-url` | `DOORIS_CCUJACK_URL` | No | `https://hmdooris-ccu.ccchh.net:2122` | | `--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-user` | `DOORIS_CCUJACK_USER` | Yes | *None* |
| `--ccujack-password` | `DOORIS_CCUJACK_PASSWORD` | Yes | *None* | | `--ccujack-password` | `DOORIS_CCUJACK_PASSWORD` | Yes | *None* |
| `--static-api-tokens` | `DOORIS_STATIC_API_TOKENS` | No | *None* |
## API Development ## API Development

View file

@ -6,7 +6,6 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"aiohttp>=3.13.5", "aiohttp>=3.13.5",
"fastapi>=0.136.1", "fastapi>=0.136.1",
"paho-mqtt>=2.1.0",
"simple-openid-connect>=2.4.0", "simple-openid-connect>=2.4.0",
"uvicorn>=0.46.0", "uvicorn>=0.46.0",
] ]

View file

@ -1,6 +1,4 @@
import os import os
import logging
from pathlib import Path
from contextvars import ContextVar from contextvars import ContextVar
from argparse import ArgumentParser, Namespace from argparse import ArgumentParser, Namespace
@ -51,17 +49,9 @@ def main():
argp.add_argument( argp.add_argument(
"--ccujack-url", "--ccujack-url",
required=False, required=False,
default=os.environ.get( default=os.environ.get("DOORIS_CCUJACK_URL", "https://hmdooris-ccu.ccchh.net:2122"),
"DOORIS_CCUJACK_URL", "https://hmdooris-ccu.ccchh.net:2122"
),
help="The URL under which a CCUJACK instance is hosted that actually operates the locks", 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( argp.add_argument(
"--ccujack-user", "--ccujack-user",
required="DOORIS_CCUJACK_USER" not in os.environ, required="DOORIS_CCUJACK_USER" not in os.environ,
@ -72,78 +62,18 @@ def main():
"--ccujack-password", "--ccujack-password",
required="DOORIS_CCUJACK_PASSWORD" not in os.environ, required="DOORIS_CCUJACK_PASSWORD" not in os.environ,
default=os.environ.get("DOORIS_CCUJACK_PASSWORD", None), 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() args = argp.parse_args()
# setup logging
logging.basicConfig(
level=logging.DEBUG, format="[%(levelname)s] %(filename)s: %(message)s"
)
# setup app
app_config.set(args) app_config.set(args)
import uvicorn import uvicorn
from dooris_api.app import app from dooris_api.app import app
if args.serve_static: if args.serve_static:
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
app.mount("/", StaticFiles(directory=args.serve_static, html=True), name="static")
app.mount(
"/", StaticFiles(directory=args.serve_static, html=True), name="static"
)
# start webserver
config = uvicorn.Config(app, port=8000, log_level="debug") config = uvicorn.Config(app, port=8000, log_level="debug")
server = uvicorn.Server(config) server = uvicorn.Server(config)
server.run() server.run()

View file

@ -1,11 +1,11 @@
from typing import Optional, List, AsyncIterable from typing import Optional, List
import logging import logging
import secrets import secrets
import asyncio import sys
import os
from datetime import datetime, UTC from datetime import datetime, UTC
from fastapi import FastAPI, Request, Response, status from fastapi import FastAPI, Request, Response, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.sse import EventSourceResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from simple_openid_connect.client import OpenidClient from simple_openid_connect.client import OpenidClient
from simple_openid_connect.data import TokenSuccessResponse, RpInitiatedLogoutRequest 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 import deps, models, exceptions, app_config
from dooris_api.ccujack import CCUJackClient from dooris_api.ccujack import CCUJackClient
from dooris_api.keycloak import KeycloakClient
from dooris_api.spaceapi import SpaceApiClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -24,6 +22,12 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
app_cfg = app_config.get() 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( app.extra["oidc_client"] = OpenidClient.from_issuer_url(
url=app_cfg.openid_issuer, url=app_cfg.openid_issuer,
authentication_redirect_uri=f"{app_cfg.base_url}/auth/login-callback", 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, 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( app.extra["ccujack"] = CCUJackClient(
base_uri=app_cfg.ccujack_url, base_uri=app_cfg.ccujack_url,
auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password), auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password)
mqtt_conn=app_cfg.ccujack_mqtt,
spaceapi_client=app.extra["spaceapi"],
main_lock=app_cfg.main_lock,
) )
await app.extra["ccujack"].start() await app.extra["ccujack"].find_locks()
app.extra["keycloak"] = KeycloakClient(
base_uri=app_cfg.openid_issuer,
oidc_client=app.extra["oidc_client"],
keycloak_attribute_group=app_cfg.kc_ssh_attr_group,
authorized_keys_file=app_cfg.authorized_keys_file,
)
app.extra["keycloak"].start()
yield yield
await app.extra["ccujack"].close_connections()
await app.extra["keycloak"].stop()
app = FastAPI( app = FastAPI(
title="Dooris", title="Dooris",
@ -76,9 +61,19 @@ app.add_exception_handler(
"/api/user-info/", "/api/user-info/",
name="get-user-info", name="get-user-info",
tags=["auth"], tags=["auth"],
responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}},
) )
async def get_user_info(req: Request, current_user: deps.ApiUser) -> models.ApiUser: async def get_user_info(
return current_user 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) @app.get("/auth/login", tags=["auth"], response_class=RedirectResponse, status_code=302)
@ -124,9 +119,7 @@ async def login_init(
response_class=RedirectResponse, response_class=RedirectResponse,
status_code=302, status_code=302,
) )
async def login_callback( async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidClient) -> str:
req: Request, resp: Response, oidc_client: deps.OpenidClient
) -> str:
# check that the user is currently in an authenticating state # check that the user is currently in an authenticating state
# these cookies are set by the login_init() view # these cookies are set by the login_init() view
if ( if (
@ -153,7 +146,7 @@ async def login_callback(
auth_start_time = datetime.fromtimestamp( auth_start_time = datetime.fromtimestamp(
float(req.cookies["auth_start_time"]), UTC 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"] oidc_client, resp, auth_result, auth_start_time, req.cookies["auth_nonce"]
) )
logger.debug("successfully authenticated user") logger.debug("successfully authenticated user")
@ -175,9 +168,9 @@ async def login_callback(
"/auth/logout", tags=["auth"], response_class=RedirectResponse, status_code=302 "/auth/logout", tags=["auth"], response_class=RedirectResponse, status_code=302
) )
async def logout( async def logout(
resp: Response, oidc_client: deps.OpenidClient, current_user: deps.AuthenticatedUser resp: Response, oidc_client: deps.OpenidClient, current_user: deps.CurrentUser
) -> str: ) -> str:
deps.clear_oidc_auth_state(resp) deps.clear_auth_state(resp)
return oidc_client.initiate_logout( return oidc_client.initiate_logout(
RpInitiatedLogoutRequest( RpInitiatedLogoutRequest(
id_token_hint=current_user.raw_id_token, id_token_hint=current_user.raw_id_token,
@ -233,36 +226,17 @@ async def list_locks(ccujack: deps.CCUJackClient) -> List[models.Lock]:
case "UNREACH": case "UNREACH":
status_data["is_unreachable"] = value.v 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( result.append(
models.Lock( models.Lock(
id=i_lock.identifier, id=i_lock.identifier,
name=i_lock.title, name=i_lock.title,
status=models.LockStatus(**status_data), status=models.LockStatus(**status_data),
is_main_lock=is_main_lock,
) )
) )
return result 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( @app.patch(
"/api/locks/{lock_id}", "/api/locks/{lock_id}",
tags=["locks"], tags=["locks"],
@ -277,7 +251,7 @@ async def operate_lock(
lock_id: str, lock_id: str,
requested_op: models.LockOperation, requested_op: models.LockOperation,
ccujack: deps.CCUJackClient, ccujack: deps.CCUJackClient,
current_user: deps.AuthenticatedUser, current_user: deps.CurrentUser,
) -> None: ) -> None:
if not current_user.may_operate_locks: if not current_user.may_operate_locks:
raise exceptions.HttpProblemException.forbidden_to_operate(req.url) raise exceptions.HttpProblemException.forbidden_to_operate(req.url)

View file

@ -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 from aiohttp import ClientSession, BasicAuth, TCPConnector
import logging import logging
import asyncio import asyncio
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from dooris_api.mqtt_client import AsyncMqttClient
from dooris_api.spaceapi import SpaceApiClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -71,51 +68,21 @@ LockData = List[Tuple[CCUDeviceInfo, List[Tuple[CCUChannelInfo, List[CCUParamInf
class CCUJackClient: class CCUJackClient:
base_uri: str base_uri: str
locks: LockData 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( self.http = ClientSession(
base_url=base_uri, base_url=base_uri,
auth=auth, auth=auth,
raise_for_status=True, raise_for_status=True,
connector=TCPConnector(ssl=False), connector=TCPConnector(ssl=False),
) )
self.mqtt = AsyncMqttClient(mqtt_conn, auth.login, auth.password) self.locks = None
self.locks = []
self.param_values = dict()
self.task_process_messages = None
self.task_find_locks = None
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
async def find_locks(self): async def find_locks(self):
logger.debug("Inspecting lock devices present in CCUJack") logger.debug("Inspecting lock devices present in CCUJack")
async with self.http.get("/device") as resp: async with self.http.get("/device") as resp:
devices = CCUDeviceList.model_validate(await resp.json()) devices = CCUDeviceList.model_validate(await resp.json())
# inspect CCUJACK for locks
device_infos = await asyncio.gather( device_infos = await asyncio.gather(
*[ *[
self._inspect_ccu_device(i) self._inspect_ccu_device(i)
@ -124,70 +91,9 @@ class CCUJackClient:
] ]
) )
# save the result self.locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK]
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]
async def query_param_value(self, address: str):
logger.debug("Querying parameter value from '%s'", address) logger.debug("Querying parameter value from '%s'", address)
async with self.http.get(f"/device/{address}/~pv") as resp: async with self.http.get(f"/device/{address}/~pv") as resp:
return CCUValue.model_validate(await resp.json()) return CCUValue.model_validate(await resp.json())
@ -196,46 +102,6 @@ class CCUJackClient:
logger.debug("Writing parameter value '%s' to '%s'", value, address) logger.debug("Writing parameter value '%s' to '%s'", value, address)
await self.http.put(f"/device/{address}/~pv", json={"v": value}) 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( async def _inspect_ccu_device(
self, device_ref: CCURef self, device_ref: CCURef
) -> Tuple[CCUDeviceInfo, List[Tuple[CCUChannelInfo, List[CCUParamInfo]]]]: ) -> Tuple[CCUDeviceInfo, List[Tuple[CCUChannelInfo, List[CCUParamInfo]]]]:

View file

@ -2,27 +2,16 @@ from typing import Annotated, Optional
import logging import logging
from datetime import datetime, UTC, timedelta from datetime import datetime, UTC, timedelta
from fastapi import Request, Depends, Response from fastapi import Request, Depends, Response
from fastapi.security import APIKeyHeader
from simple_openid_connect.data import TokenSuccessResponse from simple_openid_connect.data import TokenSuccessResponse
from simple_openid_connect.client import OpenidClient from simple_openid_connect.client import OpenidClient
from dooris_api import app_config
from dooris_api import models, exceptions from dooris_api import models, exceptions
from dooris_api.ccujack import CCUJackClient from dooris_api.ccujack import CCUJackClient
from dooris_api.spaceapi import SpaceApiClient
logger = logging.getLogger(__name__) 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: async def get_oidc_client(req: Request) -> OpenidClient:
return req.app.extra["oidc_client"] 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)] 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 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) # 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 # 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( logger.debug(
"user is fully authenticated, returning current user from existing id_token" "user is fully authenticated, returning current user from existing id_token"
) )
id_token = oidc_client.decode_id_token( id_token = oidc_client.decode_id_token(
req.cookies["id_token"], req.cookies["id_token"], nonce=req.cookies["auth_nonce"]
nonce=req.cookies.get("auth_nonce", None), )
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 # 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( logger.debug(
"user has been previously authenticated, trying to recover with refresh_token" "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"]) token_resp = oidc_client.exchange_refresh_token(req.cookies["refresh_token"])
if isinstance(token_resp, TokenSuccessResponse): if isinstance(token_resp, TokenSuccessResponse):
logger.debug("successfully got new tokens from refresh token") logger.debug("successfully got new tokens from refresh token")
persist_oidc_auth_state( persist_auth_state(oidc_client, resp, token_resp, auth_start_time, req.cookies["auth_nonce"])
oidc_client, resp, token_resp, auth_start_time, None
)
# return the newly gotten info # return the newly gotten info
id_token = oidc_client.decode_id_token(token_resp.id_token) id_token = oidc_client.decode_id_token(token_resp.id_token)
return models.ApiUser.from_id_token(id_token, token_resp.id_token) return models.CurrentUser(
else: id_token=id_token, raw_id_token=token_resp.id_token
logger.debug(
"failed to exchange refresh token for new access token: %s", token_resp
) )
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 # otherwise we can't meaningfully recover any user information or the user is simply not authenticated
else: 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, oidc_client: OpenidClient,
resp: Response, resp: Response,
tokens: TokenSuccessResponse, 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("access_token", "", max_age=0)
resp.set_cookie("refresh_token", "", max_age=0) resp.set_cookie("refresh_token", "", max_age=0)
resp.set_cookie("id_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) resp.set_cookie("auth_start_time", "", max_age=0)
def get_logged_in_token_user(req: Request, token: Optional[str]): CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)]
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)]
def get_ccujack(req: Request) -> CCUJackClient: def get_ccujack(req: Request) -> CCUJackClient:
@ -214,12 +135,3 @@ def get_ccujack(req: Request) -> CCUJackClient:
CCUJackClient = Annotated[CCUJackClient, Depends(get_ccujack)] 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)]

View file

@ -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)

View file

@ -1,6 +1,6 @@
from typing import Optional, Literal, Self from typing import Optional, Literal, List
from datetime import datetime, UTC from datetime import datetime
from pydantic import BaseModel, HttpUrl, Field from pydantic import BaseModel, HttpUrl
from enum import Enum from enum import Enum
from simple_openid_connect.data import IdToken from simple_openid_connect.data import IdToken
@ -27,27 +27,24 @@ class HttpProblemDetail(BaseModel):
instance: Optional[HttpUrl] instance: Optional[HttpUrl]
class ApiUser(BaseModel): class CurrentUser(BaseModel):
is_anonymous: bool id_token: IdToken
is_ccchh_user: bool raw_id_token: str
is_token_user: bool
may_operate_locks: bool @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 username: str
guaranteed_session_until: Optional[datetime] ccchh_roles: List[str]
raw_id_token: Optional[str] = Field(exclude=True)
@classmethod
def from_id_token(cls, id_token: IdToken, raw_id_token: str) -> Self:
return cls(
is_anonymous=False,
is_ccchh_user=True,
is_token_user=False,
may_operate_locks="intern@" in getattr(id_token, "ccchh-roles", []),
username=id_token.preferred_username,
guaranteed_session_until=datetime.fromtimestamp(id_token.exp, UTC),
raw_id_token=raw_id_token,
)
class LockStatus(BaseModel): class LockStatus(BaseModel):
@ -62,7 +59,6 @@ class LockStatus(BaseModel):
class Lock(BaseModel): class Lock(BaseModel):
name: str name: str
id: str id: str
is_main_lock: bool
status: LockStatus status: LockStatus

View file

@ -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()

View file

@ -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"))

11
api/uv.lock generated
View file

@ -400,7 +400,6 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "paho-mqtt" },
{ name = "simple-openid-connect" }, { name = "simple-openid-connect" },
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
@ -414,7 +413,6 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.5" }, { name = "aiohttp", specifier = ">=3.13.5" },
{ name = "fastapi", specifier = ">=0.136.1" }, { name = "fastapi", specifier = ">=0.136.1" },
{ name = "paho-mqtt", specifier = ">=2.1.0" },
{ name = "simple-openid-connect", specifier = ">=2.4.0" }, { name = "simple-openid-connect", specifier = ">=2.4.0" },
{ name = "uvicorn", specifier = ">=0.46.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" }, { 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]] [[package]]
name = "parso" name = "parso"
version = "0.8.7" version = "0.8.7"

View file

@ -1,3 +1,3 @@
# Update schema # 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`

View file

@ -89,23 +89,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/api/locks/{lock_id}": {
parameters: { parameters: {
query?: never; query?: never;
@ -127,21 +110,6 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { 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 */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** 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/) * @description Statically known HTTP problem types using the [type URI scheme](https://datatracker.ietf.org/doc/rfc4151/)
* @enum {string} * @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 */
Lock: { Lock: {
/** Name */ /** Name */
@ -208,6 +176,17 @@ export interface components {
*/ */
activity_state: "unknown" | "locking" | "unlocking" | "stable"; 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 */
ValidationError: { ValidationError: {
/** Location */ /** Location */
@ -245,7 +224,16 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { 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: { operate_lock_api_locks__lock_id__patch: {
parameters: { parameters: {
query?: never; query?: never;
@ -406,15 +365,6 @@ export interface operations {
"application/json": components["schemas"]["HttpProblemDetail"]; "application/json": components["schemas"]["HttpProblemDetail"];
}; };
}; };
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HttpProblemDetail"];
};
};
/** @description Not Found */ /** @description Not Found */
404: { 404: {
headers: { headers: {

View file

@ -1,5 +1,5 @@
import {Fetcher} from "openapi-typescript-fetch" 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" import type {ui} from "../i18n/ui.ts"
const fetcher = Fetcher.for<paths>() const fetcher = Fetcher.for<paths>()
@ -29,7 +29,7 @@ declare global {
lang: keyof typeof ui; lang: keyof typeof ui;
doors: Array<DoorType>; doors: Array<DoorType>;
auth: AuthType; 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({}) const {data: userInfo} = await getUserInfo({})
apiError.current = null apiError.current = null
auth.authenticated = !userInfo.is_anonymous auth.authenticated = true
auth.authorized = userInfo.may_operate_locks auth.authorized = userInfo.is_authorized
auth.until = userInfo.guaranteed_session_until ? new Date(userInfo.guaranteed_session_until) : null auth.until = userInfo.guaranteed_session_until ? new Date(userInfo.guaranteed_session_until) : null
auth.username = userInfo.username ?? "" auth.username = userInfo.username ?? ""
auth.recentLogout = false auth.recentLogout = false
if (auth.authenticated) { triggerAuthTimeout()
triggerAuthTimeout()
}
} catch (e) { } catch (e) {
// check which operation threw the exception // check which operation threw the exception
if (e instanceof getUserInfo.Error) { if (e instanceof getUserInfo.Error) {
const error = e.getActualType() const error = e.getActualType()
if (error.status === 401) {
if (error.status >= 500 && error.status < 600) { 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" 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 { } finally {
localStorage.setItem("auth", JSON.stringify(auth)) localStorage.setItem("auth", JSON.stringify(auth))
@ -127,24 +125,15 @@ async function checkUser() {
} }
} }
async function subscribeDoorEvents() { async function fetchDoors() {
if (doors.length === 0) { if (doors.length === 0) {
loading.doors = true loading.doors = true
} }
refresh() refresh()
const getDoors = fetcher.path("/api/locks/").method("get").create()
const evtSource = new EventSource("/api/locks/stream") try {
const {data: doorInfo} = await getDoors({})
evtSource.onerror = () => {
if (!window.navigator.onLine) {
apiError.current = "networkError"
} else {
apiError.current = "serverError"
}
}
evtSource.onmessage = (event) => {
const doorInfo: Array<components["schemas"]["Lock"]> = JSON.parse(event.data)
apiError.current = null apiError.current = null
while (doors.length) { while (doors.length) {
@ -175,6 +164,29 @@ async function subscribeDoorEvents() {
loading.doors = false loading.doors = false
refresh() 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() loadAuthFromLocalStorage()
subscribeDoorEvents() const doorsInterval = setInterval(fetchDoors, 250) // TODO: replace with SSE
checkUser() checkUser()
document.addEventListener("loadeddata", () => { document.addEventListener("loadeddata", () => {

View file

@ -96,4 +96,31 @@ export const ui = {
"chat.dooris": "dOwOris", "chat.dooris": "dOwOris",
"chat.user": "my fren :3", "chat.user": "my fren :3",
}, },
} as const meow: {
"dooris": "meoowris 🐈️ <span class='opacity-50 text-xs'>(DOORIS)</span>",
"unauthorized.title": "rawwwwr 😾⛔️",
"unauthorized.description": `meow mreoow: <a class='underline' href='https://wiki.hamburg.ccc.de/club:prozesse:aufnahmeprozesse-fuer-berechtigungsgruppen'>🌐 meow</a>`,
"unauthenticated.title": "meow? 🫴🐈️",
"unauthenticated.description": `meow mreoow: <a class='underline' href='https://wiki.hamburg.ccc.de/club:prozesse:aufnahmeprozesse-fuer-berechtigungsgruppen'>🌐 meow</a>`,
"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

61
flake.lock generated
View file

@ -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
}

View file

@ -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
];
};
}
);
}

View file

@ -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 $@

1782
ssh-cli/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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<Self> {
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<Vec<ApiLock>> {
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(())
}
}

View file

@ -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,
}

View file

@ -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<String> {
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::<Vec<_>>().join(" ");
tracing::trace!(
argv = ?args,
"Taking CLI arguments from argv"
);
Ok(args)
}
}
}
impl CliArgs {
pub fn parse() -> eyre::Result<Self> {
let raw_args = get_arg_string()?;
let raw_args_list = raw_args.split_whitespace().collect::<Vec<_>>();
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."
))
}
}
}

View file

@ -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 <help | show-locks | lock <door> [wait|nowait] | unlock <door> [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 <lock> [wait|nowait] -> Locks the door identified with <lock>.");
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 <lock> [wait|nowait] -> Unlocks the lock identified with <lock>.");
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<ApiLock> {
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!(" !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
}
}

View file

@ -1,7 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDTjbowlWL4JrOcqQrlpWg9MBwejsfUvEEnTjkFekOsmwAAAIhz+nzqc/p8
6gAAAAtzc2gtZWQyNTUxOQAAACDTjbowlWL4JrOcqQrlpWg9MBwejsfUvEEnTjkFekOsmw
AAAEDHkXZZNymkxkR3QMacM0R8MzCWg0+JPkL5GsFdP6Jzs9ONujCVYvgms5ypCuWlaD0w
HB6Ox9S8QSdOOQV6Q6ybAAAABHRlc3QB
-----END OPENSSH PRIVATE KEY-----

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINONujCVYvgms5ypCuWlaD0wHB6Ox9S8QSdOOQV6Q6yb test