push sheeet
Some checks failed
Periodic Merges (6h) / master → staging-nixos (push) Failing after 12m50s
Periodic Merges (6h) / master → staging-next (push) Failing after 12m54s
Periodic Merges (24h) / merge-base(master,staging) → haskell-updates (push) Failing after 11m54s
Periodic Merges (6h) / staging-next → staging (push) Failing after 12m13s
Periodic Merges (24h) / staging-next-25.05 → staging-25.05 (push) Failing after 13m24s
Periodic Merges (24h) / release-25.05 → staging-next-25.05 (push) Failing after 14m28s

This commit is contained in:
Dark Steveneq
2025-10-09 14:15:47 +02:00
commit 646b892680
49168 changed files with 5897842 additions and 0 deletions

View File

@@ -0,0 +1,301 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.archisteamfarm;
format = pkgs.formats.json { };
configFile = format.generate "ASF.json" (
cfg.settings
// {
# we disable it because ASF cannot update itself anyways
# and nixos takes care of restarting the service
# is in theory not needed as this is already the default for default builds
UpdateChannel = 0;
Headless = true;
}
// lib.optionalAttrs (cfg.ipcPasswordFile != null) {
IPCPassword = "#ipcPassword#";
}
);
ipc-config = format.generate "IPC.config" cfg.ipcSettings;
mkBot =
n: c:
format.generate "${n}.json" (
c.settings
// {
SteamLogin = if c.username == "" then n else c.username;
Enabled = c.enabled;
}
// lib.optionalAttrs (c.passwordFile != null) {
SteamPassword = c.passwordFile;
# sets the password format to file (https://github.com/JustArchiNET/ArchiSteamFarm/wiki/Security#file)
PasswordFormat = 4;
}
);
in
{
options.services.archisteamfarm = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
If enabled, starts the ArchisSteamFarm service.
For configuring the SteamGuard token you will need to use the web-ui, which is enabled by default over on 127.0.0.1:1242.
You cannot configure ASF in any way outside of nix, since all the config files get wiped on restart and replaced with the programnatically set ones by nix.
'';
default = false;
};
web-ui = lib.mkOption {
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "" // {
description = "Whether to start the web-ui. This is the preferred way of configuring things such as the steam guard token.";
};
package = lib.mkPackageOption pkgs [ "ArchiSteamFarm" "ui" ] {
extraDescription = ''
::: {.note}
Contents must be in lib/dist
:::
'';
};
};
};
default = {
enable = true;
};
example = {
enable = false;
};
description = "The Web-UI hosted on 127.0.0.1:1242.";
};
package = lib.mkPackageOption pkgs "ArchiSteamFarm" {
extraDescription = ''
::: {.warning}
Should always be the latest version, for security reasons,
since this module uses very new features and to not get out of sync with the Steam API.
:::
'';
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/archisteamfarm";
description = ''
The ASF home directory used to store all data.
If left as the default value this directory will automatically be created before the ASF server starts, otherwise the sysadmin is responsible for ensuring the directory exists with appropriate ownership and permissions.'';
};
settings = lib.mkOption {
type = format.type;
description = ''
The ASF.json file, all the options are documented [here](https://github.com/JustArchiNET/ArchiSteamFarm/wiki/Configuration#global-config).
Do note that `AutoRestart` and `UpdateChannel` is always to `false` respectively `0` because NixOS takes care of updating everything.
`Headless` is also always set to `true` because there is no way to provide inputs via a systemd service.
You should try to keep ASF up to date since upstream does not provide support for anything but the latest version and you're exposing yourself to all kinds of issues - as is outlined [here](https://github.com/JustArchiNET/ArchiSteamFarm/wiki/Configuration#updateperiod).
'';
example = {
Statistics = false;
};
default = { };
};
ipcPasswordFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = "Path to a file containing the password. The file must be readable by the `archisteamfarm` user/group.";
};
ipcSettings = lib.mkOption {
type = format.type;
description = ''
Settings to write to IPC.config.
All options can be found [here](https://github.com/JustArchiNET/ArchiSteamFarm/wiki/IPC#custom-configuration).
'';
example = {
Kestrel = {
Endpoints = {
HTTP = {
Url = "http://*:1242";
};
};
};
};
default = { };
};
bots = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
username = lib.mkOption {
type = lib.types.str;
description = "Name of the user to log in. Default is attribute name.";
default = "";
};
passwordFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = ''
Path to a file containing the password. The file must be readable by the `archisteamfarm` user/group.
Omit or set to null to provide the password a different way, such as through the web-ui.
'';
};
enabled = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the bot on startup.";
};
settings = lib.mkOption {
type = lib.types.attrs;
description = ''
Additional settings that are documented [here](https://github.com/JustArchiNET/ArchiSteamFarm/wiki/Configuration#bot-config).
'';
default = { };
};
};
}
);
description = ''
Bots name and configuration.
'';
example = {
exampleBot = {
username = "alice";
passwordFile = "/var/lib/archisteamfarm/secrets/password";
settings = {
SteamParentalCode = "1234";
};
};
};
default = { };
};
};
config = lib.mkIf cfg.enable {
users = {
users.archisteamfarm = {
home = cfg.dataDir;
isSystemUser = true;
group = "archisteamfarm";
description = "Archis-Steam-Farm service user";
};
groups.archisteamfarm = { };
};
systemd.services = {
archisteamfarm = {
description = "Archis-Steam-Farm Service";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = lib.mkMerge [
(lib.mkIf (lib.hasPrefix "/var/lib/" cfg.dataDir) {
StateDirectory = lib.last (lib.splitString "/" cfg.dataDir);
StateDirectoryMode = "700";
})
{
User = "archisteamfarm";
Group = "archisteamfarm";
WorkingDirectory = cfg.dataDir;
Type = "simple";
ExecStart = "${lib.getExe cfg.package} --no-restart --service --system-required --path ${cfg.dataDir}";
Restart = "always";
# copied from the default systemd service at
# https://github.com/JustArchiNET/ArchiSteamFarm/blob/main/ArchiSteamFarm/overlay/variant-base/linux/ArchiSteamFarm%40.service
CapabilityBoundingSet = "";
DevicePolicy = "closed";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateIPC = true;
PrivateMounts = true;
PrivateTmp = true; # instead of rw /tmp
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = "AF_INET AF_INET6 AF_NETLINK AF_UNIX";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SecureBits = "noroot-locked";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
"mincore"
];
UMask = "0077";
}
];
preStart =
let
createBotsScript =
pkgs.runCommand "ASF-bots"
{
preferLocalBuild = true;
}
''
mkdir -p $out
# clean potential removed bots
rm -rf $out/*.json
for i in ${
lib.concatStringsSep " " (map (x: "${lib.getName x},${x}") (lib.mapAttrsToList mkBot cfg.bots))
}; do IFS=",";
set -- $i
ln -fs $2 $out/$1
done
'';
replaceSecretBin = "${pkgs.replace-secret}/bin/replace-secret";
in
''
mkdir -p config
cp --no-preserve=mode ${configFile} config/ASF.json
${lib.optionalString (cfg.ipcPasswordFile != null) ''
${replaceSecretBin} '#ipcPassword#' '${cfg.ipcPasswordFile}' config/ASF.json
''}
${lib.optionalString (cfg.ipcSettings != { }) ''
ln -fs ${ipc-config} config/IPC.config
''}
${lib.optionalString (cfg.bots != { }) ''
ln -fs ${createBotsScript}/* config/
''}
rm -f www
${lib.optionalString cfg.web-ui.enable ''
ln -s ${cfg.web-ui.package}/ www
''}
'';
};
};
};
meta = {
buildDocsInSandbox = false;
maintainers = with lib.maintainers; [ SuperSandro2000 ];
};
}

View File

@@ -0,0 +1,300 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
mkEnableOption
mkIf
mkOption
mkMerge
literalExpression
;
inherit (lib)
mapAttrsToList
filterAttrs
unique
recursiveUpdate
types
;
mkValueStringArmagetron =
with lib;
v:
if isInt v then
toString v
else if isFloat v then
toString v
else if isString v then
v
else if true == v then
"1"
else if false == v then
"0"
else if null == v then
""
else
throw "unsupported type: ${builtins.typeOf v}: ${(lib.generators.toPretty { } v)}";
settingsFormat = pkgs.formats.keyValue {
mkKeyValue = lib.generators.mkKeyValueDefault {
mkValueString = mkValueStringArmagetron;
} " ";
listsAsDuplicateKeys = true;
};
cfg = config.services.armagetronad;
enabledServers = lib.filterAttrs (n: v: v.enable) cfg.servers;
nameToId = serverName: "armagetronad-${serverName}";
getStateDirectory = serverName: "armagetronad/${serverName}";
getServerRoot = serverName: "/var/lib/${getStateDirectory serverName}";
in
{
options = {
services.armagetronad = {
servers = mkOption {
description = "Armagetron server definitions.";
default = { };
type = types.attrsOf (
types.submodule {
options = {
enable = mkEnableOption "armagetronad";
package = lib.mkPackageOption pkgs "armagetronad-dedicated" {
example = ''
pkgs.armagetronad."0.2.9-sty+ct+ap".dedicated
'';
extraDescription = ''
Ensure that you use a derivation which contains the path `bin/armagetronad-dedicated`.
'';
};
host = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Host to listen on. Used for SERVER_IP.";
};
port = mkOption {
type = types.port;
default = 4534;
description = "Port to listen on. Used for SERVER_PORT.";
};
dns = mkOption {
type = types.nullOr types.str;
default = null;
description = "DNS address to use for this server. Optional.";
};
openFirewall = mkOption {
type = types.bool;
default = true;
description = "Set to true to open the configured UDP port for Armagetron Advanced.";
};
name = mkOption {
type = types.str;
description = "The name of this server.";
};
settings = mkOption {
type = settingsFormat.type;
default = { };
description = ''
Armagetron Advanced server rules configuration. Refer to:
<https://wiki.armagetronad.org/index.php?title=Console_Commands>
or `armagetronad-dedicated --doc` for a list.
This attrset is used to populate `settings_custom.cfg`; see:
<https://wiki.armagetronad.org/index.php/Configuration_Files>
'';
example = literalExpression ''
{
CYCLE_RUBBER = 40;
}
'';
};
roundSettings = mkOption {
type = settingsFormat.type;
default = { };
description = ''
Armagetron Advanced server per-round configuration. Refer to:
<https://wiki.armagetronad.org/index.php?title=Console_Commands>
or `armagetronad-dedicated --doc` for a list.
This attrset is used to populate `everytime.cfg`; see:
<https://wiki.armagetronad.org/index.php/Configuration_Files>
'';
example = literalExpression ''
{
SAY = [
"Hosted on NixOS"
"https://nixos.org"
"iD Tech High Rubber rul3z!! Happy New Year 2008!!1"
];
}
'';
};
};
}
);
};
};
};
config = mkIf (enabledServers != { }) {
systemd.tmpfiles.settings = mkMerge (
mapAttrsToList (
serverName: serverCfg:
let
serverId = nameToId serverName;
serverRoot = getServerRoot serverName;
serverInfo = (
{
SERVER_IP = serverCfg.host;
SERVER_PORT = serverCfg.port;
SERVER_NAME = serverCfg.name;
}
// (lib.optionalAttrs (serverCfg.dns != null) { SERVER_DNS = serverCfg.dns; })
);
customSettings = serverCfg.settings;
everytimeSettings = serverCfg.roundSettings;
serverInfoCfg = settingsFormat.generate "server_info.${serverName}.cfg" serverInfo;
customSettingsCfg = settingsFormat.generate "settings_custom.${serverName}.cfg" customSettings;
everytimeSettingsCfg = settingsFormat.generate "everytime.${serverName}.cfg" everytimeSettings;
in
{
"10-armagetronad-${serverId}" = {
"${serverRoot}/data" = {
d = {
group = serverId;
user = serverId;
mode = "0750";
};
};
"${serverRoot}/settings" = {
d = {
group = serverId;
user = serverId;
mode = "0750";
};
};
"${serverRoot}/var" = {
d = {
group = serverId;
user = serverId;
mode = "0750";
};
};
"${serverRoot}/resource" = {
d = {
group = serverId;
user = serverId;
mode = "0750";
};
};
"${serverRoot}/input" = {
"f+" = {
group = serverId;
user = serverId;
mode = "0640";
};
};
"${serverRoot}/settings/server_info.cfg" = {
"L+" = {
argument = "${serverInfoCfg}";
};
};
"${serverRoot}/settings/settings_custom.cfg" = {
"L+" = {
argument = "${customSettingsCfg}";
};
};
"${serverRoot}/settings/everytime.cfg" = {
"L+" = {
argument = "${everytimeSettingsCfg}";
};
};
};
}
) enabledServers
);
systemd.services = mkMerge (
mapAttrsToList (
serverName: serverCfg:
let
serverId = nameToId serverName;
in
{
"armagetronad-${serverName}" = {
description = "Armagetron Advanced Dedicated Server for ${serverName}";
wants = [ "basic.target" ];
after = [
"basic.target"
"network.target"
"multi-user.target"
];
wantedBy = [ "multi-user.target" ];
serviceConfig =
let
serverRoot = getServerRoot serverName;
in
{
Type = "simple";
StateDirectory = getStateDirectory serverName;
ExecStart = "${lib.getExe serverCfg.package} --daemon --input ${serverRoot}/input --userdatadir ${serverRoot}/data --userconfigdir ${serverRoot}/settings --vardir ${serverRoot}/var --autoresourcedir ${serverRoot}/resource";
Restart = "on-failure";
CapabilityBoundingSet = "";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RestrictNamespaces = true;
RestrictSUIDSGID = true;
User = serverId;
Group = serverId;
};
};
}
) enabledServers
);
networking.firewall.allowedUDPPorts = unique (
mapAttrsToList (serverName: serverCfg: serverCfg.port) (
filterAttrs (serverName: serverCfg: serverCfg.openFirewall) enabledServers
)
);
users.users = mkMerge (
mapAttrsToList (serverName: serverCfg: {
${nameToId serverName} = {
group = nameToId serverName;
description = "Armagetron Advanced dedicated user for server ${serverName}";
isSystemUser = true;
};
}) enabledServers
);
users.groups = mkMerge (
mapAttrsToList (serverName: serverCfg: {
${nameToId serverName} = { };
}) enabledServers
);
};
}

View File

@@ -0,0 +1,193 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.crossfire-server;
serverPort = 13327;
in
{
options.services.crossfire-server = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
If enabled, the Crossfire game server will be started at boot.
'';
};
package = lib.mkPackageOption pkgs "crossfire-server" {
extraDescription = ''
::: {.note}
This will also be used for map/arch data, if you don't change {option}`dataDir`
:::
'';
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "${cfg.package}/share/crossfire";
defaultText = lib.literalExpression ''"''${config.services.crossfire.package}/share/crossfire"'';
description = ''
Where to load readonly data from -- maps, archetypes, treasure tables,
and the like. If you plan to edit the data on the live server (rather
than overlaying the crossfire-maps and crossfire-arch packages and
nixos-rebuilding), point this somewhere read-write and copy the data
there before starting the server.
'';
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/crossfire";
description = ''
Where to store runtime data (save files, persistent items, etc).
If left at the default, this will be automatically created on server
startup if it does not already exist. If changed, it is the admin's
responsibility to make sure that the directory exists and is writeable
by the `crossfire` user.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to open ports in the firewall for the server.
'';
};
configFiles = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = ''
Text to append to the corresponding configuration files. Note that the
files given in the example are *not* the complete set of files available
to customize; look in /etc/crossfire after enabling the server to see
the available files, and read the comments in each file for detailed
documentation on the format and what settings are available.
Note that the motd, rules, and news files, if configured here, will
overwrite the example files that come with the server, rather than being
appended to them as the other configuration files are.
'';
example = lib.literalExpression ''
{
dm_file = '''
admin:secret_password:localhost
alice:xyzzy:*
''';
ban_file = '''
# Bob is a jerk
bob@*
# So is everyone on 192.168.86.255/24
*@192.168.86.
''';
metaserver2 = '''
metaserver2_notification on
localhostname crossfire.example.net
''';
motd = "Welcome to CrossFire!";
news = "No news yet.";
rules = "Don't be a jerk.";
settings = '''
# be nicer to newbies and harsher to experienced players
balanced_stat_loss true
# don't let players pick up and use admin-created items
real_wiz false
''';
}
'';
default = { };
};
};
config = lib.mkIf cfg.enable {
users.users.crossfire = {
description = "Crossfire server daemon user";
home = cfg.stateDir;
createHome = false;
isSystemUser = true;
group = "crossfire";
};
users.groups.crossfire = { };
# Merge the cfg.configFiles setting with the default files shipped with
# Crossfire.
# For most files this consists of reading ${crossfire}/etc/crossfire/${name}
# and appending the user setting to it; the motd, news, and rules are handled
# specially, with user-provided values completely replacing the original.
environment.etc =
lib.attrsets.mapAttrs'
(
name: value:
lib.attrsets.nameValuePair "crossfire/${name}" {
mode = "0644";
text =
(lib.optionalString (
!lib.elem name [
"motd"
"news"
"rules"
]
) (lib.fileContents "${cfg.package}/etc/crossfire/${name}"))
+ "\n${value}";
}
)
(
{
ban_file = "";
dm_file = "";
exp_table = "";
forbid = "";
metaserver2 = "";
motd = lib.fileContents "${cfg.package}/etc/crossfire/motd";
news = lib.fileContents "${cfg.package}/etc/crossfire/news";
rules = lib.fileContents "${cfg.package}/etc/crossfire/rules";
settings = "";
stat_bonus = "";
}
// cfg.configFiles
);
systemd.services.crossfire-server = {
description = "Crossfire Server Daemon";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = lib.mkMerge [
{
ExecStart = "${cfg.package}/bin/crossfire-server -conf /etc/crossfire -local '${cfg.stateDir}' -data '${cfg.dataDir}'";
Restart = "always";
User = "crossfire";
Group = "crossfire";
WorkingDirectory = cfg.stateDir;
}
(lib.mkIf (cfg.stateDir == "/var/lib/crossfire") {
StateDirectory = "crossfire";
})
];
# The crossfire server needs access to a bunch of files at runtime that
# are not created automatically at server startup; they're meant to be
# installed in $PREFIX/var/crossfire by `make install`. And those files
# need to be writeable, so we can't just point at the ones in the nix
# store. Instead we take the approach of copying them out of the store
# on first run. If `bookarch` already exists, we assume the rest of the
# files do as well, and copy nothing -- otherwise we risk overwriting
# server state information every time the server is upgraded.
preStart = ''
if [ ! -e "${cfg.stateDir}"/bookarch ]; then
${pkgs.rsync}/bin/rsync -a --chmod=u=rwX,go=rX \
"${cfg.package}/var/crossfire/" "${cfg.stateDir}/"
fi
'';
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ serverPort ];
};
};
}

View File

@@ -0,0 +1,368 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.factorio;
name = "Factorio";
stateDir = "/var/lib/${cfg.stateDirName}";
mkSavePath = name: "${stateDir}/saves/${name}.zip";
configFile = pkgs.writeText "factorio.conf" ''
use-system-read-write-data-directories=true
[path]
read-data=${cfg.package}/share/factorio/data
write-data=${stateDir}
'';
serverSettings = {
name = cfg.game-name;
description = cfg.description;
visibility = {
public = cfg.public;
lan = cfg.lan;
};
username = cfg.username;
password = cfg.password;
token = cfg.token;
game_password = cfg.game-password;
require_user_verification = cfg.requireUserVerification;
max_upload_in_kilobytes_per_second = 0;
minimum_latency_in_ticks = 0;
ignore_player_limit_for_returning_players = false;
allow_commands = "admins-only";
autosave_interval = cfg.autosave-interval;
autosave_slots = 5;
afk_autokick_interval = 0;
auto_pause = true;
only_admins_can_pause_the_game = true;
autosave_only_on_server = true;
non_blocking_saving = cfg.nonBlockingSaving;
}
// cfg.extraSettings;
serverSettingsString = builtins.toJSON (lib.filterAttrsRecursive (n: v: v != null) serverSettings);
serverSettingsFile = pkgs.writeText "server-settings.json" serverSettingsString;
playerListOption =
name: list:
lib.optionalString (
list != [ ]
) "--${name}=${pkgs.writeText "${name}.json" (builtins.toJSON list)}";
modDir = pkgs.factorio-utils.mkModDirDrv cfg.mods cfg.mods-dat;
in
{
options = {
services.factorio = {
enable = lib.mkEnableOption name;
port = lib.mkOption {
type = lib.types.port;
default = 34197;
description = ''
The port to which the service should bind.
'';
};
bind = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = ''
The address to which the service should bind.
'';
};
allowedPlayers = lib.mkOption {
# I would personally prefer for `allowedPlayers = []` to mean "no-one
# can connect" but Factorio seems to ignore empty whitelists (even with
# --use-server-whitelist) so we can't implement that behaviour, so we
# might as well match theirs.
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"Rseding91"
"Oxyd"
];
description = ''
If non-empty, only these player names are allowed to connect. The game
will not be able to save any changes made in-game with the /whitelist
console command, though they will still take effect until the server
is restarted.
If empty, the whitelist defaults to open, but can be managed with the
in-game /whitelist console command (see: /help whitelist), which will
cause changes to be saved to the game's state directory (see also:
`stateDirName`).
'';
};
# Opting not to include the banlist in addition the the whitelist because:
# - banlists are not as often known in advance,
# - losing banlist changes on restart seems much more of a headache.
admins = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "username" ];
description = ''
List of player names which will be admin.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to automatically open the specified UDP port in the firewall.
'';
};
saveName = lib.mkOption {
type = lib.types.str;
default = "default";
description = ''
The name of the savegame that will be used by the server.
When not present in /var/lib/''${config.services.factorio.stateDirName}/saves,
a new map with default settings will be generated before starting the service.
'';
};
loadLatestSave = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Load the latest savegame on startup. This overrides saveName, in that the latest
save will always be used even if a saved game of the given name exists. It still
controls the 'canonical' name of the savegame.
Set this to true to have the server automatically reload a recent autosave after
a crash or desync.
'';
};
# TODO Add more individual settings as nixos-options?
# TODO XXX The server tries to copy a newly created config file over the old one
# on shutdown, but fails, because it's in the nix store. When is this needed?
# Can an admin set options in-game and expect to have them persisted?
configFile = lib.mkOption {
type = lib.types.path;
default = configFile;
defaultText = lib.literalExpression "configFile";
description = ''
The server's configuration file.
The default file generated by this module contains lines essential to
the server's operation. Use its contents as a basis for any
customizations.
'';
};
extraSettingsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
File, which is dynamically applied to server-settings.json before
startup.
This option should be used for credentials.
For example a settings file could contain:
```json
{
"game-password": "hunter1"
}
```
'';
};
stateDirName = lib.mkOption {
type = lib.types.str;
default = "factorio";
description = ''
Name of the directory under /var/lib holding the server's data.
The configuration and map will be stored here.
'';
};
mods = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
description = ''
Mods the server should install and activate.
The derivations in this list must "build" the mod by simply copying
the .zip, named correctly, into the output directory. Eventually,
there will be a way to pull in the most up-to-date list of
derivations via nixos-channel. Until then, this is for experts only.
'';
};
mods-dat = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Mods settings can be changed by specifying a dat file, in the [mod
settings file
format](https://wiki.factorio.com/Mod_settings_file_format).
'';
};
game-name = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "Factorio Game";
description = ''
Name of the game as it will appear in the game listing.
'';
};
description = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "";
description = ''
Description of the game that will appear in the listing.
'';
};
extraSettings = lib.mkOption {
type = lib.types.attrs;
default = { };
example = {
max_players = 64;
};
description = ''
Extra game configuration that will go into server-settings.json
'';
};
public = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Game will be published on the official Factorio matching server.
'';
};
lan = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Game will be broadcast on LAN.
'';
};
username = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Your factorio.com login credentials. Required for games with visibility public.
This option is insecure. Use extraSettingsFile instead.
'';
};
package = lib.mkPackageOption pkgs "factorio-headless" {
example = "factorio-headless-experimental";
};
password = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Your factorio.com login credentials. Required for games with visibility public.
This option is insecure. Use extraSettingsFile instead.
'';
};
token = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Authentication token. May be used instead of 'password' above.
'';
};
game-password = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Game password.
This option is insecure. Use extraSettingsFile instead.
'';
};
requireUserVerification = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
When set to true, the server will only allow clients that have a valid factorio.com account.
'';
};
autosave-interval = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
example = 10;
description = ''
Autosave interval in minutes.
'';
};
nonBlockingSaving = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Highly experimental feature, enable only at your own risk of losing your saves.
On UNIX systems, server will fork itself to create an autosave.
Autosaving on connected Windows clients will be disabled regardless of autosave_only_on_server option.
'';
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.factorio = {
description = "Factorio headless server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
preStart =
(toString [
"test -e ${stateDir}/saves/${cfg.saveName}.zip"
"||"
"${cfg.package}/bin/factorio"
"--config=${cfg.configFile}"
"--create=${mkSavePath cfg.saveName}"
(lib.optionalString (cfg.mods != [ ]) "--mod-directory=${modDir}")
])
+ (lib.optionalString (cfg.extraSettingsFile != null) (
"\necho ${lib.strings.escapeShellArg serverSettingsString}"
+ " \"$(cat ${cfg.extraSettingsFile})\" | ${lib.getExe pkgs.jq} -s add"
+ " > ${stateDir}/server-settings.json"
));
serviceConfig = {
Restart = "always";
KillSignal = "SIGINT";
DynamicUser = true;
StateDirectory = cfg.stateDirName;
UMask = "0007";
ExecStart = toString [
"${cfg.package}/bin/factorio"
"--config=${cfg.configFile}"
"--port=${toString cfg.port}"
"--bind=${cfg.bind}"
(lib.optionalString (!cfg.loadLatestSave) "--start-server=${mkSavePath cfg.saveName}")
"--server-settings=${
if (cfg.extraSettingsFile != null) then "${stateDir}/server-settings.json" else serverSettingsFile
}"
(lib.optionalString cfg.loadLatestSave "--start-server-load-latest")
(lib.optionalString (cfg.mods != [ ]) "--mod-directory=${modDir}")
(playerListOption "server-adminlist" cfg.admins)
(playerListOption "server-whitelist" cfg.allowedPlayers)
(lib.optionalString (cfg.allowedPlayers != [ ]) "--use-server-whitelist")
];
# Sandboxing
NoNewPrivileges = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectControlGroups = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
"AF_NETLINK"
];
RestrictRealtime = true;
RestrictNamespaces = true;
MemoryDenyWriteExecute = true;
};
};
networking.firewall.allowedUDPPorts = lib.optional cfg.openFirewall cfg.port;
};
}

View File

@@ -0,0 +1,231 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.freeciv;
inherit (config.users) groups;
rootDir = "/run/freeciv";
argsFormat = {
type =
with lib.types;
let
valueType =
nullOr (oneOf [
bool
int
float
str
(listOf valueType)
])
// {
description = "freeciv-server params";
};
in
valueType;
generate =
name: value:
let
mkParam =
k: v:
if v == null then
[ ]
else if lib.isBool v then
lib.optional v ("--" + k)
else
[
("--" + k)
v
];
mkParams = k: v: map (mkParam k) (if lib.isList v then v else [ v ]);
in
lib.escapeShellArgs (lib.concatLists (lib.concatLists (lib.mapAttrsToList mkParams value)));
};
in
{
options = {
services.freeciv = {
enable = lib.mkEnableOption ''freeciv'';
settings = lib.mkOption {
description = ''
Parameters of freeciv-server.
'';
default = { };
type = lib.types.submodule {
freeformType = argsFormat.type;
options.Announce = lib.mkOption {
type = lib.types.enum [
"IPv4"
"IPv6"
"none"
];
default = "none";
description = "Announce game in LAN using given protocol.";
};
options.auth = lib.mkEnableOption "server authentication";
options.Database = lib.mkOption {
type = lib.types.nullOr lib.types.str;
apply = pkgs.writeText "auth.conf";
default = ''
[fcdb]
backend="sqlite"
database="/var/lib/freeciv/auth.sqlite"
'';
description = "Enable database connection with given configuration.";
};
options.debug = lib.mkOption {
type = lib.types.ints.between 0 3;
default = 0;
description = "Set debug log level.";
};
options.exit-on-end = lib.mkEnableOption "exit instead of restarting when a game ends";
options.Guests = lib.mkEnableOption "guests to login if auth is enabled";
options.Newusers = lib.mkEnableOption "new users to login if auth is enabled";
options.port = lib.mkOption {
type = lib.types.port;
default = 5556;
description = "Listen for clients on given port";
};
options.quitidle = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = "Quit if no players for given time in seconds.";
};
options.read = lib.mkOption {
type = lib.types.lines;
apply = v: pkgs.writeTextDir "read.serv" v + "/read";
default = ''
/fcdb lua sqlite_createdb()
'';
description = "Startup script.";
};
options.saves = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "/var/lib/freeciv/saves/";
description = ''
Save games to given directory,
a sub-directory named after the starting date of the service
will me inserted to preserve older saves.
'';
};
};
};
openFirewall = lib.mkEnableOption "opening the firewall for the port listening for clients";
};
};
config = lib.mkIf cfg.enable {
users.groups.freeciv = { };
# Use with:
# journalctl -u freeciv.service -f -o cat &
# cat >/run/freeciv.stdin
# load saves/2020-11-14_05-22-27/freeciv-T0005-Y-3750-interrupted.sav.bz2
systemd.sockets.freeciv = {
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenFIFO = "/run/freeciv.stdin";
SocketGroup = groups.freeciv.name;
SocketMode = "660";
RemoveOnStop = true;
};
};
systemd.services.freeciv = {
description = "Freeciv Service";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment.HOME = "/var/lib/freeciv";
serviceConfig = {
Restart = "on-failure";
RestartSec = "5s";
StandardInput = "fd:freeciv.socket";
StandardOutput = "journal";
StandardError = "journal";
ExecStart = pkgs.writeShellScript "freeciv-server" (
''
set -eux
savedir=$(date +%Y-%m-%d_%H-%M-%S)
''
+ "${pkgs.freeciv}/bin/freeciv-server"
+ " "
+ lib.optionalString (cfg.settings.saves != null) (
lib.concatStringsSep " " [
"--saves"
"${lib.escapeShellArg cfg.settings.saves}/$savedir"
]
)
+ " "
+ argsFormat.generate "freeciv-server" (cfg.settings // { saves = null; })
);
DynamicUser = true;
# Create rootDir in the host's mount namespace.
RuntimeDirectory = [ (baseNameOf rootDir) ];
RuntimeDirectoryMode = "755";
StateDirectory = [ "freeciv" ];
WorkingDirectory = "/var/lib/freeciv";
# Avoid mounting rootDir in the own rootDir of ExecStart='s mount namespace.
InaccessiblePaths = [ "-+${rootDir}" ];
# This is for BindPaths= and BindReadOnlyPaths=
# to allow traversal of directories they create in RootDirectory=.
UMask = "0066";
RootDirectory = rootDir;
RootDirectoryStartOnly = true;
MountAPIVFS = true;
BindReadOnlyPaths = [
builtins.storeDir
"/etc"
"/run"
];
# The following options are only for optimizing:
# systemd-analyze security freeciv
AmbientCapabilities = "";
CapabilityBoundingSet = "";
# ProtectClock= adds DeviceAllow=char-rtc r
DeviceAllow = "";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateNetwork = lib.mkDefault false;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallFilter = [
"@system-service"
# Groups in @system-service which do not contain a syscall listed by:
# perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' freeciv-server
# in tests, and seem likely not necessary for freeciv-server.
"~@aio"
"~@chown"
"~@ipc"
"~@keyring"
"~@memlock"
"~@resources"
"~@setuid"
"~@sync"
"~@timer"
];
SystemCallArchitectures = "native";
SystemCallErrorNumber = "EPERM";
};
};
networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.settings.port ]; };
};
meta.maintainers = with lib.maintainers; [ julm ];
}

View File

@@ -0,0 +1,361 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.mchprs;
settingsFormat = pkgs.formats.toml { };
whitelistFile = pkgs.writeText "whitelist.json" (
builtins.toJSON (
lib.mapAttrsToList (n: v: {
name = n;
uuid = v;
}) cfg.whitelist.list
)
);
configToml =
(removeAttrs cfg.settings [
"address"
"port"
])
// {
bind_address = cfg.settings.address + ":" + toString cfg.settings.port;
whitelist = cfg.whitelist.enable;
};
configTomlFile = settingsFormat.generate "Config.toml" configToml;
in
{
options = {
services.mchprs = {
enable = lib.mkEnableOption "MCHPRS, a Minecraft server";
declarativeSettings = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to use a declarative configuration for MCHPRS.
'';
};
declarativeWhitelist = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to use a declarative whitelist.
The options {option}`services.mchprs.whitelist.list`
will be applied if and only if set to `true`.
'';
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/mchprs";
description = ''
Directory to store MCHPRS database and other state/data files.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to open ports in the firewall for the server.
Only has effect when
{option}`services.mchprs.declarativeSettings` is `true`.
'';
};
maxRuntime = lib.mkOption {
type = lib.types.str;
default = "infinity";
example = "7d";
description = ''
Automatically restart the server after
{option}`services.mchprs.maxRuntime`.
The {manpage}`systemd.time(7)` time span format is described here:
<https://www.freedesktop.org/software/systemd/man/systemd.time.html#Parsing%20Time%20Spans>.
If `null`, then the server is not restarted automatically.
'';
};
package = lib.mkPackageOption pkgs "mchprs" { };
settings = lib.mkOption {
type = lib.types.submodule {
freeformType = settingsFormat.type;
options = {
port = lib.mkOption {
type = lib.types.port;
default = 25565;
description = ''
Port for the server.
Only has effect when
{option}`services.mchprs.declarativeSettings` is `true`.
'';
};
address = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = ''
Address for the server.
Please use enclosing square brackets when using ipv6.
Only has effect when
{option}`services.mchprs.declarativeSettings` is `true`.
'';
};
motd = lib.mkOption {
type = lib.types.str;
default = "Minecraft High Performance Redstone Server";
description = ''
Message of the day.
Only has effect when
{option}`services.mchprs.declarativeSettings` is `true`.
'';
};
chat_format = lib.mkOption {
type = lib.types.str;
default = "<{username}> {message}";
description = ''
How to format chat message interpolating `username`
and `message` with curly braces.
Only has effect when
{option}`services.mchprs.declarativeSettings` is `true`.
'';
};
max_players = lib.mkOption {
type = lib.types.ints.positive;
default = 99999;
description = ''
Maximum number of simultaneous players.
Only has effect when
{option}`services.mchprs.declarativeSettings` is `true`.
'';
};
view_distance = lib.mkOption {
type = lib.types.ints.positive;
default = 8;
description = ''
Maximal distance (in chunks) between players and loaded chunks.
Only has effect when
{option}`services.mchprs.declarativeSettings` is `true`.
'';
};
bungeecord = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Enable compatibility with
[BungeeCord](https://github.com/SpigotMC/BungeeCord).
Only has effect when
{option}`services.mchprs.declarativeSettings` is `true`.
'';
};
schemati = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Mimic the verification and directory layout used by the
Open Redstone Engineers
[Schemati plugin](https://github.com/OpenRedstoneEngineers/Schemati).
Only has effect when
{option}`services.mchprs.declarativeSettings` is `true`.
'';
};
block_in_hitbox = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Allow placing blocks inside of players
(hitbox logic is simplified).
Only has effect when
{option}`services.mchprs.declarativeSettings` is `true`.
'';
};
auto_redpiler = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Use redpiler automatically.
Only has effect when
{option}`services.mchprs.declarativeSettings` is `true`.
'';
};
};
};
default = { };
description = ''
Configuration for MCHPRS via `Config.toml`.
See <https://github.com/MCHPR/MCHPRS/blob/master/README.md> for documentation.
'';
};
whitelist = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether or not the whitelist (in `whitelist.json`) shoud be enabled.
Only has effect when {option}`services.mchprs.declarativeSettings` is `true`.
'';
};
list = lib.mkOption {
type =
let
minecraftUUID =
lib.types.strMatching "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
// {
description = "Minecraft UUID";
};
in
lib.types.attrsOf minecraftUUID;
default = { };
example = lib.literalExpression ''
{
username1 = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
username2 = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy";
};
'';
description = ''
Whitelisted players, only has an effect when
{option}`services.mchprs.declarativeWhitelist` is
`true` and the whitelist is enabled
via {option}`services.mchprs.whitelist.enable`.
This is a mapping from Minecraft usernames to UUIDs.
You can use <https://mcuuid.net/> to get a
Minecraft UUID for a username.
'';
};
};
};
};
config = lib.mkIf cfg.enable {
users.users.mchprs = {
description = "MCHPRS service user";
home = cfg.dataDir;
createHome = true;
isSystemUser = true;
group = "mchprs";
};
users.groups.mchprs = { };
systemd.services.mchprs = {
description = "MCHPRS Service";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = "${lib.getExe cfg.package}";
Restart = "always";
RuntimeMaxSec = cfg.maxRuntime;
User = "mchprs";
WorkingDirectory = cfg.dataDir;
StandardOutput = "journal";
StandardError = "journal";
# Hardening
CapabilityBoundingSet = [ "" ];
DeviceAllow = [ "" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
UMask = "0077";
};
preStart =
(
if cfg.declarativeSettings then
''
if [ -e .declarativeSettings ]; then
# Settings were declarative before, no need to back up anything
cp -f ${configTomlFile} Config.toml
else
# Declarative settings for the first time, backup stateful files
cp -b --suffix=.stateful ${configTomlFile} Config.toml
echo "Autogenerated file that implies that this server configuration is managed declaratively by NixOS" \
> .declarativeSettings
fi
''
else
''
if [ -e .declarativeSettings ]; then
rm .declarativeSettings
fi
''
)
+ (
if cfg.declarativeWhitelist then
''
if [ -e .declarativeWhitelist ]; then
# Whitelist was declarative before, no need to back up anything
ln -sf ${whitelistFile} whitelist.json
else
# Declarative whitelist for the first time, backup stateful files
ln -sb --suffix=.stateful ${whitelistFile} whitelist.json
echo "Autogenerated file that implies that this server's whitelist is managed declaratively by NixOS" \
> .declarativeWhitelist
fi
''
else
''
if [ -e .declarativeWhitelist ]; then
rm .declarativeWhitelist
fi
''
);
};
networking.firewall = lib.mkIf (cfg.declarativeSettings && cfg.openFirewall) {
allowedUDPPorts = [ cfg.settings.port ];
allowedTCPPorts = [ cfg.settings.port ];
};
};
meta.maintainers = with lib.maintainers; [ gdd ];
}

View File

@@ -0,0 +1,326 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.minecraft-server;
# We don't allow eula=false anyways
eulaFile = builtins.toFile "eula.txt" ''
# eula.txt managed by NixOS Configuration
eula=true
'';
whitelistFile = pkgs.writeText "whitelist.json" (
builtins.toJSON (
lib.mapAttrsToList (n: v: {
name = n;
uuid = v;
}) cfg.whitelist
)
);
cfgToString = v: if builtins.isBool v then lib.boolToString v else toString v;
serverPropertiesFile = pkgs.writeText "server.properties" (
''
# server.properties managed by NixOS configuration
''
+ lib.concatStringsSep "\n" (
lib.mapAttrsToList (n: v: "${n}=${cfgToString v}") cfg.serverProperties
)
);
stopScript = pkgs.writeShellScript "minecraft-server-stop" ''
echo stop > ${config.systemd.sockets.minecraft-server.socketConfig.ListenFIFO}
# Wait for the PID of the minecraft server to disappear before
# returning, so systemd doesn't attempt to SIGKILL it.
while kill -0 "$1" 2> /dev/null; do
sleep 1s
done
'';
# To be able to open the firewall, we need to read out port values in the
# server properties, but fall back to the defaults when those don't exist.
# These defaults are from https://minecraft.wiki/w/Server.properties#Java_Edition
defaultServerPort = 25565;
serverPort = cfg.serverProperties.server-port or defaultServerPort;
rconPort =
if cfg.serverProperties.enable-rcon or false then
cfg.serverProperties."rcon.port" or 25575
else
null;
queryPort =
if cfg.serverProperties.enable-query or false then
cfg.serverProperties."query.port" or 25565
else
null;
in
{
options = {
services.minecraft-server = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
If enabled, start a Minecraft Server. The server
data will be loaded from and saved to
{option}`services.minecraft-server.dataDir`.
'';
};
declarative = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to use a declarative Minecraft server configuration.
Only if set to `true`, the options
{option}`services.minecraft-server.whitelist` and
{option}`services.minecraft-server.serverProperties` will be
applied.
'';
};
eula = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether you agree to [Mojangs EULA](https://www.minecraft.net/eula).
This option must be set to `true` to run Minecraft server.
'';
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/minecraft";
description = ''
Directory to store Minecraft database and other state/data files.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to open ports in the firewall for the server.
'';
};
whitelist = lib.mkOption {
type =
let
minecraftUUID =
lib.types.strMatching "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
// {
description = "Minecraft UUID";
};
in
lib.types.attrsOf minecraftUUID;
default = { };
description = ''
Whitelisted players, only has an effect when
{option}`services.minecraft-server.declarative` is
`true` and the whitelist is enabled
via {option}`services.minecraft-server.serverProperties` by
setting `white-list` to `true`.
This is a mapping from Minecraft usernames to UUIDs.
You can use <https://mcuuid.net/> to get a
Minecraft UUID for a username.
'';
example = lib.literalExpression ''
{
username1 = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
username2 = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy";
};
'';
};
serverProperties = lib.mkOption {
type =
with lib.types;
attrsOf (oneOf [
bool
int
str
]);
default = { };
example = lib.literalExpression ''
{
server-port = 43000;
difficulty = 3;
gamemode = 1;
max-players = 5;
motd = "NixOS Minecraft server!";
white-list = true;
enable-rcon = true;
"rcon.password" = "hunter2";
}
'';
description = ''
Minecraft server properties for the server.properties file. Only has
an effect when {option}`services.minecraft-server.declarative`
is set to `true`. See
<https://minecraft.wiki/w/Server.properties#Java_Edition>
for documentation on these values.
'';
};
package = lib.mkPackageOption pkgs "minecraft-server" {
example = "pkgs.minecraft-server_1_12_2";
};
jvmOpts = lib.mkOption {
type = lib.types.separatedString " ";
default = "-Xmx2048M -Xms2048M";
# Example options from https://minecraft.wiki/w/Tutorial:Server_startup_script
example =
"-Xms4092M -Xmx4092M -XX:+UseG1GC -XX:+CMSIncrementalPacing "
+ "-XX:+CMSClassUnloadingEnabled -XX:ParallelGCThreads=2 "
+ "-XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10";
description = "JVM options for the Minecraft server.";
};
};
};
config = lib.mkIf cfg.enable {
users.users.minecraft = {
description = "Minecraft server service user";
home = cfg.dataDir;
createHome = true;
isSystemUser = true;
group = "minecraft";
};
users.groups.minecraft = { };
systemd.sockets.minecraft-server = {
bindsTo = [ "minecraft-server.service" ];
socketConfig = {
ListenFIFO = "/run/minecraft-server.stdin";
SocketMode = "0660";
SocketUser = "minecraft";
SocketGroup = "minecraft";
RemoveOnStop = true;
FlushPending = true;
};
};
systemd.services.minecraft-server = {
description = "Minecraft Server Service";
wantedBy = [ "multi-user.target" ];
requires = [ "minecraft-server.socket" ];
after = [
"network.target"
"minecraft-server.socket"
];
serviceConfig = {
ExecStart = "${cfg.package}/bin/minecraft-server ${cfg.jvmOpts}";
ExecStop = "${stopScript} $MAINPID";
Restart = "always";
User = "minecraft";
WorkingDirectory = cfg.dataDir;
StandardInput = "socket";
StandardOutput = "journal";
StandardError = "journal";
# Hardening
CapabilityBoundingSet = [ "" ];
DeviceAllow = [ "" ];
LockPersonality = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
UMask = "0077";
};
preStart = ''
ln -sf ${eulaFile} eula.txt
''
+ (
if cfg.declarative then
''
if [ -e .declarative ]; then
# Was declarative before, no need to back up anything
ln -sf ${whitelistFile} whitelist.json
cp -f ${serverPropertiesFile} server.properties
else
# Declarative for the first time, backup stateful files
ln -sb --suffix=.stateful ${whitelistFile} whitelist.json
cp -b --suffix=.stateful ${serverPropertiesFile} server.properties
# server.properties must have write permissions, because every time
# the server starts it first parses the file and then regenerates it..
chmod +w server.properties
echo "Autogenerated file that signifies that this server configuration is managed declaratively by NixOS" \
> .declarative
fi
''
else
''
if [ -e .declarative ]; then
rm .declarative
fi
''
);
};
networking.firewall = lib.mkIf cfg.openFirewall (
if cfg.declarative then
{
allowedUDPPorts = [ serverPort ];
allowedTCPPorts = [
serverPort
]
++ lib.optional (queryPort != null) queryPort
++ lib.optional (rconPort != null) rconPort;
}
else
{
allowedUDPPorts = [ defaultServerPort ];
allowedTCPPorts = [ defaultServerPort ];
}
);
assertions = [
{
assertion = cfg.eula;
message =
"You must agree to Mojangs EULA to run minecraft-server."
+ " Read https://account.mojang.com/documents/minecraft_eula and"
+ " set `services.minecraft-server.eula` to `true` if you agree.";
}
];
};
}

View File

@@ -0,0 +1,183 @@
{
config,
lib,
pkgs,
...
}:
let
CONTAINS_NEWLINE_RE = ".*\n.*";
# The following values are reserved as complete option values:
# { - start of a group.
# """ - start of a multi-line string.
RESERVED_VALUE_RE = "[[:space:]]*(\"\"\"|\\{)[[:space:]]*";
NEEDS_MULTILINE_RE = "${CONTAINS_NEWLINE_RE}|${RESERVED_VALUE_RE}";
# There is no way to encode """ on its own line in a Minetest config.
UNESCAPABLE_RE = ".*\n\"\"\"\n.*";
toConfMultiline =
name: value:
assert lib.assertMsg (
(builtins.match UNESCAPABLE_RE value) == null
) ''""" can't be on its own line in a minetest config.'';
"${name} = \"\"\"\n${value}\n\"\"\"\n";
toConf =
values:
lib.concatStrings (
lib.mapAttrsToList (
name: value:
{
bool = "${name} = ${toString value}\n";
int = "${name} = ${toString value}\n";
null = "";
set = "${name} = {\n${toConf value}}\n";
string =
if (builtins.match NEEDS_MULTILINE_RE value) != null then
toConfMultiline name value
else
"${name} = ${value}\n";
}
.${builtins.typeOf value}
) values
);
cfg = config.services.minetest-server;
flag =
val: name:
lib.optionals (val != null) [
"--${name}"
"${toString val}"
];
flags = [
"--server"
]
++ (
if cfg.configPath != null then
[
"--config"
cfg.configPath
]
else
[
"--config"
(builtins.toFile "minetest.conf" (toConf cfg.config))
]
)
++ (flag cfg.gameId "gameid")
++ (flag cfg.world "world")
++ (flag cfg.logPath "logfile")
++ (flag cfg.port "port")
++ cfg.extraArgs;
in
{
options = {
services.minetest-server = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "If enabled, starts a Minetest Server.";
};
gameId = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Id of the game to use. To list available games run
`minetestserver --gameid list`.
If only one game exists, this option can be null.
'';
};
world = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Name of the world to use. To list available worlds run
`minetestserver --world list`.
If only one world exists, this option can be null.
'';
};
configPath = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to the config to use.
If set to null, the config of the running user will be used:
`~/.minetest/minetest.conf`.
'';
};
config = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { };
description = ''
Settings to add to the minetest config file.
This option is ignored if `configPath` is set.
'';
};
logPath = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to logfile for logging.
If set to null, logging will be output to stdout which means
all output will be caught by systemd.
'';
};
port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
description = ''
Port number to bind to.
If set to null, the default 30000 will be used.
'';
};
extraArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Additional command line flags to pass to the minetest executable.
'';
};
};
};
config = lib.mkIf cfg.enable {
users.users.minetest = {
description = "Minetest Server Service user";
home = "/var/lib/minetest";
createHome = true;
uid = config.ids.uids.minetest;
group = "minetest";
};
users.groups.minetest.gid = config.ids.gids.minetest;
systemd.services.minetest-server = {
description = "Minetest Server Service";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig.Restart = "always";
serviceConfig.User = "minetest";
serviceConfig.Group = "minetest";
script = ''
cd /var/lib/minetest
exec ${pkgs.minetest}/bin/minetest ${lib.escapeShellArgs flags}
'';
};
};
}

View File

@@ -0,0 +1,67 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
concatStringsSep
mkEnableOption
mkIf
mkOption
types
;
cfg = config.services.openarena;
in
{
options = {
services.openarena = {
enable = mkEnableOption "OpenArena game server";
package = lib.mkPackageOption pkgs "openarena" { };
openPorts = mkOption {
type = types.bool;
default = false;
description = "Whether to open firewall ports for OpenArena";
};
extraFlags = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Extra flags to pass to {command}`oa_ded`";
example = [
"+set dedicated 2"
"+set sv_hostname 'My NixOS OpenArena Server'"
# Load a map. Mandatory for clients to be able to connect.
"+map oa_dm1"
];
};
};
};
config = mkIf cfg.enable {
networking.firewall = mkIf cfg.openPorts {
allowedUDPPorts = [ 27960 ];
};
systemd.services.openarena = {
description = "OpenArena";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
DynamicUser = true;
StateDirectory = "openarena";
ExecStart = "${cfg.package}/bin/oa_ded +set fs_basepath ${cfg.package}/share/openarena +set fs_homepath /var/lib/openarena ${concatStringsSep " " cfg.extraFlags}";
Restart = "on-failure";
# Hardening
CapabilityBoundingSet = "";
NoNewPrivileges = true;
PrivateDevices = true;
};
};
};
}

View File

@@ -0,0 +1,132 @@
{
config,
pkgs,
lib,
...
}:
let
inherit (lib)
literalMD
mkEnableOption
mkIf
mkOption
types
;
cfg = config.services.quake3-server;
configFile = pkgs.writeText "q3ds-extra.cfg" ''
set net_port ${builtins.toString cfg.port}
${cfg.extraConfig}
'';
defaultBaseq3 = pkgs.requireFile rec {
name = "baseq3";
hashMode = "recursive";
sha256 = "5dd8ee09eabd45e80450f31d7a8b69b846f59738726929298d8a813ce5725ed3";
message = ''
Unfortunately, we cannot download ${name} automatically.
Please purchase a legitimate copy of Quake 3 and change into the installation directory.
You can either add all relevant files to the nix-store like this:
mkdir /tmp/baseq3
cp baseq3/pak*.pk3 /tmp/baseq3
nix-store --add-fixed sha256 --recursive /tmp/baseq3
Alternatively you can set services.quake3-server.baseq3 to a path and copy the baseq3 directory into
$services.quake3-server.baseq3/.q3a/
'';
};
home = pkgs.runCommand "quake3-home" { } ''
mkdir -p $out/.q3a/baseq3
for file in ${cfg.baseq3}/*; do
ln -s $file $out/.q3a/baseq3/$(basename $file)
done
ln -s ${configFile} $out/.q3a/baseq3/nix.cfg
'';
in
{
options = {
services.quake3-server = {
enable = mkEnableOption "Quake 3 dedicated server";
package = lib.mkPackageOption pkgs "ioquake3" { };
port = mkOption {
type = types.port;
default = 27960;
description = ''
UDP Port the server should listen on.
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Open the firewall.
'';
};
extraConfig = mkOption {
type = types.lines;
default = "";
example = ''
seta rconPassword "superSecret" // sets RCON password for remote console
seta sv_hostname "My Quake 3 server" // name that appears in server list
'';
description = ''
Extra configuration options. Note that options changed via RCON will not be persisted. To list all possible
options, use "cvarlist 1" via RCON.
'';
};
baseq3 = mkOption {
type = types.either types.package types.path;
default = defaultBaseq3;
defaultText = literalMD "Manually downloaded Quake 3 installation directory.";
example = "/var/lib/q3ds";
description = ''
Path to the baseq3 files (pak*.pk3). If this is on the nix store (type = package) all .pk3 files should be saved
in the top-level directory. If this is on another filesystem (e.g /var/lib/baseq3) the .pk3 files are searched in
$baseq3/.q3a/baseq3/
'';
};
};
};
config =
let
baseq3InStore = builtins.typeOf cfg.baseq3 == "set";
in
mkIf cfg.enable {
networking.firewall.allowedUDPPorts = mkIf cfg.openFirewall [ cfg.port ];
systemd.services.q3ds = {
description = "Quake 3 dedicated server";
wantedBy = [ "multi-user.target" ];
after = [ "networking.target" ];
environment.HOME = if baseq3InStore then home else cfg.baseq3;
serviceConfig = with lib; {
Restart = "always";
DynamicUser = true;
WorkingDirectory = home;
# It is possible to alter configuration files via RCON. To ensure reproducibility we have to prevent this
ReadOnlyPaths = if baseq3InStore then home else cfg.baseq3;
ExecStartPre = optionalString (
!baseq3InStore
) "+${pkgs.coreutils}/bin/cp ${configFile} ${cfg.baseq3}/.q3a/baseq3/nix.cfg";
ExecStart = "${cfg.package}/bin/ioq3ded +exec nix.cfg";
};
};
};
meta.maintainers = with lib.maintainers; [ f4814n ];
}

View File

@@ -0,0 +1,462 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.teeworlds;
register = cfg.register;
bool = b: if b != null && b then "1" else "0";
optionalSetting = s: setting: lib.optionalString (s != null) "${setting} ${s}";
lookup =
attrs: key: default:
if attrs ? key then attrs."${key}" else default;
inactivePenaltyOptions = {
"spectator" = "1";
"spectator/kick" = "2";
"kick" = "3";
};
skillLevelOptions = {
"casual" = "0";
"normal" = "1";
"competitive" = "2";
};
tournamentModeOptions = {
"disable" = "0";
"enable" = "1";
"restrictSpectators" = "2";
};
teeworldsConf = pkgs.writeText "teeworlds.cfg" ''
sv_port ${toString cfg.port}
sv_register ${bool cfg.register}
sv_name ${cfg.name}
${optionalSetting cfg.motd "sv_motd"}
${optionalSetting cfg.password "password"}
${optionalSetting cfg.rconPassword "sv_rcon_password"}
${optionalSetting cfg.server.bindAddr "bindaddr"}
${optionalSetting cfg.server.hostName "sv_hostname"}
sv_high_bandwidth ${bool cfg.server.enableHighBandwidth}
sv_inactivekick ${lookup inactivePenaltyOptions cfg.server.inactivePenalty "spectator/kick"}
sv_inactivekick_spec ${bool cfg.server.kickInactiveSpectators}
sv_inactivekick_time ${toString cfg.server.inactiveTime}
sv_max_clients ${toString cfg.server.maxClients}
sv_max_clients_per_ip ${toString cfg.server.maxClientsPerIP}
sv_skill_level ${lookup skillLevelOptions cfg.server.skillLevel "normal"}
sv_spamprotection ${bool cfg.server.enableSpamProtection}
sv_gametype ${cfg.game.gameType}
sv_map ${cfg.game.map}
sv_match_swap ${bool cfg.game.swapTeams}
sv_player_ready_mode ${bool cfg.game.enableReadyMode}
sv_player_slots ${toString cfg.game.playerSlots}
sv_powerups ${bool cfg.game.enablePowerups}
sv_scorelimit ${toString cfg.game.scoreLimit}
sv_strict_spectate_mode ${bool cfg.game.restrictSpectators}
sv_teamdamage ${bool cfg.game.enableTeamDamage}
sv_timelimit ${toString cfg.game.timeLimit}
sv_tournament_mode ${lookup tournamentModeOptions cfg.server.tournamentMode "disable"}
sv_vote_kick ${bool cfg.game.enableVoteKick}
sv_vote_kick_bantime ${toString cfg.game.voteKickBanTime}
sv_vote_kick_min ${toString cfg.game.voteKickMinimumPlayers}
${optionalSetting cfg.server.bindAddr "bindaddr"}
${optionalSetting cfg.server.hostName "sv_hostname"}
sv_high_bandwidth ${bool cfg.server.enableHighBandwidth}
sv_inactivekick ${lookup inactivePenaltyOptions cfg.server.inactivePenalty "spectator/kick"}
sv_inactivekick_spec ${bool cfg.server.kickInactiveSpectators}
sv_inactivekick_time ${toString cfg.server.inactiveTime}
sv_max_clients ${toString cfg.server.maxClients}
sv_max_clients_per_ip ${toString cfg.server.maxClientsPerIP}
sv_skill_level ${lookup skillLevelOptions cfg.server.skillLevel "normal"}
sv_spamprotection ${bool cfg.server.enableSpamProtection}
sv_gametype ${cfg.game.gameType}
sv_map ${cfg.game.map}
sv_match_swap ${bool cfg.game.swapTeams}
sv_player_ready_mode ${bool cfg.game.enableReadyMode}
sv_player_slots ${toString cfg.game.playerSlots}
sv_powerups ${bool cfg.game.enablePowerups}
sv_scorelimit ${toString cfg.game.scoreLimit}
sv_strict_spectate_mode ${bool cfg.game.restrictSpectators}
sv_teamdamage ${bool cfg.game.enableTeamDamage}
sv_timelimit ${toString cfg.game.timeLimit}
sv_tournament_mode ${lookup tournamentModeOptions cfg.server.tournamentMode "disable"}
sv_vote_kick ${bool cfg.game.enableVoteKick}
sv_vote_kick_bantime ${toString cfg.game.voteKickBanTime}
sv_vote_kick_min ${toString cfg.game.voteKickMinimumPlayers}
${lib.concatStringsSep "\n" cfg.extraOptions}
'';
in
{
options = {
services.teeworlds = {
enable = lib.mkEnableOption "Teeworlds Server";
package = lib.mkPackageOption pkgs "teeworlds-server" { };
openPorts = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open firewall ports for Teeworlds.";
};
name = lib.mkOption {
type = lib.types.str;
default = "unnamed server";
description = ''
Name of the server.
'';
};
register = lib.mkOption {
type = lib.types.bool;
example = true;
default = false;
description = ''
Whether the server registers as a public server in the global server list. This is disabled by default for privacy reasons.
'';
};
motd = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
The server's message of the day text.
'';
};
password = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Password to connect to the server.
'';
};
rconPassword = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Password to access the remote console. If not set, a randomly generated one is displayed in the server log.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 8303;
description = ''
Port the server will listen on.
'';
};
extraOptions = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Extra configuration lines for the {file}`teeworlds.cfg`. See [Teeworlds Documentation](https://www.teeworlds.com/?page=docs&wiki=server_settings).
'';
example = [
"sv_map dm1"
"sv_gametype dm"
];
};
server = {
bindAddr = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
The address the server will bind to.
'';
};
enableHighBandwidth = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to enable high bandwidth mode on LAN servers. This will double the amount of bandwidth required for running the server.
'';
};
hostName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Hostname for the server.
'';
};
inactivePenalty = lib.mkOption {
type = lib.types.enum [
"spectator"
"spectator/kick"
"kick"
];
example = "spectator";
default = "spectator/kick";
description = ''
Specify what to do when a client goes inactive (see [](#opt-services.teeworlds.server.inactiveTime)).
- `spectator`: send the client into spectator mode
- `spectator/kick`: send the client into a free spectator slot, otherwise kick the client
- `kick`: kick the client
'';
};
kickInactiveSpectators = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to kick inactive spectators.
'';
};
inactiveTime = lib.mkOption {
type = lib.types.ints.unsigned;
default = 3;
description = ''
The amount of minutes a client has to idle before it is considered inactive.
'';
};
maxClients = lib.mkOption {
type = lib.types.ints.unsigned;
default = 12;
description = ''
The maximum amount of clients that can be connected to the server at the same time.
'';
};
maxClientsPerIP = lib.mkOption {
type = lib.types.ints.unsigned;
default = 12;
description = ''
The maximum amount of clients with the same IP address that can be connected to the server at the same time.
'';
};
skillLevel = lib.mkOption {
type = lib.types.enum [
"casual"
"normal"
"competitive"
];
default = "normal";
description = ''
The skill level shown in the server browser.
'';
};
enableSpamProtection = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to enable chat spam protection.
'';
};
};
game = {
gameType = lib.mkOption {
type = lib.types.str;
example = "ctf";
default = "dm";
description = ''
The game type to use on the server.
The default gametypes are `dm`, `tdm`, `ctf`, `lms`, and `lts`.
'';
};
map = lib.mkOption {
type = lib.types.str;
example = "ctf5";
default = "dm1";
description = ''
The map to use on the server.
'';
};
swapTeams = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to swap teams each round.
'';
};
enableReadyMode = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to enable "ready mode"; where players can pause/unpause the game
and start the game in warmup, using their ready state.
'';
};
playerSlots = lib.mkOption {
type = lib.types.ints.unsigned;
default = 8;
description = ''
The amount of slots to reserve for players (as opposed to spectators).
'';
};
enablePowerups = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to allow powerups such as the ninja.
'';
};
scoreLimit = lib.mkOption {
type = lib.types.ints.unsigned;
example = 400;
default = 20;
description = ''
The score limit needed to win a round.
'';
};
restrictSpectators = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to restrict access to information such as health, ammo and armour in spectator mode.
'';
};
enableTeamDamage = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to enable team damage; whether to allow team mates to inflict damage on one another.
'';
};
timeLimit = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = ''
Time limit of the game. In cases of equal points, there will be sudden death.
Setting this to 0 disables a time limit.
'';
};
tournamentMode = lib.mkOption {
type = lib.types.enum [
"disable"
"enable"
"restrictSpectators"
];
default = "disable";
description = ''
Whether to enable tournament mode. In tournament mode, players join as spectators.
If this is set to `restrictSpectators`, tournament mode is enabled but spectator chat is restricted.
'';
};
enableVoteKick = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to enable voting to kick players.
'';
};
voteKickBanTime = lib.mkOption {
type = lib.types.ints.unsigned;
default = 5;
description = ''
The amount of minutes that a player is banned for if they get kicked by a vote.
'';
};
voteKickMinimumPlayers = lib.mkOption {
type = lib.types.ints.unsigned;
default = 5;
description = ''
The minimum amount of players required to start a kick vote.
'';
};
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/var/lib/teeworlds/teeworlds.env";
description = ''
Environment file as defined in {manpage}`systemd.exec(5)`.
Secrets may be passed to the service without adding them to the world-readable
Nix store, by specifying placeholder variables as the option value in Nix and
setting these variables accordingly in the environment file.
```
# snippet of teeworlds-related config
services.teeworlds.password = "$TEEWORLDS_PASSWORD";
```
```
# content of the environment file
TEEWORLDS_PASSWORD=verysecretpassword
```
Note that this file needs to be available on the host on which
`teeworlds` is running.
'';
};
};
};
config = lib.mkIf cfg.enable {
networking.firewall = lib.mkIf cfg.openPorts {
allowedUDPPorts = [ cfg.port ];
};
systemd.services.teeworlds = {
description = "Teeworlds Server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
DynamicUser = true;
RuntimeDirectory = "teeworlds";
RuntimeDirectoryMode = "0700";
EnvironmentFile = lib.mkIf (cfg.environmentFile != null) [ cfg.environmentFile ];
ExecStartPre = ''
${pkgs.envsubst}/bin/envsubst \
-i ${teeworldsConf} \
-o /run/teeworlds/teeworlds.yaml
'';
ExecStart = "${lib.getExe cfg.package} -f /run/teeworlds/teeworlds.yaml";
# Hardening
CapabilityBoundingSet = false;
PrivateDevices = true;
PrivateUsers = true;
ProtectHome = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
SystemCallArchitectures = "native";
};
};
};
}

View File

@@ -0,0 +1,192 @@
{
config,
lib,
options,
pkgs,
...
}:
let
cfg = config.services.terraria;
opt = options.services.terraria;
worldSizeMap = {
small = 1;
medium = 2;
large = 3;
};
valFlag =
name: val:
lib.optionalString (val != null) "-${name} \"${lib.escape [ "\\" "\"" ] (toString val)}\"";
boolFlag = name: val: lib.optionalString val "-${name}";
flags = [
(valFlag "port" cfg.port)
(valFlag "maxPlayers" cfg.maxPlayers)
(valFlag "password" cfg.password)
(valFlag "motd" cfg.messageOfTheDay)
(valFlag "world" cfg.worldPath)
(valFlag "autocreate" (builtins.getAttr cfg.autoCreatedWorldSize worldSizeMap))
(valFlag "banlist" cfg.banListPath)
(boolFlag "secure" cfg.secure)
(boolFlag "noupnp" cfg.noUPnP)
];
tmuxCmd = "${lib.getExe pkgs.tmux} -S ${lib.escapeShellArg cfg.dataDir}/terraria.sock";
stopScript = pkgs.writeShellScript "terraria-stop" ''
if ! [ -d "/proc/$1" ]; then
exit 0
fi
lastline=$(${tmuxCmd} capture-pane -p | grep . | tail -n1)
# If the service is not configured to auto-start a world, it will show the world selection prompt
# If the last non-empty line on-screen starts with "Choose World", we know the prompt is open
if [[ "$lastline" =~ ^'Choose World' ]]; then
# In this case, nothing needs to be saved, so we can kill the process
${tmuxCmd} kill-session
else
# Otherwise, we send the `exit` command
${tmuxCmd} send-keys Enter exit Enter
fi
# Wait for the process to stop
tail --pid="$1" -f /dev/null
'';
in
{
options = {
services.terraria = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
If enabled, starts a Terraria server. The server can be connected to via `tmux -S ''${config.${opt.dataDir}}/terraria.sock attach`
for administration by users who are a part of the `terraria` group (use `C-b d` shortcut to detach again).
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 7777;
description = ''
Specifies the port to listen on.
'';
};
maxPlayers = lib.mkOption {
type = lib.types.ints.u8;
default = 255;
description = ''
Sets the max number of players (between 1 and 255).
'';
};
password = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Sets the server password. Leave `null` for no password.
'';
};
messageOfTheDay = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Set the server message of the day text.
'';
};
worldPath = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The path to the world file (`.wld`) which should be loaded.
If no world exists at this path, one will be created with the size
specified by `autoCreatedWorldSize`.
'';
};
autoCreatedWorldSize = lib.mkOption {
type = lib.types.enum [
"small"
"medium"
"large"
];
default = "medium";
description = ''
Specifies the size of the auto-created world if `worldPath` does not
point to an existing world.
'';
};
banListPath = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The path to the ban list.
'';
};
secure = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Adds additional cheat protection to the server.";
};
noUPnP = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Disables automatic Universal Plug and Play.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open ports in the firewall";
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/terraria";
example = "/srv/terraria";
description = "Path to variable state data directory for terraria.";
};
};
};
config = lib.mkIf cfg.enable {
users.users.terraria = {
description = "Terraria server service user";
group = "terraria";
home = cfg.dataDir;
createHome = true;
uid = config.ids.uids.terraria;
};
users.groups.terraria = {
gid = config.ids.gids.terraria;
};
systemd.services.terraria = {
description = "Terraria Server Service";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
User = "terraria";
Group = "terraria";
Type = "forking";
GuessMainPID = true;
UMask = 7;
ExecStart = "${tmuxCmd} new -d ${pkgs.terraria-server}/bin/TerrariaServer ${lib.concatStringsSep " " flags}";
ExecStop = "${stopScript} $MAINPID";
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
allowedUDPPorts = [ cfg.port ];
};
};
}

View File

@@ -0,0 +1,213 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.xonotic;
serverCfg = pkgs.writeText "xonotic-server.cfg" (
toString cfg.prependConfig
+ "\n"
+ builtins.concatStringsSep "\n" (
lib.mapAttrsToList (
key: option:
let
escape = s: lib.escape [ "\"" ] s;
quote = s: "\"${s}\"";
toValue = x: quote (escape (toString x));
value = (
if lib.isList option then
builtins.concatStringsSep " " (builtins.map (x: toValue x) option)
else
toValue option
);
in
"${key} ${value}"
) cfg.settings
)
+ "\n"
+ toString cfg.appendConfig
);
in
{
options.services.xonotic = {
enable = lib.mkEnableOption "Xonotic dedicated server";
package = lib.mkPackageOption pkgs "xonotic-dedicated" { };
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Open the firewall for TCP and UDP on the specified port.
'';
};
dataDir = lib.mkOption {
type = lib.types.path;
readOnly = true;
default = "/var/lib/xonotic";
description = ''
Data directory.
'';
};
settings = lib.mkOption {
description = ''
Generates the `server.cfg` file. Refer to [upstream's example][0] for
details.
[0]: https://gitlab.com/xonotic/xonotic/-/blob/master/server/server.cfg
'';
default = { };
type = lib.types.submodule {
freeformType =
with lib.types;
let
scalars = oneOf [
singleLineStr
int
float
];
in
attrsOf (oneOf [
scalars
(nonEmptyListOf scalars)
]);
options.sv_public = lib.mkOption {
type = lib.types.int;
default = 0;
example = [
(-1)
1
];
description = ''
Controls whether the server will be publicly listed.
'';
};
options.hostname = lib.mkOption {
type = lib.types.singleLineStr;
default = "Xonotic $g_xonoticversion Server";
description = ''
The name that will appear in the server list. `$g_xonoticversion`
gets replaced with the current version.
'';
};
options.sv_motd = lib.mkOption {
type = lib.types.singleLineStr;
default = "";
description = ''
Text displayed when players join the server.
'';
};
options.sv_termsofservice_url = lib.mkOption {
type = lib.types.singleLineStr;
default = "";
description = ''
URL for the Terms of Service for playing on your server.
'';
};
options.maxplayers = lib.mkOption {
type = lib.types.int;
default = 16;
description = ''
Number of player slots on the server, including spectators.
'';
};
options.net_address = lib.mkOption {
type = lib.types.singleLineStr;
default = "0.0.0.0";
description = ''
The address Xonotic will listen on.
'';
};
options.port = lib.mkOption {
type = lib.types.port;
default = 26000;
description = ''
The port Xonotic will listen on.
'';
};
};
};
# Still useful even though we're using RFC 42 settings because *some* keys
# can be repeated.
appendConfig = lib.mkOption {
type = with lib.types; nullOr lines;
default = null;
description = ''
Literal text to insert at the end of `server.cfg`.
'';
};
# Certain changes need to happen at the beginning of the file.
prependConfig = lib.mkOption {
type = with lib.types; nullOr lines;
default = null;
description = ''
Literal text to insert at the start of `server.cfg`.
'';
};
};
config = lib.mkIf cfg.enable {
systemd.services.xonotic = {
description = "Xonotic server";
wantedBy = [ "multi-user.target" ];
environment = {
# Required or else it tries to write the lock file into the nix store
HOME = cfg.dataDir;
};
serviceConfig = {
DynamicUser = true;
User = "xonotic";
StateDirectory = "xonotic";
ExecStart = "${cfg.package}/bin/xonotic-dedicated";
# Symlink the configuration from the nix store to where Xonotic actually
# looks for it
ExecStartPre = [
"${pkgs.coreutils}/bin/mkdir -p ${cfg.dataDir}/.xonotic/data"
''
${pkgs.coreutils}/bin/ln -sf ${serverCfg} \
${cfg.dataDir}/.xonotic/data/server.cfg
''
];
# Cargo-culted from search results about writing Xonotic systemd units
ExecReload = "${pkgs.util-linux}/bin/kill -HUP $MAINPID";
Restart = "on-failure";
RestartSec = 10;
};
unitConfig = {
StartLimitBurst = 5;
};
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [
cfg.settings.port
];
networking.firewall.allowedUDPPorts = lib.mkIf cfg.openFirewall [
cfg.settings.port
];
};
meta.maintainers = with lib.maintainers; [ CobaltCause ];
}