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