1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.teeworlds;
9 register = cfg.register;
10
11 bool = b: if b != null && b then "1" else "0";
12 optionalSetting = s: setting: lib.optionalString (s != null) "${setting} ${s}";
13 lookup =
14 attrs: key: default:
15 if attrs ? key then attrs."${key}" else default;
16
17 inactivePenaltyOptions = {
18 "spectator" = "1";
19 "spectator/kick" = "2";
20 "kick" = "3";
21 };
22 skillLevelOptions = {
23 "casual" = "0";
24 "normal" = "1";
25 "competitive" = "2";
26 };
27 tournamentModeOptions = {
28 "disable" = "0";
29 "enable" = "1";
30 "restrictSpectators" = "2";
31 };
32
33 teeworldsConf = pkgs.writeText "teeworlds.cfg" ''
34 sv_port ${toString cfg.port}
35 sv_register ${bool cfg.register}
36 sv_name ${cfg.name}
37 ${optionalSetting cfg.motd "sv_motd"}
38 ${optionalSetting cfg.password "password"}
39 ${optionalSetting cfg.rconPassword "sv_rcon_password"}
40
41 ${optionalSetting cfg.server.bindAddr "bindaddr"}
42 ${optionalSetting cfg.server.hostName "sv_hostname"}
43 sv_high_bandwidth ${bool cfg.server.enableHighBandwidth}
44 sv_inactivekick ${lookup inactivePenaltyOptions cfg.server.inactivePenalty "spectator/kick"}
45 sv_inactivekick_spec ${bool cfg.server.kickInactiveSpectators}
46 sv_inactivekick_time ${toString cfg.server.inactiveTime}
47 sv_max_clients ${toString cfg.server.maxClients}
48 sv_max_clients_per_ip ${toString cfg.server.maxClientsPerIP}
49 sv_skill_level ${lookup skillLevelOptions cfg.server.skillLevel "normal"}
50 sv_spamprotection ${bool cfg.server.enableSpamProtection}
51
52 sv_gametype ${cfg.game.gameType}
53 sv_map ${cfg.game.map}
54 sv_match_swap ${bool cfg.game.swapTeams}
55 sv_player_ready_mode ${bool cfg.game.enableReadyMode}
56 sv_player_slots ${toString cfg.game.playerSlots}
57 sv_powerups ${bool cfg.game.enablePowerups}
58 sv_scorelimit ${toString cfg.game.scoreLimit}
59 sv_strict_spectate_mode ${bool cfg.game.restrictSpectators}
60 sv_teamdamage ${bool cfg.game.enableTeamDamage}
61 sv_timelimit ${toString cfg.game.timeLimit}
62 sv_tournament_mode ${lookup tournamentModeOptions cfg.server.tournamentMode "disable"}
63 sv_vote_kick ${bool cfg.game.enableVoteKick}
64 sv_vote_kick_bantime ${toString cfg.game.voteKickBanTime}
65 sv_vote_kick_min ${toString cfg.game.voteKickMinimumPlayers}
66
67 ${optionalSetting cfg.server.bindAddr "bindaddr"}
68 ${optionalSetting cfg.server.hostName "sv_hostname"}
69 sv_high_bandwidth ${bool cfg.server.enableHighBandwidth}
70 sv_inactivekick ${lookup inactivePenaltyOptions cfg.server.inactivePenalty "spectator/kick"}
71 sv_inactivekick_spec ${bool cfg.server.kickInactiveSpectators}
72 sv_inactivekick_time ${toString cfg.server.inactiveTime}
73 sv_max_clients ${toString cfg.server.maxClients}
74 sv_max_clients_per_ip ${toString cfg.server.maxClientsPerIP}
75 sv_skill_level ${lookup skillLevelOptions cfg.server.skillLevel "normal"}
76 sv_spamprotection ${bool cfg.server.enableSpamProtection}
77
78 sv_gametype ${cfg.game.gameType}
79 sv_map ${cfg.game.map}
80 sv_match_swap ${bool cfg.game.swapTeams}
81 sv_player_ready_mode ${bool cfg.game.enableReadyMode}
82 sv_player_slots ${toString cfg.game.playerSlots}
83 sv_powerups ${bool cfg.game.enablePowerups}
84 sv_scorelimit ${toString cfg.game.scoreLimit}
85 sv_strict_spectate_mode ${bool cfg.game.restrictSpectators}
86 sv_teamdamage ${bool cfg.game.enableTeamDamage}
87 sv_timelimit ${toString cfg.game.timeLimit}
88 sv_tournament_mode ${lookup tournamentModeOptions cfg.server.tournamentMode "disable"}
89 sv_vote_kick ${bool cfg.game.enableVoteKick}
90 sv_vote_kick_bantime ${toString cfg.game.voteKickBanTime}
91 sv_vote_kick_min ${toString cfg.game.voteKickMinimumPlayers}
92
93 ${lib.concatStringsSep "\n" cfg.extraOptions}
94 '';
95
96in
97{
98 options = {
99 services.teeworlds = {
100 enable = lib.mkEnableOption "Teeworlds Server";
101
102 package = lib.mkPackageOption pkgs "teeworlds-server" { };
103
104 openPorts = lib.mkOption {
105 type = lib.types.bool;
106 default = false;
107 description = "Whether to open firewall ports for Teeworlds.";
108 };
109
110 name = lib.mkOption {
111 type = lib.types.str;
112 default = "unnamed server";
113 description = ''
114 Name of the server.
115 '';
116 };
117
118 register = lib.mkOption {
119 type = lib.types.bool;
120 example = true;
121 default = false;
122 description = ''
123 Whether the server registers as a public server in the global server list. This is disabled by default for privacy reasons.
124 '';
125 };
126
127 motd = lib.mkOption {
128 type = lib.types.nullOr lib.types.str;
129 default = null;
130 description = ''
131 The server's message of the day text.
132 '';
133 };
134
135 password = lib.mkOption {
136 type = lib.types.nullOr lib.types.str;
137 default = null;
138 description = ''
139 Password to connect to the server.
140 '';
141 };
142
143 rconPassword = lib.mkOption {
144 type = lib.types.nullOr lib.types.str;
145 default = null;
146 description = ''
147 Password to access the remote console. If not set, a randomly generated one is displayed in the server log.
148 '';
149 };
150
151 port = lib.mkOption {
152 type = lib.types.port;
153 default = 8303;
154 description = ''
155 Port the server will listen on.
156 '';
157 };
158
159 extraOptions = lib.mkOption {
160 type = lib.types.listOf lib.types.str;
161 default = [ ];
162 description = ''
163 Extra configuration lines for the {file}`teeworlds.cfg`. See [Teeworlds Documentation](https://www.teeworlds.com/?page=docs&wiki=server_settings).
164 '';
165 example = [
166 "sv_map dm1"
167 "sv_gametype dm"
168 ];
169 };
170
171 server = {
172 bindAddr = lib.mkOption {
173 type = lib.types.nullOr lib.types.str;
174 default = null;
175 description = ''
176 The address the server will bind to.
177 '';
178 };
179
180 enableHighBandwidth = lib.mkOption {
181 type = lib.types.bool;
182 default = false;
183 description = ''
184 Whether to enable high bandwidth mode on LAN servers. This will double the amount of bandwidth required for running the server.
185 '';
186 };
187
188 hostName = lib.mkOption {
189 type = lib.types.nullOr lib.types.str;
190 default = null;
191 description = ''
192 Hostname for the server.
193 '';
194 };
195
196 inactivePenalty = lib.mkOption {
197 type = lib.types.enum [
198 "spectator"
199 "spectator/kick"
200 "kick"
201 ];
202 example = "spectator";
203 default = "spectator/kick";
204 description = ''
205 Specify what to do when a client goes inactive (see [](#opt-services.teeworlds.server.inactiveTime)).
206
207 - `spectator`: send the client into spectator mode
208
209 - `spectator/kick`: send the client into a free spectator slot, otherwise kick the client
210
211 - `kick`: kick the client
212 '';
213 };
214
215 kickInactiveSpectators = lib.mkOption {
216 type = lib.types.bool;
217 default = false;
218 description = ''
219 Whether to kick inactive spectators.
220 '';
221 };
222
223 inactiveTime = lib.mkOption {
224 type = lib.types.ints.unsigned;
225 default = 3;
226 description = ''
227 The amount of minutes a client has to idle before it is considered inactive.
228 '';
229 };
230
231 maxClients = lib.mkOption {
232 type = lib.types.ints.unsigned;
233 default = 12;
234 description = ''
235 The maximum amount of clients that can be connected to the server at the same time.
236 '';
237 };
238
239 maxClientsPerIP = lib.mkOption {
240 type = lib.types.ints.unsigned;
241 default = 12;
242 description = ''
243 The maximum amount of clients with the same IP address that can be connected to the server at the same time.
244 '';
245 };
246
247 skillLevel = lib.mkOption {
248 type = lib.types.enum [
249 "casual"
250 "normal"
251 "competitive"
252 ];
253 default = "normal";
254 description = ''
255 The skill level shown in the server browser.
256 '';
257 };
258
259 enableSpamProtection = lib.mkOption {
260 type = lib.types.bool;
261 default = true;
262 description = ''
263 Whether to enable chat spam protection.
264 '';
265 };
266 };
267
268 game = {
269 gameType = lib.mkOption {
270 type = lib.types.str;
271 example = "ctf";
272 default = "dm";
273 description = ''
274 The game type to use on the server.
275
276 The default gametypes are `dm`, `tdm`, `ctf`, `lms`, and `lts`.
277 '';
278 };
279
280 map = lib.mkOption {
281 type = lib.types.str;
282 example = "ctf5";
283 default = "dm1";
284 description = ''
285 The map to use on the server.
286 '';
287 };
288
289 swapTeams = lib.mkOption {
290 type = lib.types.bool;
291 default = true;
292 description = ''
293 Whether to swap teams each round.
294 '';
295 };
296
297 enableReadyMode = lib.mkOption {
298 type = lib.types.bool;
299 default = false;
300 description = ''
301 Whether to enable "ready mode"; where players can pause/unpause the game
302 and start the game in warmup, using their ready state.
303 '';
304 };
305
306 playerSlots = lib.mkOption {
307 type = lib.types.ints.unsigned;
308 default = 8;
309 description = ''
310 The amount of slots to reserve for players (as opposed to spectators).
311 '';
312 };
313
314 enablePowerups = lib.mkOption {
315 type = lib.types.bool;
316 default = true;
317 description = ''
318 Whether to allow powerups such as the ninja.
319 '';
320 };
321
322 scoreLimit = lib.mkOption {
323 type = lib.types.ints.unsigned;
324 example = 400;
325 default = 20;
326 description = ''
327 The score limit needed to win a round.
328 '';
329 };
330
331 restrictSpectators = lib.mkOption {
332 type = lib.types.bool;
333 default = false;
334 description = ''
335 Whether to restrict access to information such as health, ammo and armour in spectator mode.
336 '';
337 };
338
339 enableTeamDamage = lib.mkOption {
340 type = lib.types.bool;
341 default = false;
342 description = ''
343 Whether to enable team damage; whether to allow team mates to inflict damage on one another.
344 '';
345 };
346
347 timeLimit = lib.mkOption {
348 type = lib.types.ints.unsigned;
349 default = 0;
350 description = ''
351 Time limit of the game. In cases of equal points, there will be sudden death.
352 Setting this to 0 disables a time limit.
353 '';
354 };
355
356 tournamentMode = lib.mkOption {
357 type = lib.types.enum [
358 "disable"
359 "enable"
360 "restrictSpectators"
361 ];
362 default = "disable";
363 description = ''
364 Whether to enable tournament mode. In tournament mode, players join as spectators.
365 If this is set to `restrictSpectators`, tournament mode is enabled but spectator chat is restricted.
366 '';
367 };
368
369 enableVoteKick = lib.mkOption {
370 type = lib.types.bool;
371 default = true;
372 description = ''
373 Whether to enable voting to kick players.
374 '';
375 };
376
377 voteKickBanTime = lib.mkOption {
378 type = lib.types.ints.unsigned;
379 default = 5;
380 description = ''
381 The amount of minutes that a player is banned for if they get kicked by a vote.
382 '';
383 };
384
385 voteKickMinimumPlayers = lib.mkOption {
386 type = lib.types.ints.unsigned;
387 default = 5;
388 description = ''
389 The minimum amount of players required to start a kick vote.
390 '';
391 };
392 };
393
394 environmentFile = lib.mkOption {
395 type = lib.types.nullOr lib.types.path;
396 default = null;
397 example = "/var/lib/teeworlds/teeworlds.env";
398 description = ''
399 Environment file as defined in {manpage}`systemd.exec(5)`.
400
401 Secrets may be passed to the service without adding them to the world-readable
402 Nix store, by specifying placeholder variables as the option value in Nix and
403 setting these variables accordingly in the environment file.
404
405 ```
406 # snippet of teeworlds-related config
407 services.teeworlds.password = "$TEEWORLDS_PASSWORD";
408 ```
409
410 ```
411 # content of the environment file
412 TEEWORLDS_PASSWORD=verysecretpassword
413 ```
414
415 Note that this file needs to be available on the host on which
416 `teeworlds` is running.
417 '';
418 };
419
420 };
421 };
422
423 config = lib.mkIf cfg.enable {
424 networking.firewall = lib.mkIf cfg.openPorts {
425 allowedUDPPorts = [ cfg.port ];
426 };
427
428 systemd.services.teeworlds = {
429 description = "Teeworlds Server";
430 wantedBy = [ "multi-user.target" ];
431 after = [ "network.target" ];
432
433 serviceConfig = {
434 DynamicUser = true;
435 RuntimeDirectory = "teeworlds";
436 RuntimeDirectoryMode = "0700";
437 EnvironmentFile = lib.mkIf (cfg.environmentFile != null) [ cfg.environmentFile ];
438 ExecStartPre = ''
439 ${pkgs.envsubst}/bin/envsubst \
440 -i ${teeworldsConf} \
441 -o /run/teeworlds/teeworlds.yaml
442 '';
443 ExecStart = "${lib.getExe cfg.package} -f /run/teeworlds/teeworlds.yaml";
444
445 # Hardening
446 CapabilityBoundingSet = false;
447 PrivateDevices = true;
448 PrivateUsers = true;
449 ProtectHome = true;
450 ProtectKernelLogs = true;
451 ProtectKernelModules = true;
452 ProtectKernelTunables = true;
453 RestrictAddressFamilies = [
454 "AF_INET"
455 "AF_INET6"
456 ];
457 RestrictNamespaces = true;
458 SystemCallArchitectures = "native";
459 };
460 };
461 };
462}