Compare commits

...
Author SHA1 Message Date
4817966757 app: fix syntax error in cat language
All checks were successful
Build Container / Build Container (push) Successful in 5m49s
2026-09-29 16:22:56 +02:00
4abc76bc2d app: add cat language 2026-09-29 16:22:56 +02:00
81c960db6c
update favicon
All checks were successful
Build Container / Build Container (push) Successful in 5m54s
2026-09-29 16:17:55 +02:00
3ead6f1f77
favicon.ico jetzt in weniger groß
All checks were successful
Build Container / Build Container (push) Successful in 4m24s
2026-09-28 20:38:05 +02:00
2865a9077f
add favicon: try NO. 1
Some checks failed
Build Container / Build Container (push) Has been cancelled
2026-09-28 20:33:55 +02:00
4370c3f984
api: fix configuration parameter for main lock
All checks were successful
Build Container / Build Container (push) Successful in 4m26s
2026-09-28 15:17:31 +02:00
fd841d0fa0
ssh-cli: don't print desired state as that is often out-of-sync
All checks were successful
Build Container / Build Container (push) Successful in 4m35s
2026-09-28 15:08:33 +02:00
51c67c49ae
api: fix response generation with new "is_main_lock" field
All checks were successful
Build Container / Build Container (push) Successful in 4m25s
2026-08-11 17:02:17 +02:00
ecd6c2c48f
implement spaceapid integration into API
All checks were successful
Build Container / Build Container (push) Successful in 4m38s
2026-08-11 16:04:13 +02:00
394b423b8e
rename Containerfile build stages to be more clear and consistent
All checks were successful
Build Container / Build Container (push) Successful in 4m25s
2026-07-23 14:24:16 +02:00
38888fd58c
fix compilation error in ssh-cli 2026-07-23 14:23:54 +02:00
5850cff536
display lock warnings on every operation not just show-locks
Some checks failed
Build Container / Build Container (push) Failing after 2m43s
2026-07-23 14:19:42 +02:00
d0a865e85a
correct open/close wording which was incosistent 2026-07-23 14:13:30 +02:00
a750a67d17
add show-locks to usage string 2026-07-22 19:47:46 +02:00
14730ecb47
fix bug in keycloak cron not writing newline speparators to authorized_keys
All checks were successful
Build Container / Build Container (push) Successful in 4m29s
2026-07-21 21:48:03 +02:00
0d4c796975
add help text concerning show-locks command in ssh-cli
All checks were successful
Build Container / Build Container (push) Successful in 4m18s
2026-07-19 21:11:36 +02:00
7c64d57eb5
implement wait feature in ssh-cli
All checks were successful
Build Container / Build Container (push) Successful in 4m16s
2026-07-19 20:47:29 +02:00
bc0c1b85ca
improve ssh-cli interaction surface
All checks were successful
Build Container / Build Container (push) Successful in 4m17s
- better CLI parameter parsing
- an actual help message
- a new command to list currently known locks
2026-07-19 20:19:47 +02:00
fb19ca3840
document how different dooris components work together
All checks were successful
Build Container / Build Container (push) Successful in 4m27s
2026-07-19 17:25:27 +02:00
a6949ed066
ship ssh-cli in docker container
All checks were successful
Build Container / Build Container (push) Successful in 4m30s
2026-07-19 16:51:51 +02:00
f5c69ae5e9
set authorized_keys file mode automatically from api process
All checks were successful
Build Container / Build Container (push) Successful in 1m33s
2026-07-19 16:21:40 +02:00
504d64ea57
init small ssh-cli to accept commands over SSH and operate local locks
All checks were successful
Build Container / Build Container (push) Successful in 1m42s
2026-07-18 23:20:07 +02:00
a7692dc894
fix bug in API static tokens where not actually extracted from request 2026-07-18 23:19:34 +02:00
58f5a8cd43
init nix based devshell 2026-07-18 21:09:51 +02:00
f904aa2831
fix typo in keycloak ssh key count debug message
All checks were successful
Build Container / Build Container (push) Successful in 1m40s
2026-07-18 20:58:58 +02:00
ba404112d7
add debug message about fetched keycloak keys
All checks were successful
Build Container / Build Container (push) Successful in 1m42s
2026-07-18 20:50:29 +02:00
ee9edfcc70
clear up static api token error message 2026-07-18 20:49:11 +02:00
ae8324c5c6
init locks as empty list to avoid internal server errors
All checks were successful
Build Container / Build Container (push) Successful in 1m43s
2026-07-18 20:43:47 +02:00
914a4497c7
api: implement automatic ssh key fetching from keycloak
All checks were successful
Build Container / Build Container (push) Successful in 1m26s
2026-05-31 22:03:59 +02:00
279a45a9e8
api: don't print static tokens on startup (was only for debugging)
All checks were successful
Build Container / Build Container (push) Successful in 1m32s
2026-05-31 20:54:42 +02:00
64ab73e9c3
api: initialize ccujack asynchonously via immedietaly running cron 2026-05-31 20:54:36 +02:00
b752888812
api: fix mqtt reconnect logic errors 2026-05-31 20:53:45 +02:00
2e1742279d
update frontend to match new user-info endpoint
All checks were successful
Build Container / Build Container (push) Successful in 1m27s
2026-05-29 09:57:19 +02:00
99668e2a33
document current api parameters in README
All checks were successful
Build Container / Build Container (push) Successful in 1m27s
2026-05-28 17:54:13 +02:00
186ab662fb
api: implement static dooris tokens 2026-05-28 17:52:13 +02:00
63a9485209
api: restructure user authentication modelling to support other user backends
All checks were successful
Build Container / Build Container (push) Successful in 1m26s
2026-05-28 17:29:57 +02:00
e5b880d038
api: fix syntax error in mqtt connector
All checks were successful
Build Container / Build Container (push) Successful in 1m30s
2026-05-28 16:27:20 +02:00
6bbfc5dc68
api: implement automatic mqtt reconnect in ccujack cron
All checks were successful
Build Container / Build Container (push) Successful in 1m27s
2026-05-28 16:23:03 +02:00
319670b6ae
api: fix variable misnomer failing at program shutdown
All checks were successful
Build Container / Build Container (push) Successful in 1m27s
2026-05-19 17:13:46 +02:00
8281848215
use server sent events instead of polling
All checks were successful
Build Container / Build Container (push) Successful in 1m27s
2026-05-19 17:09:02 +02:00
8bc4e7f28e
api: debounce multiple mqtt parameter updates for frontend
All checks were successful
Build Container / Build Container (push) Successful in 1m27s
2026-05-19 17:07:00 +02:00
c0d6bd4548
restructure dockerfile to not have npm dependencies in final image
All checks were successful
Build Container / Build Container (push) Successful in 1m27s
2026-05-19 16:45:42 +02:00
5bdf04cbb6
api: tentatively make auth_nonce optional after token refresh
All checks were successful
Build Container / Build Container (push) Successful in 1m38s
2026-05-19 16:34:53 +02:00
41fd939d30
api: fix ccujack cron task not being properly stopped
All checks were successful
Build Container / Build Container (push) Successful in 1m36s
2026-05-19 15:17:48 +02:00
2349e58924
api: automatically rediscover locks from CCUJack
All checks were successful
Build Container / Build Container (push) Successful in 1m35s
closes CCCHH/dooris#5
2026-05-19 15:12:55 +02:00
b1b624a7b3
api: simplify logging setup 2026-05-19 15:01:04 +02:00
7ac0a4106c
api: implement Server-Sent-Events on top of mqtt parameters
All checks were successful
Build Container / Build Container (push) Successful in 1m47s
2026-05-19 14:54:27 +02:00
4103c0ca5f
api: process mqtt messages to keep a current local lock state 2026-05-19 14:36:40 +02:00
44d484cfc1
api: use proper connection shutdown for downstream services 2026-05-19 14:08:41 +02:00
0331dd6406
api: connect to CCUJACK MQTT broker on startup
All checks were successful
Build Container / Build Container (push) Successful in 1m29s
2026-05-19 13:09:54 +02:00
1a50d67df6
api: implement abstraction for connecting to CCUJACK MQTT broker 2026-05-19 13:07:03 +02:00
34 changed files with 3260 additions and 156 deletions

View file

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

View file

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

2
.gitignore vendored
View file

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

View file

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

View file

@ -6,10 +6,51 @@ Based on prior work of [hmdooris](https://git.hamburg.ccc.de/CCCHH/hmdooris).
Project structure:
```
├── api # Python application interacting with HomeMatic and providing the API.
└── app # Web UI
├── api # Python application interacting with HomeMatic and providing the API.
├── ssh-cli # CLI application that allows door management over SSH
└── app # Web UI
```
## Architecture Explanation
The deployment of dooris is a little bit involved but I hope this paragraph can give some clarity about the required details.
This dooris implementation works together with an existing HomeMatic installation that does the actual work of turning locks. Dooris is in principal just the access layer as well as integration layer into other CCH services.
For example, dooris enables us to bind guard access to the underlying HomeMatic to our Keycloak roles as well as enabling new access methods like dooris-over-ssh.
The appliction is implemented using the following different components:
- An **API** (or backend).
This python application is intended to be accessible over simple HTTP(S) connections and serves as the central point of managing everything.
It interacts directly with HomeMatic by automatically querying its API for locks and connecting to its integrated MQTT server for state updates.
All other components only every talk to this API and the API is in charge of managing all the different integrations.
- An astro **frontend** served at the same HTTP(S) host as the API.
This is the user facing part of dooris and what you see when you navigate to said url.
In the background, this frontend simply talks to the API for available doors, state updates, lock interactions and whatelse.
Since the frontend is also the user facing part of dooris, it is the intended place for catching common error conditions and displaying helpful hints.
- The **ssh-cli** rust application whose setup is a bit contrived but which serves as an alternative access layer to the HTTP(S) frontend.
Even though dooris is mainly packaged as a docker container, using this part of dooris requires configuration on the underlying linux host.
The way the integration works is as follows:
- On the underlying docker host, a local `dooris` user is created with a home directory configured that is shared with the running dooris container. User ID, group ID and permissions must also match between the host user and the container user (`10000` for uid and gid by default).
- The container has an entrypoint script which copies the bundled `ssh-cli` binary into said shared directory. Afterwards, the web service is started normally.
- The web service must be configured with a static API token (either via environment variables or cli parameters) to allow the ssh-cli access.
- The keycloak instance backing dooris must have the [attribute-endpoints-provider plugin](https://git.hamburg.ccc.de/CCCHH/keycloak-attribute-endpoints-provider) installed and configured to expose an endpoint containing a list of ssh public keys and accessible by the dooris clients service account. That endpoint must be configured in the dooris backend instance.
- The dooris backend regularly queries the configured keycloak endpoint for a list of ssh keys and writes them into the host-container shared directory as an authorized_keys file.
- Finally, the ssh server on the host must have a specific configuration section for the local dooris user to use the automatically written authorized_keys file as well as force the command to be the placed `ssh-cli` binary. The binary must also be configured via environment variables to connect to the running backend (including authorization).
This can be done, for example, with the following config section:
```sshd_config
Match User dooris
AuthorizedKeysFile /my/shared/directory/dooris_authorized_keys
ForceCommand /my/shared/directory/ssh-cli
SetEnv DOORIS_SERVER_URL=https://dooris.ccchh.net/ DOORIS_API_TOKEN=foobar123
```
- When a user logs in with the `dooris` user now, they are always dropped into the `ssh-cli` binary. This works in cases where a command is specified on the users cli or where it isn't.
In any case, the `ssh-cli` binary interprets the given command (via the `SSH_ORIGINAL_COMMAND` environment variable), figures out what to do, and contacts the running API with the configured credentials to do it.
## Configuration
The final application can be configured either via CLI arguments or via environment variables.
@ -26,8 +67,10 @@ THe following table lists all available configuration parameters:
| `--base-url` | `DOORIS_BASE_URL` | Yes | *None* |
| `--serve-static` | `DOORIS_SERVE_STATIC` | No | *None* |
| `--ccujack-url` | `DOORIS_CCUJACK_URL` | No | `https://hmdooris-ccu.ccchh.net:2122` |
| `--ccujack-mqtt` | `DOORIS_CCUJACK_MQTT` | No | `hmdooris-ccu.ccchh.net:1883` |
| `--ccujack-user` | `DOORIS_CCUJACK_USER` | Yes | *None* |
| `--ccujack-password` | `DOORIS_CCUJACK_PASSWORD` | Yes | *None* |
| `--static-api-tokens` | `DOORIS_STATIC_API_TOKENS` | No | *None* |
## API Development

View file

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

View file

@ -1,4 +1,6 @@
import os
import logging
from pathlib import Path
from contextvars import ContextVar
from argparse import ArgumentParser, Namespace
@ -49,9 +51,17 @@ 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,
@ -62,18 +72,78 @@ 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_LOCK", 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")
app.mount(
"/", StaticFiles(directory=args.serve_static, html=True), name="static"
)
# start webserver
config = uvicorn.Config(app, port=8000, log_level="debug")
server = uvicorn.Server(config)
server.run()

View file

@ -1,11 +1,11 @@
from typing import Optional, List
from typing import Optional, List, AsyncIterable
import logging
import secrets
import sys
import os
import asyncio
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,6 +13,8 @@ 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__)
@ -22,12 +24,6 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI):
app_cfg = app_config.get()
root_logger = logging.getLogger("")
root_logger.setLevel(logging.INFO)
root_logger.addHandler(logging.StreamHandler(sys.stderr))
app_logger = logging.getLogger("dooris_api")
app_logger.setLevel(logging.DEBUG)
app.extra["oidc_client"] = OpenidClient.from_issuer_url(
url=app_cfg.openid_issuer,
authentication_redirect_uri=f"{app_cfg.base_url}/auth/login-callback",
@ -36,14 +32,33 @@ 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)
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"].find_locks()
await app.extra["ccujack"].start()
app.extra["keycloak"] = KeycloakClient(
base_uri=app_cfg.openid_issuer,
oidc_client=app.extra["oidc_client"],
keycloak_attribute_group=app_cfg.kc_ssh_attr_group,
authorized_keys_file=app_cfg.authorized_keys_file,
)
app.extra["keycloak"].start()
yield
await app.extra["ccujack"].close_connections()
await app.extra["keycloak"].stop()
app = FastAPI(
title="Dooris",
@ -61,19 +76,9 @@ app.add_exception_handler(
"/api/user-info/",
name="get-user-info",
tags=["auth"],
responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}},
)
async def get_user_info(
req: Request, current_user: deps.CurrentUser
) -> models.UserStatus:
return models.UserStatus(
is_authorized=current_user.may_operate_locks,
guaranteed_session_until=datetime.fromtimestamp(
current_user.id_token.exp, UTC
),
username=current_user.id_token.preferred_username,
ccchh_roles=current_user.ccchh_roles,
)
async def get_user_info(req: Request, current_user: deps.ApiUser) -> models.ApiUser:
return current_user
@app.get("/auth/login", tags=["auth"], response_class=RedirectResponse, status_code=302)
@ -119,7 +124,9 @@ async def login_init(
response_class=RedirectResponse,
status_code=302,
)
async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidClient) -> str:
async def login_callback(
req: Request, resp: Response, oidc_client: deps.OpenidClient
) -> str:
# check that the user is currently in an authenticating state
# these cookies are set by the login_init() view
if (
@ -146,7 +153,7 @@ async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidC
auth_start_time = datetime.fromtimestamp(
float(req.cookies["auth_start_time"]), UTC
)
deps.persist_auth_state(
deps.persist_oidc_auth_state(
oidc_client, resp, auth_result, auth_start_time, req.cookies["auth_nonce"]
)
logger.debug("successfully authenticated user")
@ -168,9 +175,9 @@ async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidC
"/auth/logout", tags=["auth"], response_class=RedirectResponse, status_code=302
)
async def logout(
resp: Response, oidc_client: deps.OpenidClient, current_user: deps.CurrentUser
resp: Response, oidc_client: deps.OpenidClient, current_user: deps.AuthenticatedUser
) -> str:
deps.clear_auth_state(resp)
deps.clear_oidc_auth_state(resp)
return oidc_client.initiate_logout(
RpInitiatedLogoutRequest(
id_token_hint=current_user.raw_id_token,
@ -226,17 +233,36 @@ 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"],
@ -251,7 +277,7 @@ async def operate_lock(
lock_id: str,
requested_op: models.LockOperation,
ccujack: deps.CCUJackClient,
current_user: deps.CurrentUser,
current_user: deps.AuthenticatedUser,
) -> None:
if not current_user.may_operate_locks:
raise exceptions.HttpProblemException.forbidden_to_operate(req.url)

View file

@ -1,9 +1,12 @@
from typing import List, Tuple, Optional, Any
from typing import List, Tuple, Optional, Any, Dict
from aiohttp import ClientSession, BasicAuth, TCPConnector
import logging
import asyncio
from pydantic import BaseModel, Field
from dooris_api.mqtt_client import AsyncMqttClient
from dooris_api.spaceapi import SpaceApiClient
logger = logging.getLogger(__name__)
@ -68,21 +71,51 @@ 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):
def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str, spaceapi_client: SpaceApiClient, main_lock: Optional[str]):
self.http = ClientSession(
base_url=base_uri,
auth=auth,
raise_for_status=True,
connector=TCPConnector(ssl=False),
)
self.locks = None
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
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)
@ -91,9 +124,70 @@ class CCUJackClient:
]
)
self.locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK]
# 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]
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())
@ -102,6 +196,46 @@ class CCUJackClient:
logger.debug("Writing parameter value '%s' to '%s'", value, address)
await self.http.put(f"/device/{address}/~pv", json={"v": value})
async def _update_spaceapi(self):
"""
Write the current lock state into spaceapid
"""
if not self.main_lock:
logger.debug("No main lock is configured, skipping spaceapid update")
return
logger.info(f"Pushing current state of lock {self.main_lock} to spaceapid")
for (i_lock, lock_channels) in self.locks:
if i_lock.identifier.lower() == self.main_lock.lower() or i_lock.title.lower() == self.main_lock.lower():
is_currently_open = False
# find the correct parameter and extract it into a usable variable
for (i_channel, params) in lock_channels:
for i_param in params:
if i_param.id == "LOCK_STATE":
# do a proper lookup and not just directly access the cache because the method
# could have been triggered by something other than MQTT in which case the value
# would not necessarily be available yet
value = await self.query_param_value(
f"{i_lock.address}/{i_channel.index}/{i_param.id}"
)
match value.v:
case 0: # unknown
is_currently_open = False
case 1: # locked
is_currently_open = False
case 2: # unlocked
is_currently_open = True
# actually post the update
await self.spaceapi_client.set_status(is_currently_open)
break
else:
logger.error(f"Could not push current lock state of lock {self.main_lock} to spaceapid because no such lock is known")
async def _inspect_ccu_device(
self, device_ref: CCURef
) -> Tuple[CCUDeviceInfo, List[Tuple[CCUChannelInfo, List[CCUParamInfo]]]]:

View file

@ -2,16 +2,27 @@ 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"]
@ -19,24 +30,23 @@ async def get_oidc_client(req: Request) -> OpenidClient:
OpenidClient = Annotated[OpenidClient, Depends(get_oidc_client)]
async def get_current_user(
async def get_logged_in_oidc_user(
req: Request, resp: Response, oidc_client: OpenidClient
) -> Optional[models.CurrentUser]:
) -> Optional[models.ApiUser]:
# easiest case: we still have an access token (which is the most fleeting component)
# everything else should still be valid so we can just use it
if all(i in req.cookies for i in ("access_token", "id_token", "auth_nonce")):
if all(i in req.cookies for i in ("access_token", "id_token")):
logger.debug(
"user is fully authenticated, returning current user from existing id_token"
)
id_token = oidc_client.decode_id_token(
req.cookies["id_token"], nonce=req.cookies["auth_nonce"]
)
return models.CurrentUser(
id_token=id_token, raw_id_token=req.cookies["id_token"]
req.cookies["id_token"],
nonce=req.cookies.get("auth_nonce", None),
)
return models.ApiUser.from_id_token(id_token, req.cookies["id_token"])
# if we have a refresh token, try to get new tokens
elif all(i in req.cookies for i in ("refresh_token", "auth_nonce")):
elif all(i in req.cookies for i in ("refresh_token",)):
logger.debug(
"user has been previously authenticated, trying to recover with refresh_token"
)
@ -44,24 +54,26 @@ async def get_current_user(
token_resp = oidc_client.exchange_refresh_token(req.cookies["refresh_token"])
if isinstance(token_resp, TokenSuccessResponse):
logger.debug("successfully got new tokens from refresh token")
persist_auth_state(oidc_client, resp, token_resp, auth_start_time, req.cookies["auth_nonce"])
persist_oidc_auth_state(
oidc_client, resp, token_resp, auth_start_time, None
)
# return the newly gotten info
id_token = oidc_client.decode_id_token(token_resp.id_token)
return models.CurrentUser(
id_token=id_token, raw_id_token=token_resp.id_token
)
return models.ApiUser.from_id_token(id_token, token_resp.id_token)
else:
logger.debug("failed to exchange refresh token for new access token: %s", token_resp)
logger.debug(
"failed to exchange refresh token for new access token: %s", token_resp
)
# otherwise we can't meaningfully recover any user information or the user is simply not authenticated
else:
logger.debug("no currently authenticated user")
logger.debug("no currently authenticated oidc user")
raise exceptions.HttpProblemException.unauthorized(req.url)
return None
def persist_auth_state(
def persist_oidc_auth_state(
oidc_client: OpenidClient,
resp: Response,
tokens: TokenSuccessResponse,
@ -117,7 +129,7 @@ def persist_auth_state(
)
def clear_auth_state(resp: Response):
def clear_oidc_auth_state(resp: Response):
resp.set_cookie("access_token", "", max_age=0)
resp.set_cookie("refresh_token", "", max_age=0)
resp.set_cookie("id_token", "", max_age=0)
@ -127,7 +139,74 @@ def clear_auth_state(resp: Response):
resp.set_cookie("auth_start_time", "", max_age=0)
CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)]
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)]
def get_ccujack(req: Request) -> CCUJackClient:
@ -135,3 +214,12 @@ def get_ccujack(req: Request) -> CCUJackClient:
CCUJackClient = Annotated[CCUJackClient, Depends(get_ccujack)]
def get_spaceapi_client(req: Request) -> SpaceApiClient:
return req.app.extra["spaceapi"]
SpaceApiClient = Annotated[SpaceApiClient, Depends(get_spaceapi_client)]

View file

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

View file

@ -0,0 +1,177 @@
#
# 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

@ -0,0 +1,28 @@
import logging
from aiohttp import ClientSession, BasicAuth
logger = logging.getLogger(__name__)
class SpaceApiClient:
"""
Client for interacting with spaceapid
ref: https://git.hamburg.ccc.de/CCCHH/spaceapid
"""
base_uri: str
http: ClientSession
def __init__(self, base_uri: str, auth: BasicAuth):
self.base_uri = base_uri
self.http = ClientSession(
base_url=base_uri,
auth=auth,
raise_for_status=True,
)
async def set_status(self, is_open: bool):
logger.info(f"Setting club status to open={is_open} via spaceapid")
await self.http.put("/state/open", data=str(is_open).encode("ASCII"))

11
api/uv.lock generated
View file

@ -400,6 +400,7 @@ source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "fastapi" },
{ name = "paho-mqtt" },
{ name = "simple-openid-connect" },
{ name = "uvicorn" },
]
@ -413,6 +414,7 @@ dev = [
requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.5" },
{ name = "fastapi", specifier = ">=0.136.1" },
{ name = "paho-mqtt", specifier = ">=2.1.0" },
{ name = "simple-openid-connect", specifier = ">=2.4.0" },
{ name = "uvicorn", specifier = ">=0.46.0" },
]
@ -734,6 +736,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/6c/d8a02ffb24876b5f51fbd781f479fc6525a518553a4196bd0433dae9ff8e/orderedmultidict-1.0.2-py2.py3-none-any.whl", hash = "sha256:ab5044c1dca4226ae4c28524cfc5cc4c939f0b49e978efa46a6ad6468049f79b", size = 11897, upload-time = "2025-11-18T08:00:41.44Z" },
]
[[package]]
name = "paho-mqtt"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
]
[[package]]
name = "parso"
version = "0.8.7"

BIN
app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
app/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

1
app/public/favicon.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0c6.648 0 12 5.352 12 12s-5.352 12-12 12S0 18.648 0 12 5.352 0 12 0" style="fill:#15191e;stroke:none;stroke-width:3.77953;fill-opacity:1"/><path d="M12.853 2.111q-.285 0-.557.062a2.55 2.55 0 0 0-1.58 1.126H8.69a2.495 2.495 0 0 0-2.482 2.482v10.757H3.725a.827.827 0 0 0-.827.828.827.827 0 0 0 .827.827h6.636c.138.939 1.092 1.61 2.04 1.374v-.002l5.495-1.372h2.379a.827.827 0 0 0 .827-.827.827.827 0 0 0-.827-.828H18.62V5.42a2.49 2.49 0 0 0-1.881-2.408l-3.31-.827a2.4 2.4 0 0 0-.576-.073m-.024 1.65q.097.002.199.028l3.31.827c.372.093.627.42.627.803v11.3L12 17.961V4.59a.807.807 0 0 1 .83-.829M8.69 4.954h1.655v11.584H7.863V5.781c0-.466.36-.827.827-.827m4.965 4.965a.827.827 0 0 0-.828.827.827.827 0 0 0 .828.828h.008a.827.827 0 0 0 .827-.828.827.827 0 0 0-.827-.827z" style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:#fff;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stop-color:#000;stop-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,3 +1,3 @@
# Update schema
`pnpm dlx openapi-typescript http://localhost:8000/openapi.json -o ./schema.ts`
`pnpm dlx openapi-typescript http://localhost:8000/api/openapi.json -o ./schema.ts`

View file

@ -89,6 +89,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/locks/stream": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Watch Locks */
get: operations["watch_locks_api_locks_stream_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/locks/{lock_id}": {
parameters: {
query?: never;
@ -110,6 +127,21 @@ export interface paths {
export type webhooks = Record<string, never>;
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 */
@ -135,7 +167,7 @@ export interface components {
* @description Statically known HTTP problem types using the [type URI scheme](https://datatracker.ietf.org/doc/rfc4151/)
* @enum {string}
*/
HttpProblemType: "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" | "type:noc@hamburg.ccc.de,2026:LOCK_NOT_FOUND";
HttpProblemType: "type:noc@hamburg.ccc.de,2026:UNAUTHORIZED" | "type:noc@hamburg.ccc.de,2026,FORBIDDEN_TO_OPERATE" | "type:noc@hamburg.ccc.de,2026:LOCK_NOT_FOUND";
/** Lock */
Lock: {
/** Name */
@ -176,17 +208,6 @@ 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 */
@ -224,16 +245,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserStatus"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HttpProblemDetail"];
"application/json": components["schemas"]["ApiUser"];
};
};
};
@ -332,6 +344,35 @@ export interface operations {
};
};
};
watch_locks_api_locks_stream_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/event-stream": unknown;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"text/event-stream": components["schemas"]["HttpProblemDetail"];
};
};
};
};
operate_lock_api_locks__lock_id__patch: {
parameters: {
query?: never;
@ -365,6 +406,15 @@ export interface operations {
"application/json": components["schemas"]["HttpProblemDetail"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HttpProblemDetail"];
};
};
/** @description Not Found */
404: {
headers: {

View file

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

View file

@ -96,4 +96,31 @@ export const ui = {
"chat.dooris": "dOwOris",
"chat.user": "my fren :3",
},
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

View file

@ -5,6 +5,7 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width"/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
<link rel="icon" type="image/png" href="/favicon.png"/>
<link rel="icon" href="/favicon.ico"/>
<meta name="generator" content={Astro.generator}/>
<title>dooris</title>

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784356753,
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

36
flake.nix Normal file
View file

@ -0,0 +1,36 @@
{
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
];
};
}
);
}

8
oci/entrypoint.sh Executable file
View file

@ -0,0 +1,8 @@
#!/bin/sh
echo "Copying ssh-cli into state directory"
cp -v /usr/local/bin/ssh-cli /srv/state/ssh-cli
echo "Running user script"
exec $@

1782
ssh-cli/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

13
ssh-cli/Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[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"

78
ssh-cli/src/api_client.rs Normal file
View file

@ -0,0 +1,78 @@
use eyre::Context;
use eyre::eyre;
use reqwest::Url;
use reqwest::header::HeaderValue;
use crate::api_models::ApiLock;
use crate::api_models::DesiredLockState;
use crate::api_models::LockOperationRequest;
/// Client for interacting with the dooris API
pub struct ApiClient {
client: reqwest::Client,
authorization: HeaderValue,
server_url: Url,
}
impl ApiClient {
pub fn new_from_env() -> eyre::Result<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(())
}
}

62
ssh-cli/src/api_models.rs Normal file
View file

@ -0,0 +1,62 @@
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,
}

104
ssh-cli/src/cli.rs Normal file
View file

@ -0,0 +1,104 @@
use eyre::{OptionExt, eyre};
use std::env::VarError;
/// CLI parameters given to this program
#[derive(Debug, Eq, PartialEq)]
pub enum CliArgs {
/// The user requested a help message
Help,
/// The user requested to lock a specific lock
Lock {
/// The name of the lock that should be locked
lock_name: String,
/// Whether the CLI should wait until the lock operation is not just started but until it is completely finished
wait_until_done: bool,
},
/// The user requested to unlock a specific lock
Unlock {
/// The name of the lock that should be unlocked
lock_name: String,
/// Whether the CLI should wait until the unlock operation is not just started but until it is completely finished
wait_until_done: bool,
},
/// The user wants to see a list of currently connected locks as well as their status
ShowLocks,
}
/// Get passed arguments from SSH_ORIGINAL_COMMAND environment variable but falling back to direct CLI args
fn get_arg_string() -> eyre::Result<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."
))
}
}
}

189
ssh-cli/src/main.rs Normal file
View file

@ -0,0 +1,189 @@
//! 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!(" 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!(" !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
}
}

7
ssh-cli/test_id_ed25519 Normal file
View file

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

View file

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