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" ];
|
service.ports = [ "127.0.0.1:7880:7880" "7881:7881/tcp" "7882:7882/udp" ];
|
||||||
};
|
};
|
||||||
matrix-rtc-jwt = {
|
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";
|
service.container_name = "matrix-rtc-jwt";
|
||||||
# Avoid public-IP hairpin NAT for same-host OpenID and SFU calls; TLS stays verified.
|
# Avoid public-IP hairpin NAT for same-host OpenID and SFU calls; TLS stays verified.
|
||||||
out.service.extra_hosts = [ "iscg.dev:host-gateway" ];
|
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