This commit is contained in:
Harald Hoyer 2024-01-11 10:26:46 +00:00
parent 66c05f9093
commit 45d6f4b0f3
205 changed files with 9040 additions and 342 deletions

View file

@ -0,0 +1,97 @@
{ lib, config, pkgs, ... }:
with lib;
with lib.plusultra;
let
cfg = config.plusultra.services.attic;
toml-format = pkgs.formats.toml { };
raw-server-toml = toml-format.generate "server.toml" cfg.settings;
server-toml = pkgs.runCommand "checked-server.toml" { config = raw-server-toml; } ''
cat $config
export ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64="dGVzdCBzZWNyZXQ="
export ATTIC_SERVER_DATABASE_URL="sqlite://:memory:"
${cfg.package}/bin/atticd --mode check-config -f "$config"
cat < $config > $out
'';
is-local-postgres =
let
url = cfg.settings.database.url or "";
local-db-strings = [ "localhost" "127.0.0.1" "/run/postgresql" ];
is-local-db-url = any (flip hasInfix url) local-db-strings;
in
config.services.postgresql.enable
&& hasPrefix "postgresql://" url
&& is-local-db-url;
in
{
options.plusultra.services.attic = {
enable = mkEnableOption "Attic";
package = mkOpt types.package pkgs.attic-server "The attic-server package to use.";
credentials = mkOpt (types.nullOr types.path) null "The path to an optional EnvironmentFile for the atticd service to use.";
user = mkOpt types.str "atticd" "The user under which attic runs.";
group = mkOpt types.str "atticd" "The group under which attic runs.";
settings = mkOpt toml-format.type { } "Settings for the atticd config file.";
};
config = mkIf cfg.enable {
assertions = [
{
assertion = !isStorePath cfg.credentials;
message = "plusultra.services.attic.credentials CANNOT be in the Nix Store.";
}
];
users = {
users = optionalAttrs (cfg.user == "atticd") {
atticd = {
group = cfg.group;
isSystemUser = true;
};
};
groups = optionalAttrs (cfg.group == "atticd") {
atticd = { };
};
};
plusultra = {
tools.attic = enabled;
services.attic.settings = {
database.url = mkDefault "sqlite:///var/lib/atticd/server.db?mode=rwc";
storage = mkDefault {
type = "local";
path = "/var/lib/atticd/storage";
};
};
};
systemd.services.atticd = {
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ]
++ optionals is-local-postgres [ "postgresql.service" "nss-lookup.target" ];
serviceConfig = {
ExecStart = "${cfg.package}/bin/atticd -f ${server-toml}";
StateDirectory = "atticd";
User = cfg.user;
Group = cfg.group;
DynamicUser = true;
} // optionalAttrs (cfg.credentials != null) {
EnvironmentFile = mkDefault cfg.credentials;
};
};
};
}

View file

@ -0,0 +1,41 @@
{ lib, config, options, ... }:
let
cfg = config.plusultra.services.avahi;
inherit (lib) types mkEnableOption mkIf;
in
{
options.plusultra.services.avahi = with types; {
enable = mkEnableOption "Avahi";
};
config = mkIf cfg.enable {
services.avahi = {
enable = true;
nssmdns = true;
publish = {
enable = true;
addresses = true;
domain = true;
hinfo = true;
userServices = true;
workstation = true;
};
extraServiceFiles = {
smb = ''
<?xml version="1.0" standalone='no'?><!--*-nxml-*-->
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">%h</name>
<service>
<type>_smb._tcp</type>
<port>445</port>
</service>
</service-group>
'';
};
};
};
}

View file

@ -0,0 +1,66 @@
{ lib, pkgs, config, ... }:
let
inherit (lib) types mkIf;
inherit (lib.plusultra) mkBoolOpt mkOpt;
inherit (pkgs) fortune toot;
inherit (pkgs.snowfallorg) cow2img;
cfg = config.plusultra.services.cowsay-mastodon-poster;
script = ''
if [ ! -f ~/.config/toot/config.json ]; then
echo "File ~/.config/toot/config.json does not exist. Run 'toot login_cli' first."
exit 1
fi
tmp_dir=$(mktemp -d)
pushd $tmp_dir > /dev/null
${cow2img}/bin/cow2img --no-spinner ${if cfg.short then "--message \"$(${fortune}/bin/fortune -s)\"" else ""}
cow_name=$(cat ./cow/name)
cow_message=$(cat ./cow/message)
post="$cow_name saying:"$'\n\n'"$cow_message"
${toot}/bin/toot post --media ./cow/image.png --description "$post" "#hachybots"
popd > /dev/null
rm -rf $tmp_dir
'';
in
{
options.plusultra.services.cowsay-mastodon-poster = with types; {
enable = mkBoolOpt false "Whether or not to enable cowsay posts.";
short = mkBoolOpt false "Use short fortunes only.";
user = mkOpt str config.plusultra.user.name "The user to run as.";
group = mkOpt str "users" "The group to run as.";
};
config = mkIf cfg.enable {
systemd = {
timers.cowsay-mastodon-poster = {
wantedBy = [ "timers.target" ];
timerConfig = {
# Run once a day at 10am.
OnCalendar = "*-*-* 10:00:00";
Unit = "cowsay-mastodon-poster.service";
};
};
services.cowsay-mastodon-poster = {
after = [ "network-online.target" ];
description = "Post a cowsay image to Mastodon.";
inherit script;
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
};
};
};
};
}

View file

@ -0,0 +1,198 @@
{ config, lib, pkgs, ... }:
let
inherit (builtins) map removeAttrs;
inherit (lib) mapAttrs flatten concatMap concatMapStringsSep;
cfg = config.plusultra.services.dex;
process-client-settings = client:
if client ? secretFile then
(removeAttrs client [ "secretFile" ])
// { secret = client.secretFile; }
else
client;
settings =
mapAttrs
(name: value:
if name == "staticClients" then
map process-client-settings value
else
value
)
(cfg.settings // {
storage = (cfg.settings.storage or { }) // {
type = cfg.settings.storage.type or "sqlite3";
config = cfg.settings.storage.config or {
file = "${cfg.stateDir}/dex.db";
};
};
});
secret-files = concatMap
(client:
if client ? secretFile then
[ client.secretFile ]
else
[ ]
)
(settings.staticClients or [ ]);
format = pkgs.formats.yaml { };
configYaml = format.generate "config.yaml" settings;
replace-config-secrets = pkgs.writeShellScript "replace-config-secrets"
(concatMapStringsSep "\n"
(file: "${pkgs.replace-secret}/bin/replace-secret '${file}' '${file}' ${cfg.stateDir}/config.yaml")
secret-files
);
in
{
options.plusultra.services.dex = {
enable = lib.mkEnableOption "Dex, the OpenID Connect and OAuth 2 identity provider";
stateDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/dex";
description = "The state directory where config and data are stored.";
};
user = lib.mkOption {
type = lib.types.str;
default = "dex";
description = "The user to run Dex as.";
};
group = lib.mkOption {
type = lib.types.str;
default = "dex";
description = "The group to run Dex as.";
};
settings = lib.mkOption {
type = format.type;
default = { };
example = lib.literalExpression ''
{
# External url
issuer = "http://127.0.0.1:5556/dex";
storage = {
type = "postgres";
config.host = "/var/run/postgres";
};
web = {
http = "127.0.0.1:5556";
};
enablePasswordDB = true;
staticClients = [
{
id = "oidcclient";
name = "Client";
redirectURIs = [ "https://example.com/callback" ];
# The content of `secretFile` will be written into to the config as `secret`.
secretFile = "/etc/dex/oidcclient";
}
];
}
'';
description = lib.mdDoc ''
The available options can be found in
[the example configuration](https://github.com/dexidp/dex/blob/v${pkgs.dex.version}/config.yaml.dist).
'';
};
};
config = lib.mkIf cfg.enable {
users = {
users = lib.optionalAttrs (cfg.user == "dex") {
dex = {
group = cfg.group;
home = cfg.stateDir;
isSystemUser = true;
};
};
groups = lib.optionalAttrs (cfg.group == "dex") {
dex = { };
};
};
systemd = {
tmpfiles.rules = [
"d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group}"
];
services = {
dex = {
description = "dex identity provider";
wantedBy = [ "multi-user.target" ];
after = [ "networking.target" ];
preStart = ''
cp --remove-destination ${configYaml} ${cfg.stateDir}/config.yaml
chmod 600 ${cfg.stateDir}/config.yaml
${replace-config-secrets}
'';
serviceConfig = {
ExecStart = "${pkgs.dex-oidc}/bin/dex serve ${cfg.stateDir}/config.yaml";
WorkingDirectory = cfg.stateDir;
User = cfg.user;
Group = cfg.group;
AmbientCapabilities = "CAP_NET_BIND_SERVICE";
BindReadOnlyPaths = [
"/nix/store"
"-/etc/resolv.conf"
"-/etc/nsswitch.conf"
"-/etc/hosts"
"-/etc/localtime"
"-/etc/dex"
];
BindPaths = [ cfg.stateDir ] ++ lib.optional (settings.storage.type == "postgres") "/var/run/postgresql";
CapabilityBoundingSet = "CAP_NET_BIND_SERVICE";
## ProtectClock= adds DeviceAllow=char-rtc r
#DeviceAllow = "";
#DynamicUser = true;
#LockPersonality = true;
#MemoryDenyWriteExecute = true;
#NoNewPrivileges = true;
#PrivateDevices = true;
#PrivateMounts = true;
## Port needs to be exposed to the host network
##PrivateNetwork = true;
#PrivateTmp = true;
#PrivateUsers = true;
#ProcSubset = "pid";
#ProtectClock = true;
#ProtectHome = true;
#ProtectHostname = true;
## Would re-mount paths ignored by temporary root
##ProtectSystem = "strict";
#ProtectControlGroups = true;
#ProtectKernelLogs = true;
#ProtectKernelModules = true;
#ProtectKernelTunables = true;
#ProtectProc = "invisible";
#RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
#RestrictNamespaces = true;
#RestrictRealtime = true;
#RestrictSUIDSGID = true;
#SystemCallArchitectures = "native";
#SystemCallFilter = [ "@system-service" "~@privileged @resources @setuid @keyring" ];
#TemporaryFileSystem = "/:ro";
# Does not work well with the temporary root
#UMask = "0066";
};
};
};
};
};
}

View file

@ -0,0 +1,121 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.dex;
fixClient = client: if client ? secretFile then ((builtins.removeAttrs client [ "secretFile" ]) // { secret = client.secretFile; }) else client;
filteredSettings = mapAttrs (n: v: if n == "staticClients" then (builtins.map fixClient v) else v) cfg.settings;
secretFiles = flatten (builtins.map (c: if c ? secretFile then [ c.secretFile ] else [ ]) (cfg.settings.staticClients or [ ]));
settingsFormat = pkgs.formats.yaml { };
configFile = settingsFormat.generate "config.yaml" filteredSettings;
startPreScript = pkgs.writeShellScript "dex-start-pre" (''
'' + (concatStringsSep "\n" (builtins.map
(file: ''
${pkgs.replace-secret}/bin/replace-secret '${file}' '${file}' /run/dex/config.yaml
'')
secretFiles)));
in
{
options.services.dex = {
enable = mkEnableOption "the OpenID Connect and OAuth2 identity provider";
settings = mkOption {
type = settingsFormat.type;
default = { };
example = literalExpression ''
{
# External url
issuer = "http://127.0.0.1:5556/dex";
storage = {
type = "postgres";
config.host = "/var/run/postgres";
};
web = {
http = "127.0.0.1:5556";
};
enablePasswordDB = true;
staticClients = [
{
id = "oidcclient";
name = "Client";
redirectURIs = [ "https://example.com/callback" ];
secretFile = "/etc/dex/oidcclient"; # The content of `secretFile` will be written into to the config as `secret`.
}
];
}
'';
description = ''
The available options can be found in
<link xlink:href="https://github.com/dexidp/dex/blob/v${pkgs.dex.version}/config.yaml.dist">the example configuration</link>.
'';
};
};
config = mkIf cfg.enable {
systemd.services.dex = {
description = "dex identity provider";
wantedBy = [ "multi-user.target" ];
after = [ "networking.target" ] ++ (optional (cfg.settings.storage.type == "postgres") "postgresql.service");
serviceConfig = {
ExecStart = "${pkgs.dex-oidc}/bin/dex serve /run/dex/config.yaml";
ExecStartPre = [
"${pkgs.coreutils}/bin/install -m 600 ${configFile} /run/dex/config.yaml"
"+${startPreScript}"
];
RuntimeDirectory = "dex";
AmbientCapabilities = "CAP_NET_BIND_SERVICE";
BindReadOnlyPaths = [
"/nix/store"
"-/etc/resolv.conf"
"-/etc/nsswitch.conf"
"-/etc/hosts"
"-/etc/localtime"
"-/etc/dex"
];
BindPaths = optional (cfg.settings.storage.type == "postgres") "/var/run/postgresql";
CapabilityBoundingSet = "CAP_NET_BIND_SERVICE";
# ProtectClock= adds DeviceAllow=char-rtc r
DeviceAllow = "";
DynamicUser = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
# Port needs to be exposed to the host network
#PrivateNetwork = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectHome = true;
ProtectHostname = true;
# Would re-mount paths ignored by temporary root
#ProtectSystem = "strict";
ProtectControlGroups = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [ "@system-service" "~@privileged @resources @setuid @keyring" ];
TemporaryFileSystem = "/:ro";
# Does not work well with the temporary root
#UMask = "0066";
};
};
};
# uses attributes of the linked package
meta.buildDocsInSandbox = false;
}

View file

@ -0,0 +1,79 @@
{ lib, config, pkgs, ... }:
with lib;
with lib.plusultra;
let
cfg = config.plusultra.services.homer;
yaml-format = pkgs.formats.yaml { };
settings-yaml = yaml-format.generate "config.yml" cfg.settings;
settings-path =
if cfg.settings-path != null then
cfg.settings-path
else
builtins.toString settings-yaml;
in
{
options.plusultra.services.homer = {
enable = mkEnableOption "Homer";
package = mkOpt types.package pkgs.plusultra.homer "The package of Homer assets to use.";
settings = mkOpt yaml-format.type { } "Configuration for Homer's config.yml file.";
settings-path = mkOpt (types.nullOr types.path) null "A replacement for the generated config.yml file.";
host = mkOpt (types.nullOr types.str) null "The host to serve Homer on.";
nginx = {
forceSSL = mkOption {
type = types.bool;
default = false;
description = "Whether or not to force the use of SSL.";
};
};
acme = {
enable = mkOption {
type = types.bool;
default = false;
description =
"Whether or not to automatically fetch and configure SSL certs.";
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.host != null;
message = "plusultra.services.homer.host must be set.";
}
{
assertion = cfg.settings-path != null -> cfg.settings == { };
message = "plusultra.services.homer.settings and plusultra.services.homer.settings-path are mutually exclusive.";
}
{
assertion = cfg.nginx.forceSSL -> cfg.acme.enable;
message = "plusultra.services.homer.nginx.forceSSL requires setting plusultra.services.homer.acme.enable to true.";
}
];
services.nginx = {
enable = true;
virtualHosts."${cfg.host}" = {
enableACME = cfg.acme.enable;
forceSSL = cfg.nginx.forceSSL;
locations."/" = {
root = "${cfg.package}/share/homer";
};
locations."= /assets/config.yml" = {
alias = settings-path;
};
};
};
};
}

View file

@ -0,0 +1,158 @@
{ config, options, lib, pkgs, ... }:
let
inherit (builtins) toString;
inherit (lib) types;
cfg = config.plusultra.services.infrared;
format = pkgs.formats.json { };
serversType = (types.submodule ({ config, ... }: {
options = {
domain = lib.mkOption {
type = types.str;
default = "";
description = ''
The domain to proxy. Should be fully qualified domain name.
Note: Every string is accepted. So localhost is also valid.
'';
example = "minecraft.example.com";
};
host = lib.mkOption {
type = types.str;
default = "";
description = "The host where the Minecraft server is running. Defaults to local host.";
};
port = lib.mkOption {
type = types.port;
default = 25566;
description = "The port where the Minecraft server is running.";
};
settings = lib.mkOption {
default = { };
description = ''
Infrared configuration (<filename>config.json</filename>). Refer to
<link xlink:href="https://github.com/haveachin/infrared#proxy-config" />
for details.
'';
type = types.submodule {
freeformType = format.type;
options = {
domainName = lib.mkOption {
type = types.str;
default = config.domain;
defaultText = lib.literalExpression ''
""
'';
description = "The domain to proxy.";
};
proxyTo = lib.mkOption {
type = types.str;
default = "${config.host}:${toString config.port}";
defaultText = ":25565";
description = "The address that the proxy should send incoming connections to.";
};
};
};
};
};
}));
in
{
options.plusultra.services.infrared = {
enable = lib.mkEnableOption "Infrared";
stateDir = lib.mkOption {
type = types.path;
default = "/var/lib/infrared";
description = "The state directory where configurations are stored.";
};
user = lib.mkOption {
type = types.str;
default = "infrared";
description = "User under which Infrared is ran.";
};
group = lib.mkOption {
type = types.str;
default = "infrared";
description = "Group under which Infrared is ran.";
};
openFirewall = lib.mkOption {
type = types.bool;
default = false;
description = "Whether to open the firewall for ports specified by each server's listenTo address.";
};
servers = lib.mkOption {
type = types.listOf serversType;
default = [ ];
description = "The servers to proxy.";
example = lib.literalExpression ''
[
{
domain = "minecraft.example.com";
port = 25567;
}
]
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
];
networking.firewall = lib.mkIf cfg.openFirewall {
allowedUDPPorts = builtins.map (server: server.port) cfg.servers;
allowedTCPPorts = builtins.map (server: server.port) cfg.servers;
};
systemd.tmpfiles.rules =
[ "d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -" ] ++
builtins.map
(server:
let
config = format.generate "${server.domain}.json" server.settings;
in
"L+ '${cfg.stateDir}/${server.domain}.json' - - - - ${config}"
)
cfg.servers;
systemd.services.infrared = {
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.stateDir;
ExecStart = "${pkgs.plusultra.infrared}/bin/infrared -config-path ${cfg.stateDir}";
};
};
users = {
users = lib.optionalAttrs (cfg.user == "infrared") {
infrared = {
group = cfg.group;
home = "/var/lib/infrared";
isSystemUser = true;
};
};
groups = lib.optionalAttrs (cfg.group == "infrared") {
infrared = { };
};
};
};
}

View file

@ -0,0 +1,195 @@
{ config, options, lib, pkgs, ... }:
let
inherit (lib) types;
cfg = config.plusultra.services.minecraft;
in
{
options.plusultra.services.minecraft = {
enable = lib.mkEnableOption "Minecraft server";
eula = lib.mkOption {
type = types.bool;
default = false;
description = lib.mdDoc ''
Whether you agree to
[Mojang's EULA](https://account.mojang.com/documents/minecraft_eula).
This option must be set to `true` to run Minecraft server.
'';
};
infrared = {
enable = lib.mkEnableOption "Infrared";
};
servers = lib.mkOption {
default = { };
description = "The Minecraft servers to run.";
example = lib.literalExpression ''
{
# A default vanilla server.
vanilla-1 = {};
# A vanilla server with a custom port.
vanilla-2 = {
port = 4000;
};
# A vanilla server proxied by Infrared (when enabled).
vanilla-3 = {
domain = "minecraft.example.com";
};
# A Forge server.
forge-1 = {
type = "forge";
};
# Use a custom Minecraft server version.
custom-vanilla = {
package = pkgs.minecraft-server_1_12_2;
};
# Use a custom Forge server version.
custom-forge = {
type = "forge";
package = pkgs.minecraft-forge_1_19_2-43_1_25;
};
}
'';
type = types.attrsOf (types.submodule ({ config, name, ... }: {
options = {
type = lib.mkOption {
type = types.enum [ "vanilla" "forge" ];
default = "vanilla";
description = "The kind of Minecraft server to create.";
};
package = lib.mkOption {
type = types.package;
default =
if config.type == "vanilla" then
pkgs.minecraft-server
else
pkgs.plusultra.minecraft-forge;
defaultText = lib.literalExpression ''
pkgs.minecraft-server
'';
};
dataDir = lib.mkOption {
type = types.path;
default = "/var/lib/minecraft/${name}";
defaultText = "/var/lib/minecraft/<name>";
description = "The datrectory where data for the server is stored.";
};
port = lib.mkOption {
type = types.port;
default = 25565;
description = "The port for the server to listen on.";
};
domain = lib.mkOption {
type = types.str;
default = "";
description = "The domain to pass to Infrared (if enabled).";
};
jvmOpts = lib.mkOption {
type = types.separatedString " ";
default = "-Xmx2048M -Xms2048M";
# Example options from https://minecraft.gamepedia.com/Tutorials/Server_startup_script
example = "-Xms4092M -Xmx4092M -XX:+UseG1GC -XX:+CMSIncrementalPacing "
+ "-XX:+CMSClassUnloadingEnabled -XX:ParallelGCThreads=2 "
+ "-XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10";
description = lib.mdDoc "JVM options for the Minecraft server.";
};
serverProperties = lib.mkOption {
type = types.attrsOf (types.oneOf [ types.bool types.int types.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 = lib.mdDoc ''
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.gamepedia.com/Server.properties#Java_Edition_3>
for documentation on these values.
'';
};
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 = lib.mdDoc ''
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";
};
'';
};
openFirewall = lib.mkOption {
type = types.bool;
default = false;
description = lib.mdDoc ''
Whether to open ports in the firewall for the server.
'';
};
declarative = lib.mkOption {
type = types.bool;
default = false;
description = lib.mdDoc ''
Whether to use a declarative Minecraft server configuration.
Only if set to `true`, the options
{option}`plusultra.services.minecraft.servers.<name>.whitelist` and
{option}`plusultra.services.minecraft.servers.<name>.serverProperties` will be
applied.
'';
};
extraInfraredOptions = lib.mkOption {
type = types.attrs;
default = { };
description = lib.mdDoc ''
Extra options passed to Infrared (if enabled) when configuring this server.
'';
};
};
}));
};
};
}

View file

@ -0,0 +1,109 @@
{ options
, config
, pkgs
, lib
, host ? ""
, format ? ""
, inputs ? { }
, ...
}:
with lib;
with lib.plusultra; let
cfg = config.plusultra.services.openssh;
user = config.users.users.${config.plusultra.user.name};
user-id = builtins.toString user.uid;
# TODO: This is a hold-over from an earlier Snowfall Lib version which used
# the specialArg `name` to provide the host name.
name = host;
default-key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCwaaCUq3Ooq1BaHbg5IwVxWj/xmNJY2dDthHKPZefrHXv/ksM/IREgm38J0CdoMpVS0Zp1C/vFrwGfaYZ2lCF5hBVdV3gf+mvj8Yb8Xpm6aM4L5ig+oBMp/3cz1+g/I4aLMJfCKCtdD6Q2o4vtkTpid6X+kL3UGZbX0HFn3pxoDinzOXQnVGSGw+pQhLASvQeVXWTJjVfIWhj9L2NRJau42cBRRlAH9kE3HUbcgLgyPUZ28aGXLLmiQ6CUjiIlce5ee16WNLHQHOzVfPJfF1e1F0HwGMMBe39ey3IEQz6ab1YqlIzjRx9fQ9hQK6Du+Duupby8JmBlbUAxhh8KJFCJB2cXW/K5Et4R8GHMS6MyIoKQwFUXGyrszVfiuNTGZIkPAYx9zlCq9M/J+x1xUZLHymL85WLPyxhlhN4ysM9ILYiyiJ3gYrPIn5FIZrW7MCQX4h8k0bEjWUwH5kF3dZpEvIT2ssyIu12fGzXkYaNQcJEb5D9gT1mNyi2dxQ62NPZ5orfYyIZ7fn22d1P/jegG+7LQeXPiy5NLE6b7MP5Rq2dL8Y9Oi8pOBtoY9BpLh7saSBbNFXTBtH/8OfAQacxDsZD/zTFtCzZjtTK6yiAaXCZTvMIOuoYGZvEk6zWXrjVsU8FlqF+4JOTfePqr/SSUXNJyKnrvQJ1BfHQiYsrckw==";
other-hosts =
lib.filterAttrs
(key: host:
key != name && (host.config.plusultra.user.name or null) != null)
((inputs.self.nixosConfigurations or { }) // (inputs.self.darwinConfigurations or { }));
other-hosts-config =
lib.concatMapStringsSep
"\n"
(
name:
let
remote = other-hosts.${name};
remote-user-name = remote.config.plusultra.user.name;
remote-user-id = builtins.toString remote.config.users.users.${remote-user-name}.uid;
forward-gpg =
optionalString (config.programs.gnupg.agent.enable && remote.config.programs.gnupg.agent.enable)
''
RemoteForward /run/user/${remote-user-id}/gnupg/S.gpg-agent /run/user/${user-id}/gnupg/S.gpg-agent.extra
RemoteForward /run/user/${remote-user-id}/gnupg/S.gpg-agent.ssh /run/user/${user-id}/gnupg/S.gpg-agent.ssh
'';
in
''
Host ${name}
User ${remote-user-name}
ForwardAgent yes
Port ${builtins.toString cfg.port}
${forward-gpg}
''
)
(builtins.attrNames other-hosts);
in
{
options.plusultra.services.openssh = with types; {
enable = mkBoolOpt false "Whether or not to configure OpenSSH support.";
authorizedKeys =
mkOpt (listOf str) [ default-key ] "The public keys to apply.";
port = mkOpt port 2222 "The port to listen on (in addition to 22).";
manage-other-hosts = mkOpt bool true "Whether or not to add other host configurations to SSH config.";
};
config = mkIf cfg.enable {
services.openssh = {
enable = true;
settings = {
PermitRootLogin =
if format == "install-iso"
then "yes"
else "no";
PasswordAuthentication = false;
};
extraConfig = ''
StreamLocalBindUnlink yes
'';
ports = [
22
cfg.port
];
};
programs.ssh.extraConfig = ''
Host *
HostKeyAlgorithms +ssh-rsa
${optionalString cfg.manage-other-hosts other-hosts-config}
'';
plusultra.user.extraOptions.openssh.authorizedKeys.keys =
cfg.authorizedKeys;
plusultra.home.extraOptions = {
programs.zsh.shellAliases =
foldl
(aliases: system:
aliases
// {
"ssh-${system}" = "ssh ${system} -t tmux a";
})
{ }
(builtins.attrNames other-hosts);
};
};
}

View file

@ -0,0 +1,13 @@
{ options, config, pkgs, lib, ... }:
with lib;
with lib.plusultra;
let cfg = config.plusultra.services.printing;
in
{
options.plusultra.services.printing = with types; {
enable = mkBoolOpt false "Whether or not to configure printing support.";
};
config = mkIf cfg.enable { services.printing.enable = true; };
}

View file

@ -0,0 +1,76 @@
{ lib, config, ... }:
let
cfg = config.plusultra.services.samba;
inherit (lib)
types
mkEnableOption
mkIf
mapAttrs
optionalAttrs;
inherit (lib.plusultra)
mkOpt
mkBoolOpt;
bool-to-yes-no = value: if value then "yes" else "no";
shares-submodule = with types; submodule ({ name, ... }: {
options = {
path = mkOpt str null "The path to serve.";
public = mkBoolOpt false "Whether the share is public.";
browseable = mkBoolOpt true "Whether the share is browseable.";
comment = mkOpt str name "An optional comment.";
read-only = mkBoolOpt false "Whether the share should be read only.";
only-owner-editable = mkBoolOpt false "Whether the share is only writable by the system owner (plusultra.user.name).";
extra-config = mkOpt attrs { } "Extra configuration options for the share.";
};
});
in
{
options.plusultra.services.samba = with types; {
enable = mkEnableOption "Samba";
workgroup = mkOpt str "WORKGROUP" "The workgroup to use.";
browseable = mkBoolOpt true "Whether the shares are browseable.";
shares = mkOpt (attrsOf shares-submodule) { } "The shares to serve.";
};
config = mkIf cfg.enable {
networking.firewall = {
allowedTCPPorts = [ 5357 ];
allowedUDPPorts = [ 3702 ];
};
services.samba-wsdd = {
enable = true;
discovery = true;
workgroup = "WORKGROUP";
};
services.samba = {
enable = true;
openFirewall = true;
extraConfig = ''
browseable = ${bool-to-yes-no cfg.browseable}
'';
shares = mapAttrs
(name: value: {
inherit (value) path comment;
public = bool-to-yes-no value.public;
browseable = bool-to-yes-no value.browseable;
"read only" = bool-to-yes-no value.read-only;
} // (optionalAttrs value.only-owner-editable {
"write list" = config.plusultra.user.name;
"read list" = "guest, nobody";
"create mask" = "0755";
}) // value.extra-config)
cfg.shares;
};
};
}

View file

@ -0,0 +1,69 @@
{ lib, pkgs, config, ... }:
with lib;
with lib.plusultra;
let cfg = config.plusultra.services.tailscale;
in
{
options.plusultra.services.tailscale = with types; {
enable = mkBoolOpt false "Whether or not to configure Tailscale";
autoconnect = {
enable = mkBoolOpt false "Whether or not to enable automatic connection to Tailscale";
key = mkOpt str "" "The authentication key to use";
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.autoconnect.enable -> cfg.autoconnect.key != "";
message = "plusultra.services.tailscale.autoconnect.key must be set";
}
];
environment.systemPackages = with pkgs; [ tailscale ];
services.tailscale = enabled;
networking = {
firewall = {
trustedInterfaces = [ config.services.tailscale.interfaceName ];
allowedUDPPorts = [ config.services.tailscale.port ];
# Strict reverse path filtering breaks Tailscale exit node use and some subnet routing setups.
checkReversePath = "loose";
};
networkmanager.unmanaged = [ "tailscale0" ];
};
systemd.services.tailscale-autoconnect = mkIf cfg.autoconnect.enable {
description = "Automatic connection to Tailscale";
# Make sure tailscale is running before trying to connect to tailscale
after = [ "network-pre.target" "tailscale.service" ];
wants = [ "network-pre.target" "tailscale.service" ];
wantedBy = [ "multi-user.target" ];
# Set this service as a oneshot job
serviceConfig.Type = "oneshot";
# Have the job run this shell script
script = with pkgs; ''
# Wait for tailscaled to settle
sleep 2
# Check if we are already authenticated to tailscale
status="$(${tailscale}/bin/tailscale status -json | ${jq}/bin/jq -r .BackendState)"
if [ $status = "Running" ]; then # if so, then do nothing
exit 0
fi
# Otherwise authenticate with tailscale
${tailscale}/bin/tailscale up -authkey "${cfg.autoconnect.key}"
'';
};
};
}

View file

@ -0,0 +1,174 @@
{ lib, config, pkgs, inputs, ... }:
with lib;
with lib.plusultra;
let
cfg = config.plusultra.services.vault-agent;
# nixos-vault-service places generated files here:
# https://github.com/DeterminateSystems/nixos-vault-service/blob/45e65627dff5dc4bb40d0f2595916f37e78959c1/module/helpers.nix#L4
secret-files-root = "/tmp/detsys-vault";
environment-files-root = "/run/keys/environment";
create-environment-files-submodule = service-name: types.submodule ({ name, ... }: {
options = {
text = mkOpt (types.nullOr types.str) null "An inline template for Vault to template.";
source = mkOpt (types.nullOr types.path) null "The file with environment variables for Vault to template.";
path = mkOption {
readOnly = true;
type = types.str;
description = "The path to the environment file.";
default = "${environment-files-root}/${service-name}/${name}.EnvFile";
defaultText = "${environment-files-root}/<service-name>/<template-name>.EnvFile";
};
};
});
secret-files-submodule = types.submodule ({ name, ... }: {
options = {
text = mkOpt (types.nullOr types.str) null "An inline template for Vault to template.";
source = mkOpt (types.nullOr types.path) null "The file for Vault to template.";
permissions = mkOpt types.str "0400" "The octal mode of this file.";
change-action = mkOpt (types.nullOr (types.enum [ "restart" "stop" "none" ])) null "The action to take when secrets change.";
path = mkOption {
readOnly = true;
type = types.str;
description = "The path to the secret file.";
default = "${secret-files-root}/${name}";
};
};
});
services-submodule =
types.submodule
({ name, config, ... }: {
options = {
enable = mkBoolOpt true "Whether to enable Vault Agent for this service.";
settings = mkOpt types.attrs { } "Vault Agent configuration.";
secrets = {
environment = {
force = mkOpt types.bool false "Whether or not to force the use of Vault Agent's environment files.";
change-action = mkOpt (types.enum [ "restart" "stop" "none" ]) "restart" "The action to take when secrets change.";
templates = mkOpt (types.attrsOf (create-environment-files-submodule name)) { } "Environment variable files for Vault to template.";
template = mkOpt (types.nullOr (types.either types.path types.str)) null "An environment variable template.";
paths = mkOption {
readOnly = true;
type = types.listOf types.str;
description = "Paths to all of the environment files";
default =
if config.secrets.environment.template != null then
[ "${environment-files-root}/${name}/EnvFile" ]
else
(
mapAttrsToList
(template-name: value: value.path)
config.secrets.environment.templates
);
};
};
file = {
change-action = mkOpt (types.enum [ "restart" "stop" "none" ]) "restart" "The action to take when secrets change.";
files = mkOption {
description = "Secret files to template.";
default = { };
type = types.attrsOf secret-files-submodule;
};
};
};
};
});
in
{
# imports = [
# inputs.vault-service.nixosModules.nixos-vault-service
# ];
options.plusultra.services.vault-agent = {
enable = mkEnableOption "Vault Agent";
settings = mkOpt types.attrs { } "Default Vault Agent configuration.";
services = mkOption {
description = "Services to install Vault Agent into.";
default = { };
type = types.attrsOf services-submodule;
};
};
config = mkIf cfg.enable {
assertions = flatten (mapAttrsToList
(service-name: service:
(mapAttrsToList
(template-name: template:
{
assertion = (template.source != null && template.text == null) || (template.source == null && template.text != null);
message = "plusultra.services.vault-agent.services.${service-name}.secrets.environment.templates.${template-name} must set either `source` or `text`.";
}
)
service.secrets.environment.templates)
++
(mapAttrsToList
(file-name: file:
{
assertion = (file.source != null && file.text == null) || (file.source == null && file.text != null);
message = "plusultra.services.vault-agent.services.${service-name}.secrets.file.files.${file-name} must set either `source` or `text`.";
}
)
service.secrets.file.files)
)
cfg.services);
systemd.services = mapAttrs
(service-name: value: mkIf value.secrets.environment.force {
serviceConfig.EnvironmentFile = mkForce value.secrets.environment.paths;
})
cfg.services;
detsys.vaultAgent = {
defaultAgentConfig = cfg.settings;
systemd.services = mapAttrs
(service-name: value: {
inherit (value) enable;
agentConfig = value.settings;
environment = {
changeAction = value.secrets.environment.change-action;
templateFiles = mapAttrs
(template-name: value: {
file =
if value.source != null then
value.source
else
pkgs.writeText "${service-name}-${template-name}-env-template" value.text;
})
value.secrets.environment.templates;
template =
if (builtins.isPath value.secrets.environment.template) || (builtins.isNull value.secrets.environment.template) then
value.secrets.environment.template
else
pkgs.writeText "${service-name}-env-template" value.secrets.environment.template;
};
secretFiles = {
defaultChangeAction = value.secrets.file.change-action;
files = mapAttrs
(file-name: value: {
changeAction = value.change-action;
template = value.text;
templateFile = value.source;
perms = value.permissions;
})
value.secrets.file.files;
};
})
cfg.services;
};
};
}

View file

@ -0,0 +1,202 @@
{ lib, config, pkgs, ... }:
with lib;
with lib.plusultra;
let
cfg = config.plusultra.services.vault;
package = if cfg.ui then pkgs.vault-bin else pkgs.vault;
has-policies = (builtins.length (builtins.attrNames cfg.policies)) != 0;
format-policy = name: file: pkgs.runCommandNoCC
"formatted-vault-policy"
{
inherit file;
buildInputs = [ package ];
}
''
name="$(basename "$file")"
cp "$file" "./$name"
# Ensure that vault can overwrite the file.
chmod +w "./$name"
# Create this variable here to avoid swallowing vault's exit code.
vault_output=
set +e
vault_output=$(vault policy fmt "./$name" 2>&1)
vault_status=$?
set -e
if [ "$vault_status" != 0 ]; then
echo 'Error formatting policy "${name}"'
echo "This is normally caused by a syntax error in the policy file."
echo "$file"
echo ""
echo "Vault Output:"
echo "$vault_output"
exit 1
fi
mv "./$name" $out
'';
policies = mapAttrs
(name: value:
if builtins.isPath value then
format-policy name value
else
format-policy name (pkgs.writeText "${name}.hcl" value)
)
cfg.policies;
in
{
options.plusultra.services.vault = {
enable = mkEnableOption "Vault";
ui = mkBoolOpt true "Whether the UI should be enabled.";
storage = {
backend = mkOpt types.str "file" "The storage backend for Vault.";
};
settings = mkOpt types.str "" "Configuration for Vault's config file.";
mutable-policies = mkBoolOpt false "Whether policies not specified in Nix should be removed.";
policies = mkOpt (types.attrsOf (types.either types.str types.path)) { } "Policies to install when Vault runs.";
policy-agent = {
user = mkOpt types.str "vault" "The user to run the Vault Agent as.";
group = mkOpt types.str "vault" "The group to run the Vault Agent as.";
auth = {
roleIdFilePath = mkOpt types.str "/var/lib/vault/role-id" "The file to read the role-id from.";
secretIdFilePath = mkOpt types.str "/var/lib/vault/secret-id" "The file to read the secret-id from.";
};
};
};
config = mkIf cfg.enable {
services.vault = {
enable = true;
inherit package;
extraConfig = ''
ui = ${if cfg.ui then "true" else "false"}
${cfg.settings}
'';
};
systemd.services.vault = { };
systemd.services.vault-policies = mkIf (has-policies || !cfg.mutable-policies) {
wantedBy = [ "vault.service" ];
after = [ "vault.service" ];
serviceConfig = {
Type = "oneshot";
User = cfg.policy-agent.user;
Group = cfg.policy-agent.group;
Restart = "on-failure";
RestartSec = 30;
RemainAfterExit = "yes";
};
restartTriggers = (mapAttrsToList (name: value: "${name}=${value}") policies);
path = [
package
pkgs.curl
pkgs.jq
];
environment = {
VAULT_ADDR = "http://${config.services.vault.address}";
};
script =
let
write-policies-commands = mapAttrsToList
(name: policy:
''
echo Writing policy '${name}': '${policy}'
vault policy write '${name}' '${policy}'
''
)
policies;
write-policies = concatStringsSep "\n" write-policies-commands;
known-policies = mapAttrsToList (name: value: name) policies;
remove-unknown-policies = ''
current_policies=$(vault policy list -format=json | jq -r '.[]')
known_policies=(${concatStringsSep " " (builtins.map (policy: "\"${policy}\"") known-policies)})
while read current_policy; do
is_known=false
for known_policy in "''${known_policies[@]}"; do
if [ "$known_policy" = "$current_policy" ]; then
is_known=true
break
fi
done
if [ "$is_known" = "false" ] && [ "$current_policy" != "default" ] && [ "$current_policy" != "root" ]; then
echo "Removing policy: $current_policy"
vault policy delete "$current_policy"
else
echo "Keeping policy: $current_policy"
fi
done <<< "$current_policies"
'';
in
''
if ! [ -f '${cfg.policy-agent.auth.roleIdFilePath}' ]; then
echo 'role-id file not found: ${cfg.policy-agent.auth.roleIdFilePath}'
exit 1
fi
if ! [ -f '${cfg.policy-agent.auth.secretIdFilePath}' ]; then
echo 'secret-id file not found: ${cfg.policy-agent.auth.secretIdFilePath}'
exit 1
fi
role_id="$(cat '${cfg.policy-agent.auth.roleIdFilePath}')"
secret_id="$(cat '${cfg.policy-agent.auth.secretIdFilePath}')"
seal_status=$(curl -s "$VAULT_ADDR/v1/sys/seal-status" | jq ".sealed")
echo "Seal Status: $seal_status"
if [ seal_status = "true" ]; then
echo "Vault is currently sealed, cannot install policies."
exit 1
fi
echo "Getting token..."
token=$(vault write -field=token auth/approle/login \
role_id="$role_id" \
secret_id="$secret_id" \
)
echo "Logging in..."
export VAULT_TOKEN="$(vault login -method=token -token-only token="$token")"
echo "Writing policies..."
${write-policies}
${optionalString (!cfg.mutable-policies) remove-unknown-policies}
'';
};
};
}

View file

@ -0,0 +1,493 @@
{ config, lib, pkgs, ... }:
let
inherit (builtins) toString;
inherit (lib) types mkIf mkOption mkDefault;
inherit (lib) optional optionals optionalAttrs optionalString;
inherit (pkgs) sqlite;
format = pkgs.formats.ini {
mkKeyValue = key: value:
let
value' =
if builtins.isNull value then
""
else if builtins.isBool value then
if value == true then "true" else "false"
else
toString value;
in
"${key} = ${value'}";
};
cfg = config.plusultra.services.writefreely;
isSqlite = cfg.database.type == "sqlite3";
isMysql = cfg.database.type == "mysql";
isMysqlLocal = isMysql && cfg.database.createLocally == true;
hostProtocol = if cfg.acme.enable then "https" else "http";
settings = cfg.settings // {
app = cfg.settings.app or { } // {
host = cfg.settings.app.host or "${hostProtocol}://${cfg.host}";
};
database =
if cfg.database.type == "sqlite3" then {
type = "sqlite3";
filename = cfg.settings.database.filename or "writefreely.db";
database = cfg.database.name;
} else {
type = "mysql";
username = cfg.database.user;
password = "#dbpass#";
database = cfg.database.name;
host = cfg.database.host;
port = cfg.database.port;
tls = cfg.database.tls;
};
server = cfg.settings.server or { } // {
bind = cfg.settings.server.bind or "localhost";
gopher_port = cfg.settings.server.gopher_port or 0;
autocert = !cfg.nginx.enable && cfg.acme.enable;
templates_parent_dir =
cfg.settings.server.templates_parent_dir or cfg.package.src;
static_parent_dir = cfg.settings.server.static_parent_dir or assets;
pages_parent_dir =
cfg.settings.server.pages_parent_dir or cfg.package.src;
keys_parent_dir = cfg.settings.server.keys_parent_dir or cfg.stateDir;
};
};
configFile = format.generate "config.ini" settings;
assets = pkgs.stdenvNoCC.mkDerivation {
pname = "writefreely-assets";
inherit (cfg.package) version src;
nativeBuildInputs = with pkgs.nodePackages; [ less ];
buildPhase = ''
mkdir -p $out
cp -r static $out/
'';
installPhase = ''
less_dir=$src/less
css_dir=$out/static/css
lessc $less_dir/app.less $css_dir/write.css
lessc $less_dir/fonts.less $css_dir/fonts.css
lessc $less_dir/icons.less $css_dir/icons.css
lessc $less_dir/prose.less $css_dir/prose.css
'';
};
withConfigFile = text: ''
db_pass=${
optionalString (cfg.database.passwordFile != null)
"$(head -n1 ${cfg.database.passwordFile})"
}
cp -f ${configFile} '${cfg.stateDir}/config.ini'
sed -e "s,#dbpass#,$db_pass,g" -i '${cfg.stateDir}/config.ini'
chmod 440 '${cfg.stateDir}/config.ini'
${text}
'';
withMysql = text:
withConfigFile ''
query () {
local result=$(${config.services.mysql.package}/bin/mysql \
--user=${cfg.database.user} \
--password=$db_pass \
--database=${cfg.database.name} \
--silent \
--raw \
--skip-column-names \
--execute "$1" \
)
echo $result
}
${text}
'';
withSqlite = text:
withConfigFile ''
query () {
local result=$(${sqlite}/bin/sqlite3 \
'${cfg.stateDir}/${settings.database.filename}'
"$1" \
)
echo $result
}
${text}
'';
in
{
options.plusultra.services.writefreely = {
enable =
lib.mkEnableOption (lib.mdDoc "Writefreely, build a digital writing community");
package = lib.mkOption {
type = lib.types.package;
default = pkgs.writefreely;
defaultText = lib.literalExpression "pkgs.writefreely";
description = lib.mdDoc "Writefreely package to use.";
};
stateDir = mkOption {
type = types.path;
default = "/var/lib/writefreely";
description = lib.mdDoc "The state directory where keys and data are stored.";
};
user = mkOption {
type = types.str;
default = "writefreely";
description = lib.mdDoc "User under which Writefreely is ran.";
};
group = mkOption {
type = types.str;
default = "writefreely";
description = lib.mdDoc "Group under which Writefreely is ran.";
};
host = mkOption {
type = types.str;
default = "";
description = lib.mdDoc "The public host name to serve.";
example = "example.com";
};
settings = mkOption {
default = { };
description = lib.mdDoc ''
Writefreely configuration (`config.ini`). Refer to
[writefreely.org/docs/latest/admin/config](https://writefreely.org/docs/latest/admin/config)
for details.
'';
type = types.submodule {
freeformType = format.type;
options = {
app = {
theme = mkOption {
type = types.str;
default = "write";
description = "The theme to apply.";
};
};
server = {
port = mkOption {
type = types.port;
default = if cfg.nginx.enable then 18080 else 80;
defaultText = "80";
description = "The port WriteFreely should listen on.";
};
};
};
};
};
database = {
type = mkOption {
type = types.enum [ "sqlite3" "mysql" ];
default = "sqlite3";
description = "The database provider to use.";
};
name = mkOption {
type = types.str;
default = "writefreely";
description = "The name of the database to store data in.";
};
user = mkOption {
type = types.nullOr types.str;
default = if cfg.database.type == "mysql" then "writefreely" else null;
defaultText = "writefreely";
description = lib.mdDoc "The database user to connect as.";
};
passwordFile = mkOption {
type = types.nullOr types.path;
default = null;
description = lib.mdDoc "The file to load the database password from.";
};
host = mkOption {
type = types.str;
default = "localhost";
description = lib.mdDoc "The database host to connect to.";
};
port = mkOption {
type = types.port;
default = 3306;
description = lib.mdDoc "The port used when connecting to the database host.";
};
tls = mkOption {
type = types.bool;
default = false;
description =
lib.mdDoc "Whether or not TLS should be used for the database connection.";
};
migrate = mkOption {
type = types.bool;
default = true;
description =
lib.mdDoc "Whether or not to automatically run migrations on startup.";
};
createLocally = mkOption {
type = types.bool;
default = false;
description = lib.mdDoc ''
When `plusultra.services.writefreely.database.type` is set to
`"mysql"`, this option will enable the MySQL service locally.
'';
};
};
admin = {
name = mkOption {
type = types.nullOr types.str;
description = "The name of the first admin user.";
default = null;
};
initialPasswordFile = mkOption {
type = types.path;
description = ''
Path to a file containing the initial password for the admin user.
If not provided, the default password will be set to <code>nixos</code>.
'';
default = pkgs.writeText "default-admin-pass" "nixos";
defaultText = "/nix/store/xxx-default-admin-pass";
};
};
nginx = {
enable = mkOption {
type = types.bool;
default = false;
description =
"Whether or not to enable and configure nginx as a proxy for WriteFreely.";
};
forceSSL = mkOption {
type = types.bool;
default = false;
description = "Whether or not to force the use of SSL.";
};
};
acme = {
enable = mkOption {
type = types.bool;
default = false;
description =
"Whether or not to automatically fetch and configure SSL certs.";
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.host != "";
message = "plusultra.services.writefreely.host must be set";
}
{
assertion = isMysqlLocal -> cfg.database.passwordFile != null;
message =
"plusultra.services.writefreely.database.passwordFile must be set if plusultra.services.writefreely.database.createLocally is set to true";
}
{
assertion = isSqlite -> !cfg.database.createLocally;
message =
"plusultra.services.writefreely.database.createLocally has no use when plusultra.services.writefreely.database.type is set to sqlite3";
}
];
users = {
users = optionalAttrs (cfg.user == "writefreely") {
writefreely = {
group = cfg.group;
home = cfg.stateDir;
isSystemUser = true;
};
};
groups =
optionalAttrs (cfg.group == "writefreely") { writefreely = { }; };
};
systemd.tmpfiles.rules =
[ "d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -" ];
systemd.services.writefreely = {
after = [ "network.target" ]
++ optional isSqlite "writefreely-sqlite-init.service"
++ optional isMysql "writefreely-mysql-init.service"
++ optional isMysqlLocal "mysql.service";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.stateDir;
Restart = "always";
RestartSec = 20;
ExecStart =
"${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' serve";
AmbientCapabilities =
optionalString (settings.server.port < 1024) "cap_net_bind_service";
};
preStart = ''
if ! test -d "${cfg.stateDir}/keys"; then
mkdir -p ${cfg.stateDir}/keys
# Key files end up with the wrong permissions by default.
# We need to correct them so that Writefreely can read them.
chmod -R 750 "${cfg.stateDir}/keys"
${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' keys generate
fi
'';
};
systemd.services.writefreely-sqlite-init = mkIf isSqlite {
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.stateDir;
ReadOnlyPaths = optional (cfg.admin.initialPasswordFile != null)
cfg.admin.initialPasswordFile;
};
script =
let
migrateDatabase = optionalString cfg.database.migrate ''
${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db migrate
'';
createAdmin = optionalString (cfg.admin.name != null) ''
if [[ $(query "SELECT COUNT(*) FROM users") == 0 ]]; then
admin_pass=$(head -n1 ${cfg.admin.initialPasswordFile})
${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' --create-admin ${cfg.admin.name}:$admin_pass
fi
'';
in
withSqlite ''
if ! test -f '${settings.database.filename}'; then
${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db init
fi
${migrateDatabase}
${createAdmin}
'';
};
systemd.services.writefreely-mysql-init = mkIf isMysql {
wantedBy = [ "multi-user.target" ];
after = optional isMysqlLocal "mysql.service";
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.stateDir;
ReadOnlyPaths = optional isMysqlLocal cfg.database.passwordFile
++ optional (cfg.admin.initialPasswordFile != null)
cfg.admin.initialPasswordFile;
};
script =
let
updateUser = optionalString isMysqlLocal ''
# WriteFreely currently *requires* a password for authentication, so we
# need to update the user in MySQL accordingly. By default MySQL users
# authenticate with auth_socket or unix_socket.
# See: https://github.com/writefreely/writefreely/issues/568
${config.services.mysql.package}/bin/mysql --skip-column-names --execute "ALTER USER '${cfg.database.user}'@'localhost' IDENTIFIED VIA unix_socket OR mysql_native_password USING PASSWORD('$db_pass'); FLUSH PRIVILEGES;"
'';
migrateDatabase = optionalString cfg.database.migrate ''
${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db migrate
'';
createAdmin = optionalString (cfg.admin.name != null) ''
if [[ $(query 'SELECT COUNT(*) FROM users') == 0 ]]; then
admin_pass=$(head -n1 ${cfg.admin.initialPasswordFile})
${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' --create-admin ${cfg.admin.name}:$admin_pass
fi
'';
in
withMysql ''
${updateUser}
if [[ $(query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '${cfg.database.name}'") == 0 ]]; then
${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db init
fi
${migrateDatabase}
${createAdmin}
'';
};
services.mysql = mkIf isMysqlLocal {
enable = true;
package = mkDefault pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [{
name = cfg.database.user;
ensurePermissions = {
"${cfg.database.name}.*" = "ALL PRIVILEGES";
# WriteFreely requires the use of passwords, so we need permissions
# to `ALTER` the user to add password support and also to reload
# permissions so they can be used.
"*.*" = "CREATE USER, RELOAD";
};
}];
};
services.nginx = lib.mkIf cfg.nginx.enable {
enable = true;
recommendedProxySettings = true;
virtualHosts."${cfg.host}" = {
enableACME = cfg.acme.enable;
forceSSL = cfg.nginx.forceSSL;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString settings.server.port}";
};
};
};
};
}