1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8with lib;
9
10let
11 cfg = config.services.syncplay;
12
13 cmdArgs =
14 [
15 "--port"
16 cfg.port
17 ]
18 ++ optionals (cfg.isolateRooms) [ "--isolate-rooms" ]
19 ++ optionals (!cfg.ready) [ "--disable-ready" ]
20 ++ optionals (!cfg.chat) [ "--disable-chat" ]
21 ++ optionals (cfg.salt != null) [
22 "--salt"
23 cfg.salt
24 ]
25 ++ optionals (cfg.motdFile != null) [
26 "--motd-file"
27 cfg.motdFile
28 ]
29 ++ optionals (cfg.roomsDBFile != null) [
30 "--rooms-db-file"
31 cfg.roomsDBFile
32 ]
33 ++ optionals (cfg.permanentRoomsFile != null) [
34 "--permanent-rooms-file"
35 cfg.permanentRoomsFile
36 ]
37 ++ [
38 "--max-chat-message-length"
39 cfg.maxChatMessageLength
40 ]
41 ++ [
42 "--max-username-length"
43 cfg.maxUsernameLength
44 ]
45 ++ optionals (cfg.statsDBFile != null) [
46 "--stats-db-file"
47 cfg.statsDBFile
48 ]
49 ++ optionals (cfg.certDir != null) [
50 "--tls"
51 cfg.certDir
52 ]
53 ++ optionals cfg.ipv4Only [ "--ipv4-only" ]
54 ++ optionals cfg.ipv6Only [ "--ipv6-only" ]
55 ++ optionals (cfg.interfaceIpv4 != "") [
56 "--interface-ipv4"
57 cfg.interfaceIpv4
58 ]
59 ++ optionals (cfg.interfaceIpv6 != "") [
60 "--interface-ipv6"
61 cfg.interfaceIpv6
62 ]
63 ++ cfg.extraArgs;
64
65 useACMEHostDir = optionalString (
66 cfg.useACMEHost != null
67 ) config.security.acme.certs.${cfg.useACMEHost}.directory;
68in
69{
70 imports = [
71 (mkRemovedOptionModule [ "services" "syncplay" "user" ]
72 "The syncplay service now uses DynamicUser, override the systemd unit settings if you need the old functionality."
73 )
74 (mkRemovedOptionModule [ "services" "syncplay" "group" ]
75 "The syncplay service now uses DynamicUser, override the systemd unit settings if you need the old functionality."
76 )
77 ];
78
79 options = {
80 services.syncplay = {
81 enable = mkOption {
82 type = types.bool;
83 default = false;
84 description = ''
85 If enabled, start the Syncplay server.
86 '';
87 };
88
89 port = mkOption {
90 type = types.port;
91 default = 8999;
92 description = ''
93 TCP port to bind to.
94 '';
95 };
96
97 passwordFile = mkOption {
98 type = types.nullOr types.path;
99 default = null;
100 description = ''
101 Path to the file that contains the server password. If
102 `null`, the server doesn't require a password.
103 '';
104 };
105
106 isolateRooms = mkOption {
107 type = types.bool;
108 default = false;
109 description = ''
110 Enable room isolation.
111 '';
112 };
113
114 ready = mkOption {
115 type = types.bool;
116 default = true;
117 description = ''
118 Check readiness of users.
119 '';
120 };
121
122 chat = mkOption {
123 type = types.bool;
124 default = true;
125 description = ''
126 Chat with users in the same room.
127 '';
128 };
129
130 salt = mkOption {
131 type = types.nullOr types.str;
132 default = null;
133 description = ''
134 Salt to allow room operator passwords generated by this server
135 instance to still work when the server is restarted. The salt will be
136 readable in the nix store and the processlist. If this is not
137 intended use `saltFile` instead. Mutually exclusive with
138 {option}`services.syncplay.saltFile`.
139 '';
140 };
141
142 saltFile = mkOption {
143 type = types.nullOr types.path;
144 default = null;
145 description = ''
146 Path to the file that contains the server salt. This allows room
147 operator passwords generated by this server instance to still work
148 when the server is restarted. `null`, the server doesn't load the
149 salt from a file. Mutually exclusive with
150 {option}`services.syncplay.salt`.
151 '';
152 };
153
154 motd = mkOption {
155 type = types.nullOr types.str;
156 default = null;
157 description = ''
158 Text to display when users join. The motd will be readable in the nix store
159 and the processlist. If this is not intended use `motdFile` instead.
160 Will be overriden by {option}`services.syncplay.motdFile`.
161 '';
162 };
163
164 motdFile = mkOption {
165 type = types.nullOr types.str;
166 default = if cfg.motd != null then (builtins.toFile "motd" cfg.motd) else null;
167 defaultText = literalExpression ''if services.syncplay.motd != null then (builtins.toFile "motd" services.syncplay.motd) else null'';
168 description = ''
169 Path to text to display when users join.
170 Will override {option}`services.syncplay.motd`.
171 '';
172 };
173
174 roomsDBFile = mkOption {
175 type = types.nullOr types.str;
176 default = null;
177 example = "rooms.db";
178 description = ''
179 Path to SQLite database file to store room states.
180 Relative to the working directory provided by systemd.
181 '';
182 };
183
184 permanentRooms = mkOption {
185 type = types.listOf types.str;
186 default = [ ];
187 description = ''
188 List of rooms that will be listed even if the room is empty.
189 Will be overriden by {option}`services.syncplay.permanentRoomsFile`.
190 '';
191 };
192
193 permanentRoomsFile = mkOption {
194 type = types.nullOr types.str;
195 default =
196 if cfg.permanentRooms != [ ] then
197 (builtins.toFile "perm" (builtins.concatStringsSep "\n" cfg.permanentRooms))
198 else
199 null;
200 defaultText = literalExpression ''if services.syncplay.permanentRooms != [ ] then (builtins.toFile "perm" (builtins.concatStringsSep "\n" services.syncplay.permanentRooms)) else null'';
201 description = ''
202 File with list of rooms that will be listed even if the room is empty,
203 newline delimited.
204 Will override {option}`services.syncplay.permanentRooms`.
205 '';
206 };
207
208 maxChatMessageLength = mkOption {
209 type = types.ints.unsigned;
210 default = 150;
211 description = ''
212 Maximum number of characters in a chat message.
213 '';
214 };
215
216 maxUsernameLength = mkOption {
217 type = types.ints.unsigned;
218 default = 16;
219 description = ''
220 Maximum number of characters in a username.
221 '';
222 };
223
224 statsDBFile = mkOption {
225 type = types.nullOr types.str;
226 default = null;
227 example = "stats.db";
228 description = ''
229 Path to SQLite database file to store stats.
230 Relative to the working directory provided by systemd.
231 '';
232 };
233
234 certDir = mkOption {
235 type = types.nullOr types.path;
236 default = null;
237 description = ''
238 TLS certificates directory to use for encryption. See
239 <https://github.com/Syncplay/syncplay/wiki/TLS-support>.
240 '';
241 };
242
243 useACMEHost = mkOption {
244 type = types.nullOr types.str;
245 default = null;
246 example = "syncplay.example.com";
247 description = ''
248 If set, use NixOS-generated ACME certificate with the specified name for TLS.
249
250 Note that it requires {option}`security.acme` to be setup, e.g., credentials provided if using DNS-01 validation.
251 '';
252 };
253
254 ipv4Only = mkOption {
255 type = types.bool;
256 default = false;
257 description = ''
258 Listen only on IPv4 when strting the server.
259 '';
260 };
261
262 ipv6Only = mkOption {
263 type = types.bool;
264 default = false;
265 description = ''
266 Listen only on IPv6 when strting the server.
267 '';
268 };
269
270 interfaceIpv4 = mkOption {
271 type = types.str;
272 default = "";
273 description = ''
274 The IP address to bind to for IPv4. Leaving it empty defaults to using all.
275 '';
276 };
277
278 interfaceIpv6 = mkOption {
279 type = types.str;
280 default = "";
281 description = ''
282 The IP address to bind to for IPv6. Leaving it empty defaults to using all.
283 '';
284 };
285
286 extraArgs = mkOption {
287 type = types.listOf types.str;
288 default = [ ];
289 description = ''
290 Additional arguments to be passed to the service.
291 '';
292 };
293
294 package = mkOption {
295 type = types.package;
296 default = pkgs.syncplay-nogui;
297 defaultText = literalExpression "pkgs.syncplay-nogui";
298 description = ''
299 Package to use for syncplay.
300 '';
301 };
302 };
303 };
304
305 config = mkIf cfg.enable {
306 assertions = [
307 {
308 assertion = cfg.salt == null || cfg.saltFile == null;
309 message = "services.syncplay.salt and services.syncplay.saltFile are mutually exclusive.";
310 }
311 {
312 assertion = cfg.certDir == null || cfg.useACMEHost == null;
313 message = "services.syncplay.certDir and services.syncplay.useACMEHost are mutually exclusive.";
314 }
315 {
316 assertion = !cfg.ipv4Only || !cfg.ipv6Only;
317 message = "services.syncplay.ipv4Only and services.syncplay.ipv6Only are mutually exclusive.";
318 }
319 ];
320
321 warnings =
322 optional (cfg.interfaceIpv4 != "" && cfg.ipv6Only)
323 "You have specified services.syncplay.interfaceIpv4 but IPv4 is disabled by services.syncplay.ipv6Only."
324 ++ optional (cfg.interfaceIpv6 != "" && cfg.ipv4Only)
325 "You have specified services.syncplay.interfaceIpv6 but IPv6 is disabled by services.syncplay.ipv4Only.";
326
327 security.acme.certs = mkIf (cfg.useACMEHost != null) {
328 "${cfg.useACMEHost}".reloadServices = [ "syncplay.service" ];
329 };
330
331 networking.firewall.allowedTCPPorts = [ cfg.port ];
332 systemd.services.syncplay = {
333 description = "Syncplay Service";
334 wantedBy = [ "multi-user.target" ];
335 wants = [ "network-online.target" ];
336 after = [ "network-online.target" ];
337
338 serviceConfig = {
339 DynamicUser = true;
340 StateDirectory = "syncplay";
341 WorkingDirectory = "%S/syncplay";
342 LoadCredential =
343 optional (cfg.passwordFile != null) "password:${cfg.passwordFile}"
344 ++ optional (cfg.saltFile != null) "salt:${cfg.saltFile}"
345 ++ optionals (cfg.useACMEHost != null) [
346 "cert.pem:${useACMEHostDir}/cert.pem"
347 "privkey.pem:${useACMEHostDir}/key.pem"
348 "chain.pem:${useACMEHostDir}/chain.pem"
349 ];
350 };
351
352 script = ''
353 ${optionalString (cfg.passwordFile != null) ''
354 export SYNCPLAY_PASSWORD=$(cat "''${CREDENTIALS_DIRECTORY}/password")
355 ''}
356 ${optionalString (cfg.saltFile != null) ''
357 export SYNCPLAY_SALT=$(cat "''${CREDENTIALS_DIRECTORY}/salt")
358 ''}
359 exec ${cfg.package}/bin/syncplay-server ${escapeShellArgs cmdArgs} ${
360 optionalString (cfg.useACMEHost != null) "--tls $CREDENTIALS_DIRECTORY"
361 }
362 '';
363 };
364 };
365}