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