1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.factorio;
7 name = "Factorio";
8 stateDir = "/var/lib/${cfg.stateDirName}";
9 mkSavePath = name: "${stateDir}/saves/${name}.zip";
10 configFile = pkgs.writeText "factorio.conf" ''
11 use-system-read-write-data-directories=true
12 [path]
13 read-data=${cfg.package}/share/factorio/data
14 write-data=${stateDir}
15 '';
16 serverSettings = {
17 name = cfg.game-name;
18 description = cfg.description;
19 visibility = {
20 public = cfg.public;
21 lan = cfg.lan;
22 };
23 username = cfg.username;
24 password = cfg.password;
25 token = cfg.token;
26 game_password = cfg.game-password;
27 require_user_verification = cfg.requireUserVerification;
28 max_upload_in_kilobytes_per_second = 0;
29 minimum_latency_in_ticks = 0;
30 ignore_player_limit_for_returning_players = false;
31 allow_commands = "admins-only";
32 autosave_interval = cfg.autosave-interval;
33 autosave_slots = 5;
34 afk_autokick_interval = 0;
35 auto_pause = true;
36 only_admins_can_pause_the_game = true;
37 autosave_only_on_server = true;
38 non_blocking_saving = cfg.nonBlockingSaving;
39 } // cfg.extraSettings;
40 serverSettingsFile = pkgs.writeText "server-settings.json" (builtins.toJSON (filterAttrsRecursive (n: v: v != null) serverSettings));
41 serverAdminsFile = pkgs.writeText "server-adminlist.json" (builtins.toJSON cfg.admins);
42 modDir = pkgs.factorio-utils.mkModDirDrv cfg.mods cfg.mods-dat;
43in
44{
45 options = {
46 services.factorio = {
47 enable = mkEnableOption (lib.mdDoc name);
48 port = mkOption {
49 type = types.port;
50 default = 34197;
51 description = lib.mdDoc ''
52 The port to which the service should bind.
53 '';
54 };
55
56 bind = mkOption {
57 type = types.str;
58 default = "0.0.0.0";
59 description = lib.mdDoc ''
60 The address to which the service should bind.
61 '';
62 };
63
64 admins = mkOption {
65 type = types.listOf types.str;
66 default = [];
67 example = [ "username" ];
68 description = lib.mdDoc ''
69 List of player names which will be admin.
70 '';
71 };
72
73 openFirewall = mkOption {
74 type = types.bool;
75 default = false;
76 description = lib.mdDoc ''
77 Whether to automatically open the specified UDP port in the firewall.
78 '';
79 };
80 saveName = mkOption {
81 type = types.str;
82 default = "default";
83 description = lib.mdDoc ''
84 The name of the savegame that will be used by the server.
85
86 When not present in /var/lib/''${config.services.factorio.stateDirName}/saves,
87 a new map with default settings will be generated before starting the service.
88 '';
89 };
90 loadLatestSave = mkOption {
91 type = types.bool;
92 default = false;
93 description = lib.mdDoc ''
94 Load the latest savegame on startup. This overrides saveName, in that the latest
95 save will always be used even if a saved game of the given name exists. It still
96 controls the 'canonical' name of the savegame.
97
98 Set this to true to have the server automatically reload a recent autosave after
99 a crash or desync.
100 '';
101 };
102 # TODO Add more individual settings as nixos-options?
103 # TODO XXX The server tries to copy a newly created config file over the old one
104 # on shutdown, but fails, because it's in the nix store. When is this needed?
105 # Can an admin set options in-game and expect to have them persisted?
106 configFile = mkOption {
107 type = types.path;
108 default = configFile;
109 defaultText = literalExpression "configFile";
110 description = lib.mdDoc ''
111 The server's configuration file.
112
113 The default file generated by this module contains lines essential to
114 the server's operation. Use its contents as a basis for any
115 customizations.
116 '';
117 };
118 stateDirName = mkOption {
119 type = types.str;
120 default = "factorio";
121 description = lib.mdDoc ''
122 Name of the directory under /var/lib holding the server's data.
123
124 The configuration and map will be stored here.
125 '';
126 };
127 mods = mkOption {
128 type = types.listOf types.package;
129 default = [];
130 description = lib.mdDoc ''
131 Mods the server should install and activate.
132
133 The derivations in this list must "build" the mod by simply copying
134 the .zip, named correctly, into the output directory. Eventually,
135 there will be a way to pull in the most up-to-date list of
136 derivations via nixos-channel. Until then, this is for experts only.
137 '';
138 };
139 mods-dat = mkOption {
140 type = types.nullOr types.path;
141 default = null;
142 description = lib.mdDoc ''
143 Mods settings can be changed by specifying a dat file, in the [mod
144 settings file
145 format](https://wiki.factorio.com/Mod_settings_file_format).
146 '';
147 };
148 game-name = mkOption {
149 type = types.nullOr types.str;
150 default = "Factorio Game";
151 description = lib.mdDoc ''
152 Name of the game as it will appear in the game listing.
153 '';
154 };
155 description = mkOption {
156 type = types.nullOr types.str;
157 default = "";
158 description = lib.mdDoc ''
159 Description of the game that will appear in the listing.
160 '';
161 };
162 extraSettings = mkOption {
163 type = types.attrs;
164 default = {};
165 example = { admins = [ "username" ];};
166 description = lib.mdDoc ''
167 Extra game configuration that will go into server-settings.json
168 '';
169 };
170 public = mkOption {
171 type = types.bool;
172 default = false;
173 description = lib.mdDoc ''
174 Game will be published on the official Factorio matching server.
175 '';
176 };
177 lan = mkOption {
178 type = types.bool;
179 default = false;
180 description = lib.mdDoc ''
181 Game will be broadcast on LAN.
182 '';
183 };
184 username = mkOption {
185 type = types.nullOr types.str;
186 default = null;
187 description = lib.mdDoc ''
188 Your factorio.com login credentials. Required for games with visibility public.
189 '';
190 };
191 package = mkOption {
192 type = types.package;
193 default = pkgs.factorio-headless;
194 defaultText = literalExpression "pkgs.factorio-headless";
195 example = literalExpression "pkgs.factorio-headless-experimental";
196 description = lib.mdDoc ''
197 Factorio version to use. This defaults to the stable channel.
198 '';
199 };
200 password = mkOption {
201 type = types.nullOr types.str;
202 default = null;
203 description = lib.mdDoc ''
204 Your factorio.com login credentials. Required for games with visibility public.
205 '';
206 };
207 token = mkOption {
208 type = types.nullOr types.str;
209 default = null;
210 description = lib.mdDoc ''
211 Authentication token. May be used instead of 'password' above.
212 '';
213 };
214 game-password = mkOption {
215 type = types.nullOr types.str;
216 default = null;
217 description = lib.mdDoc ''
218 Game password.
219 '';
220 };
221 requireUserVerification = mkOption {
222 type = types.bool;
223 default = true;
224 description = lib.mdDoc ''
225 When set to true, the server will only allow clients that have a valid factorio.com account.
226 '';
227 };
228 autosave-interval = mkOption {
229 type = types.nullOr types.int;
230 default = null;
231 example = 10;
232 description = lib.mdDoc ''
233 Autosave interval in minutes.
234 '';
235 };
236 nonBlockingSaving = mkOption {
237 type = types.bool;
238 default = false;
239 description = lib.mdDoc ''
240 Highly experimental feature, enable only at your own risk of losing your saves.
241 On UNIX systems, server will fork itself to create an autosave.
242 Autosaving on connected Windows clients will be disabled regardless of autosave_only_on_server option.
243 '';
244 };
245 };
246 };
247
248 config = mkIf cfg.enable {
249 systemd.services.factorio = {
250 description = "Factorio headless server";
251 wantedBy = [ "multi-user.target" ];
252 after = [ "network.target" ];
253
254 preStart = toString [
255 "test -e ${stateDir}/saves/${cfg.saveName}.zip"
256 "||"
257 "${cfg.package}/bin/factorio"
258 "--config=${cfg.configFile}"
259 "--create=${mkSavePath cfg.saveName}"
260 (optionalString (cfg.mods != []) "--mod-directory=${modDir}")
261 ];
262
263 serviceConfig = {
264 Restart = "always";
265 KillSignal = "SIGINT";
266 DynamicUser = true;
267 StateDirectory = cfg.stateDirName;
268 UMask = "0007";
269 ExecStart = toString [
270 "${cfg.package}/bin/factorio"
271 "--config=${cfg.configFile}"
272 "--port=${toString cfg.port}"
273 "--bind=${cfg.bind}"
274 (optionalString (!cfg.loadLatestSave) "--start-server=${mkSavePath cfg.saveName}")
275 "--server-settings=${serverSettingsFile}"
276 (optionalString cfg.loadLatestSave "--start-server-load-latest")
277 (optionalString (cfg.mods != []) "--mod-directory=${modDir}")
278 (optionalString (cfg.admins != []) "--server-adminlist=${serverAdminsFile}")
279 ];
280
281 # Sandboxing
282 NoNewPrivileges = true;
283 PrivateTmp = true;
284 PrivateDevices = true;
285 ProtectSystem = "strict";
286 ProtectHome = true;
287 ProtectControlGroups = true;
288 ProtectKernelModules = true;
289 ProtectKernelTunables = true;
290 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ];
291 RestrictRealtime = true;
292 RestrictNamespaces = true;
293 MemoryDenyWriteExecute = true;
294 };
295 };
296
297 networking.firewall.allowedUDPPorts = optional cfg.openFirewall cfg.port;
298 };
299}