1{
2 config,
3 lib,
4 pkgs,
5 utils,
6 ...
7}:
8let
9 inherit (lib)
10 getExe
11 mapAttrs
12 mkEnableOption
13 mkIf
14 mkPackageOption
15 mkOption
16 types
17 optional
18 optionalString
19 ;
20
21 cfg = config.services.lasuite-meet;
22
23 pythonEnvironment = mapAttrs (
24 _: value:
25 if value == null then
26 "None"
27 else if value == true then
28 "True"
29 else if value == false then
30 "False"
31 else
32 toString value
33 ) cfg.settings;
34
35 commonServiceConfig = {
36 RuntimeDirectory = "lasuite-meet";
37 StateDirectory = "lasuite-meet";
38 WorkingDirectory = "/var/lib/lasuite-meet";
39
40 User = "lasuite-meet";
41 DynamicUser = true;
42 SupplementaryGroups = mkIf cfg.redis.createLocally [
43 config.services.redis.servers.lasuite-meet.group
44 ];
45 # hardening
46 AmbientCapabilities = "";
47 CapabilityBoundingSet = [ "" ];
48 DevicePolicy = "closed";
49 LockPersonality = true;
50 NoNewPrivileges = true;
51 PrivateDevices = true;
52 PrivateTmp = true;
53 PrivateUsers = true;
54 ProcSubset = "pid";
55 ProtectClock = true;
56 ProtectControlGroups = true;
57 ProtectHome = true;
58 ProtectHostname = true;
59 ProtectKernelLogs = true;
60 ProtectKernelModules = true;
61 ProtectKernelTunables = true;
62 ProtectProc = "invisible";
63 ProtectSystem = "strict";
64 RemoveIPC = true;
65 RestrictAddressFamilies = [
66 "AF_INET"
67 "AF_INET6"
68 "AF_UNIX"
69 ];
70 RestrictNamespaces = true;
71 RestrictRealtime = true;
72 RestrictSUIDSGID = true;
73 SystemCallArchitectures = "native";
74 MemoryDenyWriteExecute = true;
75 EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
76 UMask = "0077";
77 };
78in
79{
80 options.services.lasuite-meet = {
81 enable = mkEnableOption "SuiteNumérique Meet";
82
83 backendPackage = mkPackageOption pkgs "lasuite-meet" { };
84
85 frontendPackage = mkPackageOption pkgs "lasuite-meet-frontend" { };
86
87 bind = mkOption {
88 type = types.str;
89 default = "unix:/run/lasuite-meet/gunicorn.sock";
90 example = "127.0.0.1:8000";
91 description = ''
92 The path, host/port or file descriptior to bind the gunicorn socket to.
93
94 See <https://docs.gunicorn.org/en/stable/settings.html#bind> for possible options.
95 '';
96 };
97
98 enableNginx = mkEnableOption "enable and configure Nginx for reverse proxying" // {
99 default = true;
100 };
101
102 secretKeyPath = mkOption {
103 type = types.nullOr types.path;
104 default = null;
105 description = ''
106 Path to the Django secret key.
107
108 The key can be generated using:
109 ```
110 python3 -c 'import secrets; print(secrets.token_hex())'
111 ```
112
113 If not set, the secret key will be automatically generated.
114 '';
115 };
116
117 postgresql = {
118 createLocally = mkEnableOption "Configure local PostgreSQL database server for meet";
119 };
120
121 redis = {
122 createLocally = mkEnableOption "Configure local Redis cache server for meet";
123 };
124
125 livekit = {
126 enable = mkEnableOption "Configure local livekit server" // {
127 default = true;
128 };
129
130 openFirewall = mkEnableOption "Open firewall ports for livekit";
131
132 keyFile = mkOption {
133 type = lib.types.path;
134 description = ''
135 LiveKit key file holding one or multiple application secrets.
136 Use `livekit-server generate-keys` to generate a random key name and secret.
137
138 The file should have the YAML format `<keyname>: <secret>`.
139 Example:
140 `lasuite-meet: f6lQGaHtM5HfgZjIcec3cOCRfiDqIine4CpZZnqdT5cE`
141
142 Individual key/secret pairs need to be passed to clients to connect to this instance.
143 '';
144
145 };
146
147 settings = mkOption {
148 type = types.attrs;
149 default = { };
150 description = ''
151 Settings to pass to the livekit server.
152
153 See `services.livekit.settings` for more details.
154 '';
155 };
156 };
157
158 gunicorn = {
159 extraArgs = mkOption {
160 type = types.listOf types.str;
161 default = [
162 "--name=meet"
163 "--workers=3"
164 ];
165 description = ''
166 Extra arguments to pass to the gunicorn process.
167 '';
168 };
169 };
170
171 celery = {
172 extraArgs = mkOption {
173 type = types.listOf types.str;
174 default = [ ];
175 description = ''
176 Extra arguments to pass to the celery process.
177 '';
178 };
179 };
180
181 domain = mkOption {
182 type = types.str;
183 description = ''
184 Domain name of the meet instance.
185 '';
186 };
187
188 settings = mkOption {
189 type = types.submodule {
190 freeformType = types.attrsOf (
191 types.nullOr (
192 types.oneOf [
193 types.str
194 types.bool
195 types.path
196 types.int
197 ]
198 )
199 );
200
201 options = {
202 DJANGO_CONFIGURATION = mkOption {
203 type = types.str;
204 internal = true;
205 default = "Production";
206 description = "The configuration that Django will use";
207 };
208
209 DJANGO_SETTINGS_MODULE = mkOption {
210 type = types.str;
211 internal = true;
212 default = "meet.settings";
213 description = "The configuration module that Django will use";
214 };
215
216 DJANGO_SECRET_KEY_FILE = mkOption {
217 type = types.path;
218 default =
219 if cfg.secretKeyPath == null then "/var/lib/lasuite-meet/django_secret_key" else cfg.secretKeyPath;
220 description = "The path to the file containing Django's secret key";
221 };
222
223 DJANGO_DATA_DIR = mkOption {
224 type = types.path;
225 default = "/var/lib/lasuite-meet";
226 description = "Path to the data directory";
227 };
228
229 DJANGO_ALLOWED_HOSTS = mkOption {
230 type = types.str;
231 default = if cfg.enableNginx then "localhost,127.0.0.1,${cfg.domain}" else "";
232 defaultText = lib.literalExpression ''
233 if cfg.enableNginx then "localhost,127.0.0.1,''${cfg.domain}" else ""
234 '';
235 description = "Comma-separated list of hosts that are able to connect to the server";
236 };
237
238 DB_NAME = mkOption {
239 type = types.str;
240 default = "lasuite-meet";
241 description = "Name of the database";
242 };
243
244 DB_USER = mkOption {
245 type = types.str;
246 default = "lasuite-meet";
247 description = "User of the database";
248 };
249
250 DB_HOST = mkOption {
251 type = types.nullOr types.str;
252 default = if cfg.postgresql.createLocally then "/run/postgresql" else null;
253 description = "Host of the database";
254 };
255
256 REDIS_URL = mkOption {
257 type = types.nullOr types.str;
258 default =
259 if cfg.redis.createLocally then
260 "unix://${config.services.redis.servers.lasuite-meet.unixSocket}?db=0"
261 else
262 null;
263 description = "URL of the redis backend";
264 };
265
266 CELERY_BROKER_URL = mkOption {
267 type = types.nullOr types.str;
268 default =
269 if cfg.redis.createLocally then
270 "redis+socket://${config.services.redis.servers.lasuite-meet.unixSocket}?db=1"
271 else
272 null;
273 description = "URL of the redis backend for celery";
274 };
275
276 LIVEKIT_API_URL = mkOption {
277 type = types.nullOr types.str;
278 default = if cfg.enableNginx && cfg.livekit.enable then "http://${cfg.domain}/livekit" else null;
279 defaultText = lib.literalExpression ''
280 if cfg.enableNginx && cfg.livekit.enable then
281 "http://$${cfg.domain}/livekit"
282 else
283 null
284 '';
285 description = "URL to the livekit server";
286 };
287 };
288 };
289
290 default = { };
291 example = ''
292 {
293 DJANGO_ALLOWED_HOSTS = "*";
294 }
295 '';
296 description = ''
297 Configuration options of meet.
298 See https://github.com/suitenumerique/meet/blob/v${cfg.backendPackage.version}/docs/env.md
299 `REDIS_URL` and `CELERY_BROKER_URL` are set if `services.lasuite-meet.redis.createLocally` is true.
300 `DB_NAME` `DB_USER` and `DB_HOST` are set if `services.lasuite-meet.postgresql.createLocally` is true.
301 '';
302 };
303
304 environmentFile = mkOption {
305 type = types.nullOr types.path;
306 default = null;
307 description = ''
308 Path to environment file.
309
310 This can be useful to pass secrets to meet via tools like `agenix` or `sops`.
311 '';
312 };
313 };
314
315 config = mkIf cfg.enable {
316 systemd.services.lasuite-meet = {
317 description = "Meet from SuiteNumérique";
318 after = [
319 "network.target"
320 ]
321 ++ (optional cfg.postgresql.createLocally "postgresql.service")
322 ++ (optional cfg.redis.createLocally "redis-lasuite-meet.service");
323
324 wants =
325 (optional cfg.postgresql.createLocally "postgresql.service")
326 ++ (optional cfg.redis.createLocally "redis-lasuite-meet.service");
327
328 wantedBy = [ "multi-user.target" ];
329
330 preStart = ''
331 if [ ! -f .version ]; then
332 touch .version
333 fi
334
335 ${optionalString (cfg.secretKeyPath == null) ''
336 if [[ ! -f /var/lib/lasuite-meet/django_secret_key ]]; then
337 (
338 umask 0377
339 tr -dc A-Za-z0-9 < /dev/urandom | head -c64 | ${pkgs.moreutils}/bin/sponge /var/lib/lasuite-meet/django_secret_key
340 )
341 fi
342 ''}
343 if [ "${cfg.backendPackage.version}" != "$(cat .version)" ]; then
344 ${getExe cfg.backendPackage} migrate
345 echo -n "${cfg.backendPackage.version}" > .version
346 fi
347 '';
348
349 environment = pythonEnvironment;
350
351 serviceConfig = {
352 BindReadOnlyPaths = "${cfg.backendPackage}/share/static:/var/lib/lasuite-meet/static";
353
354 ExecStart = utils.escapeSystemdExecArgs (
355 [
356 (lib.getExe' cfg.backendPackage "gunicorn")
357 "--bind=${cfg.bind}"
358 ]
359 ++ cfg.gunicorn.extraArgs
360 ++ [ "meet.wsgi:application" ]
361 );
362 }
363 // commonServiceConfig;
364 };
365
366 systemd.services.lasuite-meet-celery = {
367 description = "Meet Celery broker from SuiteNumérique";
368 after = [
369 "network.target"
370 ]
371 ++ (optional cfg.postgresql.createLocally "postgresql.service")
372 ++ (optional cfg.redis.createLocally "redis-lasuite-meet.service");
373
374 wants =
375 (optional cfg.postgresql.createLocally "postgresql.service")
376 ++ (optional cfg.redis.createLocally "redis-lasuite-meet.service");
377
378 wantedBy = [ "multi-user.target" ];
379
380 environment = pythonEnvironment;
381
382 serviceConfig = {
383 ExecStart = utils.escapeSystemdExecArgs (
384 [ (lib.getExe' cfg.backendPackage "celery") ]
385 ++ cfg.celery.extraArgs
386 ++ [
387 "--app=meet.celery_app"
388 "worker"
389 ]
390 );
391 }
392 // commonServiceConfig;
393 };
394
395 services.postgresql = mkIf cfg.postgresql.createLocally {
396 enable = true;
397 ensureDatabases = [ "lasuite-meet" ];
398 ensureUsers = [
399 {
400 name = "lasuite-meet";
401 ensureDBOwnership = true;
402 }
403 ];
404 };
405
406 services.redis.servers.lasuite-meet = mkIf cfg.redis.createLocally { enable = true; };
407
408 services.livekit = mkIf cfg.livekit.enable {
409 inherit (cfg.livekit)
410 enable
411 settings
412 keyFile
413 openFirewall
414 ;
415 };
416
417 services.nginx = mkIf cfg.enableNginx {
418 enable = true;
419
420 virtualHosts.${cfg.domain} = {
421 root = cfg.frontendPackage;
422
423 extraConfig = ''
424 error_page 404 = /index.html;
425 '';
426
427 locations."/api" = {
428 proxyPass = "http://${cfg.bind}";
429 recommendedProxySettings = true;
430 };
431
432 locations."/admin" = {
433 proxyPass = "http://${cfg.bind}";
434 recommendedProxySettings = true;
435 };
436
437 locations."/static" = {
438 root = "${cfg.backendPackage}/share";
439 };
440
441 locations."/livekit" = mkIf cfg.livekit.enable {
442 proxyPass = "http://localhost:${toString config.services.livekit.settings.port}";
443 recommendedProxySettings = true;
444 proxyWebsockets = true;
445 extraConfig = ''
446 rewrite ^/livekit/(.*)$ /$1 break;
447 '';
448 };
449 };
450 };
451 };
452
453 meta = {
454 buildDocsInSandbox = false;
455 maintainers = [ lib.maintainers.soyouzpanda ];
456 };
457}