matrix-rtc: separate standalone publication from room creation
This commit is contained in:
parent
56120933f3
commit
2ff4caa788
5 changed files with 321 additions and 1 deletions
|
|
@ -187,7 +187,11 @@ in
|
|||
service.ports = [ "127.0.0.1:7880:7880" "7881:7881/tcp" "7882:7882/udp" ];
|
||||
};
|
||||
matrix-rtc-jwt = {
|
||||
service.image = "ghcr.io/element-hq/lk-jwt-service:0.9.0";
|
||||
# Pinned-source policy fork: verified remote users may publish, but only
|
||||
# iscg.dev may create SFU rooms. Source/tests/rollback: services/matrix-rtc-jwt.
|
||||
service.image = "iscg/lk-jwt-service:0.9.0-policy1-6e21c58c02d6";
|
||||
# Local test-gated image: restore/load it before starting on a new host.
|
||||
out.service.pull_policy = "never";
|
||||
service.container_name = "matrix-rtc-jwt";
|
||||
# Avoid public-IP hairpin NAT for same-host OpenID and SFU calls; TLS stays verified.
|
||||
out.service.extra_hosts = [ "iscg.dev:host-gateway" ];
|
||||
|
|
|
|||
22
services/matrix-rtc-jwt/Dockerfile
Normal file
22
services/matrix-rtc-jwt/Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Upstream v0.9.0 (0e176b1), with the reviewed standalone publication patch.
|
||||
FROM docker.io/library/rust:1-alpine@sha256:44819b27be4c97b0e4d7203bd2e02c4820f97700211b6a37fb4c246a2de59383 AS builder
|
||||
RUN apk add --no-cache musl-dev cmake make gcc g++ perl linux-headers ca-certificates
|
||||
WORKDIR /proj
|
||||
ENV CARGO_BUILD_JOBS=2 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0
|
||||
COPY source/ ./
|
||||
# Gate the production image on unit tests and both real HTTP/OpenID endpoint suites.
|
||||
RUN cargo test --locked -p lk-jwt-service --lib
|
||||
RUN cargo test --locked -p lk-jwt-service-integration-tests --test get_token --test sfu_get
|
||||
RUN cargo build --locked --release -p lk-jwt-service --bin lk-jwt-service --bin healthcheck \
|
||||
&& strip target/release/lk-jwt-service target/release/healthcheck
|
||||
FROM scratch
|
||||
COPY --from=builder /proj/target/release/lk-jwt-service /lk-jwt-service
|
||||
COPY --from=builder /proj/target/release/healthcheck /lk-jwt-service-healthcheck
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
LABEL org.opencontainers.image.source="https://github.com/element-hq/lk-jwt-service" \
|
||||
org.opencontainers.image.revision="0e176b1eca44e67b11484d0bdf56999807bf2b8e+iscg-policy1" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0-only"
|
||||
EXPOSE 8080
|
||||
USER 65532:65532
|
||||
CMD ["/lk-jwt-service"]
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD ["/lk-jwt-service-healthcheck"]
|
||||
97
services/matrix-rtc-jwt/README.md
Normal file
97
services/matrix-rtc-jwt/README.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# MatrixRTC standalone publication policy (iscg policy1)
|
||||
|
||||
## Policy and boundary
|
||||
|
||||
This is a narrow fork of Element's lk-jwt-service v0.9.0, upstream commit
|
||||
`0e176b1eca44e67b11484d0bdf56999807bf2b8e` (AGPL-3.0-only). It fixes both
|
||||
standalone endpoints, `/sfu/get` and `/get_token`:
|
||||
|
||||
- Matrix OpenID verification still runs, including the subject's server name
|
||||
and the claimed user ID where the protocol supplies one.
|
||||
- Every successfully authenticated participant may publish audio/video in the
|
||||
requested, room-scoped session, regardless of their homeserver.
|
||||
- `LIVEKIT_FULL_ACCESS_HOMESERVERS=iscg.dev` still gates the server-side
|
||||
`CreateRoom` call and delayed-leave delegation. It is NOT widened to `astr.to`
|
||||
or `*`. No user's JWT gains room creation, list, recording or admin privileges.
|
||||
- Existing application-service membership checks, when configured, are unchanged.
|
||||
- The SFU MUST retain `room.auto_create: false`. Otherwise remote users can
|
||||
create arbitrary SFU rooms by joining, regardless of JWT-service policy.
|
||||
- A remote user may obtain a scoped token before a local user creates the room;
|
||||
the SFU rejects joining a missing room. This is intentional upstream behaviour.
|
||||
- Existing one-hour token TTL, SFU participant limits and container hardening
|
||||
are preserved. No new appservice/admin credential is introduced.
|
||||
|
||||
**This is not Matrix room-membership authorization.** On Heather, Tuwunel
|
||||
1.9.4 has no MSC4502/MSC4512 integration, and lk-jwt-service is standalone.
|
||||
Someone with valid Matrix OpenID and knowledge of a room ID can request an SFU
|
||||
token even if not a Matrix member, including for an unencrypted call. This
|
||||
limitation existed for subscribing before the patch and now includes publishing.
|
||||
Room IDs are not authorization secrets; E2EE, when enabled by clients, protects
|
||||
media confidentiality, not resource abuse or forged room participation. Do not
|
||||
advertise this deployment as membership-checked or suitable for anonymous guests.
|
||||
|
||||
The fuller migration is homeserver-backed MSC4195/MSC4502/MSC4512 integration
|
||||
with scoped application-service membership checks. It requires a homeserver
|
||||
that supports those APIs (or a separately reviewed Tuwunel implementation),
|
||||
client compatibility checks and a backed-up migration. Adding `astr.to` or `*`
|
||||
to the full-access list does not solve membership authorization. The newer
|
||||
upstream `LIVEKIT_REMOTE_CAN_PUBLISH_HOMESERVERS` option applies to federated
|
||||
application-service requests, not to these standalone endpoints.
|
||||
|
||||
## Source and tests
|
||||
|
||||
`standalone-publication.patch` changes two authorization arguments, adds a
|
||||
startup policy banner, regression tests and updates the two HTTP integration
|
||||
suites' remote publication expectations. Creation, delegation, OpenID and
|
||||
membership-check implementations are not replaced.
|
||||
|
||||
The image build fails unless all root-library unit tests and both HTTP/OpenID
|
||||
integration suites (`get_token`, `sfu_get`) pass. Regressions cover local users,
|
||||
`astr.to`, an unrelated server, rejected OpenID, foreign/forged subject,
|
||||
room-scoped JWTs and lack of creation/admin permissions. Existing membership
|
||||
and delayed-event denial tests remain part of the unit suite.
|
||||
|
||||
The upstream archive, patch and Rust builder image are SHA256-pinned, and Cargo
|
||||
uses `--locked`. Alpine build-tool packages are fetched from its package
|
||||
repository, so this is a pinned-source build recipe, NOT a claim of bit-for-bit
|
||||
reproducible output. Preserve the exported release image and its checksum too.
|
||||
|
||||
## Build (Linux amd64 / Docker)
|
||||
|
||||
From this directory:
|
||||
|
||||
```sh
|
||||
# Run long work in tmux. No production secrets are needed by the build.
|
||||
sudo bash build.sh
|
||||
```
|
||||
|
||||
The resulting image is
|
||||
`iscg/lk-jwt-service:0.9.0-policy1-6e21c58c02d6`.
|
||||
The image is local-only and Compose uses `pull_policy: never`. On a replacement
|
||||
host, rebuild it or load the preserved Docker image archive BEFORE starting
|
||||
Arion. Do not replace it with an unreviewed latest tag. Tests and compiler run
|
||||
in build containers; no Rust toolchain is installed on the NixOS host.
|
||||
|
||||
## Deployment and rollback
|
||||
|
||||
Deploy only `matrix-rtc-jwt` with the evaluated Arion composition, after staging
|
||||
and health/token/media checks. No homeserver, SFU, Coturn or unrelated service
|
||||
restart is required. Compare the Nix source baseline to `/run/current-system`
|
||||
and inspect dry activation before switching so unrelated edits are not applied.
|
||||
|
||||
The deployment receipt records the exact image ID and new Nix generation.
|
||||
Before deployment preserve the old generation, old composition, old image,
|
||||
container start-time baseline and config source. Keep release archives outside
|
||||
cache on Heather and Poppy.
|
||||
|
||||
For an emergency rollback, use the saved pre-policy Arion composition:
|
||||
|
||||
```sh
|
||||
sudo arion --prebuilt-file /path/from/receipt/old-compose.yaml \
|
||||
up -d --no-deps --pull never matrix-rtc-jwt
|
||||
```
|
||||
|
||||
Then restore the pre-policy Nix generation/source selectively, retaining any
|
||||
subsequent unrelated edits. Do not restart the whole `heather.service`, wipe
|
||||
volumes, revert the homeserver database or disable TLS verification. Rejoin the
|
||||
call to obtain a fresh JWT; previously issued tokens do not change retroactively.
|
||||
26
services/matrix-rtc-jwt/build.sh
Normal file
26
services/matrix-rtc-jwt/build.sh
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
work=${RTC_BUILD_DIR:-"$HOME/.cache/pi-work/matrix-rtc-policy/build"}
|
||||
mkdir -p "$work"
|
||||
revision=0e176b1eca44e67b11484d0bdf56999807bf2b8e
|
||||
source_sha=32f4d1b31c39689c7b8100c1567eacc646763c96c1054513115ae59cd9efabb9
|
||||
patch_sha=6e21c58c02d68e45c12f053a70001954022b88551a8e3afa620c61c1e0a94791
|
||||
image=iscg/lk-jwt-service:0.9.0-policy1-${patch_sha:0:12}
|
||||
if [ ! -f "$work/upstream.tar.gz" ]; then
|
||||
curl -fL --retry 3 "https://codeload.github.com/element-hq/lk-jwt-service/tar.gz/$revision" -o "$work/upstream.tar.gz"
|
||||
fi
|
||||
printf '%s %s\n' "$source_sha" "$work/upstream.tar.gz" "$patch_sha" "$here/standalone-publication.patch" | sha256sum -c -
|
||||
# Fresh context, never build against an unreviewed/dirty upstream checkout.
|
||||
context=$(mktemp -d "$work/context.XXXXXX")
|
||||
mkdir "$context/source"
|
||||
tar -xzf "$work/upstream.tar.gz" --strip-components=1 -C "$context/source"
|
||||
(cd "$context/source" && patch --batch --fuzz=0 -p1 < "$here/standalone-publication.patch")
|
||||
cp "$here/Dockerfile" "$context/Dockerfile"
|
||||
# BuildKit may require separate routing on this host. The legacy builder uses
|
||||
# Docker's configured bridge egress; build/test containers never mount secrets.
|
||||
DOCKER_BUILDKIT=0 docker build --network host --memory=3g --memory-swap=4g --cpu-shares=256 -t "$image" "$context"
|
||||
printf 'Built and test-gated: %s\n' "$image"
|
||||
docker image inspect "$image" --format '{{.Id}}'
|
||||
# Context is deliberately kept for audit/retries. Remove only this task's cache
|
||||
# manually after preserving the release image/source receipt.
|
||||
171
services/matrix-rtc-jwt/standalone-publication.patch
Normal file
171
services/matrix-rtc-jwt/standalone-publication.patch
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
diff --git a/integration-tests/tests/get_token.rs b/integration-tests/tests/get_token.rs
|
||||
index 386f97b..102ff4a 100644
|
||||
--- a/integration-tests/tests/get_token.rs
|
||||
+++ b/integration-tests/tests/get_token.rs
|
||||
@@ -353,13 +353,12 @@ async fn remote_token() {
|
||||
assert_eq!(response["url"].as_str(), Some(sfu.url()));
|
||||
let jwt = response["jwt"].as_str().unwrap_or_default();
|
||||
|
||||
- // The JWT should grant joining, subscribing and updating the
|
||||
- // participant's own metadata, but not publishing, since the homeserver
|
||||
- // is remote.
|
||||
+ // Remote verified users may publish, but the service must not create a
|
||||
+ // room for them. Room creation remains gated on the full-access list.
|
||||
let claims = decode_livekit_jwt(jwt);
|
||||
assert_eq!(claims["iss"].as_str(), Some(LIVEKIT_KEY));
|
||||
assert_eq!(claims["video"]["roomJoin"].as_bool(), Some(true));
|
||||
- assert_eq!(claims["video"]["canPublish"].as_bool(), Some(false));
|
||||
+ assert_eq!(claims["video"]["canPublish"].as_bool(), Some(true));
|
||||
assert_eq!(claims["video"]["canSubscribe"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
claims["video"]["canUpdateOwnMetadata"].as_bool(),
|
||||
diff --git a/integration-tests/tests/sfu_get.rs b/integration-tests/tests/sfu_get.rs
|
||||
index 5a08445..2cc8672 100644
|
||||
--- a/integration-tests/tests/sfu_get.rs
|
||||
+++ b/integration-tests/tests/sfu_get.rs
|
||||
@@ -323,7 +323,7 @@ async fn remote_token() {
|
||||
let claims = decode_livekit_jwt(jwt);
|
||||
assert_eq!(claims["iss"].as_str(), Some(LIVEKIT_KEY));
|
||||
assert_eq!(claims["video"]["roomJoin"].as_bool(), Some(true));
|
||||
- assert_eq!(claims["video"]["canPublish"].as_bool(), Some(false));
|
||||
+ assert_eq!(claims["video"]["canPublish"].as_bool(), Some(true));
|
||||
assert_eq!(claims["video"]["canSubscribe"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
claims["sub"].as_str(),
|
||||
diff --git a/src/handler.rs b/src/handler.rs
|
||||
index 2797194..7910e1e 100644
|
||||
--- a/src/handler.rs
|
||||
+++ b/src/handler.rs
|
||||
@@ -978,7 +978,11 @@ impl Handler {
|
||||
&lk_room_alias,
|
||||
&lk_identity,
|
||||
&matrix_id,
|
||||
- is_full_access_user,
|
||||
+ // Publication is an authenticated participation permission, not a
|
||||
+ // room-creation privilege. OpenID and (when configured) membership
|
||||
+ // checks above must still succeed. Keep creation/delegation gated
|
||||
+ // below and LiveKit room.auto_create=false on the SFU.
|
||||
+ true,
|
||||
)?;
|
||||
|
||||
if is_full_access_user {
|
||||
@@ -1058,7 +1062,11 @@ impl Handler {
|
||||
&lk_room_alias,
|
||||
&lk_identity,
|
||||
&matrix_id,
|
||||
- is_full_access_user,
|
||||
+ // Publication is an authenticated participation permission, not a
|
||||
+ // room-creation privilege. OpenID and (when configured) membership
|
||||
+ // checks above must still succeed. Keep creation/delegation gated
|
||||
+ // below and LiveKit room.auto_create=false on the SFU.
|
||||
+ true,
|
||||
)?;
|
||||
|
||||
if is_full_access_user {
|
||||
diff --git a/src/handler_tests.rs b/src/handler_tests.rs
|
||||
index 54d1650..e6c943c 100644
|
||||
--- a/src/handler_tests.rs
|
||||
+++ b/src/handler_tests.rs
|
||||
@@ -5403,3 +5403,87 @@ async fn test_membership_skipped_unless_hs_is_the_only_full_access_homeserver()
|
||||
handler.close().await;
|
||||
}
|
||||
}
|
||||
+
|
||||
+// iscg standalone publication policy regression tests. The full-access list
|
||||
+// governs room creation/delegation, never whether a verified participant speaks.
|
||||
+#[tokio::test]
|
||||
+async fn test_iscg_standalone_publication_does_not_grant_creation() {
|
||||
+ for path in ["/sfu/get", "/get_token"] {
|
||||
+ for server in ["example.com", "astr.to", "unrelated.example"] {
|
||||
+ let sub = format!("@user:{server}");
|
||||
+ let created = Arc::new(AtomicUsize::new(0));
|
||||
+ let counter = created.clone();
|
||||
+ let deps = HandlerTestDeps {
|
||||
+ exchange_openid_userinfo_fn: exchange_openid_userinfo_ok(&sub),
|
||||
+ create_livekit_room_fn: Some(Box::new(move |_, _, _| {
|
||||
+ counter.fetch_add(1, Ordering::SeqCst);
|
||||
+ Ok(())
|
||||
+ })),
|
||||
+ ..Default::default()
|
||||
+ };
|
||||
+ let handler = new_get_token_handler(deps);
|
||||
+ let mut req = valid_sfu_request();
|
||||
+ req.openid_token.matrix_server_name = server.into();
|
||||
+ req.member.claimed_user_id = sub.clone();
|
||||
+ let body = if path == "/get_token" {
|
||||
+ serde_json::to_string(&req).unwrap()
|
||||
+ } else {
|
||||
+ serde_json::to_string(&LegacySfuRequest {
|
||||
+ room: req.room_id.clone(),
|
||||
+ openid_token: req.openid_token.clone(),
|
||||
+ device_id: req.member.claimed_device_id.clone(),
|
||||
+ ..Default::default()
|
||||
+ }).unwrap()
|
||||
+ };
|
||||
+ let resp = send_request(&handler, post_json(path, body)).await;
|
||||
+ assert_eq!(resp.status(), StatusCode::OK, "{path} {server}");
|
||||
+ let result: SfuResponse = serde_json::from_slice(&body_bytes(resp).await).unwrap();
|
||||
+ let claims = parse_jwt_claims(&result.jwt, &handler.livekit_auth.secret);
|
||||
+ assert_eq!(claims["video"]["canPublish"], true, "{path} {server}");
|
||||
+ assert_eq!(claims["video"]["canSubscribe"], true);
|
||||
+ assert_eq!(claims["video"]["roomJoin"], true);
|
||||
+ for privilege in ["roomCreate", "roomAdmin", "roomList", "roomRecord", "ingressAdmin"] {
|
||||
+ assert_ne!(claims["video"][privilege], true, "{path}: {privilege}");
|
||||
+ }
|
||||
+ let want_room = livekit_room_alias_for(&req.room_id, &req.slot_id);
|
||||
+ assert_eq!(claims["video"]["room"], want_room.0);
|
||||
+ assert_eq!(created.load(Ordering::SeqCst), usize::from(server == "example.com"));
|
||||
+ assert_eq!(handler.is_full_access_user(server), server == "example.com");
|
||||
+ handler.close().await;
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[tokio::test]
|
||||
+async fn test_iscg_publication_still_requires_valid_openid() {
|
||||
+ for path in ["/sfu/get", "/get_token"] {
|
||||
+ for forged_subject in [false, true] {
|
||||
+ let deps = HandlerTestDeps {
|
||||
+ exchange_openid_userinfo_fn: if forged_subject {
|
||||
+ exchange_openid_userinfo_ok("@victim:example.com")
|
||||
+ } else {
|
||||
+ Some(Box::new(|_| Err("invalid or expired token".into())))
|
||||
+ },
|
||||
+ // An accidental room creation or membership request panics.
|
||||
+ ..Default::default()
|
||||
+ };
|
||||
+ let handler = new_get_token_handler(deps);
|
||||
+ let mut req = valid_sfu_request();
|
||||
+ req.openid_token.matrix_server_name = "astr.to".into();
|
||||
+ req.member.claimed_user_id = "@user:astr.to".into();
|
||||
+ let body = if path == "/get_token" {
|
||||
+ serde_json::to_string(&req).unwrap()
|
||||
+ } else {
|
||||
+ serde_json::to_string(&LegacySfuRequest {
|
||||
+ room: req.room_id,
|
||||
+ openid_token: req.openid_token,
|
||||
+ device_id: "DEVICE".into(),
|
||||
+ ..Default::default()
|
||||
+ }).unwrap()
|
||||
+ };
|
||||
+ let resp = send_request(&handler, post_json(path, body)).await;
|
||||
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{path} forged={forged_subject}");
|
||||
+ handler.close().await;
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
diff --git a/src/main.rs b/src/main.rs
|
||||
index 2a057d6..7e7ebfd 100644
|
||||
--- a/src/main.rs
|
||||
+++ b/src/main.rs
|
||||
@@ -49,6 +49,10 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
+ info!(
|
||||
+ "iscg standalone policy v1: verified users may publish; room creation and delayed-leave delegation remain full-access only"
|
||||
+ );
|
||||
+
|
||||
let config = match parse_config() {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
Loading…
Add table
Add a link
Reference in a new issue