Writing Server Modules
Tenets
The primary goal of the opinionated server modules is to provide a more ergonomic way to create and manage servers of a specifc game. To that end, you should adhere to the following tenets:
- Modules shoud be easy to use
- Modules should be secure by default
- Whenever possible, modules should allow running multiple servers on the same host
Guidelines
From these tenets, we can conclude several guidelines:
- Modules should generally be an attribute set of server options to allow running multiple instances
- Default config values should provide a working server
- Ideally, a user consuming a server would simply need to write
services.steam-servers.some-game.my-server.enable = true;
- Ideally, a user consuming a server would simply need to write
- Default config values should provide a secure starting point
- Don't automatically open firewall ports
- Don't automatically list server on public listings (when support by the game)
- It should be easy to extend the CLI args/server config file generated
Templates
The following templates are some useful starting points for creating new modules.
Make sure to replace <game>
with the actual name of the game
modules/<game>/options.nix
{ # modules/<game>/options.nix
config,
lib,
pkgs,
...
}:
with lib; let
baseCfg = config.services.steam-servers;
moduleLib = import ../lib.nix lib;
inherit (moduleLib) mkOpt;
serverModule = {
config,
name,
...
}: {
options = {
enable = mkEnableOption (mdDoc "<game> Dedicated Server");
package = mkOption {
type = types.package;
default = pkgs.<game>;
defaultText = literalExpression "pkgs.<game>";
description = mdDoc "Package to use for <game> binary";
};
datadir = mkOption {
type = types.path;
default = "${baseCfg.datadir}/<game>/${name}";
defaultText = literalExpression "\${services.steam-servers.datadir}/<game>/\${name}";
description = mdDoc ''
Directory to store save state of the game server. (eg world, saves, etc)
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = mdDoc "Whether to open ports in the firewall";
};
config = {
# Config options
};
extraConfig = mkOpt types.attrs {} "Extra config to add to the server config";
extraArgs = mkOpt (with types; listOf str) [] "Extra command line arguments to pass to the server";
};
};
in {
options.services.steam-servers.<game> = mkOption {
type = types.attrsOf (types.submodule serverModule);
default = {};
description = mdDoc ''
Options to configure one or more <game> servers.
'';
};
}
modules/<game>/default.nix
{ # modules/<game>/default.nix
config,
lib,
...
}:
with lib; let
baseCfg = config.services.steam-servers;
cfg = baseCfg.stationeers;
enabledServers = filterAttrs (_: conf: conf.enable) cfg;
in {
imports = [./options.nix];
config = mkIf (enabledServers != {}) {
networking.firewall =
mkMerge
(map
(conf:
mkIf conf.openFirewall {
allowedUDPPorts = [conf.config.UpdatePort conf.config.GamePort];
})
(builtins.attrValues enabledServers));
services.steam-servers.servers =
mapAttrs'
(name: conf: let
args =
[
# Disable UI in unity
"-batchmode"
"-nographics"
]
++ (map escapeShellArg conf.extraArgs);
in
nameValuePair "<game>-${name}" {
inherit args;
inherit (conf) enable datadir;
symlinks = {
"settings.ini".value = conf.config;
};
executable = "${conf.package}/server_executable";
})
cfg;
};
}
modules/<game>/default.test.nix
# modules/<game>/default.test.nix
{lib, ...}:
with lib; {
name = "<game>";
nodes = {
server = {
virtualisation = {
cores = 2;
memorySize = 4096;
};
services.steam-servers."<game>".test = {
enable = true;
# Any other config needed for a meaningful test
};
};
};
testScript = ''
server.wait_for_unit("<game>-test.service")
server.wait_for_open_port(26900) # Can only check TCP ports
# Wait for some text in the syslog that indicates server started
server.wait_for_console_text("started <game> Server")
# Check save file has been created
server.succeed("test -d ${cfg.datadir}/saves/${cfg.worldName}")
'';
}