summaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
Diffstat (limited to 'services')
-rw-r--r--services/default.nix4
-rw-r--r--services/hellohtml/default.nix121
-rw-r--r--services/on-demand-minecraft/default.nix355
3 files changed, 480 insertions, 0 deletions
diff --git a/services/default.nix b/services/default.nix
new file mode 100644
index 0000000..b813155
--- /dev/null
+++ b/services/default.nix
@@ -0,0 +1,4 @@
+{
+ on-demand-minecraft = import ./on-demand-minecraft;
+ hellohtml = import ./hellohtml;
+}
diff --git a/services/hellohtml/default.nix b/services/hellohtml/default.nix
new file mode 100644
index 0000000..cc15779
--- /dev/null
+++ b/services/hellohtml/default.nix
@@ -0,0 +1,121 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+# FIXME: It is wasteful to always run the service. We should run on-demand instead.
+# This is usually achieved using SystemD sockets [4] but we are blocked on missing
+# features in Deno [1, 5].
+#
+# We have to be able to listen on a socket that's already been created by binding
+# an open file descriptor (or listening on stdin) [3]. This is not possible in Deno
+# as it is now [1, 2, 6].
+#
+# Once it becomes a possibility, we should mirror the way push-notification-api works
+# as of b9ed407 [8, 7].
+#
+# [1]: https://github.com/denoland/deno/issues/6529
+# [2]: https://github.com/denoland/deno/blob/1dd1aba2448c6c8a5a0370c4066a68aca06b859b/ext/net/ops_unix.rs#L207C34-L207C34
+# [3]: https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html#Description:~:text=Note%20that%20the,the%20service%20file).
+# [4]: https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html#Description:~:text=Socket%20units%20may%20be%20used%20to%20implement%20on%2Ddemand%20starting%20of%20services%2C%20as%20well%20as%20parallelized%20starting%20of%20services.%20See%20the%20blog%20stories%20linked%20at%20the%20end%20for%20an%20introduction.
+# [5]: https://github.com/denoland/deno/issues/14214
+# [6]: https://github.com/tokio-rs/tokio/issues/5678
+# [7]: https://github.com/benoitc/gunicorn/blob/660fd8d850f9424d5adcd50065e6060832a200d4/gunicorn/arbiter.py#L142-L155
+# [8]: https://github.com/linnnus/push-notification-api/tree/b9ed4071a4500a26b3b348a7f5fbc549e9694562
+let
+ cfg = config.services.hellohtml;
+in {
+ options.services.hellohtml = {
+ enable = lib.mkEnableOption "hellohtml service";
+
+ port = lib.mkOption {
+ description = "The port where hellohtml should listen.";
+ type = lib.types.port;
+ default = 8538;
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ # Create a user for running service.
+ users.users.hellohtml = {
+ group = "hellohtml";
+ description = "Runs hellohtml service";
+ isSystemUser = true;
+ home = "/srv/hellohtml";
+ createHome = true; # Store DB here.
+ };
+ users.groups.hellohtml = {};
+
+ # Create hellohtml service.
+ systemd.services.hellohtml = {
+ description = "HelloHTML server!!!";
+
+ wantedBy = ["multi-user.target"];
+ after = ["network.target"];
+
+ serviceConfig = let
+ src = pkgs.fetchFromGitHub {
+ owner = "linnnus";
+ repo = "hellohtml";
+ rev = "97f00500712d8551d7bbf497ec442083c63384d0";
+ hash = "sha256-6nbL2B26dc83F2gSLXadyfS8etuPhhlFy9ivG5l6Tog";
+ };
+
+ hellohtml-vendor = pkgs.stdenv.mkDerivation {
+ name = "hellohtml-vendor";
+ nativeBuildInputs = [pkgs.unstable.deno];
+ inherit src;
+ buildCommand = ''
+ # Deno wants to create cache directories.
+ HOME="$(mktemp -d)"
+ # Thought this wasn't necessary???
+ cd $src
+ # Build directory containing offline deps + import map.
+ deno vendor --output=$out ./src/server.ts
+ '';
+ outputHashAlgo = "sha256";
+ outputHashMode = "recursive";
+ outputHash = "sha256-0TGLkEvJaBpI7IlTyuYRzA20Bw/TMSMz3q8wm5oPsBM";
+ };
+
+ hellohtml-drv = pkgs.writeShellScript "hellohtml" ''
+ export HELLOHTML_DB_PATH="${config.users.users.hellohtml.home}"/hello.db
+ export HELLOHTML_PORT=${toString cfg.port}
+ export HELLOHTML_BASE_DIR="${src}"
+
+ ${pkgs.unstable.deno}/bin/deno run \
+ --allow-read=$HELLOHTML_BASE_DIR,$HELLOHTML_DB_PATH,. \
+ --allow-write=$HELLOHTML_DB_PATH \
+ --allow-net=0.0.0.0:$HELLOHTML_PORT \
+ --allow-env \
+ --no-prompt \
+ --unstable-kv \
+ --import-map=${hellohtml-vendor}/import_map.json \
+ --no-remote \
+ ${src}/src/server.ts
+ '';
+ in {
+ Type = "simple";
+ User = config.users.users.hellohtml.name;
+ Group = config.users.users.hellohtml.group;
+ ExecStart = "${hellohtml-drv}";
+
+ # Harden service
+ # NoNewPrivileges = "yes";
+ # PrivateTmp = "yes";
+ # PrivateDevices = "yes";
+ # DevicePolicy = "closed";
+ # ProtectControlGroups = "yes";
+ # ProtectKernelModules = "yes";
+ # ProtectKernelTunables = "yes";
+ # RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6 AF_NETLINK";
+ # RestrictNamespaces = "yes";
+ # RestrictRealtime = "yes";
+ # RestrictSUIDSGID = "yes";
+ # MemoryDenyWriteExecute = "yes";
+ # LockPersonality = "yes";
+ };
+ };
+ };
+}
diff --git a/services/on-demand-minecraft/default.nix b/services/on-demand-minecraft/default.nix
new file mode 100644
index 0000000..3ccbec9
--- /dev/null
+++ b/services/on-demand-minecraft/default.nix
@@ -0,0 +1,355 @@
+# This module defines an on-demand minecraft server service which turns off the
+# server when it's not being used.
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}: let
+ inherit (lib) mkIf mkOption mkEnableOption types;
+
+ cfg = config.services.on-demand-minecraft;
+in {
+ options.services.on-demand-minecraft = {
+ enable = mkEnableOption "local minecraft server";
+
+ eula = mkOption {
+ description = ''
+ Whether you agree to [Mojangs EULA](https://account.mojang.com/documents/minecraft_eula).
+ This option must be set to `true` to run a Minecraft™️ server (??).
+ '';
+ type = types.bool;
+ default = false;
+ };
+
+ frequency-check-players = mkOption {
+ description = ''
+ How often to check the number of players using the server. If
+ no players are using the server, it is shut down.
+
+ This should be a valid value for systemd's `onCalendar`
+ property.
+ '';
+ type = types.nonEmptyStr;
+ default = "*-*-* *:*:0/20";
+ };
+
+ minimum-server-lifetime = mkOption {
+ description = ''
+ Minimum required time to pass from the server is started
+ before it is allowed to be killed. This should ensure the
+ server has time to start up before it is killed.
+
+ The option is specified as a number of seconds.
+ '';
+ type = types.ints.positive;
+ default = 300;
+ };
+
+ internal-port = mkOption {
+ description = ''
+ The internal port which the minecraft server will listen to.
+ This port does not need to be exposed to the network.
+ '';
+ type = types.port;
+ default = cfg.external-port + 1;
+ };
+
+ external-port = mkOption {
+ description = ''
+ The external port of the socket which is forwarded to the
+ Minecraft server. This is the one users will connect to. You
+ will need to add it to `networking.firewall.allowedTCPPorts`
+ to open it in the firewall.
+
+ You may also have to set up port forwarding if you want to
+ play with friends who are not on the same LAN.
+ '';
+ type = types.port;
+ default = 25565;
+ };
+
+ openFirewall = mkOption {
+ description = ''
+ Open holes in the firewall so clients on LAN can connect. You must
+ set up port forwarding if you want to play over WAN.
+ '';
+ type = types.bool;
+ default = true;
+ };
+
+ package = mkOption {
+ description = "What Minecraft server to run.";
+ default = pkgs.minecraft-server;
+ type = types.package;
+ };
+
+ server-properties = mkOption {
+ description = ''
+ Minecraft server properties for the server.properties file. See
+ <https://minecraft.gamepedia.com/Server.properties#Java_Edition_3>
+ for documentation on these values. Note that some options like
+ `server-port` will be forced on because they are required for the
+ server to work.
+ '';
+ type = with types; attrsOf (oneOf [bool int str]);
+ default = {};
+ example = lib.literalExpression ''
+ {
+ difficulty = 3;
+ gamemode = 1;
+ motd = "My NixOS server!";
+ }
+ '';
+ };
+
+ jvm-options = mkOption {
+ description = "JVM options for the Minecraft server. List of command line arguments.";
+ type = types.listOf lib.types.str;
+ default = ["-Xmx2048M" "-Xms2048M"];
+ };
+ };
+
+ config = mkIf cfg.enable {
+ # Create a user to run the server under.
+ users.users.minecrafter = {
+ description = "On-demand minecraft server service user";
+ home = "/srv/minecrafter";
+ createHome = true;
+ group = "minecrafter";
+ isSystemUser = true;
+ };
+ users.groups.minecrafter = {};
+
+ # Create an internal socket and hook it up to minecraft-server process as
+ # stdin. That way we can send commands to it.
+ systemd.sockets.minecraft-server = {
+ bindsTo = ["minecraft-server.service"];
+ socketConfig = {
+ ListenFIFO = "/run/minecraft-server.stdin";
+ SocketMode = "0660";
+ SocketUser = "minecrafter";
+ SocketGroup = "minecrafter";
+ RemoveOnStop = true;
+ FlushPending = true;
+ };
+ };
+
+ # Create a service which runs the server.
+ systemd.services.minecraft-server = let
+ server-properties =
+ cfg.server-properties
+ // {
+ server-port = cfg.internal-port;
+ };
+ cfg-to-str = v:
+ if builtins.isBool v
+ then
+ (
+ if v
+ then "true"
+ else "false"
+ )
+ else toString v;
+ server-properties-file = pkgs.writeText "server.properties" (''
+ # server.properties managed by NixOS configuration.
+ ''
+ + lib.concatStringsSep "\n" (lib.mapAttrsToList
+ (n: v: "${n}=${cfg-to-str v}")
+ server-properties));
+
+ # We don't allow eula=false anyways
+ eula-file = builtins.toFile "eula.txt" ''
+ # eula.txt managed by NixOS Configuration
+ eula=true
+ '';
+
+ # HACK: Each server is given its own subdirectory so
+ # incompatibilities between servers don't cause complaints.
+ start-server = pkgs.writeShellScript "minecraft-server-start" ''
+ # Switch to runtime directory.
+ export RUNTIME_DIR="${config.users.users.minecrafter.home}/${cfg.package.name}/"
+ ${pkgs.busybox}/bin/mkdir -p "$RUNTIME_DIR"
+ ${pkgs.busybox}/bin/chown minecrafter:minecrafter "$RUNTIME_DIR"
+ cd "$RUNTIME_DIR"
+
+ # Set up/update environment for server
+ ln -sf ${eula-file} eula.txt
+ cp -f ${server-properties-file} server.properties
+ chmod u+w server.properties # Must be writable because server regenerates it.
+
+ exec ${cfg.package}/bin/minecraft-server "$@"
+ '';
+
+ stop-server = pkgs.writeShellScript "minecraft-server-stop" ''
+ # Send the 'stop' command to the server. It listens for commands on stdin.
+ 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
+ '';
+ in {
+ description = "Actually runs the Minecraft server";
+ requires = ["minecraft-server.socket"];
+ after = ["networking.target" "minecraft-server.socket"];
+ wantedBy = []; # TEMP: Does this do anything?
+
+ serviceConfig = {
+ ExecStart = "${start-server} ${lib.escapeShellArgs cfg.jvm-options}";
+ ExecStop = "${stop-server} $MAINPID";
+ Restart = "always";
+
+ User = "minecrafter";
+ Group = "minecrafter";
+
+ 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";
+ };
+ };
+
+ # This socket listens for connections on the public port and
+ # triggers `listen-minecraft.service` when a connection is made.
+ systemd.sockets.listen-minecraft = {
+ enable = true;
+ wantedBy = ["sockets.target"];
+ requires = ["network.target"];
+ listenStreams = [(toString cfg.external-port)];
+ };
+
+ # This service is triggered by a TCP connection on the public
+ # port. It starts hook-minecraft.service if it is not running
+ # already and waits for it to return (using `after`). Then it proxifies the TCP
+ # connection to the real (local) Minecraft port.
+ systemd.services.listen-minecraft = {
+ enable = true;
+ path = with pkgs; [systemd];
+ requires = ["hook-minecraft.service" "listen-minecraft.socket"];
+ after = ["hook-minecraft.service" "listen-minecraft.socket"];
+ serviceConfig.ExecStart = ''
+ ${pkgs.systemd.out}/lib/systemd/systemd-socket-proxyd 127.0.0.1:${toString cfg.internal-port}
+ '';
+ };
+
+ # This starts Minecraft if required and waits for it to be
+ # available over TCP to unlock the `listen-minecraft.service`
+ # proxy.
+ systemd.services.hook-minecraft = {
+ enable = true;
+ # Add tools used by scripts to path.
+ path = with pkgs; [systemd libressl busybox];
+ serviceConfig = let
+ # Start the Minecraft server and the timer regularly
+ # checking whether it should stop.
+ start-mc = pkgs.writeShellScriptBin "start-mc" ''
+ echo "Starting server and stop-timer..."
+ systemctl start minecraft-server.service
+ systemctl start stop-minecraft.timer
+ '';
+ # Wait for the internal port to be accessible for max.
+ # 60 seconds before complaining.
+ wait-tcp = pkgs.writeShellScriptBin "wait-tcp" ''
+ echo "Waiting for server to start listening on port ${toString cfg.internal-port}..."
+ for i in `seq 60`; do
+ if ${pkgs.netcat.nc}/bin/nc -z 127.0.0.1 ${toString cfg.internal-port} >/dev/null; then
+ echo "Yay! ${toString cfg.internal-port} is now available. hook-minecraft is finished."
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "${toString cfg.internal-port} did not become available in time."
+ exit 1
+ '';
+ in {
+ # First we start the server, then we wait for it to become available.
+ ExecStart = "${start-mc}/bin/start-mc";
+ ExecStartPost = "${wait-tcp}/bin/wait-tcp";
+ };
+ };
+
+ # This timer runs the service of the same name, that checks if
+ # the server needs to be stopped.
+ systemd.timers.stop-minecraft = {
+ enable = true;
+ timerConfig = {
+ OnCalendar = cfg.frequency-check-players;
+ #Unit = "stop-minecraft.service";
+ };
+ };
+
+ systemd.services.stop-minecraft = let
+ # Script that returns true (exit code 0) if the server can be shut
+ # down. It uses mcping to get the player list. It does not continue if
+ # the server was started less than `minimum-server-lifetime` seconds
+ # ago.
+ #
+ # NOTE: `pkgs.mcping` is declared my personal monorepo. Hopefully
+ # everything just works out through the magic of flakes, but if you are
+ # getting errors like "missing attribute 'mcping'" that's probably why.
+ no-player-connected = pkgs.writeShellScriptBin "no-player-connected" ''
+ servicestartsec="$(date -d "$(systemctl show --property=ActiveEnterTimestamp minecraft-server.service | cut -d= -f2)" +%s)"
+ serviceelapsedsec="$(( $(date +%s) - servicestartsec))"
+
+ if [ $serviceelapsedsec -lt ${toString cfg.minimum-server-lifetime} ]; then
+ echo "Server is too young to be stopped (minimum lifetime is ${toString cfg.minimum-server-lifetime}s, current is ''${serviceelapsedsec}s)"
+ exit 1
+ fi
+
+ PLAYERS="$(${pkgs.mcping}/bin/mcping 127.0.0.1 ${toString cfg.internal-port} | ${pkgs.jq}/bin/jq .players.online)"
+ echo "There are $PLAYERS active players"
+ if [ $PLAYERS -eq 0 ]; then
+ exit 0
+ else
+ exit 1
+ fi
+ '';
+ in {
+ enable = true;
+ serviceConfig.Type = "oneshot";
+ script = ''
+ if ${no-player-connected}/bin/no-player-connected; then
+ echo "Stopping minecraft server..."
+ systemctl stop minecraft-server.service
+ systemctl stop hook-minecraft.service
+ systemctl stop stop-minecraft.timer
+ fi
+ '';
+ };
+
+ networking.firewall = mkIf cfg.openFirewall {
+ allowedUDPPorts = [cfg.external-port];
+ allowedTCPPorts = [cfg.external-port];
+ };
+
+ 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.";
+ }
+ ];
+ };
+}