Adding a coturn VM

coturn
Lyes Saadi 2026-08-02 17:42:41 +02:00
parent 5db5253d03
commit 12fb12ce6b
No known key found for this signature in database
GPG Key ID: 55A1D803917CF39A
11 changed files with 528 additions and 105 deletions

View File

@ -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;

View File

@ -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";
# };
}

View File

@ -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";
}

View File

@ -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.<interface>.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";
}

View File

@ -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";
};
};
}

View File

@ -25,10 +25,7 @@
resticClient.when = "04:56";
services = {
acme.enable = true;
coturn.enable = true;
};
services.acme.enable = true;
};
system.stateVersion = "24.11";

View File

@ -1,5 +1,8 @@
{ config, ... }:
let
turn_server = "coturn.crans.org";
in
{
age.secrets = {
ldap_synapse_password = {

View File

@ -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;
}
];
};
};
}

View File

@ -3,7 +3,7 @@
{
imports = [
./acme.nix
./coturn.nix
./livekit.nix
./nginx.nix
./restic.nix
./reverseproxy.nix

View File

@ -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 `<keyname>: <secret>`.
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 <https://docs.livekit.io/home/self-hosting/deployment/#configuration>.
For all possible values, see <https://github.com/livekit/livekit/blob/master/config-sample.yaml>.
'';
};
};
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 = { };
};
};
}

View File

@ -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