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 <informatic@hackerspace.pl>
diff --git a/app/matrix/lib/coturn.libsonnet b/app/matrix/lib/coturn.libsonnet
new file mode 100644
index 0000000..f4fef24
--- /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 0b60f10..17ad751 100644
--- a/app/matrix/lib/matrix-ng.libsonnet
+++ b/app/matrix/lib/matrix-ng.libsonnet
@@ -95,6 +95,7 @@
 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 @@
             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 @@
 
         # 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 @@
         },
     } 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 71d03d7..0b05795 100644
--- a/app/matrix/lib/synapse.libsonnet
+++ b/app/matrix/lib/synapse.libsonnet
@@ -69,6 +69,13 @@
             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 @@
             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 @@
                                 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 14a0366..4199e0e 100644
--- a/app/matrix/matrix.hackerspace.pl.jsonnet
+++ b/app/matrix/matrix.hackerspace.pl.jsonnet
@@ -36,6 +36,13 @@
                 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+: {