diff --git a/flake.nix b/flake.nix index 757108a..38af4d4 100644 --- a/flake.nix +++ b/flake.nix @@ -50,6 +50,11 @@ modules = [ ./hosts/vm/collabora ] ++ baseModules; }; + coturn = nixosSystem { + specialArgs = inputs; + modules = [ ./hosts/vm/coturn ] ++ baseModules; + }; + jitsi = nixosSystem { specialArgs = inputs; modules = [ ./hosts/vm/jitsi ] ++ baseModules; diff --git a/hosts/vm/coturn/coturn.nix b/hosts/vm/coturn/coturn.nix new file mode 100644 index 0000000..6f45b86 --- /dev/null +++ b/hosts/vm/coturn/coturn.nix @@ -0,0 +1,66 @@ +{ config, lib, ... }: + +{ + age.secrets = { + coturn_auth_secret = { + file = ../../../secrets/common/coturn_auth_secret.age; + mode = "440"; + owner = "turnserver"; + group = "turnserver"; + }; + }; + + # Taken from https://wiki.nixos.org/wiki/Matrix#Coturn_with_Synapse + services.coturn = rec { + enable = true; + no-cli = true; + no-tcp-relay = true; + min-port = 48000; + max-port = 50000; + use-auth-secret = true; + static-auth-secret-file = config.age.secrets.coturn_auth_secret.path; + secure-stun = true; + realm = "coturn.crans.org"; + cert = "${config.security.acme.certs.${realm}.directory}/full.pem"; + pkey = "${config.security.acme.certs.${realm}.directory}/key.pem"; + extraConfig = '' + # for debugging + verbose + # ban private IP ranges + no-multicast-peers + ''; + }; + + networking.firewall = + let + range = with config.services.coturn; lib.singleton { + from = min-port; + to = max-port; + }; + in + { + allowedUDPPortRanges = range; + allowedUDPPorts = [ 3478 5349 ]; + allowedTCPPortRanges = range; + allowedTCPPorts = [ 3478 5349 ]; + }; + + users.users.turnserver.extraGroups = [ "nginx" ]; + + security.acme.certs.${config.services.coturn.realm} = { + postRun = "systemctl restart coturn.service"; + }; + + services.nginx.virtualHosts = { + ${config.services.coturn.realm} = { + forceSSL = true; + enableACME = true; + }; + }; + + # services.matrix-synapse.settings = with config.services.coturn; { + # turn_uris = ["turn:${realm}:3478?transport=udp" "turn:${realm}:3478?transport=tcp"]; + # turn_shared_secret = static-auth-secret; + # turn_user_lifetime = "1h"; + # }; +} diff --git a/hosts/vm/coturn/default.nix b/hosts/vm/coturn/default.nix new file mode 100644 index 0000000..8ac2722 --- /dev/null +++ b/hosts/vm/coturn/default.nix @@ -0,0 +1,30 @@ +{ ... }: + +{ + imports = [ + # ./coturn.nix + # ./livekit.nix + ./hardware-configuration.nix + ]; + + networking.hostName = "coturn"; + boot.loader.grub.devices = [ "/dev/sda" ]; + + crans = { + enable = true; + + networking = { + id = 119; + srv = { + enable = true; + ipv4 = "185.230.79.19"; + }; + + services.acme.enable = true; + }; + + resticClient.when = "06:07"; + }; + + system.stateVersion = "26.05"; +} diff --git a/hosts/vm/coturn/hardware-configuration.nix b/hosts/vm/coturn/hardware-configuration.nix new file mode 100644 index 0000000..3782249 --- /dev/null +++ b/hosts/vm/coturn/hardware-configuration.nix @@ -0,0 +1,45 @@ +# Do not modify this file! It was generated by ‘nixos-generate-config’ +# and may be overwritten by future invocations. Please make changes +# to /etc/nixos/configuration.nix instead. +{ + config, + lib, + pkgs, + modulesPath, + ... +}: + +{ + imports = [ + (modulesPath + "/profiles/qemu-guest.nix") + ]; + + boot.initrd.availableKernelModules = [ + "ata_piix" + "uhci_hcd" + "virtio_pci" + "virtio_scsi" + "sd_mod" + "sr_mod" + ]; + boot.initrd.kernelModules = [ ]; + boot.kernelModules = [ ]; + boot.extraModulePackages = [ ]; + + fileSystems."/" = { + device = "/dev/disk/by-uuid/e2406ee7-b3ca-4fe0-8b4b-e4a38a1149ca"; + fsType = "ext4"; + }; + + swapDevices = [ ]; + + # Enables DHCP on each ethernet and wireless interface. In case of scripted networking + # (the default) this is the recommended approach. When using systemd-networkd it's + # still possible to use this option, but it's recommended to use it in conjunction + # with explicit per-interface declarations with `networking.interfaces..useDHCP`. + networking.useDHCP = lib.mkDefault true; + # networking.interfaces.ens18.useDHCP = lib.mkDefault true; + # networking.interfaces.ens19.useDHCP = lib.mkDefault true; + + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; +} diff --git a/hosts/vm/coturn/livekit.nix b/hosts/vm/coturn/livekit.nix new file mode 100644 index 0000000..64c2b72 --- /dev/null +++ b/hosts/vm/coturn/livekit.nix @@ -0,0 +1,122 @@ +{ config, pkgs, lib, ... }: + +let + keyFile = "/run/livekit.key"; + domain = "livekit.crans.org"; +in +{ + services.livekit' = { + enable = true; + + openFirewall = true; + + settings = { + port = 8100; + + room.auto_create = true; + + rtc = { + use_external_ip = false; + node_ip = config.crans.networking.srv.ipv4; + tcp_port = 8101; + port_range_start = 50000; + port_range_end = 52000; + enable_loopback_candidate = false; + turn_servers = [ + { + host = config.services.coturn.realm; + port = 3478; + protocol = "tcp"; + secretFile = config.age.secrets.coturn_auth_secret.path; + } + { + host = config.services.coturn.realm; + port = 3478; + protocol = "udp"; + secretFile = config.age.secrets.coturn_auth_secret.path; + } + { + host = config.services.coturn.realm; + port = 5349; + protocol = "tls"; + secretFile = config.age.secrets.coturn_auth_secret.path; + } + ]; + }; + + turn.enabled = false; + }; + + inherit keyFile; + }; + + networking.firewall = + let + range = with config.services.livekit'.settings.rtc; lib.singleton { + from = port_range_start; + to = port_range_end; + }; + in + { + allowedUDPPortRanges = range; + allowedUDPPorts = [ 8100 8101 8102 ]; + allowedTCPPortRanges = range; + allowedTCPPorts = [ 8100 8101 8102 ]; + }; + + users.users.livekit.extraGroups = [ "turnserver" ]; + + services.lk-jwt-service = { + enable = true; + port = 8102; + + livekitUrl = "wss://${domain}"; + + inherit keyFile; + }; + + systemd.services.livekit-key = { + before = [ + "lk-jwt-service.service" + "livekit.service" + ]; + + wantedBy = [ "multi-user.target" ]; + + path = with pkgs; [ + livekit + coreutils + gawk + ]; + + script = '' + echo "Key missing, generating key" + echo "lk-jwt-service: $(livekit-server generate-keys | tail -1 | awk '{print $3}')" > "${keyFile}" + ''; + serviceConfig.Type = "oneshot"; + unitConfig.ConditionPathExists = "!${keyFile}"; + }; + + systemd.services.lk-jwt-service.environment.LIVEKIT_FULL_ACCESS_HOMESERVERS = "crans.org"; + + services.nginx.virtualHosts."${domain}" = { + enableACME = true; + forceSSL = true; + + locations."~ /(get_token|healthz|sfu/get)" = { + proxyPass = "http://localhost:${toString config.services.lk-jwt-service.port}"; + }; + + locations."/" = { + proxyPass = "http://localhost:${toString config.services.livekit'.settings.port}"; + proxyWebsockets = true; + }; + }; + + age.secrets = { + autofocus-config = { + file = ../../../secrets/zora/services/autofocus-config.age; + mode = "444"; + }; + }; +} diff --git a/hosts/vm/neo/default.nix b/hosts/vm/neo/default.nix index f2f99ae..2c5951c 100644 --- a/hosts/vm/neo/default.nix +++ b/hosts/vm/neo/default.nix @@ -25,10 +25,7 @@ resticClient.when = "04:56"; - services = { - acme.enable = true; - coturn.enable = true; - }; + services.acme.enable = true; }; system.stateVersion = "24.11"; diff --git a/hosts/vm/neo/matrix.nix b/hosts/vm/neo/matrix.nix index feb3b62..9a4f24e 100644 --- a/hosts/vm/neo/matrix.nix +++ b/hosts/vm/neo/matrix.nix @@ -1,5 +1,8 @@ { config, ... }: +let + turn_server = "coturn.crans.org"; +in { age.secrets = { ldap_synapse_password = { diff --git a/modules/services/coturn.nix b/modules/services/coturn.nix deleted file mode 100644 index e1bc36e..0000000 --- a/modules/services/coturn.nix +++ /dev/null @@ -1,100 +0,0 @@ -{ config, lib, ... }: - -let - cfg = config.crans.services.coturn; - - inherit (lib) - mkEnableOption - mkOption - mkIf - types - ; -in - -{ - options.crans.services.coturn = { - enable = mkEnableOption "Coturn, un serveur TURN open-source."; - - secretFile = mkOption { - type = types.path; - default = config.age.secrets.coturn_auth_secret.path; - description = "Fichier contenant le secret de configuration du serveur."; - }; - - fqdn = mkOption { - type = types.str; - default = "crans.org"; - description = "Domaine pour lequel le serveur coturn est configuré."; - }; - - certFile = mkOption { - type = types.path; - default = "/var/lib/acme/${cfg.fqdn}/full.pem"; - description = "Fichier contenant le certificat associé au FQDN."; - }; - - keyFile = mkOption { - type = types.path; - default = "/var/lib/acme/${cfg.fqdn}/key.pem"; - description = "Fichier contenant la clef associé au FQDN."; - }; - }; - - config = mkIf cfg.enable { - services.coturn = { - enable = true; - no-cli = true; - no-tcp-relay = true; - min-port = 49000; - max-port = 50000; - use-auth-secret = true; - static-auth-secret-file = cfg.secretFile; - realm = cfg.fqdn; - cert = cfg.certFile; - pkey = cfg.keyFile; - extraConfig = '' - verbose - no-multicast-peers - denied-peer-ip=0.0.0.0-0.255.255.255 - denied-peer-ip=10.0.0.0-10.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=172.16.0.0-172.31.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=192.168.0.0-192.168.255.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 - denied-peer-ip=::1 - denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff - denied-peer-ip=::ffff:0.0.0.0-::ffff:255.255.255.255 - denied-peer-ip=100::-100::ffff:ffff:ffff:ffff - denied-peer-ip=2001::-2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff - denied-peer-ip=2002::-2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff - denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff - denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff - ''; - }; - - networking.firewall = { - allowedTCPPorts = [ - 3478 - 5349 - ]; - allowedUDPPorts = [ - 3478 - 5349 - ]; - allowedUDPPortRanges = [ - { - from = config.services.coturn.min-port; - to = config.services.coturn.max-port; - } - ]; - }; - }; -} diff --git a/modules/services/default.nix b/modules/services/default.nix index 5c91cc2..d179638 100644 --- a/modules/services/default.nix +++ b/modules/services/default.nix @@ -3,7 +3,7 @@ { imports = [ ./acme.nix - ./coturn.nix + ./livekit.nix ./nginx.nix ./restic.nix ./reverseproxy.nix diff --git a/modules/services/livekit.nix b/modules/services/livekit.nix new file mode 100644 index 0000000..8929bfe --- /dev/null +++ b/modules/services/livekit.nix @@ -0,0 +1,253 @@ + +{ + config, + lib, + pkgs, + utils, + ... +}: +let + cfg = config.services.livekit'; + format = pkgs.formats.json { }; + settings = lib.filterAttrsRecursive (_: v: v != null) cfg.settings; + + isLocallyDistributed = config.services.livekit.ingress.enable; +in +{ + options.services.livekit' = { + enable = lib.mkEnableOption "the livekit server"; + package = lib.mkPackageOption pkgs "livekit" { }; + + keyFile = lib.mkOption { + type = lib.types.path; + description = '' + LiveKit key file holding one or multiple application secrets. Use `livekit-server generate-keys` to generate a random key name and secret. + + The file should have the format `: `. + Example: + `lk-jwt-service: f6lQGaHtM5HfgZjIcec3cOCRfiDqIine4CpZZnqdT5cE` + + Individual key/secret pairs need to be passed to clients to connect to this instance. + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Opens port range for LiveKit on the firewall."; + }; + + redis = { + createLocally = lib.mkOption { + type = lib.types.bool; + default = isLocallyDistributed; + defaultText = "true if any other Livekit component is enabled locally else false"; + description = "Whether to set up a local redis instance."; + }; + + host = lib.mkOption { + type = with lib.types; nullOr str; + default = if cfg.redis.createLocally then "127.0.0.1" else null; + defaultText = "127.0.0.1 if config.services.livekit.redis.createLocally else null"; + description = '' + Address to bind local redis instance to. + ''; + }; + + port = lib.mkOption { + type = with lib.types; nullOr port; + default = null; + description = '' + Port to bind local redis instance to. + ''; + }; + }; + + settings = lib.mkOption { + type = lib.types.submodule { + freeformType = format.type; + options = { + port = lib.mkOption { + type = lib.types.port; + default = 7880; + description = "Main TCP port for RoomService and RTC endpoint."; + }; + + redis = { + address = lib.mkOption { + type = with lib.types; nullOr str; + default = if isLocallyDistributed then "${cfg.redis.host}:${toString cfg.redis.port}" else null; + defaultText = lib.literalExpression "Local Redis host/port when a local ingress component is enabled else null"; + example = "redis.example.com:6379"; + description = "Host and port used to connect to a redis instance."; + }; + }; + + rtc = { + port_range_start = lib.mkOption { + type = lib.types.port; + default = 50000; + description = "Start of UDP port range for WebRTC"; + }; + + port_range_end = lib.mkOption { + type = lib.types.port; + default = 51000; + description = "End of UDP port range for WebRTC"; + }; + + use_external_ip = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + When set to true, attempts to discover the host's public IP via STUN. + This is useful for cloud environments such as AWS & Google where hosts have an internal IP that maps to an external one. + ''; + }; + + turn_servers = lib.mkOption { + type = lib.types.listOf (lib.types.submodule { + freeformType = format.type; + options = { + secretFile = lib.mkOption { + type = lib.types.path; + description = '' + Path to a file containing the shared secret of a coturn server. + ''; + }; + }; + }); + default = []; + example = [ + { + host = "coturn.example.com"; + port = 443; + protocol = "tls"; + secretFile = "/run/turn_secret"; + } + ]; + description = '' + An attribute set of turn servers. + ''; + }; + }; + }; + }; + default = { }; + description = '' + LiveKit configuration file expressed in nix. + + For an example configuration, see . + For all possible values, see . + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.redis.createLocally -> cfg.redis.port != null; + message = '' + When `services.livekit.redis.createLocally` is enabled `services.livekit.redis.port` must be configured. + ''; + } + ]; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ + cfg.settings.port + ]; + allowedUDPPortRanges = [ + { + from = cfg.settings.rtc.port_range_start; + to = cfg.settings.rtc.port_range_end; + } + ]; + }; + + # Provision a redis instance, when livekit-ingress (or later livekit-egress) are enabled on the same host + services.redis.servers.livekit = lib.mkIf cfg.redis.createLocally { + enable = true; + bind = cfg.redis.host; + port = cfg.redis.port; + }; + + systemd = { + tmpfiles.settings.livekit = { + "/run/livekit-config.json"."f" = { + mode = "600"; + user = "livekit"; + group = "livekit"; + }; + }; + + services.livekit = { + description = "LiveKit SFU server"; + documentation = [ "https://docs.livekit.io" ]; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + preStart = + let + file = "/run/livekit-config.json"; + in + '' + cat ${format.generate "livekit.json" settings} > ${file} + while $(cat ${file} | ${pkgs.jq}/bin/jq -e 'first((.rtc.turn_servers[]) | select(.secretFile != null)).secretFile != null') + do + secretFile=$(cat ${file} | ${pkgs.jq}/bin/jq -r 'first((.rtc.turn_servers[]) | select(.secretFile != null)).secretFile') + cat ${file} | ${pkgs.jq}/bin/jq "first((.rtc.turn_servers[]) | select(.secretFile != null)).secret = \"$(cat $secretFile)\" | del(first((.rtc.turn_servers[]) | select(.secretFile != null)).secretFile)" > ${file} + done + ''; + + serviceConfig = { + User = "livekit"; + Group = "livekit"; + LoadCredential = [ "livekit-secrets:${cfg.keyFile}" ]; + ExecStart = utils.escapeSystemdExecArgs [ + (lib.getExe cfg.package) + "--config=/run/livekit-config.json" + "--key-file=/run/credentials/livekit.service/livekit-secrets" + ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateUsers = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_NETLINK" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + ProtectHome = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + "~@resources" + ]; + Restart = "on-failure"; + RestartSec = 5; + UMask = "077"; + }; + }; + }; + + users = { + users.livekit = { + isSystemUser = true; + group = "livekit"; + }; + groups.livekit = { }; + }; + }; +} diff --git a/secrets.nix b/secrets.nix index b052873..fb15f71 100644 --- a/secrets.nix +++ b/secrets.nix @@ -38,6 +38,7 @@ let apprentix = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDCJV6jqQWEYuwi+OJ9r/4TbBN/cK9NvYWNiJhpFzcc7 root@apprentix"; cephiroth = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOsBGkhiu6l3jeo15cQHMu3dPyL025zXPV2ZH02EDYEt root@nixos"; collabora = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFa2D9fREtO2r2oIx6q9JAKFUHtxGbgEPMjkx09DQSU8 root@collabora"; + # coturn = ""; jitsi = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB6jVMIZ5y2oXX9HOkw7r5UUjw95MlFaFuu7FnEC0Q8z root@jitsi"; livre = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEVfKNokHG6ig32hhQxTep+fKFmKahlDClPrX/dP4/gb root@livre"; neo = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMGfSvxqC2PJYRrxJaivVDujwlwCZ6AwH8hOSA9ktZ1V root@neo"; @@ -55,6 +56,7 @@ let all = attrsets.mapAttrsToList (_: key: key) hosts; acme = [ + # hosts.coturn hosts.jitsi hosts.neo hosts.reverseproxy