Compare commits

..

2 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
17 changed files with 175 additions and 675 deletions

View file

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

1
.gitignore vendored
View file

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

View file

@ -1,16 +1,3 @@
FROM docker.io/alpine:3.22 AS build-frontend
ENV PNPM_HOME=/usr/local/share/dooris/pnpm/
WORKDIR /usr/local/src/dooris/
RUN apk add --no-cache pnpm
ADD --link app/package.json app/pnpm-lock.yaml app/
RUN pnpm --dir=app/ install --frozen-lockfile --package-import-method=copy
ADD --link . /usr/local/src/dooris/
RUN pnpm --dir=app/ run build
FROM docker.io/alpine:3.22 AS base
ARG APP_UID=10000
@ -22,11 +9,12 @@ ENV UV_LINK_MODE=copy
ENV UV_CACHE_DIR=/var/cache/dooris/uv/
ENV UV_NO_MANAGED_PYTHON=true
ENV VIRTUAL_ENV=/usr/local/share/dooris/venv/
ENV PATH=$VIRTUAL_ENV/bin:$PATH
ENV PNPM_HOME=/usr/local/share/dooris/pnpm/
ENV PATH=$PNPM_HOME:$VIRTUAL_ENV/bin:$PATH
ENV DOORIS_SERVE_STATIC=/var/www/dooris/static/
WORKDIR /usr/local/src/dooris/
RUN apk add --no-cache uv python3
RUN apk add --no-cache uv python3 pnpm
RUN addgroup -g $APP_GID dooris &&\
adduser -h /usr/local/src/dooris -u $APP_UID -G dooris -D dooris &&\
mkdir -p /var/www/dooris/ /usr/local/share/dooris/ /usr/local/src/dooris/ /var/cache/dooris/ &&\
@ -37,14 +25,16 @@ RUN addgroup -g $APP_GID dooris &&\
FROM base AS deps
USER dooris
ADD --link --chown=dooris:dooris api/pyproject.toml api/uv.lock api/
ADD --link --chown=dooris:dooris app/package.json app/pnpm-lock.yaml app/
RUN uv venv $VIRTUAL_ENV &&\
uv sync --active --frozen --no-install-project --no-editable
RUN pnpm --dir=app/ install --frozen-lockfile --package-import-method=copy
FROM deps AS final
ADD --chown=dooris:dooris --link . /usr/local/src/dooris/
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
RUN --mount=type=cache,uid=$APP_UID,gid=$APP_GID,target=$UV_CACHE_DIR \
uv sync --active --frozen

View file

@ -26,10 +26,8 @@ THe following table lists all available configuration parameters:
| `--base-url` | `DOORIS_BASE_URL` | Yes | *None* |
| `--serve-static` | `DOORIS_SERVE_STATIC` | No | *None* |
| `--ccujack-url` | `DOORIS_CCUJACK_URL` | No | `https://hmdooris-ccu.ccchh.net:2122` |
| `--ccujack-mqtt` | `DOORIS_CCUJACK_MQTT` | No | `hmdooris-ccu.ccchh.net:1883` |
| `--ccujack-user` | `DOORIS_CCUJACK_USER` | Yes | *None* |
| `--ccujack-password` | `DOORIS_CCUJACK_PASSWORD` | Yes | *None* |
| `--static-api-tokens` | `DOORIS_STATIC_API_TOKENS` | No | *None* |
## API Development

View file

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

View file

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

View file

@ -1,11 +1,11 @@
from typing import Optional, List, AsyncIterable
from typing import Optional, List
import logging
import secrets
import asyncio
import sys
import os
from datetime import datetime, UTC
from fastapi import FastAPI, Request, Response, status
from fastapi.responses import RedirectResponse
from fastapi.sse import EventSourceResponse
from contextlib import asynccontextmanager
from simple_openid_connect.client import OpenidClient
from simple_openid_connect.data import TokenSuccessResponse, RpInitiatedLogoutRequest
@ -13,7 +13,6 @@ from aiohttp import BasicAuth
from dooris_api import deps, models, exceptions, app_config
from dooris_api.ccujack import CCUJackClient
from dooris_api.keycloak import KeycloakClient
logger = logging.getLogger(__name__)
@ -23,6 +22,12 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI):
app_cfg = app_config.get()
root_logger = logging.getLogger("")
root_logger.setLevel(logging.INFO)
root_logger.addHandler(logging.StreamHandler(sys.stderr))
app_logger = logging.getLogger("dooris_api")
app_logger.setLevel(logging.DEBUG)
app.extra["oidc_client"] = OpenidClient.from_issuer_url(
url=app_cfg.openid_issuer,
authentication_redirect_uri=f"{app_cfg.base_url}/auth/login-callback",
@ -33,24 +38,12 @@ async def lifespan(app: FastAPI):
app.extra["ccujack"] = CCUJackClient(
base_uri=app_cfg.ccujack_url,
auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password),
mqtt_conn=app_cfg.ccujack_mqtt,
auth=BasicAuth(app_cfg.ccujack_user, app_cfg.ccujack_password)
)
await app.extra["ccujack"].start()
app.extra["keycloak"] = KeycloakClient(
base_uri=app_cfg.openid_issuer,
oidc_client=app.extra["oidc_client"],
keycloak_attribute_group=app_cfg.kc_ssh_attr_group,
authorized_keys_file=app_cfg.authorized_keys_file,
)
app.extra["keycloak"].start()
await app.extra["ccujack"].find_locks()
yield
await app.extra["ccujack"].close_connections()
await app.extra["keycloak"].stop()
app = FastAPI(
title="Dooris",
@ -68,9 +61,19 @@ app.add_exception_handler(
"/api/user-info/",
name="get-user-info",
tags=["auth"],
responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}},
)
async def get_user_info(req: Request, current_user: deps.ApiUser) -> models.ApiUser:
return current_user
async def get_user_info(
req: Request, current_user: deps.CurrentUser
) -> models.UserStatus:
return models.UserStatus(
is_authorized=current_user.may_operate_locks,
guaranteed_session_until=datetime.fromtimestamp(
current_user.id_token.exp, UTC
),
username=current_user.id_token.preferred_username,
ccchh_roles=current_user.ccchh_roles,
)
@app.get("/auth/login", tags=["auth"], response_class=RedirectResponse, status_code=302)
@ -116,9 +119,7 @@ async def login_init(
response_class=RedirectResponse,
status_code=302,
)
async def login_callback(
req: Request, resp: Response, oidc_client: deps.OpenidClient
) -> str:
async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidClient) -> str:
# check that the user is currently in an authenticating state
# these cookies are set by the login_init() view
if (
@ -145,7 +146,7 @@ async def login_callback(
auth_start_time = datetime.fromtimestamp(
float(req.cookies["auth_start_time"]), UTC
)
deps.persist_oidc_auth_state(
deps.persist_auth_state(
oidc_client, resp, auth_result, auth_start_time, req.cookies["auth_nonce"]
)
logger.debug("successfully authenticated user")
@ -167,9 +168,9 @@ async def login_callback(
"/auth/logout", tags=["auth"], response_class=RedirectResponse, status_code=302
)
async def logout(
resp: Response, oidc_client: deps.OpenidClient, current_user: deps.AuthenticatedUser
resp: Response, oidc_client: deps.OpenidClient, current_user: deps.CurrentUser
) -> str:
deps.clear_oidc_auth_state(resp)
deps.clear_auth_state(resp)
return oidc_client.initiate_logout(
RpInitiatedLogoutRequest(
id_token_hint=current_user.raw_id_token,
@ -236,19 +237,6 @@ async def list_locks(ccujack: deps.CCUJackClient) -> List[models.Lock]:
return result
@app.get(
"/api/locks/stream",
tags=["locks"],
responses={status.HTTP_401_UNAUTHORIZED: {"model": models.HttpProblemDetail}},
response_class=EventSourceResponse,
)
async def watch_locks(ccujack: deps.CCUJackClient) -> AsyncIterable[List[models.Lock]]:
while True:
yield await list_locks(ccujack)
await ccujack.data_updated.wait()
await asyncio.sleep(0.1) # debounce multiple mqtt parameter updates
@app.patch(
"/api/locks/{lock_id}",
tags=["locks"],
@ -263,7 +251,7 @@ async def operate_lock(
lock_id: str,
requested_op: models.LockOperation,
ccujack: deps.CCUJackClient,
current_user: deps.AuthenticatedUser,
current_user: deps.CurrentUser,
) -> None:
if not current_user.may_operate_locks:
raise exceptions.HttpProblemException.forbidden_to_operate(req.url)

View file

@ -1,11 +1,9 @@
from typing import List, Tuple, Optional, Any, Dict
from typing import List, Tuple, Optional, Any
from aiohttp import ClientSession, BasicAuth, TCPConnector
import logging
import asyncio
from pydantic import BaseModel, Field
from dooris_api.mqtt_client import AsyncMqttClient
logger = logging.getLogger(__name__)
@ -70,47 +68,21 @@ LockData = List[Tuple[CCUDeviceInfo, List[Tuple[CCUChannelInfo, List[CCUParamInf
class CCUJackClient:
base_uri: str
locks: LockData
param_values: Dict[str, Any]
task_process_messages: asyncio.Task
task_find_locks: asyncio.Task
data_updated: asyncio.Event
def __init__(self, base_uri: str, auth: BasicAuth, mqtt_conn: str):
def __init__(self, base_uri: str, auth: BasicAuth):
self.http = ClientSession(
base_url=base_uri,
auth=auth,
raise_for_status=True,
connector=TCPConnector(ssl=False),
)
self.mqtt = AsyncMqttClient(mqtt_conn, auth.login, auth.password)
self.locks = None
self.param_values = dict()
self.task_process_messages = None
self.task_find_locks = None
self.data_updated = asyncio.Event()
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)
@ -119,66 +91,9 @@ class CCUJackClient:
]
)
# save the result
new_locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK]
if new_locks != self.locks:
logger.info("Found new locks, updating state")
self.locks = new_locks
self.data_updated.set()
self.data_updated.clear()
# update active mqtt subscriptions based on newly discovered devices
mqtt_topics = set()
for i_lock, lock_channels in self.locks:
for i_channel, channel_params in lock_channels:
for i_param in channel_params:
mqtt_topics.add(
f"device/status/{i_lock.address}/{i_channel.index}/{i_param.id}"
)
await self.mqtt.update_subscriptions(mqtt_topics)
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()
except Exception as e:
logger.exception(f"could not process incoming mqtt message: {e}")
finally:
self.data_updated.clear()
async def cron(self):
while True:
try:
logger.info("Running CCUJack cron")
if not self.mqtt.is_connected:
logger.warning("MQTT client was discovered to be disconnected; reconnecting now")
await self.mqtt.connect()
await self.find_locks()
except asyncio.CancelledError:
logger.info("CCUJack task cron stopped")
raise
except Exception as e:
logger.exception(f"Error in CCUJack cron task: {e}")
finally:
await asyncio.sleep(15 * 60) # 15 minutes
async def query_param_value(self, address: str) -> CCUValue:
if address in self.param_values:
return self.param_values[address]
self.locks = [i for i in device_infos if i[0].type == DEVICE_TYPE_LOCK]
async def query_param_value(self, address: str):
logger.debug("Querying parameter value from '%s'", address)
async with self.http.get(f"/device/{address}/~pv") as resp:
return CCUValue.model_validate(await resp.json())

View file

@ -1,12 +1,10 @@
from typing import Annotated, Optional
import logging
from datetime import datetime, UTC, timedelta
from fastapi import Request, Depends, Response, Header
from fastapi.security import APIKeyHeader
from fastapi import Request, Depends, Response
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
@ -14,9 +12,6 @@ from dooris_api.ccujack import CCUJackClient
logger = logging.getLogger(__name__)
api_key_security_scheme = APIKeyHeader(name="Authorization", scheme_name="Static-Token", auto_error=False)
async def get_oidc_client(req: Request) -> OpenidClient:
return req.app.extra["oidc_client"]
@ -24,23 +19,24 @@ async def get_oidc_client(req: Request) -> OpenidClient:
OpenidClient = Annotated[OpenidClient, Depends(get_oidc_client)]
async def get_logged_in_oidc_user(
async def get_current_user(
req: Request, resp: Response, oidc_client: OpenidClient
) -> Optional[models.ApiUser]:
) -> Optional[models.CurrentUser]:
# easiest case: we still have an access token (which is the most fleeting component)
# everything else should still be valid so we can just use it
if all(i in req.cookies for i in ("access_token", "id_token")):
if all(i in req.cookies for i in ("access_token", "id_token", "auth_nonce")):
logger.debug(
"user is fully authenticated, returning current user from existing id_token"
)
id_token = oidc_client.decode_id_token(
req.cookies["id_token"],
nonce=req.cookies.get("auth_nonce", None),
req.cookies["id_token"], nonce=req.cookies["auth_nonce"]
)
return models.CurrentUser(
id_token=id_token, raw_id_token=req.cookies["id_token"]
)
return models.ApiUser.from_id_token(id_token, req.cookies["id_token"])
# if we have a refresh token, try to get new tokens
elif all(i in req.cookies for i in ("refresh_token",)):
elif all(i in req.cookies for i in ("refresh_token", "auth_nonce")):
logger.debug(
"user has been previously authenticated, trying to recover with refresh_token"
)
@ -48,26 +44,24 @@ async def get_logged_in_oidc_user(
token_resp = oidc_client.exchange_refresh_token(req.cookies["refresh_token"])
if isinstance(token_resp, TokenSuccessResponse):
logger.debug("successfully got new tokens from refresh token")
persist_oidc_auth_state(
oidc_client, resp, token_resp, auth_start_time, None
)
persist_auth_state(oidc_client, resp, token_resp, auth_start_time, req.cookies["auth_nonce"])
# return the newly gotten info
id_token = oidc_client.decode_id_token(token_resp.id_token)
return models.ApiUser.from_id_token(id_token, token_resp.id_token)
else:
logger.debug(
"failed to exchange refresh token for new access token: %s", token_resp
return models.CurrentUser(
id_token=id_token, raw_id_token=token_resp.id_token
)
else:
logger.debug("failed to exchange refresh token for new access token: %s", token_resp)
# otherwise we can't meaningfully recover any user information or the user is simply not authenticated
else:
logger.debug("no currently authenticated oidc user")
logger.debug("no currently authenticated user")
return None
raise exceptions.HttpProblemException.unauthorized(req.url)
def persist_oidc_auth_state(
def persist_auth_state(
oidc_client: OpenidClient,
resp: Response,
tokens: TokenSuccessResponse,
@ -123,7 +117,7 @@ def persist_oidc_auth_state(
)
def clear_oidc_auth_state(resp: Response):
def clear_auth_state(resp: Response):
resp.set_cookie("access_token", "", max_age=0)
resp.set_cookie("refresh_token", "", max_age=0)
resp.set_cookie("id_token", "", max_age=0)
@ -133,65 +127,7 @@ def clear_oidc_auth_state(resp: Response):
resp.set_cookie("auth_start_time", "", max_age=0)
def get_logged_in_token_user(req: Request, token: Optional[str]):
if not token or not token.startswith("Static-Token "):
logger.debug("No valid API-Token was provided")
return None
token = token.removeprefix("Static-Token ")
valid_tokens = app_config.get().static_api_tokens
if any((i == token for i in valid_tokens)):
logger.debug("Successfully authenticated a static API-Token")
return models.ApiUser(
is_anonymous=False,
is_ccchh_user=False,
is_token_user=True,
may_operate_locks=True,
username="static-token",
guaranteed_session_until=None,
raw_id_token=None,
)
return None
async def get_api_user(
req: Request, resp: Response, oidc_client: OpenidClient, 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
) -> models.ApiUser:
user = await get_api_user(req, resp, oidc_client)
if user.is_anonymous:
raise exceptions.HttpProblemException.unauthorized(req.url)
else:
return user
AuthenticatedUser = Annotated[models.ApiUser, Depends(get_authenticated_user)]
CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)]
def get_ccujack(req: Request) -> CCUJackClient:

View file

@ -1,83 +0,0 @@
from typing import List
from aiohttp import ClientSession, ClientRequest, ClientHandlerType, ClientResponse
import asyncio
import logging
from pathlib import Path
from simple_openid_connect.client import OpenidClient
from simple_openid_connect.data import TokenErrorResponse
logger = logging.getLogger(__name__)
class ClientCredentialsAuth:
def __init__(self, oidc_client: OpenidClient):
self.oidc_client = oidc_client
async def __call__(self, req: ClientRequest, handler: ClientHandlerType) -> ClientResponse:
resp = self.oidc_client.client_credentials_grant.authenticate()
if isinstance(resp, TokenErrorResponse):
raise Exception(f"Could not authenticate against keycloak with client-credentials-grant: {resp.error} ({resp.error_description})")
req.headers["Authorization"] = f"Bearer {resp.access_token}"
return await handler(req)
class KeycloakClient:
keycloak_attribute_group: str
authorized_keys_file: Path
ssh_keys: List[str]
data_updated: asyncio.Event
task_cron = asyncio.Task
def __init__(self, base_uri: str, oidc_client: OpenidClient, keycloak_attribute_group: str, authorized_keys_file: Path):
self.base_uri: str
self.keycloak_attribute_group = keycloak_attribute_group
self.ssh_keys = []
self.data_updated = asyncio.Event()
self.authorized_keys_file = authorized_keys_file
self.http = ClientSession(
base_url=base_uri,
middlewares=[ClientCredentialsAuth(oidc_client)],
raise_for_status=True,
)
def start(self):
self.task_cron = asyncio.get_running_loop().create_task(self.cron(), name="keycloak-cron")
async def stop(self):
await self.http.close()
self.task_cron.cancel()
self.task_cron = None
async def cron(self):
while True:
try:
logger.info("Running keycloak cron")
await self.fetch_ssh_keys()
await self.write_authorized_keys()
except asyncio.CancelledError:
logger.info("keycloak cron stopped")
raise
except Exception as e:
logger.exception(f"Error in Keycloak cron task: {e}")
finally:
await asyncio.sleep(15 * 60) # 15 minutes
async def fetch_ssh_keys(self):
logger.info("Fetching ssh keys from keycloak")
async with self.http.get(f"./attribute-endpoints-provider/export/{self.keycloak_attribute_group}") as resp:
keys = await resp.json()
assert isinstance(keys, list)
self.ssh_keys = keys
self.data_updated.set()
self.data_updated.clear()
async def write_authorized_keys(self):
logger.info(f"Writing authorized keys to {self.authorized_keys_file.absolute()}")
with self.authorized_keys_file.open(mode="wt", encoding="UTF-8") as f:
f.writelines(self.ssh_keys)

View file

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

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

11
api/uv.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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