Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,9 @@ Dockerfile
**/.mypy_cache/
**/.dmypy.json
**/dmypy.json

# Node / frontend
**/node_modules/
**/dist/
**/.__mf__temp/
**/.vite/
2 changes: 0 additions & 2 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ jobs:
pnpm-version: "11"
working-directory: ui
- name: Test with pytest
env:
SYNAPSE_REGISTRATION_SECRET: "ci-test-secret" # pragma: allowlist secret
run: |
uv run py.test -v --junitxml=pytest.xml
- name: Publish Test Report
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/release_please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ jobs:
python-version: "3.12"
- name: Generate and attach OpenAPI spec
env:
SYNAPSE_REGISTRATION_SECRET: "ci-test-secret" # pragma: allowlist secret
GH_TOKEN: ${{ github.token }}
run: |
uv run matrixrmapi openapi > openapi.json
Expand Down
7 changes: 5 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ COPY --from=production_build /ui_build /ui_build
COPY --from=production_build /docker-entrypoint.sh /docker-entrypoint.sh
COPY --from=production_build /container-init.sh /container-init.sh
COPY --from=ghcr.io/pvarki/kraftwerk-helper-tool:1.3.0-260513 /kw_product_init /kw_product_init
COPY --from=ghcr.io/pvarki/keycloak-helper-tool:1.1.2-260630 /kc_client_init /kc_client_init
WORKDIR /app
# Install system level deps for running the package (not devel versions for building wheels)
# and install the wheels we built in the previous step. generate default config
Expand Down Expand Up @@ -159,12 +158,16 @@ RUN --mount=type=ssh uv sync \
FROM devel_build as devel_shell
# Copy everything to the image
COPY --from=ghcr.io/pvarki/kraftwerk-helper-tool:1.3.0-260513 /kw_product_init /kw_product_init
COPY --from=ghcr.io/pvarki/keycloak-helper-tool:1.1.2-260630 /kc_client_init /kc_client_init
WORKDIR /app
ENV COREPACK_HOME=/opt/corepack
RUN apt-get update && apt-get install -y zsh \
&& sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" \
&& echo "source /root/.profile" >>/root/.zshrc \
&& pip3 install git-up \
&& ln -s /app/docker/container-init.sh /container-init.sh \
&& mkdir -p "$COREPACK_HOME" \
&& corepack prepare pnpm@11.1.0 --activate \
&& chmod -R 0777 "$COREPACK_HOME" \
&& chmod 0777 /app/ui /app/ui/node_modules \
&& true
ENTRYPOINT ["/bin/zsh", "-l"]
44 changes: 32 additions & 12 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@ Startup sequence

The startup runs as a background task so the HTTP server is available immediately::

1. Poll GET /health on Synapse until it responds 200 (up to 5 minutes).
2. Acquire a file lock and register the admin bot via the Synapse HMAC-signed
registration endpoint (idempotent: if a valid token file already exists,
re-registration is skipped).
1. Poll GET /health on MAS and Synapse until they respond 200 (up to 5 minutes each).
2. Acquire a file lock; ensure the bot user exists in MAS and create its access token
via the MAS admin API (held in memory, replaced automatically before expiry).
3. Remove rate-limiting for the bot user so concurrent room operations never hit 429.
4. Ensure the Space and four rooms exist (creates them if missing, looks them up by
alias otherwise).
Expand Down Expand Up @@ -78,8 +77,9 @@ User lifecycle endpoints
``homeserver.yaml`` will join the user when they first log in via OIDC.

``POST /api/v1/users/revoked``
Device certificate revoked. Deactivates and erases the user from Synapse (their messages
are removed from the server). If Synapse is not ready yet, returns success with a warning.
Device certificate revoked. Deactivates the user via MAS, erasing them from Synapse
(their messages are removed) and invalidating their sessions.
If MAS is not ready yet, returns success with a warning.

``POST /api/v1/users/promoted``
User promoted to admin in Deploy App. Sets the user's power level to 100 in the Space and
Expand Down Expand Up @@ -116,19 +116,39 @@ Configuration
* - ``SYNAPSE_URL``
- ``http://synapse:8008``
- Internal URL of the Synapse homeserver
* - ``SYNAPSE_REGISTRATION_SECRET``
- *(required)*
- Shared secret for bot registration (HMAC-SHA1)
* - ``MAS_URL``
- ``http://mas:8081``
- Internal URL of the MAS internal listener (oauth + admin API)
* - ``MAS_HEALTH_URL``
- ``http://mas:8081``
- Internal URL of the MAS health listener
* - ``MAS_ADMIN_CLIENT_ID``
- *(unset)*
- Admin client id (ULID); the same value is configured in the MAS container
* - ``MAS_ADMIN_CLIENT_SECRET``
- *(unset)*
- Admin client secret; the same value is configured in the MAS container
* - ``SYNAPSE_BOT_USERNAME``
- ``matrixrmapi-bot``
- Local part of the admin bot Matrix user
* - ``SYNAPSE_TOKEN_FILE``
- ``/data/persistent/synapse_admin_token``
- File where the bot's access token is cached between restarts
* - ``SERVER_DOMAIN``
- *(from kraftwerk manifest)*
- Matrix server_name; derived automatically from the product DNS label

Matrix Authentication Service
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Synapse delegates authentication to MAS (`element-hq/matrix-authentication-service`_).
This service uses the MAS admin API at ``MAS_URL`` for user provisioning, deactivation
and bot token creation, authenticating via the ``client_credentials`` grant of an OAuth2
client listed in MAS's ``policy.data.admin_clients``. The admin client id (a ULID, as
required by MAS — a public identifier) comes from
``MAS_ADMIN_CLIENT_ID`` and must match the client configured in MAS, with the shared
secret in ``MAS_ADMIN_CLIENT_SECRET``. The bot token is short-lived, held in memory
only, and replaced automatically before it expires.

.. _element-hq/matrix-authentication-service: https://github.com/element-hq/matrix-authentication-service

Docker
------

Expand Down
18 changes: 17 additions & 1 deletion devspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,28 @@ dev:
namespace: app-matrix
container: matrixrmapi
devImage: ${runtime.images.matrixrmapi}
patches:
- op: replace
path: spec.containers.name=matrixrmapi.resources.limits.memory
value: 1Gi
- op: replace
path: spec.containers.name=matrixrmapi.resources.requests.memory
value: 1Gi
command:
- /bin/bash
- -lc
- source /container-init.sh && uv sync --frozen && uv run uvicorn --host 0.0.0.0 --port 8012 --log-level debug --factory matrixrmapi.app:get_app --reload
- mkdir -p /ui_files/matrix && (pnpm --dir /app/ui exec vite build --outDir /ui_files/matrix --emptyOutDir --watch &) && uv run --no-sync uvicorn --host 0.0.0.0 --port 8012 --log-level debug --factory matrixrmapi.app:get_app --reload
sync:
- path: ./src:/app/src
excludePaths:
- __pycache__/
- "**/__pycache__/"
- "*.pyc"
- path: ./ui/src:/app/ui/src
- path: ./ui/public:/app/ui/public
- path: ./ui/public:/ui_files/matrix
disableDownload: true
initialSync: preferLocal
- path: ./docker:/app/docker
# container-init.sh is provided read-only via the configmap mount
# (/container-init.sh -> /app/docker/container-init.sh symlink), so DevSpace
Expand Down
40 changes: 1 addition & 39 deletions docker/container-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ set -e
GW_IP=$(getent ahostsv4 host.docker.internal | grep RAW | awk '{ print $1 }')
echo "GW_IP=$GW_IP"
grep -v "$GW_IP" /etc/hosts > /etc/hosts.new && cat /etc/hosts.new > /etc/hosts
echo "$GW_IP ${SERVER_DOMAIN} ${MTLS_DOMAIN} ${KCDOMAIN}" >>/etc/hosts
echo "$GW_IP ${SERVER_DOMAIN} ${MTLS_DOMAIN}" >>/etc/hosts
echo "*** BEGIN /etc/hosts ***"
cat /etc/hosts
echo "*** END /etc/hosts ***"
Expand All @@ -17,44 +17,6 @@ else
sleep 2
fi

if [ "${NGINX_HTTPS_PORT}" == "443" ]; then
export MTLS_BASEURL="https://mtls.${SERVER_DOMAIN}"
else
export MTLS_BASEURL="https://mtls.${SERVER_DOMAIN}:${NGINX_HTTPS_PORT}"
fi

# Generate the manifest using environment variables
# TODO use envsubst + dedicated file
cat <<EOF > /tmp/manifest.json
{
"rasenmaeher": {
"mtls": {
"base_uri": "${MTLS_BASEURL}"
},
"kc": {
"base_uri": "https://${KCDOMAIN}:9443",
"realm": "${KCREALM}"
}
},
"oidc": {
"client_registration": {
"client_name": "Synapse"
}
}
}
EOF

MAX_RETRIES=5
COUNT=0
until /kc_client_init get_jwt /tmp/manifest.json || [ $COUNT -eq $MAX_RETRIES ]; do
echo "JWT fetch failed, retrying in 2s..."
sleep 2
((COUNT++))
done

# Register the synapse server as an OIDC integration
/kc_client_init register_oidc /tmp/manifest.json

if [ -f /data/persistent/firstrun.done ]
then
echo "First run already cone"
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"gunicorn>=21.0,<25.0",
"httpx>=0.28.1,<1.0",
"filelock>=3.12,<4.0",
"python-ulid>=3.0,<4.0",
]

[project.urls]
Expand Down
6 changes: 3 additions & 3 deletions src/matrixrmapi/api/description.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@
PRODUCT_SHORTNAME = "matrix"


class ProductComponent(BaseModel): # pylint: disable=too-few-public-methods
class ProductComponent(BaseModel):
"""Project component info"""

type: Literal["link", "markdown", "component"]
ref: str


class ProductDescriptionExtended(BaseModel): # pylint: disable=too-few-public-methods
class ProductDescriptionExtended(BaseModel):
"""Description of a product"""

shortname: str = Field(
Expand All @@ -37,7 +37,7 @@ class ProductDescriptionExtended(BaseModel): # pylint: disable=too-few-public-m
docs: str = Field(description="Link to documentation")
component: ProductComponent = Field(description="Component type and ref")

class Config: # pylint: disable=too-few-public-methods
class Config:
"""Pydantic configs"""

extra = Extra.forbid
Expand Down
28 changes: 22 additions & 6 deletions src/matrixrmapi/api/healthcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,34 @@

import logging

from fastapi import APIRouter
import httpx
from fastapi import APIRouter, Request
from libpvarki.schemas.product import ProductHealthCheckResponse

from ..config import MAS_HEALTH_URL, SYNAPSE_URL

LOGGER = logging.getLogger(__name__)

router = APIRouter()


@router.get("")
async def request_healthcheck() -> ProductHealthCheckResponse:
"""Check that we are healthy, return accordingly"""
return ProductHealthCheckResponse(
healthy=True, extra="Dummy, nothing actually checked"
)
async def request_healthcheck(request: Request) -> ProductHealthCheckResponse:
"""Check that the Matrix integration is initialised and Synapse and MAS respond"""
if getattr(request.app.state, "synapse", None) is None or not getattr(
request.app.state, "rooms", None
):
return ProductHealthCheckResponse(
healthy=False, extra="Matrix integration not initialised"
)
async with httpx.AsyncClient() as client:
for name, url in (("Synapse", SYNAPSE_URL), ("MAS", MAS_HEALTH_URL)):
try:
resp = await client.get(f"{url}/health", timeout=2.0)
resp.raise_for_status()
except httpx.HTTPError as exc:
LOGGER.warning("%s health check failed: %s", name, exc)
return ProductHealthCheckResponse(
healthy=False, extra=f"{name} health check failed"
)
return ProductHealthCheckResponse(healthy=True)
36 changes: 25 additions & 11 deletions src/matrixrmapi/api/usercrud.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
import logging
from typing import Dict, Optional

import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from libpvarki.middleware import MTLSHeader
from libpvarki.schemas.generic import OperationResultResponse
from libpvarki.schemas.product import UserCRUDRequest

from ..config import get_manifest, get_server_domain
from ..synapseutils.synapse_admin import SynapseAdmin, matrix_user_id
from ..utils.mas_admin import MasAdmin
from ..utils.synapse_admin import SynapseAdmin, matrix_user_id
from ..types import AdminAction

LOGGER = logging.getLogger(__name__)
Expand All @@ -33,6 +35,12 @@ def get_synapse(request: Request) -> Optional[SynapseAdmin]:
return val


def get_mas(request: Request) -> Optional[MasAdmin]:
"""Return MasAdmin from app state, or None if not yet ready."""
val: Optional[MasAdmin] = getattr(request.app.state, "mas", None)
return val


def get_rooms(request: Request) -> Optional[Dict[str, str]]:
"""Return room IDs dict from app state, or None if not yet ready."""
val: Optional[Dict[str, str]] = getattr(request.app.state, "rooms", None)
Expand Down Expand Up @@ -77,23 +85,29 @@ async def user_revoked(
user: UserCRUDRequest,
request: Request,
) -> OperationResultResponse:
"""Device cert revoked — deactivate and erase user from Synapse."""
"""Device cert revoked — deactivate user in MAS (MAS erases them from Synapse
and invalidates their sessions)."""
comes_from_rm(request)
synapse = get_synapse(request)
if synapse is None:
LOGGER.warning("Synapse not ready; cannot deactivate %s", user.callsign)
mas = get_mas(request)
if mas is None:
LOGGER.warning("MAS not ready; cannot deactivate %s", user.callsign)
return OperationResultResponse(success=True)
try:
uid = matrix_user_id(user.callsign, get_server_domain())
# validates the callsign
matrix_user_id(user.callsign, get_server_domain())
except ValueError as exc:
LOGGER.error("Invalid callsign for Matrix: %s", exc)
return OperationResultResponse(success=False)
localpart = user.callsign.lower()
try:
await synapse.deactivate(uid)
except Exception as exc: # pylint: disable=broad-except
LOGGER.error("Failed to deactivate %s in Synapse: %s", uid, exc)
deactivated = await mas.deactivate_user(localpart)
except httpx.HTTPError as exc:
LOGGER.error("Failed to deactivate %s in MAS: %s", localpart, exc)
return OperationResultResponse(success=False)
LOGGER.info("Deactivated and erased %s from Synapse", uid)
if deactivated:
LOGGER.info("Deactivated %s in MAS (erased from Synapse)", localpart)
else:
LOGGER.info("%s not in MAS (never logged in); nothing to deactivate", localpart)
return OperationResultResponse(success=True)


Expand All @@ -118,7 +132,7 @@ async def apply_admin_action(
await synapse.force_join(admin_id, uid)
else:
await synapse.kick(admin_id, uid)
except Exception as exc: # pylint: disable=broad-except
except Exception as exc:
LOGGER.error("Failed to %s %s: %s", action.value, uid, exc)
return OperationResultResponse(success=False)
LOGGER.info("%sd %s (power level %d)", action.value.capitalize(), uid, level)
Expand Down
12 changes: 8 additions & 4 deletions src/matrixrmapi/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,17 @@
from matrixrmapi import __version__
from .config import LOG_LEVEL, get_manifest
from .api import all_routers, all_routers_v2
from .synapseutils.synapse_admin import SynapseAdmin
from .synapseutils.startup import synapse_startup
from .utils.mas_admin import MasAdmin
from .utils.synapse_admin import SynapseAdmin
from .utils.startup import connect_to_matrix

LOGGER = logging.getLogger(__name__)


@asynccontextmanager
async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Start Synapse integration as a non-blocking background task."""
task = asyncio.create_task(synapse_startup(app))
"""Start MAS and Synapse integration as a non-blocking background task."""
task = asyncio.create_task(connect_to_matrix(app))
try:
yield
finally:
Expand All @@ -34,6 +35,9 @@ async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
synapse: Optional[SynapseAdmin] = getattr(app.state, "synapse", None)
if synapse:
await synapse.close()
mas: Optional[MasAdmin] = getattr(app.state, "mas", None)
if mas:
await mas.close()


def get_app() -> FastAPI:
Expand Down
Loading
Loading