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;
43in
44{
45 options = {
46 services.factorio = {
47 enable = mkEnableOption name;
48 port = mkOption {
49 type = types.int;
50 default = 34197;
51 description = ''
52 The port to which the service should bind.
53 '';
54 };
55
56 admins = mkOption {
57 type = types.listOf types.str;
58 default = [];
59 example = [ "username" ];
60 description = ''
61 List of player names which will be admin.
62 '';
63 };
64
65 openFirewall = mkOption {
66 type = types.bool;
67 default = false;
68 description = ''
69 Whether to automatically open the specified UDP port in the firewall.
70 '';
71 };
72 saveName = mkOption {
73 type = types.str;
74 default = "default";
75 description = ''
76 The name of the savegame that will be used by the server.
77
78 When not present in ${stateDir}/saves, a new map with default
79 settings will be generated before starting the service.
80 '';
81 };
82 # TODO Add more individual settings as nixos-options?
83 # TODO XXX The server tries to copy a newly created config file over the old one
84 # on shutdown, but fails, because it's in the nix store. When is this needed?
85 # Can an admin set options in-game and expect to have them persisted?
86 configFile = mkOption {
87 type = types.path;
88 default = configFile;
89 defaultText = "configFile";
90 description = ''
91 The server's configuration file.
92
93 The default file generated by this module contains lines essential to
94 the server's operation. Use its contents as a basis for any
95 customizations.
96 '';
97 };
98 stateDirName = mkOption {
99 type = types.str;
100 default = "factorio";
101 description = ''
102 Name of the directory under /var/lib holding the server's data.
103
104 The configuration and map will be stored here.
105 '';
106 };
107 mods = mkOption {
108 type = types.listOf types.package;
109 default = [];
110 description = ''
111 Mods the server should install and activate.
112
113 The derivations in this list must "build" the mod by simply copying
114 the .zip, named correctly, into the output directory. Eventually,
115 there will be a way to pull in the most up-to-date list of
116 derivations via nixos-channel. Until then, this is for experts only.
117 '';
118 };
119 game-name = mkOption {
120 type = types.nullOr types.str;
121 default = "Factorio Game";
122 description = ''
123 Name of the game as it will appear in the game listing.
124 '';
125 };
126 description = mkOption {
127 type = types.nullOr types.str;
128 default = "";
129 description = ''
130 Description of the game that will appear in the listing.
131 '';
132 };
133 extraSettings = mkOption {
134 type = types.attrs;
135 default = {};
136 example = { admins = [ "username" ];};
137 description = ''
138 Extra game configuration that will go into server-settings.json
139 '';
140 };
141 public = mkOption {
142 type = types.bool;
143 default = false;
144 description = ''
145 Game will be published on the official Factorio matching server.
146 '';
147 };
148 lan = mkOption {
149 type = types.bool;
150 default = false;
151 description = ''
152 Game will be broadcast on LAN.
153 '';
154 };
155 username = mkOption {
156 type = types.nullOr types.str;
157 default = null;
158 description = ''
159 Your factorio.com login credentials. Required for games with visibility public.
160 '';
161 };
162 package = mkOption {
163 type = types.package;
164 default = pkgs.factorio-headless;
165 defaultText = "pkgs.factorio-headless";
166 example = "pkgs.factorio-headless-experimental";
167 description = ''
168 Factorio version to use. This defaults to the stable channel.
169 '';
170 };
171 password = mkOption {
172 type = types.nullOr types.str;
173 default = null;
174 description = ''
175 Your factorio.com login credentials. Required for games with visibility public.
176 '';
177 };
178 token = mkOption {
179 type = types.nullOr types.str;
180 default = null;
181 description = ''
182 Authentication token. May be used instead of 'password' above.
183 '';
184 };
185 game-password = mkOption {
186 type = types.nullOr types.str;
187 default = null;
188 description = ''
189 Game password.
190 '';
191 };
192 requireUserVerification = mkOption {
193 type = types.bool;
194 default = true;
195 description = ''
196 When set to true, the server will only allow clients that have a valid factorio.com account.
197 '';
198 };
199 autosave-interval = mkOption {
200 type = types.nullOr types.int;
201 default = null;
202 example = 10;
203 description = ''
204 Autosave interval in minutes.
205 '';
206 };
207 nonBlockingSaving = mkOption {
208 type = types.bool;
209 default = false;
210 description = ''
211 Highly experimental feature, enable only at your own risk of losing your saves.
212 On UNIX systems, server will fork itself to create an autosave.
213 Autosaving on connected Windows clients will be disabled regardless of autosave_only_on_server option.
214 '';
215 };
216 };
217 };
218
219 config = mkIf cfg.enable {
220 systemd.services.factorio = {
221 description = "Factorio headless server";
222 wantedBy = [ "multi-user.target" ];
223 after = [ "network.target" ];
224
225 preStart = toString [
226 "test -e ${stateDir}/saves/${cfg.saveName}.zip"
227 "||"
228 "${cfg.package}/bin/factorio"
229 "--config=${cfg.configFile}"
230 "--create=${mkSavePath cfg.saveName}"
231 (optionalString (cfg.mods != []) "--mod-directory=${modDir}")
232 ];
233
234 serviceConfig = {
235 Restart = "always";
236 KillSignal = "SIGINT";
237 DynamicUser = true;
238 StateDirectory = cfg.stateDirName;
239 UMask = "0007";
240 ExecStart = toString [
241 "${cfg.package}/bin/factorio"
242 "--config=${cfg.configFile}"
243 "--port=${toString cfg.port}"
244 "--start-server=${mkSavePath cfg.saveName}"
245 "--server-settings=${serverSettingsFile}"
246 (optionalString (cfg.mods != []) "--mod-directory=${modDir}")
247 (optionalString (cfg.admins != []) "--server-adminlist=${serverAdminsFile}")
248 ];
249
250 # Sandboxing
251 NoNewPrivileges = true;
252 PrivateTmp = true;
253 PrivateDevices = true;
254 ProtectSystem = "strict";
255 ProtectHome = true;
256 ProtectControlGroups = true;
257 ProtectKernelModules = true;
258 ProtectKernelTunables = true;
259 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ];
260 RestrictRealtime = true;
261 RestrictNamespaces = true;
262 MemoryDenyWriteExecute = true;
263 };
264 };
265
266 networking.firewall.allowedUDPPorts = if cfg.openFirewall then [ cfg.port ] else [];
267 };
268}