From 690ed45f66f3a79a73ee48dbd176e1f51b77b315 Mon Sep 17 00:00:00 2001 From: Piotr Dobrowolski Date: Sat, 7 May 2022 11:27:24 +0200 Subject: [PATCH] app/matrix: add coturn deployment TURN server is required for proper cross-NAT voice/video calls via Matrix. Change-Id: I8182292dd8ef30690ae4b9487c22aedcff098710 Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1387 Reviewed-by: informatic --- app/matrix/lib/coturn.libsonnet | 177 +++++++++++++++++++++++ app/matrix/lib/matrix-ng.libsonnet | 30 ++++ app/matrix/lib/synapse.libsonnet | 10 ++ app/matrix/matrix.hackerspace.pl.jsonnet | 7 + 4 files changed, 224 insertions(+) create mode 100644 app/matrix/lib/coturn.libsonnet diff --git a/app/matrix/lib/coturn.libsonnet b/app/matrix/lib/coturn.libsonnet new file mode 100644 index 00000000..f4fef24b --- /dev/null +++ b/app/matrix/lib/coturn.libsonnet @@ -0,0 +1,177 @@ +local kube = import "../../../kube/kube.libsonnet"; + +{ + local app = self, + local cfg = app.cfg, + cfg:: { + image: error "cfg.image must be set", + realm: error "cfg.realm must be set", + authSecret: error "cfg.authSecret must be set", + storageClassName: error "cfg.storageClassName must be set", + + portStart: 49152, + portEnd: 49172, + loadBalancerIP: null, + }, + + ns:: error "ns needs to be provided", + + configMap: app.ns.Contain(kube.ConfigMap("coturn")) { + data: { + "coturn.conf": ||| + # VoIP traffic is all UDP. There is no reason to let users connect to arbitrary TCP endpoints via the relay. + no-tcp-relay + + no-tls + no-dtls + + # don't let the relay ever try to connect to private IP address ranges within your network (if any) + # given the turn server is likely behind your firewall, remember to include any privileged public IPs too. + denied-peer-ip=10.0.0.0-10.255.255.255 + denied-peer-ip=192.168.0.0-192.168.255.255 + denied-peer-ip=172.16.0.0-172.31.255.255 + + # recommended additional local peers to block, to mitigate external access to internal services. + # https://www.rtcsec.com/article/slack-webrtc-turn-compromise-and-bug-bounty/#how-to-fix-an-open-turn-relay-to-address-this-vulnerability + no-multicast-peers + denied-peer-ip=0.0.0.0-0.255.255.255 + denied-peer-ip=100.64.0.0-100.127.255.255 + denied-peer-ip=127.0.0.0-127.255.255.255 + denied-peer-ip=169.254.0.0-169.254.255.255 + denied-peer-ip=192.0.0.0-192.0.0.255 + denied-peer-ip=192.0.2.0-192.0.2.255 + denied-peer-ip=192.88.99.0-192.88.99.255 + denied-peer-ip=198.18.0.0-198.19.255.255 + denied-peer-ip=198.51.100.0-198.51.100.255 + denied-peer-ip=203.0.113.0-203.0.113.255 + denied-peer-ip=240.0.0.0-255.255.255.255 + + # special case the turn server itself so that client->TURN->TURN->client flows work + # this should be one of the turn server's listening IPs + # FIXME allowed-peer-ip=10.0.0.1 + + # consider whether you want to limit the quota of relayed streams per user (or total) to avoid risk of DoS. + user-quota=12 # 4 streams per video call, so 12 streams = 3 simultaneous relayed calls per user. + total-quota=1200 + + use-auth-secret + |||, + }, + }, + + dataVolume: app.ns.Contain(kube.PersistentVolumeClaim("coturn-data")) { + spec+: { + storageClassName: cfg.storageClassName, + resources: { + requests: { + storage: "10Gi", + }, + }, + }, + }, + + deployment: app.ns.Contain(kube.Deployment("coturn")) { + spec+: { + replicas: 1, + template+: { + spec+: { + volumes_: { + config: kube.ConfigMapVolume(app.configMap), + data: kube.PersistentVolumeClaimVolume(app.dataVolume), + }, + containers_: { + coturn: kube.Container("coturn") { + image: cfg.image, + ports_: { + turn: { containerPort: 3478 }, + } + { + ["fwd-%d" % [n]]: { containerPort: n } + for n in std.range(cfg.portStart, cfg.portEnd) + }, + + command: [ + # This disgusting hack comes from the fact that + # official coturn containers have turnserver + # binary set up with CAP_NET_BIND_SERVICE=+ep, + # while there's really no use that in our case. + # + # Due to our PSP we can't exec said binary, nor + # can we chmod/chown/setcap on it, as we are + # running as an unprivileged user. + # + # Copying it over is the easiest method of + # stripping said spurious cap. + "/bin/sh", "-c", + "cp /usr/bin/turnserver /tmp/turnserver && \\ + exec /tmp/turnserver \\ + -c /config/coturn.conf \\ + --log-binding \\ + --realm=$(COTURN_REALM) \\ + --static-auth-secret=$(COTRN_STATIC_AUTH_SECRET) \\ + --min-port $(COTURN_MIN_PORT) \\ + --max-port $(COTURN_MAX_PORT) \\ + " + if cfg.loadBalancerIP != null then "-X $(COTURN_EXTERNAL_IP)" else "", + ], + volumeMounts_: { + config: { mountPath: "/config" }, + data: { mountPath: "/var/lib/coturn" }, + }, + env_: { + COTURN_REALM: cfg.realm, + COTURN_STATIC_AUTH_SECRET: cfg.authSecret, + COTURN_EXTERNAL_IP: cfg.loadBalancerIP, + COTURN_MIN_PORT: cfg.portStart, + COTURN_MAX_PORT: cfg.portEnd, + }, + }, + }, + securityContext: { + runAsUser: 1000, + runAsGroup: 1000, + fsGroup: 2000, + }, + }, + }, + }, + }, + + svcTCP: app.ns.Contain(kube.Service("coturn-tcp")) { + target_pod:: app.deployment.spec.template, + metadata+: { + annotations+: { + "metallb.universe.tf/allow-shared-ip": "coturn", + }, + }, + spec+: { + type: "LoadBalancer", + loadBalancerIP: cfg.loadBalancerIP, + externalTrafficPolicy: "Local", + ports: [ + { name: "turn", port: 3478, protocol: "TCP" }, + ] + [ + { name: "fwd-%d" % [n], port: n, protocol: "TCP" } + for n in std.range(cfg.portStart, cfg.portEnd) + ], + }, + }, + + svcUDP: app.ns.Contain(kube.Service("coturn-udp")) { + target_pod:: app.deployment.spec.template, + metadata+: { + annotations+: { + "metallb.universe.tf/allow-shared-ip": "coturn", + }, + }, + spec+: { + type: "LoadBalancer", + loadBalancerIP: cfg.loadBalancerIP, + externalTrafficPolicy: "Local", + ports: [ + { name: "turn", port: 3478, protocol: "UDP" }, + ] + [ + { name: "fwd-%d" % [n], port: n, protocol: "UDP" } + for n in std.range(cfg.portStart, cfg.portEnd) + ], + }, + }, +} diff --git a/app/matrix/lib/matrix-ng.libsonnet b/app/matrix/lib/matrix-ng.libsonnet index 0b60f105..17ad7513 100644 --- a/app/matrix/lib/matrix-ng.libsonnet +++ b/app/matrix/lib/matrix-ng.libsonnet @@ -95,6 +95,7 @@ local cas = import "./cas.libsonnet"; local wellKnown = import "./wellknown.libsonnet"; local synapse = import "./synapse.libsonnet"; local mediaRepo = import "./media-repo.libsonnet"; +local coturn = import "./coturn.libsonnet"; { local app = self, @@ -115,6 +116,7 @@ local mediaRepo = import "./media-repo.libsonnet"; appserviceTelegram: "dock.mau.dev/tulir/mautrix-telegram@sha256:c6e25cb57e1b67027069e8dc2627338df35d156315c004a6f2b34b6aeaa79f77", wellKnown: "registry.k0.hswaw.net/q3k/wellknown:1611960794-adbf560851a46ad0e58b42f0daad7ef19535687c", mediaRepo: "turt2live/matrix-media-repo:v1.2.8", + coturn: "coturn/coturn:4.5.2-r11-alpine", }, # OpenID Connect provider configuration. @@ -193,6 +195,23 @@ local mediaRepo = import "./media-repo.libsonnet"; # List of administrative users MXIDs (used in matrix-media-repo only) admins: [], + + # Deploy coturn STUN/TURN server + coturn: { + enable: false, + config: { + domain: error "coturn.config.domain must be set", + + # Default to public domain - this may be adjusted when multiple + # turn servers are deployed. + realm: self.domain, + + # Set this to assigned LoadBalacer IP for correct NAT resolution + loadBalancerIP: null, + + authSecret: { secretKeyRef: { name: "coturn", key: "auth_secret" } }, + }, + }, }, # DEPRECATED: this needs to be removed in favor of namespace.Contain() in @@ -279,6 +298,17 @@ local mediaRepo = import "./media-repo.libsonnet"; }, } else {}, + coturn: if cfg.coturn.enable then coturn { + ns: app.namespace, + cfg+: { + storageClassName: cfg.storageClassName, + image: cfg.images.coturn, + realm: cfg.coturn.config.realm, + loadBalancerIP: cfg.coturn.config.loadBalancerIP, + authSecret: cfg.coturn.config.authSecret, + }, + } else null, + synapse: synapse { ns: app.namespace, postgres: app.postgres3, diff --git a/app/matrix/lib/synapse.libsonnet b/app/matrix/lib/synapse.libsonnet index 71d03d72..0b05795d 100644 --- a/app/matrix/lib/synapse.libsonnet +++ b/app/matrix/lib/synapse.libsonnet @@ -69,6 +69,13 @@ local kube = import "../../../kube/kube.libsonnet"; server_url: "https://%s/_cas" % [cfg.webDomain], service_url: "https://%s" % [cfg.webDomain], }, + } else {}) + (if cfg.coturn.enable then { + turn_uris: [ "turn:%s?transport=udp" % cfg.coturn.config.domain, "turn:%s?transport=tcp" % cfg.coturn.config.domain ], + + # Lifetime of single TURN user credentials - 1 day, recommended by TURN REST + # spec, see https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00#section-2.2 + turn_user_lifetime: 24 * 60 * 60 * 1000, + turn_allow_guests: true, } else {}), configMap: app.ns.Contain(kube.ConfigMap("synapse")) { @@ -87,6 +94,8 @@ local kube = import "../../../kube/kube.libsonnet"; enabled: true, client_secret: "$(OIDC_CLIENT_SECRET)", }, + } else {}) + (if cfg.coturn.enable then { + turn_shared_secret: "$(TURN_SHARED_SECRET)", } else {}), # Synapse process Deployment/StatefulSet base resource. @@ -151,6 +160,7 @@ local kube = import "../../../kube/kube.libsonnet"; REDIS_PASSWORD: app.redis.cfg.password, POD_NAME: { fieldRef: { fieldPath: "metadata.name" } }, OIDC_CLIENT_SECRET: if cfg.oidc.enable then cfg.oidc.config.client_secret else "", + TURN_SHARED_SECRET: if cfg.coturn.enable then cfg.coturn.config.authSecret else "", X_SECRETS_CONFIG: std.manifestYamlDoc(app.secretsConfig), X_LOCAL_CONFIG: std.manifestYamlDoc(worker.cfg.localConfig), diff --git a/app/matrix/matrix.hackerspace.pl.jsonnet b/app/matrix/matrix.hackerspace.pl.jsonnet index 14a03668..4199e0e4 100644 --- a/app/matrix/matrix.hackerspace.pl.jsonnet +++ b/app/matrix/matrix.hackerspace.pl.jsonnet @@ -36,6 +36,13 @@ matrix { password: std.strReplace(importstr "secrets/plain/media-repo-matrix-postgres", "\n", ""), }, }, + coturn+: { + enable: true, + config+: { + domain: "turn.hackerspace.pl", + loadBalancerIP: "185.236.240.59", + }, + }, }, riot+: {