aboutsummaryrefslogtreecommitdiff
path: root/modules/apps
diff options
context:
space:
mode:
Diffstat (limited to 'modules/apps')
-rw-r--r--modules/apps/cli.nix140
-rw-r--r--modules/apps/default.nix11
-rw-r--r--modules/apps/filebackup.nix282
-rw-r--r--modules/apps/icinga2.nix375
-rw-r--r--modules/apps/icingaweb2.nix398
-rw-r--r--modules/apps/juandelacosa.nix68
-rw-r--r--modules/apps/mariadb/default.nix442
-rw-r--r--modules/apps/mariadb/mysqld.nix285
-rw-r--r--modules/apps/mariadb/procedures.sql134
-rw-r--r--modules/apps/mariadb/replicate.nix87
-rw-r--r--modules/apps/mariadb/roles.nix250
-rw-r--r--modules/apps/mariadb/slave-watchdog.nix103
-rw-r--r--modules/apps/mediawiki/default.nix323
-rw-r--r--modules/apps/mediawiki/localSettings.nix158
-rw-r--r--modules/apps/mysqlbackup.nix428
-rw-r--r--modules/apps/mywatch.nix61
-rw-r--r--modules/apps/nginx.nix146
-rw-r--r--modules/apps/pgbackup.nix337
-rw-r--r--modules/apps/php-fpm.nix139
-rw-r--r--modules/apps/postgresql/default.nix203
-rw-r--r--modules/apps/postgresql/functions.pgsql25
-rw-r--r--modules/apps/postgresql/server.nix218
-rw-r--r--modules/apps/sproxy-web.nix71
-rw-r--r--modules/apps/sproxy.nix144
-rw-r--r--modules/apps/strongswan/default.nix101
-rw-r--r--modules/apps/strongswan/options/ca.nix20
-rw-r--r--modules/apps/strongswan/options/conn.nix88
-rw-r--r--modules/apps/strongswan/options/lib.nix26
-rw-r--r--modules/apps/strongswan/options/setup.nix24
29 files changed, 5087 insertions, 0 deletions
diff --git a/modules/apps/cli.nix b/modules/apps/cli.nix
new file mode 100644
index 0000000..00365d8
--- /dev/null
+++ b/modules/apps/cli.nix
@@ -0,0 +1,140 @@
+{ config, pkgs, lib, ...}:
+
+let
+
+ inherit (builtins)
+ toString ;
+ inherit (lib)
+ concatMapStrings filterAttrs mapAttrsToList mkOption
+ types unique ;
+ inherit (types)
+ attrsOf path str submodule ;
+
+ explicit = filterAttrs (n: v: n != "_module" && v != null);
+ apps = explicit config.nixsap.apps.cli;
+
+ exec = name: { user, command, ... }:
+ let
+ uid = toString config.users.users.${user}.uid;
+ gid = uid;
+ src = pkgs.writeText "${name}.c" ''
+ #include <unistd.h>
+ #include <grp.h>
+ #include <pwd.h>
+ #include <stdio.h>
+ #include <stdlib.h>
+ #include <sys/types.h>
+
+ int main (int __attribute__((unused)) argc, char *argv[])
+ {
+ int rc;
+
+ if (getuid() != ${uid}) {
+ if (geteuid() != 0) {
+ fprintf(stderr, "Forbidden.\n");
+ return EXIT_FAILURE;
+ }
+
+ rc = initgroups("${user}", ${gid});
+ if (0 != rc) {
+ perror("initgroups()");
+ return EXIT_FAILURE;
+ }
+
+ rc = setgid(${gid});
+ if (0 != rc) {
+ perror("setgid()");
+ return EXIT_FAILURE;
+ }
+
+ rc = setuid(${uid});
+ if (0 != rc) {
+ perror("setuid()");
+ return EXIT_FAILURE;
+ }
+
+ if ((getuid() != ${uid}) || (geteuid() != ${uid})) {
+ fprintf(stderr, "Something went wrong.\n");
+ return EXIT_FAILURE;
+ }
+
+ struct passwd * pw = getpwuid(${uid});
+ if (NULL == pw) {
+ perror("getpwuid()");
+ return EXIT_FAILURE;
+ }
+
+ if (NULL != pw->pw_dir) {
+ rc = chdir(pw->pw_dir);
+ if (0 != rc) {
+ rc = chdir("/");
+ }
+ } else {
+ rc = chdir("/");
+ }
+ if (0 != rc) {
+ perror("chdir()");
+ return EXIT_FAILURE;
+ }
+ }
+
+ argv[0] = "${command}";
+ execv(argv[0], argv);
+
+ perror("execv()");
+ return EXIT_FAILURE;
+ }
+ '';
+ in pkgs.runCommand name {} "gcc -Wall -Wextra -Werror -std=gnu99 -O2 ${src} -o $out";
+
+ cliapp = submodule({name, ...}:
+ {
+ options = {
+ user = mkOption {
+ description = ''
+ User (and group) to run as. Only users in this group can execute
+ this application.
+ '';
+ type = str;
+ default = name;
+ };
+ command = mkOption {
+ description = "Path to executable";
+ type = path;
+ };
+ };
+ });
+
+in {
+ options.nixsap = {
+ apps.cli = mkOption {
+ description = ''
+ Command line applications that should run as other users and likely
+ have special privileges, e. g. to access secret keys. This is
+ implemented with setuid-wrappers. Each wrapper is launched as root,
+ immediately switches to specified user, then executes something
+ useful. This is like sudo, but access is controlled via wrapper's
+ group: only users in wrapper's group can execute the wrapper.
+
+ Starting as set-uid-non-root is not sufficient, because we might
+ need supplementary groups, like "keys".
+ '';
+ type = attrsOf cliapp;
+ default = {};
+ };
+ };
+
+ config = {
+ nixsap.system.users.daemons = unique (mapAttrsToList (_: a: a.user) apps);
+ security.setuidOwners = mapAttrsToList (n: a:
+ { program = n;
+ owner = "root";
+ group = a.user;
+ setuid = true;
+ setgid = false;
+ permissions = "u+rx,g+x,o=";
+ source = exec n a;
+ }) apps;
+ };
+}
+
diff --git a/modules/apps/default.nix b/modules/apps/default.nix
new file mode 100644
index 0000000..240d970
--- /dev/null
+++ b/modules/apps/default.nix
@@ -0,0 +1,11 @@
+{lib, ... }:
+
+let
+ all = lib.filterAttrs
+ ( n: _: n != "default.nix" && ! lib.hasPrefix "." n )
+ (builtins.readDir ./.);
+
+in {
+ imports = map (p: ./. + "/${p}") ( builtins.attrNames all );
+}
+
diff --git a/modules/apps/filebackup.nix b/modules/apps/filebackup.nix
new file mode 100644
index 0000000..4aee0a9
--- /dev/null
+++ b/modules/apps/filebackup.nix
@@ -0,0 +1,282 @@
+{ config, pkgs, lib, ... }:
+let
+
+ inherit (builtins)
+ isBool isList isString toString ;
+ inherit (lib)
+ concatMapStringsSep concatStringsSep filter filterAttrs
+ flatten hasPrefix mapAttrsToList mkIf
+ mkOption optionalString removeSuffix ;
+ inherit (lib.types)
+ attrsOf bool either enum int listOf nullOr path str submodule ;
+
+ cfg = config.nixsap.apps.filebackup;
+ privateDir = "/run/filebackup";
+
+ s3cmd = "${pkgs.s3cmd}/bin/s3cmd ${optionalString (cfg.s3cfg != null) "-c '${cfg.s3cfg}'"}";
+
+ gpgPubKeys = flatten [ cfg.encrypt ];
+ gpg = "${pkgs.gpg}/bin/gpg2";
+ pubring = pkgs.runCommand "pubring.gpg" {} ''
+ ${gpg} --homedir . --import ${toString gpgPubKeys}
+ cp pubring.gpg $out
+ '';
+
+ default = d: t: mkOption { type = t; default = d; };
+ optional = type: mkOption { type = nullOr type; default = null; };
+ sub = options: submodule { inherit options; } ;
+ mandatory = type: mkOption { inherit type; };
+ concatMapAttrsSep = s: f: attrs: concatStringsSep s (mapAttrsToList f attrs);
+
+ command = sub
+ {
+ absolute-names = optional bool;
+ exclude = optional (either str (listOf str));
+ exclude-from = optional path;
+ exclude-vcs = optional bool;
+ exclude-vcs-ignores = optional bool;
+ group = optional str;
+ ignore-case = optional bool;
+ mode = optional str;
+ owner = optional str;
+ path = mandatory (either path (listOf path));
+ };
+
+ job = name: o:
+ let
+ args = filterAttrs (k: v:
+ v != null && k != "_module"
+ && ( k != "path" )
+ ) o;
+
+ mkArg = k: v:
+ if isBool v then (optionalString v "--${k}")
+ else if isList v then concatMapStringsSep " " (i: "--${k}='${i}'") v
+ else if isString v then "--${k}='${v}'"
+ else "--${k}=${toString v}" ;
+
+ tar = pkgs.writeBashScript "tar-${name}" ''
+ exec ${pkgs.gnutar}/bin/tar -c -f- \
+ ${concatMapAttrsSep " " mkArg args} \
+ "$@"
+ '';
+
+ in pkgs.writeBashScript "tar-${name}-job" ''
+ set -euo pipefail
+ cd "${cfg.tarballDir}/$DATE"
+ host=$(${pkgs.nettools}/bin/hostname -f)
+
+ tarball="${name}@$host,$DATE.tar.xz"
+ ${
+ if (gpgPubKeys != []) then
+ ''aim="$tarball.gpg"''
+ else
+ ''aim="$tarball"''
+ }
+
+ if ! [ -r "$aim" ]; then
+ ${tar} ${concatMapStringsSep " " (p: "'${p}'") (flatten [o.path])} \
+ | ${pkgs.pxz}/bin/pxz -2 -T2 > "$tarball.tmp"
+ mv "$tarball".tmp "$tarball"
+
+ ${optionalString (gpgPubKeys != []) ''
+ recipient=( $(${gpg} --homedir '${privateDir}/gnupg' -k --with-colons --fast-list-mode | \
+ ${pkgs.gawk}/bin/awk -F: '/^pub/{print $5}') )
+ r=( "''${recipient[@]/#/-r}" )
+ ${gpg} --homedir '${privateDir}/gnupg' --batch --no-tty --yes \
+ "''${r[@]}" --trust-model always \
+ --compress-algo none \
+ -v -e "$tarball"
+ rm -f "$tarball"
+ ''}
+ else
+ echo "$aim exists. Not creating." >&2
+ fi
+ ${optionalString (cfg.s3uri != null) ''
+ remote="${removeSuffix "/" cfg.s3uri}/$DATE/$aim"
+ if ! ${s3cmd} ls "$remote" | ${pkgs.gnugrep}/bin/grep -qF "/$aim"; then
+ ${s3cmd} put "$aim" "$remote"
+ else
+ echo "$remote exists. Not uploading." >&2
+ fi
+ ''}
+ '';
+
+ preStart = ''
+ mkdir --mode=0750 -p '${cfg.tarballDir}'
+ chown -R ${cfg.user}:${cfg.user} '${cfg.tarballDir}'
+ chmod -R u=rwX,g=rX,o= ${cfg.tarballDir}
+
+ rm -rf '${privateDir}'
+ mkdir --mode=0700 -p '${privateDir}'
+ chown ${cfg.user}:${cfg.user} '${privateDir}'
+ '';
+
+ main = pkgs.writeBashScriptBin "filebackup" ''
+ set -euo pipefail
+ umask 0027
+ DATE=$(date --iso-8601)
+ HOME='${privateDir}'
+ PARALLEL_SHELL=${pkgs.bash}/bin/bash
+ export DATE
+ export HOME
+ export PARALLEL_SHELL
+
+ clean() {
+ ${pkgs.findutils}/bin/find '${cfg.tarballDir}' \
+ -name '*.tmp' -exec rm -rf {} + || true
+ }
+
+ listSets() {
+ ${pkgs.findutils}/bin/find '${cfg.tarballDir}' \
+ -maxdepth 1 -mindepth 1 -type d -name '????-??-??' \
+ | sort -V
+ }
+
+ enoughStorage() {
+ local n
+ local used
+ local total
+ local avg
+ local p
+ n=$(listSets | wc -l)
+ used=$(du -x -s --block-size=1M '${cfg.tarballDir}' | cut -f1)
+ total=$(df --output=size --block-size=1M '${cfg.tarballDir}' | tail -n 1)
+ if [ "$n" -eq 0 ]; then
+ echo "no sets" >&2
+ return 0
+ fi
+
+ avg=$(( used / n ))
+ p=$(( 100 * avg * (n + 1) / total ))
+ printf "estimated storage: %d of %d MiB (%d%%, max ${toString cfg.storage}%%)\n" \
+ "$((used + avg))" "$total" "$p" >&2
+ if [ "$p" -le ${toString cfg.storage} ]; then
+ return 0
+ else
+ return 1
+ fi
+ }
+
+ clean
+
+ listSets | head -n -${toString (cfg.slots - 1)} \
+ | ${pkgs.findutils}/bin/xargs --no-run-if-empty rm -rfv \
+ || true
+
+ while ! enoughStorage; do
+ listSets | head -n 1 \
+ | ${pkgs.findutils}/bin/xargs --no-run-if-empty rm -rfv \
+ || true
+ done
+
+ mkdir -p "${cfg.tarballDir}/$DATE"
+
+ ${optionalString (gpgPubKeys != []) ''
+ # shellcheck disable=SC2174
+ mkdir --mode=0700 -p '${privateDir}/gnupg'
+ ln -sf ${pubring} '${privateDir}/gnupg/pubring.gpg'
+ ''}
+
+ failed=0
+ log="${cfg.tarballDir}/$DATE/joblog.txt"
+
+ # shellcheck disable=SC2016
+ ${pkgs.parallel}/bin/parallel \
+ --halt-on-error 0 \
+ --joblog "$log" \
+ --jobs 50% \
+ --line-buffer \
+ --no-notice \
+ --no-run-if-empty \
+ --retries 2 \
+ --rpl '{nixbase} s:^/nix/store/[^-]+-tar-(.+)-job$:$1:' \
+ --tagstr '* {nixbase}:' \
+ --timeout ${toString (6 * 60 * 60)} ::: \
+ ${concatMapAttrsSep " " job cfg.files} \
+ || failed=$?
+
+ cat "$log"
+ clean
+
+ du -sh "${cfg.tarballDir}/$DATE" || true
+ exit "$failed"
+ '';
+
+ keys = filter (f: f != null && hasPrefix "/run/keys/" f) ( [cfg.s3cfg] );
+
+in {
+ options.nixsap.apps.filebackup = {
+ user = mkOption {
+ description = "User to run as";
+ default = "filebackup";
+ type = str;
+ };
+
+ tarballDir = mkOption {
+ description = "Directory to save tarballs in";
+ default = "/filebackup";
+ type = path;
+ };
+
+ slots = mkOption {
+ description = ''
+ How many backup sets should be kept locally.
+ However, old sets will be removed anyway if storage
+ constraints apply.
+ '';
+ default = 60;
+ type = int;
+ };
+
+ storage = mkOption {
+ description = ''
+ Percent of storage backups can occupy.
+ '';
+ default = 75;
+ type = int;
+ };
+
+ encrypt = mkOption {
+ description = "Public GPG key(s) for encrypting the dumps";
+ default = [ ];
+ type = either path (listOf path);
+ };
+
+ s3cfg = mkOption {
+ description = "s3cmd config file (secret)";
+ type = nullOr path;
+ default = null;
+ };
+
+ s3uri = mkOption {
+ description = "S3 bucket URI with prefix in s3cmd format";
+ type = nullOr str;
+ default = null;
+ example = "s3://backups/nightly";
+ };
+
+ files = mkOption {
+ description = "tar commands";
+ default = {};
+ type = attrsOf command;
+ };
+ };
+
+ config = mkIf (cfg.files != {}) {
+ nixsap.system.users.daemons = [ cfg.user ];
+ nixsap.deployment.keyrings.${cfg.user} = keys;
+ systemd.services.filebackup = {
+ description = "Directory backup with tar";
+ after = [ "local-fs.target" "keys.target" ];
+ wants = [ "keys.target" ];
+ startAt = "02:00";
+ inherit preStart;
+ serviceConfig = {
+ ExecStart = "${main}/bin/filebackup";
+ User = cfg.user;
+ PermissionsStartOnly = true;
+ };
+ };
+ };
+}
diff --git a/modules/apps/icinga2.nix b/modules/apps/icinga2.nix
new file mode 100644
index 0000000..6df18fc
--- /dev/null
+++ b/modules/apps/icinga2.nix
@@ -0,0 +1,375 @@
+{ config, pkgs, lib, ... }:
+
+let
+ inherit (builtins)
+ attrNames dirOf toString ;
+ inherit (lib)
+ concatMapStringsSep mapAttrsToList concatStringsSep filter hasPrefix
+ isString mkEnableOption mkIf mkOption optionalString types ;
+ inherit (types)
+ attrsOf bool either enum int listOf path str ;
+
+ environment = {
+ SSL_CERT_FILE = "/etc/ssl/certs/ca-bundle.crt";
+ };
+
+ cfg = config.nixsap.apps.icinga2;
+ rundir = "/run/icinga2";
+ pidFile = "${rundir}/icinga2.pid";
+
+ mutableDir = "mutable.d";
+ mutableTmpDir = "mutable.tmp.d";
+ mutablePath = "${cfg.stateDir}/etc/icinga2/${mutableDir}";
+ mutableTmpPath = "${cfg.stateDir}/etc/icinga2/${mutableTmpDir}";
+ mutableRestart = "${mutablePath}/restart";
+
+ icingaMutableUpdate =
+ let
+ job = n: j: pkgs.writeBashScript "icinga-mutable-${n}" ''
+ set -euo pipefail
+ f='${mutableTmpPath}/${n}.conf'
+ ${j} > "$f.tmp"
+ mv -f "$f.tmp" "$f"
+ '';
+ in pkgs.writeBashScript "icinga-mutable-update" ''
+ set -euo pipefail
+
+ rm -rf ${mutableTmpPath}
+ mkdir -p ${mutableTmpPath}
+
+ HOME=${rundir}
+ PARALLEL_SHELL=${pkgs.bash}/bin/bash
+ export PARALLEL_SHELL
+
+ # shellcheck disable=SC2016
+ ${pkgs.parallel}/bin/parallel \
+ --delay 2 \
+ --halt-on-error 0 \
+ --line-buffer \
+ --no-notice \
+ --no-run-if-empty \
+ --rpl '{name} s:^.*-icinga-mutable-(.+)$:$1:' \
+ --timeout 120 \
+ --tagstr '* {name}:' \
+ ::: \
+ ${concatStringsSep " " (
+ mapAttrsToList job cfg.mutable.conf
+ )} \
+ || exit 1 # WARNING
+
+ old=$(${pkgs.nix}/bin/nix-hash --type sha1 '${mutablePath}')
+ new=$(${pkgs.nix}/bin/nix-hash --type sha1 '${mutableTmpPath}')
+ if [ "$old" != "$new" ]; then
+ ${pkgs.gnused}/bin/sed 's,${mutablePath},${mutableTmpPath},' \
+ ${icingaConf} > \
+ ${cfg.stateDir}/etc/icinga2/icinga2.tmp.conf
+ if ! ${pkgs.icinga2}/bin/icinga2 daemon -C -x critical -c ${cfg.stateDir}/etc/icinga2/icinga2.tmp.conf; then
+ exit 2 # CRITICAL
+ fi
+ rm -f ${cfg.stateDir}/etc/icinga2/icinga2.tmp.conf
+ rm -rf ${mutablePath}.bak
+ mv -f ${mutablePath} ${mutablePath}.bak
+ mv -f ${mutableTmpPath} ${mutablePath}
+ rm -rf ${mutablePath}.bak
+ if [ -f ${pidFile} ]; then
+ pid=$(cat ${pidFile})
+ if ${pkgs.coreutils}/bin/kill -0 "$pid"; then
+ touch ${mutableRestart}
+ ${pkgs.coreutils}/bin/kill -HUP "$pid"
+ echo "Restart: $old -> $new"
+ fi
+ fi
+ else
+ echo "No changes: $old"
+ fi
+ '';
+
+ icingaMutableCheckCommand = pkgs.writeText "icinga-${cfg.mutable.checkCommand}.conf" ''
+ object CheckCommand "${cfg.mutable.checkCommand}" {
+ import "plugin-check-command"
+ command = [ "${icingaMutableUpdate}" ]
+ }
+ '';
+
+ icingaConf = pkgs.writeText "icinga2.conf"
+ ''
+ const PluginDir = "${pkgs.monitoringPlugins}/libexec"
+ const RunAsGroup = "${cfg.user}"
+ const RunAsUser = "${cfg.user}"
+
+ include <itl>
+ include <plugins>
+
+ object Endpoint NodeName {
+ host = NodeName
+ }
+ object Zone NodeName {
+ endpoints = [ NodeName ]
+ }
+
+ include "${cfg.stateDir}/etc/icinga2/features-enabled/*.conf"
+ include "${cfg.stateDir}/etc/icinga2/conf.d/*.conf"
+ include_recursive "${cfg.stateDir}/etc/icinga2/repository.d"
+ include "${mutablePath}/*.conf"
+
+ ${concatMapStringsSep "\n" (f:
+ if hasPrefix "/" f
+ then ''include "${f}"''
+ else ''include "${pkgs.writeText "icinga2.inc.conf" f}"''
+ ) cfg.configFiles}
+ '';
+
+ console = pkgs.writeBashScriptBin "icinga2console" ''
+ if [ -z "$ICINGA2_API_USERNAME" ] && [ -r ${cfg.stateDir}/etc/icinga2/conf.d/api-users.conf ]; then
+ pwd=$(${pkgs.gnused}/bin/sed -rn 's,.*password\s*=\s*"(.+)".*,\1,p' ${cfg.stateDir}/etc/icinga2/conf.d/api-users.conf)
+ export ICINGA2_API_USERNAME=root
+ export ICINGA2_API_PASSWORD="$pwd"
+ fi
+ exec ${pkgs.icinga2}/bin/icinga2 console --connect 'https://localhost/' "$@"
+ '';
+
+ configureMySQL = pkgs.writeBashScript "icinga2-mysql" ''
+ set -euo pipefail
+ nconn=$(icinga2console --eval 'len(get_objects(IdoMysqlConnection))')
+ nconn=''${nconn%.*} # float to int
+ if [ "$nconn" -eq 0 ]; then
+ exit
+ fi
+ for i in $( seq 0 $(( nconn - 1 )) ); do
+ db=$(icinga2console --eval "get_objects(IdoMysqlConnection)[$i].database")
+ host=$(icinga2console --eval "get_objects(IdoMysqlConnection)[$i].host")
+ port=$(icinga2console --eval "get_objects(IdoMysqlConnection)[$i].port")
+ pwd=$(icinga2console --eval "get_objects(IdoMysqlConnection)[$i].password")
+ user=$(icinga2console --eval "get_objects(IdoMysqlConnection)[$i].user")
+
+ # XXX Removing quotes:
+ db=''${db%\"} ; db=''${db#\"}
+ host=''${host%\"} ; host=''${host#\"}
+ pwd=''${pwd%\"} ; pwd=''${pwd#\"}
+ user=''${user%\"} ; user=''${user#\"}
+ port=''${port%.*}
+ mysql=(${pkgs.mysql}/bin/mysql --no-defaults "-h$host" "-P$port" "-u$user" "--password=$pwd")
+ while ! "''${mysql[@]}" -e ';'; do
+ sleep 20s
+ done
+ tt=$("''${mysql[@]}" -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$db';")
+ # TODO: Migrations:
+ if [ "$tt" -eq 0 ]; then
+ "''${mysql[@]}" -v "$db" < ${pkgs.icinga2}/share/icinga2-ido-mysql/schema/mysql.sql
+ fi
+ done
+ '';
+
+ configureDBs = pkgs.writeBashScriptBin "icinga2db" ''
+ set -eu
+ while ! icinga2console -e '"connected to icinga"'; do
+ sleep 30s
+ done
+ ${configureMySQL}
+ '';
+
+ preStart = ''
+ umask 0077
+ mkdir -p \
+ ${cfg.stateDir}/cache/icinga2 \
+ ${cfg.stateDir}/lib/icinga2/api/log \
+ ${cfg.stateDir}/lib/icinga2/api/repository \
+ ${cfg.stateDir}/lib/icinga2/api/zones \
+ ${cfg.stateDir}/log/icinga2/compat/archives \
+ ${cfg.stateDir}/log/icinga2/crash \
+ ${cfg.stateDir}/spool/icinga2/perfdata \
+ ${cfg.stateDir}/spool/icinga2/tmp
+
+ ${pkgs.findutils}/bin/find \
+ ${cfg.stateDir}/etc/icinga2 \
+ -mindepth 1 -maxdepth 1 \
+ -not -name ${mutableDir} \
+ -not -name pki \
+ -not -name repository.d \
+ -exec rm -rf '{}' \; || true
+
+ mkdir -p \
+ ${cfg.stateDir}/etc/icinga2/conf.d \
+ ${mutablePath} \
+ ${cfg.stateDir}/etc/icinga2/repository.d \
+ ${cfg.stateDir}/etc/icinga2/features-enabled
+ ln -sf ${pkgs.icinga2}${cfg.stateDir}/etc/icinga2/features-available \
+ ${cfg.stateDir}/etc/icinga2/features-available
+ ln -sf ${pkgs.icinga2}${cfg.stateDir}/etc/icinga2/scripts \
+ ${cfg.stateDir}/etc/icinga2/scripts
+
+ # XXX Can't include in the main file due to infinite recursion
+ ln -sf ${icingaMutableCheckCommand} \
+ ${cfg.stateDir}/etc/icinga2/conf.d/${cfg.mutable.checkCommand}.conf
+
+ # XXX: requires root (!?)
+ ${pkgs.icinga2}/bin/icinga2 api setup
+ ${pkgs.icinga2}/bin/icinga2 feature enable checker
+ ${pkgs.icinga2}/bin/icinga2 feature enable command
+ ${pkgs.icinga2}/bin/icinga2 feature enable livestatus
+
+ ${optionalString cfg.notifications ''
+ ${pkgs.icinga2}/bin/icinga2 feature enable notification
+ ''}
+
+ rm -rf ${rundir}
+ mkdir --mode=0755 -p ${rundir}
+ mkdir --mode=2710 -p ${dirOf cfg.commandPipe}
+ mkdir --mode=2710 -p ${dirOf cfg.livestatusSocket}
+ chown -R ${cfg.user}:${cfg.user} ${rundir}
+ chown -Rc ${cfg.user}:${cfg.user} ${cfg.stateDir}
+ chmod -R u=rwX,g=rX,o= ${cfg.stateDir}
+ chown ${cfg.user}:${cfg.commandGroup} ${dirOf cfg.commandPipe}
+ chown ${cfg.user}:${cfg.commandGroup} ${dirOf cfg.livestatusSocket}
+ '';
+
+ ExecStart = pkgs.writeBashScript "icinga2-start" ''
+ set -euo pipefail
+
+ umask 0077
+
+ printf 'const TicketSalt = "%s"\n' "$(${pkgs.pwgen}/bin/pwgen -1 -s 23)" \
+ > ${cfg.stateDir}/etc/icinga2/conf.d/ticketsalt.conf
+
+ if [ -e ${mutableRestart} ]; then
+ rm ${mutableRestart}
+ else
+ ${icingaMutableUpdate} || true
+ if ! ${pkgs.icinga2}/bin/icinga2 daemon -C -x critical -c ${icingaConf}; then
+ rm -rf ${mutablePath}
+ mkdir -p ${mutablePath}
+ fi
+ fi
+
+ exec ${pkgs.icinga2}/bin/icinga2 daemon -x ${cfg.logLevel} -c ${icingaConf}
+ '';
+
+in {
+
+ options.nixsap = {
+ apps.icinga2 = {
+ enable = mkEnableOption "icinga2";
+
+ logLevel = mkOption {
+ description = "Icinga2 daemon log level";
+ type = enum [ "debug" "notice" "information" "warning" "critical" ];
+ default = "information";
+ };
+
+ notifications = mkOption {
+ description = "Enable notifications";
+ type = bool;
+ default = false;
+ };
+
+ configFiles = mkOption {
+ description = ''
+ Configuration files or inline text
+ to be included in the main file'';
+ type = listOf (either str path);
+ };
+
+ mutable.conf = mkOption {
+ description = ''
+ A set of executables to write mutable config files.
+ '';
+ type = attrsOf path;
+ default = {};
+ };
+ mutable.checkCommand = mkOption {
+ description = ''
+ Name of the mutable check command. You may need to alter this
+ only in an unlikely case of conflict with your custom commands.
+ Mutable files are updated every time icinga2 restart. If you want
+ better control and observability on this, create a service with
+ this check command. If exists, this service will make icinga2
+ restart when mutable files change (and pass syntax check) via
+ sending the HUP signal to the main icinga2 process.
+ '';
+ type = str;
+ default = "mutable-conf-refresh";
+ };
+
+ # these are hard-coded into icinga2 package:
+ user = mkOption {
+ type = types.str;
+ description = "User to run as";
+ default = "icinga";
+ readOnly = true;
+ };
+
+ commandGroup = mkOption {
+ type = types.str;
+ description = "Dedicated command group for command pipe and livestatus";
+ default = "icingacmd";
+ readOnly = true;
+ };
+
+ stateDir = mkOption {
+ type = types.path;
+ description = "Icinga2 logs, state, config files";
+ default = "/icinga2";
+ readOnly = true;
+ };
+
+ commandPipe = mkOption {
+ type = types.path;
+ description = "Icinga2 command pipe";
+ default = "${rundir}/cmd/icinga2.cmd";
+ readOnly = true;
+ };
+
+ livestatusSocket = mkOption {
+ type = types.path;
+ description = "Icinga2 Livestatus socket";
+ default = "${rundir}/cmd/livestatus";
+ readOnly = true;
+ };
+ };
+ };
+
+ config = mkIf cfg.enable {
+ environment.systemPackages = [ console ];
+ nixsap.apps.icinga2.configFiles = [
+ "${pkgs.icinga2}/icinga2/etc/icinga2/conf.d/app.conf"
+ "${pkgs.icinga2}/icinga2/etc/icinga2/conf.d/commands.conf"
+ "${pkgs.icinga2}/icinga2/etc/icinga2/conf.d/notifications.conf"
+ "${pkgs.icinga2}/icinga2/etc/icinga2/conf.d/templates.conf"
+ "${pkgs.icinga2}/icinga2/etc/icinga2/conf.d/timeperiods.conf"
+ ];
+ nixsap.system.users.daemons = [ cfg.user ];
+ nixsap.system.groups = [ cfg.commandGroup ];
+ nixsap.deployment.keyrings.${cfg.user} = filter (hasPrefix "/run/keys/") cfg.configFiles;
+ users.users.${cfg.user}.extraGroups = [ "proc" ];
+ systemd.services.icinga2 = {
+ description = "Icinga2 daemon";
+ after = [ "local-fs.target" "keys.target" "network.target" ];
+ wants = [ "keys.target" ];
+ wantedBy = [ "multi-user.target" ];
+ inherit environment preStart;
+ serviceConfig = {
+ inherit ExecStart;
+ KillMode = "mixed";
+ PermissionsStartOnly = true;
+ Restart = "always";
+ TimeoutSec = 600;
+ User = cfg.user;
+ };
+ };
+
+ systemd.services.icinga2db = {
+ description = "Icinga2 databases configurator";
+ after = [ "icinga2.service" ];
+ wantedBy = [ "multi-user.target" ];
+ path = [ console ];
+ inherit environment;
+ serviceConfig = {
+ ExecStart = "${configureDBs}/bin/icinga2db";
+ User = cfg.user;
+ RemainAfterExit = true;
+ Restart = "on-failure";
+ };
+ };
+ };
+}
+
diff --git a/modules/apps/icingaweb2.nix b/modules/apps/icingaweb2.nix
new file mode 100644
index 0000000..ed52f86
--- /dev/null
+++ b/modules/apps/icingaweb2.nix
@@ -0,0 +1,398 @@
+{ config, pkgs, lib, ... }:
+
+let
+
+ inherit (lib) types
+ mkIf mkOption mkEnableOption mkDefault hasPrefix
+ concatMapStringsSep filterAttrs recursiveUpdate mapAttrsToList
+ concatStringsSep isString filter genAttrs attrNames
+ optionalString mkOptionType any;
+ inherit (types)
+ bool str int lines path either
+ nullOr attrsOf listOf enum submodule unspecified;
+ inherit (builtins) toString;
+
+ localIcinga = config.nixsap.apps.icinga2.enable;
+
+ cfg = config.nixsap.apps.icingaweb2;
+
+ attrs = opts: submodule { options = opts; };
+ mandatory = t: mkOption { type = t; };
+ optional = t: mkOption { type = nullOr t; default = null; };
+ default = d: t: mkOption { type = t; default = d; };
+ explicit = filterAttrs (n: v: n != "_module" && v != null);
+ show = v: optionalString (v != null) (toString v);
+
+ permission =
+ let
+ allowed =
+ [
+ "config/authentication/groups"
+ "config/authentication/roles/show"
+ "config/authentication/users"
+ "module"
+ "monitoring/command"
+ ];
+ in mkOptionType {
+ name = "string starting with one of ${concatMapStringsSep ", " (s: ''"${s}"'') allowed}";
+ check = x: isString x && any (p: hasPrefix p x) allowed;
+ };
+
+ role = attrs {
+ users = default [] (listOf str);
+ groups = default [] (listOf str);
+ permissions = mandatory (listOf permission);
+ objects = mandatory str;
+ };
+
+ database = attrs {
+ db = mandatory str;
+ host = mandatory str;
+ passfile = optional path;
+ port = optional int;
+ type = mandatory (enum [ "mysql" ]);
+ user = mandatory str;
+ };
+
+ configIni = pkgs.writeText "config.ini" ''
+ [global]
+ show_stacktraces = "${if cfg.stacktrace then "1" else "0"}"
+ config_backend = "db"
+ config_resource = "icingaweb2db"
+
+ [logging]
+ level = "${cfg.logLevel}"
+ ${if cfg.log == "syslog" then ''
+ log = "syslog"
+ application = "icingaweb2"
+ '' else ''
+ log = "file"
+ file = "${cfg.log}"
+ ''
+ }
+ '';
+
+ # XXX Livestatus is not supported by IcingaWeb2 (2.1.0)
+ # https://dev.icinga.org/issues/8254
+ # "We'll postpone this issue because Icinga 2.4 will introduce
+ # an API for querying monitoring data. Maybe we drop support
+ # for Livestatus completely"
+ modules.monitoring.backendsIni = pkgs.writeText "backends.ini" ''
+ [icinga2]
+ type = "ido"
+ resource = "icinga2db"
+ '';
+
+ modules.monitoring.configIni = pkgs.writeText "config.ini" ''
+ [security]
+ protected_customvars = "${concatStringsSep "," cfg.protectedCustomVars}"
+ '';
+
+ modules.monitoring.commandtransportsIni = pkgs.writeText "commandtransports.ini" ''
+ ${optionalString localIcinga ''
+ [local]
+ transport = "local"
+ path = "${config.nixsap.apps.icinga2.commandPipe}"
+ ''
+ }
+ '';
+
+ groupsIni = pkgs.writeText "groups.ini" (
+ optionalString (cfg.authentication == "database") ''
+ [database]
+ backend = "db"
+ resource = "icingaweb2db"
+ ''
+ );
+
+ authenticationIni = pkgs.writeText "authentication.ini" (
+ if cfg.authentication == "sproxy" then ''
+ [sproxy]
+ backend = "sproxy"
+ '' else ''
+ [database]
+ backend = "db"
+ resource = "icingaweb2db"
+ ''
+ );
+
+ rolesIni = pkgs.writeText "roles.ini" ''
+ [root]
+ users = "root"
+ permissions = "config/authentication/roles/show, config/authentication/users/*, config/authentication/groups/*, module/*, monitoring/command/*"
+
+ ${
+ concatStringsSep "\n\n" (
+ mapAttrsToList (n: s: ''
+ [${n}]
+ users = "${concatStringsSep ", " s.users}"
+ groups = "${concatStringsSep ", " s.groups}"
+ permissions = "${concatStringsSep ", " s.permissions}"
+ ${optionalString (s.objects != null) ''
+ monitoring/filter/objects = "${s.objects}"
+ ''}
+ '') (explicit cfg.roles)
+ )
+ }
+ '';
+
+ mkResource = name: opts:
+ let
+ mkDB = ''
+ cat <<'__EOF__'
+
+ [${name}]
+ type = "db"
+ db = "${opts.type}"
+ dbname = "${opts.db}"
+ host = "${opts.host}"
+ port = "${show opts.port}"
+ username = "${opts.user}"
+ __EOF__
+ ${optionalString (opts.passfile != null) ''
+ pwd=$(cat '${opts.passfile}')
+ printf 'password="%s"\n' "$pwd"
+ ''}
+ '';
+ in if opts.type == "mysql" then mkDB
+ else "";
+
+ genResourcesIni = pkgs.writeBashScript "resources" (concatStringsSep "\n" (
+ mapAttrsToList mkResource (explicit cfg.resources)
+ ));
+
+ defaultPool = {
+ listen.owner = config.nixsap.apps.nginx.user;
+ pm.max_children = 10;
+ pm.max_requests = 1000;
+ pm.max_spare_servers = 5;
+ pm.min_spare_servers = 3;
+ pm.strategy = "dynamic";
+ };
+
+ configureFiles = ''
+ set -euo pipefail
+ umask 0277
+ mkdir -p '${cfg.configDir}'
+ ${pkgs.findutils}/bin/find \
+ ${cfg.configDir} \
+ -mindepth 1 -maxdepth 1 \
+ -not -name dashboards \
+ -not -name preferences \
+ -exec rm -rf '{}' \; || true
+
+ mkdir -p '${cfg.configDir}/dashboards'
+ mkdir -p '${cfg.configDir}/preferences'
+ mkdir -p '${cfg.configDir}/enabledModules'
+ mkdir -p '${cfg.configDir}/modules/monitoring'
+
+ ln -sf '${pkgs.icingaweb2}/modules/monitoring' '${cfg.configDir}/enabledModules/monitoring'
+ ln -sf '${pkgs.icingaweb2}/modules/translation' '${cfg.configDir}/enabledModules/translation'
+ ${genResourcesIni} > '${cfg.configDir}/resources.ini'
+ ln -sf '${authenticationIni}' '${cfg.configDir}/authentication.ini'
+ ln -sf '${configIni}' '${cfg.configDir}/config.ini'
+ ln -sf '${groupsIni}' '${cfg.configDir}/groups.ini'
+ ln -sf '${rolesIni}' '${cfg.configDir}/roles.ini'
+
+ ln -sf '${modules.monitoring.backendsIni}' \
+ '${cfg.configDir}/modules/monitoring/backends.ini'
+
+ ln -sf '${modules.monitoring.configIni}' \
+ '${cfg.configDir}/modules/monitoring/config.ini'
+
+ ln -sf '${modules.monitoring.commandtransportsIni}' \
+ '${cfg.configDir}/modules/monitoring/commandtransports.ini'
+
+ chmod u=rX,g=,o= '${cfg.configDir}'
+ chmod -R u=rwX,g=,o= '${cfg.configDir}/dashboards'
+ chmod -R u=rwX,g=,o= '${cfg.configDir}/preferences'
+ chown -R icingaweb2:icingaweb2 '${cfg.configDir}'
+ '';
+
+ configureDB = with cfg.resources.icingaweb2db;
+ let
+ mkMyCnf = pkgs.writeBashScript "my.cnf.sh" ''
+ cat <<'__EOF__'
+ [client]
+ host = ${host}
+ ${optionalString (port != null) "port = ${toString port}"}
+ user = ${user}
+ __EOF__
+ ${optionalString (passfile != null) ''
+ pwd=$(cat '${passfile}')
+ printf 'password = %s\n' "$pwd"
+ ''}
+ '';
+ in pkgs.writeBashScript "configureDB" ''
+ set -euo pipefail
+ cnf=$(mktemp)
+ trap 'rm -f "$cnf"' EXIT
+ chmod 0600 "$cnf"
+ ${mkMyCnf} > "$cnf"
+ #shellcheck disable=SC2016
+ while ! mysql --defaults-file="$cnf" -e 'CREATE DATABASE IF NOT EXISTS `${db}`'; do
+ sleep 5s
+ done
+ tt=$(mysql --defaults-file="$cnf" -N -e 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = "${db}"')
+ if [ "$tt" -eq 0 ]; then
+ mysql --defaults-file="$cnf" -v '${db}' < '${pkgs.icingaweb2}/etc/schema/mysql.schema.sql'
+ ${optionalString (cfg.initialRootPasswordHash != "") ''
+ #shellcheck disable=SC2016
+ mysql --defaults-file="$cnf" -e \
+ 'INSERT INTO icingaweb_user (name, active, password_hash) VALUES ("root", 1, "${cfg.initialRootPasswordHash}")' '${db}'
+ ''
+ }
+ fi
+ '';
+
+ keys = filter (p: p != null && hasPrefix "/run/keys/" p)
+ [ cfg.resources.icingaweb2db.passfile
+ cfg.resources.icinga2db.passfile ];
+
+in {
+
+ options.nixsap.apps.icingaweb2 = {
+ enable = mkEnableOption "Icinga Web 2";
+ user = mkOption {
+ description = ''
+ The user the PHP-FPM pool runs as. And the owner of files.
+ '';
+ default = "icingaweb2";
+ type = str;
+ };
+ nginxServer = mkOption {
+ type = lines;
+ default = "";
+ example = ''
+ listen 8080;
+ server_name icinga.example.net;
+ '';
+ };
+ configDir = mkOption {
+ description = "Where to put config files. This directory will be created if does not exist.";
+ type = path;
+ default = "/icingaweb2";
+ };
+ fpmPool = mkOption {
+ description = "Options for the PHP FPM pool";
+ type = attrsOf unspecified;
+ default = {};
+ };
+
+ resources = mkOption {
+ description = "Composes resources.ini";
+ type = attrs {
+ icingaweb2db = mkOption {
+ description = "Database for Icinga Web 2 settings";
+ type = database;
+ };
+ icinga2db = mkOption {
+ description = "Icinga2 database (read-only)";
+ type = database;
+ };
+ };
+ };
+
+ authentication = mkOption {
+ description = ''
+ Authentication backend: either IcingaWeb2 database or Sproxy.
+ '';
+ type = enum [ "sproxy" "database" ];
+ default = "database";
+ };
+
+ protectedCustomVars = mkOption {
+ description = ''
+ Icinga2 custom variables to be masked in WebUI.
+ This can used for example to hide passwords. Wildcard are allowed.
+ '';
+ type = listOf str;
+ default = [ "*pass*" "*pw*" "community" "http*auth_pair" ];
+ };
+
+ roles = mkOption {
+ description = "Composes roles.ini";
+ type = attrsOf role;
+ default = {};
+ example = {
+ devops = {
+ groups = [ "devops" ];
+ permissions = [ "module/*" "monitoring/command/*" ];
+ objects = "*";
+ };
+ all = {
+ groups = [ "all" ];
+ permissions = [ "module/*" ];
+ objects = "hostgroup_name=Shops";
+ };
+ };
+ };
+
+ initialRootPasswordHash = mkOption {
+ description = ''
+ Initial root password for icingaweb2db.
+ Use <literal>openssl passwd -1 mysecret</literal>
+ to generate this hash. It is used only when database
+ does not exist. So you may choose not to keep/commit
+ this hash at all. You better change the root password
+ after the first login.
+ '';
+ type = str;
+ default = "";
+ };
+
+ stacktrace = mkOption {
+ description = "whether to show PHP stacktraces";
+ type = bool;
+ default = false;
+ };
+ log = mkOption {
+ type = either path (enum [ "syslog" ]);
+ default = "syslog";
+ };
+ logLevel = mkOption {
+ type = enum [ "INFO" "WARNING" "ERROR" "CRITICAL" "DEBUG" ];
+ default = "WARNING";
+ };
+ };
+
+ config = mkIf cfg.enable {
+ nixsap.deployment.keyrings.root = keys;
+ users.users.icingaweb2.extraGroups = mkIf localIcinga [ config.nixsap.apps.icinga2.commandGroup ];
+ nixsap.apps.php-fpm.icingaweb2.pool =
+ recursiveUpdate defaultPool (cfg.fpmPool // { user = cfg.user ;});
+
+ nixsap.apps.nginx.http.servers.icingaweb2 = ''
+ ${cfg.nginxServer}
+
+ root ${pkgs.icingaweb2}/public;
+ index index.php;
+ try_files $1 $uri $uri/ /index.php$is_args$args;
+
+ location ~ ^/index\.php(.*)$ {
+ fastcgi_pass unix:${config.nixsap.apps.php-fpm.icingaweb2.pool.listen.socket};
+ fastcgi_index index.php;
+ include ${pkgs.nginx}/conf/fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME ${pkgs.icingaweb2}/public/index.php;
+ fastcgi_param ICINGAWEB_CONFIGDIR ${cfg.configDir};
+ fastcgi_param REMOTE_USER $remote_user;
+ }
+ '';
+
+ systemd.services.icingaweb2cfg = {
+ description = "configure Icinga Web 2";
+ after = [ "network.target" "local-fs.target" "keys.target" ];
+ wants = [ "keys.target" ];
+ wantedBy = [ "multi-user.target" ];
+ path = with pkgs; [ mysql ];
+ preStart = configureFiles;
+ serviceConfig = {
+ ExecStart = configureDB;
+ PermissionsStartOnly = true;
+ RemainAfterExit = true;
+ User = "icingaweb2";
+ };
+ };
+ };
+}
+
diff --git a/modules/apps/juandelacosa.nix b/modules/apps/juandelacosa.nix
new file mode 100644
index 0000000..8df6af0
--- /dev/null
+++ b/modules/apps/juandelacosa.nix
@@ -0,0 +1,68 @@
+{ config, pkgs, lib, ... }:
+
+let
+ inherit (builtins) filter toString;
+ inherit (lib) types mkOption mkEnableOption mkIf hasPrefix
+ concatStrings optionalString;
+ inherit (types) str path int nullOr;
+
+ cfg = config.nixsap.apps.juandelacosa;
+
+ ExecStart = concatStrings [
+ "${pkgs.juandelacosa}/bin/juandelacosa"
+ (optionalString (cfg.myFile != null) " -f '${cfg.myFile}'")
+ (optionalString (cfg.myGroup != null) " -g ${cfg.myGroup}")
+ (if (cfg.port != null)
+ then " -p ${toString cfg.port}"
+ else " -s '${cfg.socket}'")
+ ];
+
+ keys = filter (f: f != null && hasPrefix "/run/keys/" f) [ cfg.myFile ];
+
+in {
+ options.nixsap.apps.juandelacosa = {
+ enable = mkEnableOption "Juan de la Cosa";
+ user = mkOption {
+ description = "User to run as";
+ default = "juandelacosa";
+ type = str;
+ };
+ port = mkOption {
+ description = "TCP port to listen on";
+ default = null;
+ type = nullOr int;
+ };
+ socket = mkOption {
+ description = "UNIX socket to listen on. Ignored when TCP port is set";
+ default = "/tmp/juandelacosa.sock";
+ type = path;
+ };
+ myFile = mkOption {
+ description = "MySQL client configuration file";
+ default = null;
+ type = nullOr path;
+ };
+ myGroup = mkOption {
+ description = "Options group in the MySQL client configuration file";
+ default = null;
+ type = nullOr str;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ nixsap.system.users.daemons = [ cfg.user ];
+ nixsap.deployment.keyrings.${cfg.user} = keys;
+ systemd.services.juandelacosa = {
+ description = "captain of the MariaDB";
+ wantedBy = [ "multi-user.target" ];
+ wants = [ "keys.target" ];
+ after = [ "keys.target" "network.target" "local-fs.target" ];
+ serviceConfig = {
+ inherit ExecStart;
+ User = cfg.user;
+ Restart = "on-failure";
+ };
+ };
+ };
+}
+
diff --git a/modules/apps/mariadb/default.nix b/modules/apps/mariadb/default.nix
new file mode 100644
index 0000000..cdf5d92
--- /dev/null
+++ b/modules/apps/mariadb/default.nix
@@ -0,0 +1,442 @@
+{ config, pkgs, lib, ... }:
+let
+ inherit (builtins)
+ attrNames filter isBool isInt isList isPath isString length replaceStrings
+ toString ;
+
+ inherit (lib)
+ mkOption mkEnableOption mkIf types toUpper unique
+ optionalString hasPrefix concatStringsSep splitString flatten
+ concatMapStrings concatMapStringsSep concatStrings mapAttrsToList filterAttrs;
+
+ inherit (types)
+ attrsOf either int lines listOf package str submodule ;
+
+ cfg = config.nixsap.apps.mariadb;
+
+ getDirs = l: map dirOf (filter (p: p != null && hasPrefix "/" p) l);
+ mydirs = [ cfg.mysqld.datadir ] ++ getDirs [ cfg.mysqld.log_bin cfg.mysqld.relay_log ];
+ explicit = filterAttrs (n: v: n != "_module" && v != null);
+ hasMasters = (explicit cfg.replicate) != {};
+ concatNonEmpty = sep: list: concatStringsSep sep (filter (s: s != "") list);
+
+ # XXX /run/mysqld/mysqld.sock is the default socket
+ rundir = "/run/mysqld";
+ initFile = pkgs.writeText "init" ''
+ CREATE USER IF NOT EXISTS '${cfg.user}'@'localhost' IDENTIFIED VIA unix_socket;
+ GRANT ALL ON *.* TO '${cfg.user}'@'localhost' WITH GRANT OPTION;
+ '';
+
+ mkIgnoreTablesList = quotes: { databases, ignore-tables, ... }:
+ let
+ q = optionalString quotes "`";
+ hasDot = t: 2 == length (splitString "." t);
+ all-tbl = filter (t: ! hasDot t) ignore-tables;
+ db-tbl = (filter hasDot ignore-tables) ++
+ flatten (map (t: map (d: "${q}${d}${q}.${q}${t}${q}") databases) all-tbl);
+ in unique db-tbl;
+
+ mkEntry = name: value:
+ let
+ showList = l: concatMapStringsSep "," (toString) (unique l);
+ optimizer_switch = a:
+ showList (mapAttrsToList (n: v:
+ "${n}=${if v then "on" else "off"}"
+ ) (explicit a));
+ in if hasPrefix "skip" name then (optionalString value name)
+ else if name == "optimizer_switch" then "${name} = ${optimizer_switch value}"
+ else if isBool value then "${name} = ${if value then "ON" else "OFF"}"
+ else if isInt value then "${name} = ${toString value}"
+ else if isList value then "${name} = ${showList value}"
+ else if isString value then "${name} = ${value}"
+ else abort "Unrecognized option ${name}";
+
+ show = n: v:
+ if isBool v then (if v then "1" else "0")
+ else if isInt v then toString v
+ else if isString v then "'${v}'"
+ else if isPath v then "'${v}'"
+ else abort "Unrecognized option ${n}";
+
+ mkReplOpt = ch: args@{databases, ignore-databases, ...}:
+ let wild_do_table = concatMapStringsSep "\n" (d:
+ "${ch}.replicate_wild_do_table = ${d}.%"
+ ) databases;
+ ignore_table = concatMapStringsSep "\n" (t:
+ "${ch}.replicate_ignore_table = ${t}"
+ ) (mkIgnoreTablesList false args);
+ ignore_db = concatMapStringsSep "\n" (d:
+ "${ch}.replicate_ignore_db = ${d}"
+ ) ignore-databases;
+ in ''
+ ${ignore_db}
+ ${ignore_table}
+ ${wild_do_table}
+ '';
+
+ mkDynamicReplOpt = ch: args@{databases, ignore-databases, ...}:
+ ''
+ SET default_master_connection = "${ch}";
+ SET GLOBAL replicate_ignore_db = "${concatStringsSep "," ignore-databases}";
+ SET GLOBAL replicate_wild_do_table = "${concatMapStringsSep "," (d: "${d}.%") databases}";
+ SET GLOBAL replicate_ignore_table = "${concatMapStringsSep "," (t: "${t}") (mkIgnoreTablesList false args)}";
+ '';
+
+ replCnf = pkgs.writeText "mysqld-repl.cnf" ''
+ [mysqld]
+ ${concatNonEmpty "\n" (mapAttrsToList mkReplOpt (explicit cfg.replicate))}
+ '';
+
+ mysqldCnf =
+ if hasMasters && (cfg.mysqld.server_id == null || cfg.mysqld.server_id < 1)
+ then throw "Misconfigured slave: server_id was not set to a positive integer"
+ else pkgs.writeText "mysqld.cnf" ''
+ [mysqld]
+ basedir = ${cfg.package}
+ init_file = ${initFile}
+ pid_file = ${rundir}/mysqld.pid
+ plugin_load = unix_socket=auth_socket.so
+ plugin_load_add = server_audit=server_audit.so
+ ${concatNonEmpty "\n" (mapAttrsToList mkEntry (explicit cfg.mysqld))}
+ ${optionalString hasMasters "!include ${replCnf}"}
+ '';
+
+ await = pkgs.writeBashScript "await" ''
+ count=0
+ while ! mysql -e ';' 2>/dev/null; do
+ if ! (( count % 60 )); then
+ mysql -e ';'
+ fi
+ sleep 5s
+ (( ++count ))
+ done
+ '';
+
+ conf = pkgs.writeBashScriptBin "mariadb-conf"
+ ''
+ set -euo pipefail
+ trap "" SIGHUP
+ ${await}
+ ${optionalString (cfg.configure' != "") ''
+ tmp=$(mktemp)
+ trap 'rm -f "$tmp"' EXIT
+ mysql -N mysql < ${pkgs.writeText "mariadb-make-conf2.sql" cfg.configure'} > "$tmp"
+ mysql -v mysql < "$tmp"
+ ''}
+ mysql -v mysql < ${pkgs.writeText "mariadb-conf.sql" cfg.configure}
+ '';
+
+ maintenance = pkgs.writeBashScriptBin "mariadb-maint" ''
+ set -euo pipefail
+ trap "" SIGHUP
+ ${await}
+ ${optionalString hasMasters "mysql -e 'STOP ALL SLAVES SQL_THREAD'"}
+ mysql_upgrade --user=${cfg.user}
+ mysql_tzinfo_to_sql "$TZDIR" | mysql mysql
+ mysql mysql < ${./procedures.sql}
+ cat <<'__SQL__' | mysql
+ DROP DATABASE IF EXISTS test;
+ DELETE FROM mysql.db WHERE Db='test' OR Db='test%';
+ DELETE FROM mysql.user WHERE User='${cfg.user}' AND Host NOT IN ('localhost');
+ DELETE FROM mysql.user WHERE User=${"''"};
+ DELETE FROM mysql.user WHERE User='root';
+ DELETE FROM mysql.proxies_priv WHERE User='root';
+ FLUSH PRIVILEGES;
+ ${concatMapStrings (db: ''
+ CREATE DATABASE IF NOT EXISTS `${db}`;
+ '') cfg.databases}
+ __SQL__
+ ${optionalString hasMasters "mysql -e 'START ALL SLAVES'"}
+ '';
+
+ changeMaster =
+ let
+ do = ch: opts:
+ let
+ masterOptions = filterAttrs (n: _: n != "password-file") (explicit opts.master);
+ masterOptionName = n: ''MASTER_${toUpper (replaceStrings ["-"] ["_"] n)}'';
+ changeMaster = "CHANGE MASTER '${ch}' TO " + (concatStringsSep ", " (mapAttrsToList (n: v:
+ "${masterOptionName n}=${show n v}") masterOptions)) + ";";
+ in pkgs.writeBashScript "change-master-${ch}" ''
+ cat <<'__SQL__'
+ ${changeMaster}
+ ${mkDynamicReplOpt ch opts}
+ __SQL__
+ ${optionalString (opts.master.password-file != null) ''
+ pwd=$(cat '${opts.master.password-file}')
+ echo "CHANGE MASTER '${ch}' TO MASTER_PASSWORD='$pwd';"''}
+ '';
+
+ in pkgs.writeBashScript "changeMaster" (
+ concatStringsSep "\n" (mapAttrsToList (ch: opts: ''
+ [ "$1" = ${ch} ] && exec ${do ch opts}
+ '') (explicit cfg.replicate))
+ );
+
+ importDump =
+ let
+ do = ch: opts:
+ let
+ cnf = "${rundir}/master-${ch}.cnf";
+ mysqldumpOptions = filterAttrs (n: _: n != "password-file" && n != "path")
+ (explicit opts.mysqldump);
+ binary = if opts.mysqldump.path != null then opts.mysqldump.path else "mysqldump";
+ mysqldump = concatStringsSep " " (
+ [ binary "--defaults-file=${cnf}" "--skip-comments" "--force" ]
+ ++ mapAttrsToList (n: v: "--${n}=${show n v}") mysqldumpOptions);
+ databases = concatStringsSep " " ([ "--databases" ] ++ opts.databases);
+ ignore-tables = concatMapStringsSep " " (t: "--ignore-table=${t}") (mkIgnoreTablesList false opts);
+ in pkgs.writeBashScript "import-${ch}" ''
+ set -euo pipefail
+ touch '${cnf}'
+ trap "rm -f '${cnf}'" EXIT
+ trap "exit 255" TERM INT
+ chmod 0600 '${cnf}'
+ ${optionalString (opts.mysqldump.password-file != null) ''
+ printf '[client]\npassword=' > '${cnf}'
+ cat '${opts.mysqldump.password-file}' >> '${cnf}'
+ ''}
+ echo 'SET default_master_connection="${ch}";'
+ ${optionalString (!cfg.mysqld.log_slave_updates) "echo 'SET sql_log_bin=0;'"}
+ ${mysqldump} --master-data=0 --no-data ${databases}
+ ${mysqldump} --master-data=1 ${ignore-tables} ${databases}
+ '';
+ in pkgs.writeBashScript "importDump" (
+ concatStringsSep "\n" (mapAttrsToList (ch: opts: ''
+ [ "$1" = ${ch} ] && exec ${do ch opts}
+ '') (explicit cfg.replicate))
+ );
+
+ watchdog = pkgs.writeBashScript "slave-watchdog"
+ (import ./slave-watchdog.nix {inherit importDump changeMaster;});
+
+ slaves =
+ let
+ channels = attrNames (explicit cfg.replicate);
+ truncate = ch: concatMapStringsSep "\n"
+ (t: "TRUNCATE TABLE ${t};") (mkIgnoreTablesList true cfg.replicate.${ch});
+ truncateIgnored = pkgs.writeText "truncate.sql"
+ (concatMapStringsSep "\n" truncate channels);
+ old = "${rundir}/channels";
+ new = pkgs.writeText "channels.new" (concatMapStringsSep "\n"
+ (ch: "${ch}:${cfg.replicate.${ch}.master.host}") channels);
+ in pkgs.writeBashScriptBin "mariadb-slaves" ''
+ set -euo pipefail
+ rm -f ${rundir}/*.lock
+ ${await}
+ touch ${old}
+ chmod 0600 ${old}
+ trap 'rm -f ${old}' EXIT
+ mysql -e 'SHOW ALL SLAVES STATUS\G' \
+ | awk '/Connection_name:/ {printf $2 ":"}; /Master_Host:/ {print $2}' \
+ | sort > ${old}
+ obsolete=$(comm -23 ${old} ${new} | cut -d: -f1)
+ for ch in $obsolete; do
+ echo "Deleting obsolete slave $ch"
+ mysql -e "CALL mysql.resetSlave('$ch')"
+ done
+ ${optionalString hasMasters ''
+ mysql -f < ${truncateIgnored} || echo '(errors ignored)' >&2
+ export PARALLEL_SHELL=${pkgs.bash}/bin/bash
+ export HOME='${rundir}'
+ {
+ while true; do
+ printf "${concatStringsSep "\\n" channels}\n"
+ sleep 10m
+ done
+ } | parallel \
+ --halt-on-error 0 \
+ --jobs '${toString cfg.slaveWatchdogs}' \
+ --line-buffer \
+ --no-notice \
+ --tagstr '* {}:' \
+ 'flock -E 0 -n ${rundir}/master-{}.lock ${watchdog} {}'
+ ''
+ }
+ '';
+
+ all-keys = unique (filter (f: f != null && hasPrefix "/run/keys/" f ) (flatten (
+ mapAttrsToList (ch: {master, mysqldump, ...}:
+ [ master.password-file
+ master.ssl-key
+ mysqldump.password-file
+ mysqldump.ssl-key
+ ]) (explicit cfg.replicate)
+ ) ++ [
+ cfg.mysqld.ssl_key
+ ]));
+
+in {
+
+ imports = [ ./roles.nix ];
+
+ options.nixsap = {
+ apps.mariadb = {
+ enable = mkEnableOption "MySQL";
+
+ user = mkOption {
+ description = "User to run as";
+ default = "mariadb";
+ type = str;
+ };
+
+ package = mkOption {
+ description = "MariaDB Package (10.1.x)";
+ type = package;
+ default = pkgs.mariadb;
+ };
+
+ replicate = mkOption {
+ type = attrsOf (submodule (import ./replicate.nix));
+ default = {};
+ description = "Replication channels";
+ };
+
+ slaveWatchdogs = mkOption {
+ type = either str int;
+ default = "80%";
+ description = ''
+ Number of parallel slave monitoring and recovery processes.
+ In the format of GNU Parallel, e. g. "100%", -1. +3, 7, etc.
+ '';
+ };
+
+ mysqld = mkOption {
+ type = submodule (import ./mysqld.nix);
+ default = {};
+ description = "mysqld options";
+ };
+
+ databases = mkOption {
+ description = "Databases to create if not exist";
+ type = listOf str;
+ default = [];
+ };
+
+ configure = mkOption {
+ type = lines;
+ default = "";
+ description = ''
+ Any SQL statements to execute, typically GRANT / REVOKE etc.
+ This is executed in contect of the `mysql` database.
+ '';
+ example = ''
+ CREATE USER IF NOT EXISTS 'icinga'@'%' IDENTIFIED BY PASSWORD '*AC8C3BDA823EECFF90A8381D554232C7620345B3';
+ GRANT USAGE ON *.* TO 'icinga'@'%' REQUIRE SSL;
+ REVOKE ALL, GRANT OPTION FROM 'icinga'@'%';
+ GRANT PROCESS, REPLICATION CLIENT, SHOW DATABASES ON *.* TO 'icinga'@'%';
+ GRANT SELECT ON mysql.* TO 'icinga'@'%';
+ '';
+ };
+
+ configure' = mkOption {
+ type = lines;
+ default = "";
+ internal = true;
+ description = ''
+ SQL statements that generate other SQL statements to be executed.
+ Those generated statements will be executed before `configure`.
+ '';
+ example = ''
+ SELECT CONCAT('GRANT SELECT ON `', table_schema, '`.`', table_name, '` TO \'_oms_package_vn\';')
+ FROM information_schema.tables WHERE
+ table_schema LIKE '%oms_live_vn' AND
+ table_name LIKE 'oms_package%';
+ '';
+ };
+ };
+ };
+
+ config = mkIf cfg.enable {
+ environment.systemPackages = [ cfg.package ];
+ nixsap.system.users.daemons = [ cfg.user ];
+ nixsap.deployment.keyrings.${cfg.user} = all-keys;
+
+ nixsap.apps.mariadb.configure = concatMapStringsSep "\n"
+ (n: ''
+ CREATE USER IF NOT EXISTS '${n}'@'localhost' IDENTIFIED VIA unix_socket;
+ REVOKE ALL, GRANT OPTION FROM '${n}'@'localhost';
+ GRANT SELECT, EXECUTE ON mysql.* TO '${n}'@'localhost';
+ GRANT PROCESS, REPLICATION CLIENT, SHOW DATABASES, SHOW VIEW ON *.* TO '${n}'@'localhost';
+ '') config.nixsap.system.users.sysops;
+
+ systemd.services.mariadb-slaves = {
+ description = "MariaDB slaves watchdog";
+ requires = [ "mariadb.service" ];
+ after = [ "mariadb.service" "mariadb-maintenance.service" ];
+ wantedBy = [ "multi-user.target" ];
+ path = with pkgs; [ gnused gawk cfg.package utillinux parallel ];
+ serviceConfig = {
+ ExecStart = "${slaves}/bin/mariadb-slaves";
+ User = cfg.user;
+ } // (if hasMasters
+ then {
+ Restart = "always";
+ }
+ else {
+ Type = "oneshot";
+ });
+ };
+
+ systemd.services.mariadb-maintenance = {
+ description = "MariaDB maintenance";
+ after = [ "mariadb.service" ];
+ wantedBy = [ "multi-user.target" ];
+ path = [ cfg.package ];
+ serviceConfig = {
+ ExecStart = "${maintenance}/bin/mariadb-maint";
+ User = cfg.user;
+ Type = "oneshot";
+ RemainAfterExit = true;
+ };
+ };
+
+ systemd.services.mariadb-conf = {
+ description = "MariaDB configuration";
+ after = [ "mariadb.service" "mariadb-maintenance.service" ];
+ wantedBy = [ "multi-user.target" ];
+ path = [ cfg.package ];
+ serviceConfig = {
+ ExecStart = "${conf}/bin/mariadb-conf";
+ User = cfg.user;
+ Type = "oneshot";
+ RemainAfterExit = true;
+ };
+ };
+
+ systemd.services.mariadb = {
+ description = "MariaDB server";
+ wantedBy = [ "multi-user.target" ];
+ wants = [ "keys.target" ];
+ after = [ "keys.target" "network.target" "local-fs.target" ];
+ path = [ pkgs.inetutils ];
+ environment = {
+ UMASK = "0640";
+ UMASK_DIR = " 0750";
+ };
+ preStart = ''
+ mkdir -p '${rundir}'
+ chmod 0700 '${rundir}'
+ mkdir -p ${concatMapStringsSep " " (d: "'${d}'") mydirs}
+ if [ ! -f '${cfg.mysqld.datadir}/mysql/user.MYI' ]; then
+ rm -rf '${cfg.mysqld.datadir}/mysql'
+ ${cfg.package}/bin/mysql_install_db --defaults-file=${mysqldCnf}
+ fi
+ chown -Rc '${cfg.user}':$(id -g -n '${cfg.user}') '${rundir}' ${concatMapStringsSep " " (d: "'${d}'") mydirs}
+ chmod -Rc u=rwX,g=rX,o= ${concatMapStringsSep " " (d: "'${d}'") mydirs}
+ chmod 0755 '${rundir}'
+ '';
+
+ serviceConfig = {
+ ExecStart = "${cfg.package}/bin/mysqld --defaults-file=${mysqldCnf}";
+ PermissionsStartOnly = true;
+ User = cfg.user;
+ Restart = "always";
+ TimeoutSec = 0; # XXX it can take hours to shutdown, and much more to start if you kill shutdown :-D
+ LimitNOFILE = "infinity";
+ LimitMEMLOCK = "infinity";
+ OOMScoreAdjust = -1000;
+ };
+ };
+ };
+}
diff --git a/modules/apps/mariadb/mysqld.nix b/modules/apps/mariadb/mysqld.nix
new file mode 100644
index 0000000..d66d96d
--- /dev/null
+++ b/modules/apps/mariadb/mysqld.nix
@@ -0,0 +1,285 @@
+{ lib, ... }:
+with lib;
+with lib.types;
+
+let
+ engines = [
+ "Archive"
+ "Aria"
+ "Blackhole"
+ "CSV"
+ "Example"
+ "InnoDB"
+ "Memory"
+ "MyISAM"
+ ];
+
+ syslog-facilities = [
+ "LOG_USER"
+ "LOG_MAIL"
+ "LOG_DAEMON"
+ "LOG_AUTH"
+ "LOG_SYSLOG"
+ "LOG_LPR"
+ "LOG_NEWS"
+ "LOG_UUCP"
+ "LOG_CRON"
+ "LOG_AUTHPRIV"
+ "LOG_FTP"
+ "LOG_LOCAL0"
+ "LOG_LOCAL1"
+ "LOG_LOCAL2"
+ "LOG_LOCAL3"
+ "LOG_LOCAL4"
+ "LOG_LOCAL5"
+ "LOG_LOCAL6"
+ "LOG_LOCAL7"
+ ];
+
+ syslog-priorities = [
+ "LOG_EMERG"
+ "LOG_ALERT"
+ "LOG_CRIT"
+ "LOG_ERR"
+ "LOG_WARNING"
+ "LOG_NOTICE"
+ "LOG_INFO"
+ "LOG_DEBUG"
+ ];
+
+ sql-modes = [
+ "ALLOW_INVALID_DATES"
+ "ANSI"
+ "ANSI_QUOTES"
+ "DB2"
+ "ERROR_FOR_DIVISION_BY_ZERO"
+ "HIGH_NOT_PRECEDENCE"
+ "IGNORE_BAD_TABLE_OPTIONS"
+ "IGNORE_SPACE"
+ "MAXDB"
+ "MSSQL"
+ "MYSQL323"
+ "MYSQL40"
+ "NO_AUTO_CREATE_USER"
+ "NO_AUTO_VALUE_ON_ZERO"
+ "NO_BACKSLASH_ESCAPES"
+ "NO_DIR_IN_CREATE"
+ "NO_ENGINE_SUBSTITUTION"
+ "NO_FIELD_OPTIONS"
+ "NO_KEY_OPTIONS"
+ "NO_TABLE_OPTIONS"
+ "NO_UNSIGNED_SUBTRACTION"
+ "NO_ZERO_DATE"
+ "NO_ZERO_IN_DATE"
+ "ONLY_FULL_GROUP_BY"
+ "ORACLE"
+ "PAD_CHAR_TO_FULL_LENGTH"
+ "PIPES_AS_CONCAT"
+ "POSTGRESQL"
+ "REAL_AS_FLOAT"
+ "STRICT_ALL_TABLES"
+ "STRICT_TRANS_TABLES"
+ "TRADITIONAL"
+ ];
+
+ flush-methods = [
+ "ALL_O_DIRECT"
+ "O_DIRECT"
+ "O_DSYNC"
+ "fdatasync"
+ ];
+
+ default = v: type: mkOption { type = type; default = v; };
+ mandatory = type: mkOption { inherit type; };
+ optional = type: mkOption { type = nullOr type; default = null; };
+ set = opts: mkOption { type = nullOr (submodule opts); default = null; };
+
+ oneOrMore = l: let en = enum' l; in either en (uniq (listOf en));
+
+ # XXX https://github.com/NixOS/nixpkgs/issues/9826
+ enum' = values:
+ let show = v: let t = builtins.typeOf v;
+ in if t == "string" then ''"${v}"''
+ else if t == "int" then builtins.toString v
+ else ''<${t}>'';
+ in mkOptionType {
+ name = "one of ${concatStringsSep ", " (map show values)}";
+ check = flip elem values;
+ merge = mergeOneOption;
+ };
+
+ isFloat = x: builtins.match "^[0-9]+(\\.[0-9]+)?$" (builtins.toString x) != null;
+
+ float = mkOptionType {
+ name = "positive float";
+ check = isFloat;
+ };
+
+ # https://mariadb.com/kb/en/mariadb/optimizer-switch/
+ optimizer = {
+ options = {
+ derived_merge = optional bool;
+ derived_with_keys = optional bool;
+ exists_to_in = optional bool;
+ extended_keys = optional bool;
+ firstmatch = optional bool;
+ in_to_exists = optional bool;
+ index_merge = optional bool;
+ index_merge_intersection = optional bool;
+ index_merge_sort_intersection = optional bool;
+ index_merge_sort_union = optional bool;
+ index_merge_union = optional bool;
+ join_cache_bka = optional bool;
+ join_cache_hashed = optional bool;
+ join_incremental = optional bool;
+ loosescan = optional bool;
+ materialization = optional bool;
+ mrr = optional bool;
+ mrr_cost_based = optional bool;
+ mrr_sort_keys = optional bool;
+ optimize_join_buffer_size = optional bool;
+ outer_join_with_cache = optional bool;
+ partial_match_rowid_merge = optional bool;
+ partial_match_table_scan = optional bool;
+ semijoin = optional bool;
+ semijoin_with_cache = optional bool;
+ subquery_cache = optional bool;
+ table_elimination = optional bool;
+ };
+ };
+
+in {
+ options = {
+ binlog_checksum = optional (enum ["NONE" "CRC32"]);
+ binlog_commit_wait_count = optional int;
+ binlog_commit_wait_usec = optional int;
+ binlog_direct_non_transactional_updates = optional bool;
+ binlog_format = optional (enum ["ROW" "MIXED" "STATEMENT"]);
+ binlog_optimize_thread_scheduling = optional bool;
+ binlog_row_image = optional (enum ["FULL" "NOBLOB" "MINIMAL"]);
+ binlog_stmt_cache_size = optional int;
+ character_set_server = optional str;
+ collation_server = optional str;
+ connect_timeout = optional int;
+ datadir = mandatory path;
+ default_storage_engine = optional (enum engines);
+ default_time_zone = optional str;
+ encrypt_binlog = optional bool;
+ event_scheduler = optional (either bool (enum ["DISABLED"]));
+ expire_logs_days = optional int;
+ general_log = optional bool;
+ group_concat_max_len = optional int;
+ ignore_db_dirs = optional (uniq (listOf str));
+ init_connect = optional str;
+ init_slave = optional str;
+ innodb_autoinc_lock_mode = optional (enum' [ 0 1 2 ]);
+ innodb_buffer_pool_dump_at_shutdown = optional bool;
+ innodb_buffer_pool_instances = optional int;
+ innodb_buffer_pool_load_at_startup = optional bool;
+ innodb_buffer_pool_size = optional int;
+ innodb_doublewrite = optional bool;
+ innodb_file_format = optional (enum ["antelope" "barracuda"]);
+ innodb_file_per_table = optional bool;
+ innodb_flush_log_at_trx_commit = optional (enum' [0 1 2]);
+ innodb_flush_method = optional (enum flush-methods);
+ innodb_io_capacity = optional int;
+ innodb_io_capacity_max = optional int;
+ innodb_lock_wait_timeout = optional int;
+ innodb_log_file_size = optional int;
+ innodb_open_files = optional int;
+ innodb_read_io_threads = optional int;
+ innodb_rollback_on_timeout = optional bool;
+ innodb_thread_concurrency = optional int;
+ innodb_write_io_threads = optional int;
+ interactive_timeout = optional int;
+ join_buffer_size = optional int;
+ local_infile = optional bool;
+ log_bin = optional path;
+ log_bin_index = optional str;
+ log_output = optional (oneOrMore ["TABLE" "FILE"]);
+ log_queries_not_using_indexes = optional bool;
+ log_slave_updates = default false bool;
+ log_slow_rate_limit = optional int;
+ log_slow_verbosity = optional (enum' ["query_plan" "innodb" "explain"]);
+ log_warnings = optional (enum' [ 0 1 2 3 ]);
+ long_query_time = optional float;
+ max_allowed_packet = optional int;
+ max_binlog_cache_size = optional int;
+ max_binlog_size = optional int;
+ max_binlog_stmt_cache_size = optional int;
+ max_connect_errors = optional int;
+ max_connections = optional int;
+ max_heap_table_size = optional int;
+ max_relay_log_size = optional int;
+ max_user_connections = optional int;
+ net_read_timeout = optional int;
+ net_write_timeout = optional int;
+ optimizer_switch = set optimizer;
+ port = default 3306 int;
+ query_alloc_block_size = optional int;
+ query_cache_limit = optional int;
+ query_cache_min_res_unit = optional int;
+ query_cache_size = optional int;
+ query_cache_strip_comments = optional bool;
+ query_cache_type = optional (enum' [ 0 1 "DEMAND"]);
+ query_cache_wlock_invalidate = optional bool;
+ query_prealloc_size = optional int;
+ relay_log = optional path;
+ relay_log_index = optional str;
+ relay_log_purge = optional bool;
+ relay_log_recovery = optional bool;
+ relay_log_space_limit = optional int;
+ server_audit_events = optional (uniq (listOf (enum ["CONNECT" "QUERY" "TABLE" "QUERY_DDL" "QUERY_DML"])));
+ server_audit_excl_users = optional (listOf str);
+ server_audit_file_path = optional path;
+ server_audit_file_rotate_size = optional int;
+ server_audit_file_rotations = optional int;
+ server_audit_incl_users = optional (listOf str);
+ server_audit_logging = optional bool;
+ server_audit_output_type = optional (enum ["SYSLOG" "FILE"]);
+ server_audit_query_log_limit = optional int;
+ server_audit_syslog_facility = optional (enum syslog-facilities);
+ server_audit_syslog_ident = optional str;
+ server_audit_syslog_info = optional str;
+ server_audit_syslog_priority = optional (enum syslog-priorities);
+ server_id = optional int;
+ skip_log_bin = optional bool;
+ skip_name_resolve = optional bool;
+ skip_networking = optional bool;
+ slave_compressed_protocol = optional bool;
+ slave_ddl_exec_mode = optional (enum ["IDEMPOTENT" "STRICT"]);
+ slave_domain_parallel_threads = optional int;
+ slave_exec_mode = optional (enum ["IDEMPOTENT" "STRICT"]);
+ slave_load_tmpdir = optional path;
+ slave_max_allowed_packet = optional int;
+ slave_net_timeout = optional int;
+ slave_parallel_max_queued = optional int;
+ slave_parallel_mode = optional (enum ["conservative" "optimisitic" "none" "aggressive" "minimal"]);
+ slave_parallel_threads = optional int;
+ slave_skip_errors = optional (uniq (listOf int));
+ slave_sql_verify_checksum = optional bool;
+ slave_transaction_retries = optional int;
+ slow_query_log = optional bool;
+ slow_query_log_file = optional path;
+ sort_buffer_size = optional int;
+ sql_mode = optional (uniq (listOf (enum sql-modes)));
+ ssl_ca = optional path;
+ ssl_capath = optional path;
+ ssl_cert = optional path;
+ ssl_cipher = optional str;
+ ssl_crl = optional path;
+ ssl_crlpath = optional path;
+ ssl_key = optional path;
+ table_definition_cache = optional int;
+ table_open_cache = optional int;
+ thread_cache_size = optional int;
+ tmp_table_size = optional int;
+ tmpdir = optional path;
+ wait_timeout = optional int;
+ };
+ config = {
+ ignore_db_dirs = [ "lost+found" ];
+ };
+
+}
+
diff --git a/modules/apps/mariadb/procedures.sql b/modules/apps/mariadb/procedures.sql
new file mode 100644
index 0000000..3aabe80
--- /dev/null
+++ b/modules/apps/mariadb/procedures.sql
@@ -0,0 +1,134 @@
+-- These procedures belong to the mysql DB, e. g.
+-- CALL mysql.resetSlave('foo');
+-- Keep it simple: each procedure should be self-contained.
+
+DELIMITER $$
+
+DROP PROCEDURE IF EXISTS stopSlave $$
+CREATE PROCEDURE stopSlave (IN ch VARCHAR(64))
+ COMMENT 'Stops slave channel (both I/O and SQL threads)'
+BEGIN
+ -- Ignore ERROR 1617 (HY000): There is no master connection 'foo'
+ DECLARE EXIT HANDLER FOR 1617
+ BEGIN
+ SELECT 'No such master connection'
+ AS warning;
+ END;
+
+ SET default_master_connection = ch;
+ STOP SLAVE;
+END $$
+
+DROP PROCEDURE IF EXISTS startSlave $$
+CREATE PROCEDURE startSlave (IN ch VARCHAR(64))
+ COMMENT 'Starts slave channel (both I/O and SQL threads)'
+BEGIN
+ DECLARE EXIT HANDLER FOR 1617
+ BEGIN
+ SELECT 'No such master connection'
+ AS warning;
+ END;
+
+ SET default_master_connection = ch;
+ START SLAVE;
+END $$
+
+DROP PROCEDURE IF EXISTS kickSlave $$
+CREATE PROCEDURE kickSlave (IN ch VARCHAR(64))
+ COMMENT 'Skips the next event from the master'
+BEGIN
+ DECLARE EXIT HANDLER FOR 1617
+ BEGIN
+ SELECT 'No such master connection'
+ AS warning;
+ END;
+
+ SET default_master_connection = ch;
+ STOP SLAVE;
+ SET GLOBAL sql_slave_skip_counter = 1;
+ START SLAVE;
+END $$
+
+DROP PROCEDURE IF EXISTS pauseSlave $$
+CREATE PROCEDURE pauseSlave (IN ch VARCHAR(64))
+ COMMENT 'Stops SQL thread of the slave channel'
+BEGIN
+ DECLARE EXIT HANDLER FOR 1617
+ BEGIN
+ SELECT 'No such master connection'
+ AS warning;
+ END;
+
+ SET default_master_connection = ch;
+ STOP SLAVE SQL_THREAD;
+END $$
+
+DROP PROCEDURE IF EXISTS resetSlave $$
+CREATE PROCEDURE resetSlave (IN ch VARCHAR(64))
+ COMMENT 'Stops and deletes slave channel'
+BEGIN
+ DECLARE EXIT HANDLER FOR 1617
+ BEGIN
+ SELECT 'No such master connection'
+ AS warning;
+ END;
+
+ SET default_master_connection = ch;
+ STOP SLAVE;
+ RESET SLAVE ALL;
+END $$
+
+DROP PROCEDURE IF EXISTS stopAllSlaves $$
+CREATE PROCEDURE stopAllSlaves ()
+ COMMENT 'Stops all slaves'
+BEGIN
+ STOP ALL SLAVES;
+END $$
+
+DROP PROCEDURE IF EXISTS pauseAllSlaves $$
+CREATE PROCEDURE pauseAllSlaves ()
+ COMMENT 'Stops SQL thread of all slaves'
+BEGIN
+ STOP ALL SLAVES SQL_THREAD;
+END $$
+
+DROP PROCEDURE IF EXISTS startAllSlaves $$
+CREATE PROCEDURE startAllSlaves ()
+ COMMENT 'Starts all slaves'
+BEGIN
+ START ALL SLAVES;
+END $$
+
+DROP PROCEDURE IF EXISTS enableGeneralLog $$
+CREATE PROCEDURE enableGeneralLog ()
+BEGIN
+ SET GLOBAL general_log = ON;
+END $$
+
+DROP PROCEDURE IF EXISTS disableGeneralLog $$
+CREATE PROCEDURE disableGeneralLog ()
+BEGIN
+ SET GLOBAL general_log = OFF;
+END $$
+
+DROP PROCEDURE IF EXISTS truncateGeneralLog $$
+CREATE PROCEDURE truncateGeneralLog ()
+BEGIN
+ TRUNCATE mysql.general_log;
+END $$
+
+DROP PROCEDURE IF EXISTS truncateSlowLog $$
+CREATE PROCEDURE truncateSlowLog ()
+BEGIN
+ TRUNCATE mysql.slow_log;
+END $$
+
+DROP PROCEDURE IF EXISTS showEvents $$
+CREATE PROCEDURE showEvents ()
+ COMMENT 'Shows all events for the mysql schema'
+BEGIN
+ SHOW EVENTS IN mysql;
+END $$
+
+DELIMITER ;
+
diff --git a/modules/apps/mariadb/replicate.nix b/modules/apps/mariadb/replicate.nix
new file mode 100644
index 0000000..9f51dbf
--- /dev/null
+++ b/modules/apps/mariadb/replicate.nix
@@ -0,0 +1,87 @@
+{ config, lib, ... }:
+with lib;
+with lib.types;
+let
+ mandatory = type: mkOption { inherit type; };
+ optional = type: mkOption { type = nullOr type; default = null; };
+
+ common = foldl (a: b: a//b) {} [
+ { host = mandatory str; }
+ { password-file = optional path; }
+ { port = optional int; }
+ { ssl = optional bool; }
+ { ssl-ca = optional path; }
+ { ssl-cert = optional path; }
+ { ssl-key = optional path; }
+ { ssl-verify-server-cert = optional bool; }
+ { user = mandatory str; }
+ ];
+
+ master.options = foldl (a: b: a//b) {} [
+ { connect-retry = optional int; }
+ { heartbeat-period = optional int; }
+ common
+ ];
+
+ mysqldump.options = foldl (a: b: a//b) {} [
+ { compress = optional bool; }
+ { lock-tables = optional bool; }
+ { path = optional path; }
+ { single-transaction = optional bool; }
+ common
+ ];
+
+in {
+ options = {
+ databases = mkOption {
+ type = listOf str;
+ description = ''
+ List of databases to dump and replicate. This will be written as
+ `foo.replicate_wild_do_table = db.%`.
+ '';
+ example = [ "oms_live_sg" "bob_live_sg" ];
+ };
+
+ ignore-tables = mkOption {
+ type = listOf str;
+ description = ''
+ List of tables to ignore. This will be written as
+ `foo.replicate_ignore_table = db.table`. If database prefix is
+ omitted, expressions for all databases will be generated.
+ '';
+ example = [ "schema_updates" "bob_live_sg.locks" ];
+ default = [];
+ };
+
+ ignore-databases = mkOption {
+ type = listOf str;
+ description = ''
+ List of databases to ignore. You do not need this in most cases.
+ See http://dev.mysql.com/doc/refman/en/replication-rules.html.
+ This will be written as `foo.replicate_ignore_db = mysql`. This is
+ useful when you want procedures in other databases, like `mysql`,
+ not to be replicated.
+ '';
+ default = [ "mysql" "test" "tmp" ];
+ };
+
+ master = mkOption { type = submodule (master); };
+ mysqldump = mkOption { type = submodule (mysqldump); };
+ };
+
+ config = {
+ mysqldump = {
+ compress = mkDefault true;
+ host = mkDefault config.master.host;
+ password-file = mkDefault config.master.password-file;
+ port = mkDefault config.master.port;
+ single-transaction = mkDefault true;
+ ssl = mkDefault config.master.ssl;
+ ssl-ca = mkDefault config.master.ssl-ca;
+ ssl-cert = mkDefault config.master.ssl-cert;
+ ssl-key = mkDefault config.master.ssl-key;
+ user = mkDefault config.master.user;
+ };
+ };
+}
+
diff --git a/modules/apps/mariadb/roles.nix b/modules/apps/mariadb/roles.nix
new file mode 100644
index 0000000..2971242
--- /dev/null
+++ b/modules/apps/mariadb/roles.nix
@@ -0,0 +1,250 @@
+{ config, lib, pkgs, ... }:
+let
+ inherit (builtins)
+ elemAt filter isAttrs isList length trace ;
+ inherit (lib)
+ attrNames concatMapStrings concatMapStringsSep concatStrings
+ concatStringsSep filterAttrs flatten mapAttrsToList mkIf mkOption
+ optionalString replaceStrings splitString types ;
+ inherit (types)
+ attrsOf either listOf str submodule ;
+
+ explicit = filterAttrs (n: v: n != "_module" && v != null);
+
+ inherit (config.nixsap.apps.mariadb) roles;
+ basicRoles = filterAttrs (_: v: isAttrs v) roles;
+ topRoles = filterAttrs (_: v: isList v) roles;
+ allRoles = attrNames roles;
+ sqlList = concatMapStringsSep ", " (i: "'${i}'");
+
+ concatMapAttrs = f: attrs: concatStrings (mapAttrsToList f attrs);
+
+ schemaName = object: elemAt (splitString "." object) 0;
+ isSchema = object:
+ let p = splitString "." object;
+ n = length p;
+ in (n == 1)
+ || (n == 2 && (elemAt p 1) == "%")
+ || ((elemAt p 1) == "%" && (elemAt p 2) == "%");
+
+ tableName = object: elemAt (splitString "." object) 1;
+ isTable = object:
+ let p = splitString "." object;
+ n = length p;
+ in (n == 2 && (elemAt p 1) != "%")
+ || (n > 2 && (elemAt p 2) == "%");
+
+ columnName = object: elemAt (splitString "." object) 2;
+ isColumn = object:
+ let p = splitString "." object;
+ n = length p;
+ in (n > 2 && (elemAt p 2) != "%");
+
+ grant = role: privileges:
+ {
+ schemas = concatMapAttrs (priv: objects:
+ concatMapStrings (o:
+ let
+ db = schemaName o;
+ p = "${replaceStrings [" "] ["_"] priv}_priv";
+ in ''
+ SELECT 'GRANT ${priv} ON `${db}`.* TO \'${role}\';'
+ FROM information_schema.schemata -- Not really used, but for syntax and locks
+ WHERE NOT EXISTS (
+ SELECT 1 FROM db
+ WHERE db.host = ${"''"} -- role, not user
+ AND db.user = '${role}'
+ AND '${db}' LIKE db.db
+ AND db.${p} = 'Y'
+ ) LIMIT 1;
+ '') (filter isSchema (flatten [objects]))
+ ) (explicit privileges);
+
+ tables = concatMapAttrs (priv: objects:
+ concatMapStrings (o: ''
+ SELECT CONCAT('GRANT ${priv} ON `', t.table_schema, '`.`', t.table_name, '` TO \'${role}\';')
+ FROM information_schema.tables t
+ WHERE t.table_schema LIKE '${schemaName o}'
+ AND t.table_name LIKE '${tableName o}'
+ AND NOT EXISTS (
+ SELECT 1 FROM mysql.tables_priv
+ WHERE tables_priv.host = ${"''"} -- role, not user
+ AND tables_priv.user = '${role}'
+ AND tables_priv.db = t.table_schema
+ AND tables_priv.table_name = t.table_name
+ AND FIND_IN_SET('${priv}', tables_priv.table_priv) > 0
+ );
+ '') (filter isTable (flatten [objects]))
+ ) (explicit privileges);
+
+ columns = concatMapAttrs (priv: objects:
+ let colObjs = filter isColumn (flatten [objects]);
+ in optionalString ([] != colObjs) (''
+ SELECT CONCAT ('GRANT ${priv}(',
+ GROUP_CONCAT(DISTINCT c.column_name SEPARATOR ','),
+ ') ON `', c.table_schema, '`.`', c.table_name, '` TO \'${role}\';')
+ FROM information_schema.columns c WHERE (
+ '' + concatMapStringsSep " OR " (o:
+ ''
+ ( c.table_schema LIKE '${schemaName o}' AND
+ c.table_name LIKE '${tableName o}' AND
+ c.column_name LIKE '${columnName o}')
+ '') colObjs
+ +
+ ''
+ ) AND NOT EXISTS (
+ SELECT 1 FROM columns_priv
+ WHERE columns_priv.host = ${"''"} -- role, not user
+ AND columns_priv.user = '${role}'
+ AND columns_priv.db = c.table_schema
+ AND columns_priv.table_name = c.table_name
+ AND columns_priv.column_name = c.column_name
+ AND FIND_IN_SET('${priv}', columns_priv.column_priv) > 0
+ ) GROUP BY CONCAT(c.table_schema, c.table_name);
+ '')
+ ) (explicit privileges);
+ };
+
+ refreshRolesSQL =
+ let
+ sql = concatMapAttrs (role: privileges: ''
+ ${(grant role privileges).schemas}
+ ${(grant role privileges).tables}
+ ${(grant role privileges).columns}
+ '') basicRoles;
+ in pkgs.writeText "refresh-roles.sql" sql;
+
+ refreshRoles = pkgs.writeBashScriptBin "refreshRoles" ''
+ set -euo pipefail
+
+ doze() {
+ difference=$(($(date -d "08:00" +%s) - $(date +%s)))
+ if [ $difference -lt 0 ]; then
+ sleep $((86400 + difference))
+ else
+ sleep $difference
+ fi
+ }
+
+ while true; do
+ while ! mysql -e ';'; do
+ sleep 5s
+ done
+ tmp=$(mktemp)
+ trap 'rm -f "$tmp"' EXIT
+ mysql -N mysql < ${refreshRolesSQL} >> "$tmp"
+ mysql -v mysql < "$tmp"
+ doze
+ done
+ '';
+
+ addRoles = ''
+ ${concatMapStrings (r: "CREATE ROLE IF NOT EXISTS '${r}';\n") (attrNames roles)}
+
+ ${concatStrings
+ (mapAttrsToList (role: subroles: ''
+ ${concatMapStringsSep "\n" (r: "GRANT '${r}' TO '${role}';") subroles}
+ '') topRoles)
+ }
+ '';
+
+ revokeRoles = ''
+ ${concatMapAttrs (role: subroles: ''
+ SELECT CONCAT('REVOKE \''', role, '\' FROM \''', user, '\';') FROM roles_mapping
+ WHERE user = '${role}'
+ AND role NOT IN (${sqlList subroles});
+ '') topRoles
+ }
+
+ SELECT CONCAT('DROP ROLE \''', user, '\';') FROM user WHERE is_role='Y'
+ ${optionalString (allRoles != []) "AND user NOT IN (${sqlList allRoles})"}
+ ;
+ '';
+
+ roleType =
+ let
+ objects = mkOption {
+ type = either str (listOf str);
+ default = [];
+ example = [
+ "%bleep.%.created\_at"
+ "%bob\_live\_sg.brand\_type"
+ "%bob\_live\_sg.catalog%"
+ "%bob\_live\_sg.supplier.status"
+ "bar.%"
+ "beep"
+ "foo.%.%"
+ ];
+ };
+ basicRole = submodule {
+ options.nixsap = {
+ "ALL" = objects;
+ "ALTER" = objects;
+ "CREATE" = objects;
+ "DELETE" = objects;
+ "DROP" = objects;
+ "INDEX" = objects;
+ "INSERT" = objects;
+ "SELECT" = objects;
+ "SHOW VIEW" = objects;
+ "UPDATE" = objects;
+ };
+ };
+ topRole = listOf str;
+ in either basicRole topRole;
+
+in {
+ options.nixsap.apps.mariadb = {
+ roles = mkOption {
+ type = attrsOf roleType;
+ default = {};
+ description = ''
+ Defines MariaDB roles. A role can be a "basic" one or a "top"
+ one. The basic roles are granted of regular privileges like SELECT
+ or UPDATE, while the top roles are granted of other roles. For basic
+ roles MySQL wildcards ("%" and "_") can be used to specify objects
+ to be granted on, including databases, tables and columns names. A
+ script runs periodically to find all matching objects and grants on
+ them. Objects are denoted as "schema[.table[.column]]".
+ '';
+ example = {
+ top_role = [ "basic_role" ];
+ basic_role = {
+ SELECT = [
+ "%bob\_live\_sg.brand\_type"
+ "%bob\_live\_sg.catalog%"
+ "%bob\_live\_sg.supplier.created\_at"
+ "%bob\_live\_sg.supplier.id\_supplier"
+ "%bob\_live\_sg.supplier.name%"
+ "%bob\_live\_sg.supplier.status"
+ "%bob\_live\_sg.supplier.type"
+ "%bob\_live\_sg.supplier.updated\_at"
+ ];
+ };
+ monitoring = {
+ SELECT = [
+ "%.%.created_at"
+ ];
+ };
+ };
+ };
+ };
+
+ config = {
+ nixsap.apps.mariadb.configure = optionalString (roles != {}) addRoles;
+ nixsap.apps.mariadb.configure' = revokeRoles;
+
+ systemd.services.mariadb-roles = mkIf (basicRoles != {}) {
+ description = "refresh MariaDB basic roles";
+ after = [ "mariadb.service" "mariadb-maintenance.service" ];
+ wantedBy = [ "multi-user.target" ];
+ path = [ pkgs.mariadb ];
+ serviceConfig = {
+ ExecStart = "${refreshRoles}/bin/refreshRoles";
+ User = config.nixsap.apps.mariadb.user;
+ Restart = "always";
+ };
+ };
+ };
+}
+
diff --git a/modules/apps/mariadb/slave-watchdog.nix b/modules/apps/mariadb/slave-watchdog.nix
new file mode 100644
index 0000000..8d1147e
--- /dev/null
+++ b/modules/apps/mariadb/slave-watchdog.nix
@@ -0,0 +1,103 @@
+{ changeMaster, importDump }: ''
+set -euo pipefail
+
+ch="$1"
+status=$(mktemp)
+trap 'rm -f "$status"' EXIT
+
+slave_status () {
+ if ! mysql -e ';'; then
+ echo unknown; return
+ fi
+
+ if mysql -e "SHOW SLAVE '$1' STATUS\\G" | sed 's,^ *,,' > "$status"; then
+ if grep -oE '\bMaster_Server_Id:\s*[1-9][0-9]*' "$status" >&2; then
+ io_errno=$(awk '/Last_IO_Errno:/ {print $2}' "$status")
+ sql_errno=$(awk '/Last_SQL_Errno:/ {print $2}' "$status")
+ case "$io_errno:$sql_errno" in
+ 0:0)
+ echo ok
+ return
+ ;;
+ 0:*)
+ awk '/Last_SQL_Error:/ {print $0}' "$status" >&2
+ echo "sql_error:$sql_errno"
+ return
+ ;;
+ *:*)
+ awk '/Last_IO_Error:/ {print $0}' "$status" >&2
+ echo "io_error:$io_errno"
+ return
+ ;;
+ esac
+ fi
+ fi
+ echo none
+}
+
+sql_errors=0
+none_count=0
+while true; do
+ st=$(slave_status "$ch")
+
+ case "$st" in
+ ok|unknown)
+ echo "status: $st" >&2
+ exit
+ ;;
+ none)
+ # XXX existing slave might not be initialized yet after mariadb restarts
+ (( ++none_count ))
+ echo "status: $st (count: $none_count)" >&2
+ if [ "$none_count" -lt 10 ]; then
+ sleep 1m
+ continue
+ fi
+ mysql -v -N -e "CALL mysql.resetSlave('$ch')" >&2
+ ${changeMaster} "$ch" | mysql
+ if ${importDump} "$ch" | mysql; then
+ mysql -v -N -e "CALL mysql.startSlave('$ch')" >&2
+ exit
+ else
+ echo 'Import failed. Starting over' >&2
+ mysql -v -N -e "CALL mysql.resetSlave('$ch')" >&2
+ exit 1
+ fi
+ ;;
+ io_error:*)
+ echo "status: $st" >&2
+ mysql -v -N -e "CALL mysql.stopSlave('$ch')" >&2
+ ${changeMaster} "$ch" | mysql
+ mysql -v -N -e "CALL mysql.startSlave('$ch')" >&2
+ exit 1
+ ;;
+ sql_error:1205) # Lock wait timeout exceeded
+ echo "status: $st" >&2
+ mysql -v -N -e "CALL mysql.startSlave('$ch')" >&2
+ exit 1
+ ;;
+ sql_error:*)
+ (( ++sql_errors ))
+ echo "status: $st (count: $sql_errors)" >&2
+ if [ "$sql_errors" -le 1 ]; then
+ mysql -v -N -e "CALL mysql.pauseSlave('$ch')" >&2
+ sleep 1s
+ mysql -v -N -e "CALL mysql.startSlave('$ch')" >&2
+ elif [ "$sql_errors" -le 2 ]; then
+ mysql -v -N -e "CALL mysql.stopSlave('$ch')" >&2
+ # this *unlikely* *may* change replication option (ignore tables, etc.)
+ ${changeMaster} "$ch" | mysql
+ mysql -v -N -e "CALL mysql.startSlave('$ch')" >&2
+ else
+ echo '!!! Resetting slave !!!' >&2
+ mysql -v -N -e "CALL mysql.resetSlave('$ch')" >&2
+ exit 1
+ fi
+ sleep 2m
+ ;;
+ *) echo "BUG: $st" >&2; exit 255;;
+ esac
+ sleep 1s
+done
+''
+
diff --git a/modules/apps/mediawiki/default.nix b/modules/apps/mediawiki/default.nix
new file mode 100644
index 0000000..584d86a
--- /dev/null
+++ b/modules/apps/mediawiki/default.nix
@@ -0,0 +1,323 @@
+{ config, pkgs, lib, ... }:
+
+let
+
+ inherit (lib)
+ concatMapStrings concatMapStringsSep concatStringsSep
+ filterAttrs genAttrs hasPrefix mapAttrs mapAttrsToList mkDefault
+ mkEnableOption mkIf mkOption optionalAttrs optionalString
+ recursiveUpdate types;
+ inherit (types)
+ attrsOf bool either enum int lines listOf nullOr path str
+ submodule unspecified;
+ inherit (builtins)
+ attrNames elem filter isAttrs isBool isList isString toString;
+
+ cfg = config.nixsap.apps.mediawiki;
+ user = config.nixsap.apps.mediawiki.user;
+
+ defaultPool = {
+ listen.owner = config.nixsap.apps.nginx.user;
+ pm.max_children = 10;
+ pm.max_requests = 1000;
+ pm.max_spare_servers = 5;
+ pm.min_spare_servers = 3;
+ pm.strategy = "dynamic";
+ env.MEDIAWIKI_LOCAL_SETTINGS = "${localSettings}";
+ php_value = optionalAttrs (cfg.maxUploadSize != null) {
+ post_max_size = 2 * cfg.maxUploadSize;
+ upload_max_filesize = cfg.maxUploadSize;
+ };
+ };
+
+ explicit = filterAttrs (n: v: n != "_module" && v != null);
+ concatMapAttrsSep = s: f: attrs: concatStringsSep s (mapAttrsToList f attrs);
+ enabledExtentions = attrNames (filterAttrs (_: enabled: enabled) (explicit cfg.extensions));
+
+ keys = filter (hasPrefix "/run/keys/") (mapAttrsToList (_: o: o.password-file) cfg.users);
+
+ settings =
+ let
+ show = s: n: v:
+ if isBool v then (if v then "TRUE" else "FALSE")
+ else if isString v then "'${v}'"
+ else if isList v then "array(${concatMapStringsSep "," (i: "\n${s}'${toString i}'") v})"
+ else if isAttrs v then "array(${concatMapAttrsSep "," (p: q: "\n${s}'${p}' => ${show "${s} " p q}") (explicit v)})"
+ else toString v;
+ in pkgs.writePHPFile "LocalSettings.inc.php" ''
+ <?php
+ ${concatMapAttrsSep "\n"
+ (n: v: if isAttrs v
+ # XXX This will preserve or replace defaults,
+ # but would give odd result if any element were a list:
+ then "\$${n} = array_replace_recursive (\$${n}, ${show " " n v});"
+ else "\$${n} = ${show " " n v};")
+ (explicit cfg.localSettings)}
+ ?>
+ '';
+
+ localSettings = pkgs.writePHPFile "LocalSettings.php" ''
+ <?php
+ ${concatMapStringsSep "\n " (e:
+ "require_once ('${pkgs.mediawikiExtensions.${e}}/${e}.php');"
+ ) enabledExtentions
+ }
+
+ ${optionalString (elem "GraphViz" enabledExtentions)
+ "$wgGraphVizSettings->execPath = '${pkgs.graphviz}/bin/';"
+ }
+
+ ${optionalString (elem "MathJax" enabledExtentions) ''
+ # MathJax 0.7:
+ MathJax_Parser::$MathJaxJS = '${pkgs.mathJax}/MathJax.js?config=TeX-AMS-MML_HTMLorMML-full';
+ ''}
+
+ $wgDiff = '${pkgs.diffutils}/bin/diff';
+ $wgDiff3 = '${pkgs.diffutils}/bin/diff3';
+ $wgImageMagickConvertCommand = '${pkgs.imagemagick}/bin/convert';
+ ${optionalString (cfg.logo != null) ''
+ $wgLogo = '${cfg.logo}';
+ ''}
+ ${optionalString (cfg.maxUploadSize != null)
+ "$wgMaxUploadSize = ${toString cfg.maxUploadSize};"
+ }
+
+ require_once ('${settings}');
+
+ $wgDirectoryMode = 0750;
+ ?>
+ '';
+
+ mediawiki-db =
+ let
+ psql = pkgs.writeBashScript "mw-psql" ''
+ set -euo pipefail
+ exec ${pkgs.postgresql}/bin/psql -t -w \
+ -v ON_ERROR_STOP=1 \
+ ${optionalString (cfg.localSettings.wgDBserver != "")
+ "-h '${cfg.localSettings.wgDBserver}'"} \
+ -p ${toString cfg.localSettings.wgDBport} \
+ -U ${toString cfg.localSettings.wgDBuser} \
+ -d '${cfg.localSettings.wgDBname}' \
+ "$@"
+ '';
+ mysql = pkgs.writeBashScript "mw-mysql" ''
+ set -euo pipefail
+ exec ${pkgs.mysql}/bin/mysql -N \
+ ${optionalString (cfg.localSettings.wgDBserver != "")
+ "-h '${cfg.localSettings.wgDBserver}'"} \
+ -u ${toString cfg.localSettings.wgDBuser} \
+ -D '${cfg.localSettings.wgDBname}' \
+ "$@"
+ '';
+ in pkgs.writeBashScriptBin "mediawiki-db" ''
+ set -euo pipefail
+ ${if cfg.localSettings.wgDBtype == "postgres" then ''
+ while ! ${psql} -c ';'; do
+ sleep 5s
+ done
+ exist=$(${psql} -c "SELECT COUNT(1) FROM pg_class WHERE relname = 'mwuser';")
+ if [ "''${exist//[[:space:]]/}" -eq 0 ]; then
+ {
+ # XXX this script has BEGIN, but no COMMIT:
+ cat ${pkgs.mediawiki}/maintenance/postgres/tables.sql
+ echo 'COMMIT;'
+ } | ${psql}
+ fi
+ '' else ''
+ while ! ${mysql} -e ';'; do
+ sleep 5s
+ done
+ exist=$(${mysql} -e "SELECT COUNT(1) FROM information_schema.tables
+ WHERE table_schema='${cfg.localSettings.wgDBname}'
+ AND table_name='mwuser'")
+ if [ "''${exist//[[:space:]]/}" -eq 0 ]; then
+ {
+ cat ${pkgs.mediawiki}/maintenance/tables.sql
+ } | ${mysql}
+ fi
+ ''}
+
+ export MEDIAWIKI_LOCAL_SETTINGS='${localSettings}'
+ ${pkgs.php}/bin/php ${pkgs.mediawiki}/maintenance/update.php
+ ${concatMapAttrsSep "" (n: o: ''
+ pw=$(cat '${o.password-file}')
+ if [ -z "$pw" ]; then
+ echo 'WARNING: Using random password, because ${o.password-file} is empty or cannot be read' >&2
+ pw=$(${pkgs.pwgen}/bin/pwgen -1 13)
+ fi
+ ${pkgs.php}/bin/php ${pkgs.mediawiki}/maintenance/createAndPromote.php \
+ --force --${o.role} '${n}' "$pw"
+ '') cfg.users}
+ '';
+
+ mediawiki-upload = pkgs.writeBashScriptBin "mediawiki-upload" ''
+ set -euo pipefail
+ mkdir -v -p '${cfg.localSettings.wgUploadDirectory}'
+
+ ${optionalString (elem "GraphViz" enabledExtentions)
+ # XXX: GraphViz::getUploadSubdir: mkdir(/mediawiki/graphviz/images/, 16872) failed
+ # GraphViz fails to create the directory until you create the first graph.
+ "mkdir -v -p '${cfg.localSettings.wgUploadDirectory}/graphviz'"
+ }
+
+ chmod -Rc u=rwX,g=rX,o= '${cfg.localSettings.wgUploadDirectory}'
+ chown -Rc '${user}:${user}' '${cfg.localSettings.wgUploadDirectory}'
+ '';
+
+ nginx = ''
+ ${cfg.nginxServer}
+
+ ${optionalString (cfg.maxUploadSize != null)
+ "client_max_body_size ${toString cfg.maxUploadSize};"
+ }
+
+ root ${pkgs.mediawiki};
+ index index.php;
+
+ ${optionalString (cfg.logo != null) ''
+ location = ${cfg.logo} {
+ alias ${cfg.logo};
+ }
+ ''}
+
+ ${optionalString
+ (cfg.localSettings.wgEnableUploads
+ && hasPrefix "/" cfg.localSettings.wgUploadPath) ''
+ location ${cfg.localSettings.wgUploadPath} {
+ alias ${cfg.localSettings.wgUploadDirectory};
+ }
+ ''}
+
+ ${concatMapStrings (e: ''
+ location /extensions/${e} {
+ alias ${pkgs.mediawikiExtensions.${e}};
+ }
+ '') enabledExtentions
+ }
+
+ ${optionalString (elem "MathJax" enabledExtentions) ''
+ location ${pkgs.mathJax} {
+ alias ${pkgs.mathJax};
+ }
+ ''}
+
+ location / {
+ try_files $uri $uri/ @rewrite;
+ }
+
+ location @rewrite {
+ rewrite ^/(.*)$ /index.php?title=$1&$args;
+ }
+
+ location ^~ /maintenance/ {
+ return 403;
+ }
+
+ location ~ \.php$ {
+ fastcgi_pass unix:${config.nixsap.apps.php-fpm.mediawiki.pool.listen.socket};
+ include ${pkgs.nginx}/conf/fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ }
+ '';
+
+in {
+
+ options.nixsap.apps.mediawiki = {
+ enable = mkEnableOption "Mediawiki";
+ user = mkOption {
+ description = ''
+ The user the PHP-FPM pool runs as. And the owner of uploaded files.
+ '';
+ default = "mediawiki";
+ type = str;
+ };
+ nginxServer = mkOption {
+ type = lines;
+ default = "";
+ example = ''
+ listen 8080;
+ server_name wiki.example.net;
+ '';
+ };
+ fpmPool = mkOption {
+ description = "Options for the PHP FPM pool";
+ type = attrsOf unspecified;
+ default = {};
+ };
+ logo = mkOption {
+ description = "The site logo (the image displayed in the upper-left corner of the page)";
+ type = nullOr path;
+ default = null;
+ };
+ maxUploadSize = mkOption {
+ description = ''
+ Maximum allowed size for uploaded files (bytes).
+ This affects Mediawiki itself, Nginx and PHP.
+ '';
+ type = nullOr int;
+ default = null;
+ };
+ localSettings = mkOption {
+ description = "Variables in LocalSettings.php";
+ type = submodule (import ./localSettings.nix (explicit cfg.extensions));
+ default = {};
+ };
+ extensions = mkOption {
+ description = "Mediawiki extensions";
+ default = {};
+ type = submodule
+ { options = mapAttrs
+ (e: _:
+ mkOption {
+ description = "Enable the ${e} extension";
+ type = bool;
+ default = false;
+ }) pkgs.mediawikiExtensions;
+ };
+ };
+ users = mkOption {
+ description = "Mediawiki users (only bots or sysops)";
+ default = {};
+ type = attrsOf (submodule { options = {
+ role = mkOption { type = enum [ "bot" "sysop" ]; };
+ password-file = mkOption { type = path; };
+ }; });
+ };
+ };
+
+ config = mkIf cfg.enable {
+ nixsap.apps.php-fpm.mediawiki.pool =
+ recursiveUpdate defaultPool (cfg.fpmPool // { user = cfg.user ;});
+ nixsap.deployment.keyrings.${user} = keys;
+ users.users.${config.nixsap.apps.nginx.user}.extraGroups =
+ mkIf cfg.localSettings.wgEnableUploads [ user ];
+
+ nixsap.apps.nginx.http.servers.mediawiki = nginx;
+
+ systemd.services.mediawiki-db = {
+ description = "configure Mediawiki database";
+ after = [ "network.target" "local-fs.target" "keys.target" ];
+ wants = [ "keys.target" ];
+ wantedBy = [ "multi-user.target" ];
+ serviceConfig = {
+ RemainAfterExit = true;
+ Type = "oneshot";
+ User = config.nixsap.apps.php-fpm.mediawiki.pool.user;
+ ExecStart = "${mediawiki-db}/bin/mediawiki-db";
+ };
+ };
+
+ systemd.services.mediawiki-upload = mkIf cfg.localSettings.wgEnableUploads {
+ description = "configure Mediawiki uploads";
+ after = [ "local-fs.target" ];
+ wantedBy = [ "multi-user.target" ];
+ serviceConfig = {
+ RemainAfterExit = true;
+ Type = "oneshot";
+ ExecStart = "${mediawiki-upload}/bin/mediawiki-upload";
+ };
+ };
+ };
+}
+
diff --git a/modules/apps/mediawiki/localSettings.nix b/modules/apps/mediawiki/localSettings.nix
new file mode 100644
index 0000000..cbacd07
--- /dev/null
+++ b/modules/apps/mediawiki/localSettings.nix
@@ -0,0 +1,158 @@
+extensions:
+
+{ lib, ... }:
+let
+
+ inherit (builtins) elem;
+
+ inherit (lib)
+ concatStringsSep flip genAttrs mergeOneOption mkDefault mkOption
+ mkOptionType optionalAttrs optionals types ;
+ inherit (types)
+ attrsOf bool either enum int listOf nullOr path str submodule ;
+
+ just = t: mkOption { type = t; }; # mergeable defaults
+ default = d: t: mkOption { type = t; default = d; }; # overridable defaults
+ optional = t: mkOption { type = nullOr t; default = null; };
+ set = options: mkOption { type = submodule { inherit options; }; default = {}; };
+
+ # XXX https://github.com/NixOS/nixpkgs/issues/9826
+ enum' = values:
+ let show = v: let t = builtins.typeOf v;
+ in if t == "string" then ''"${v}"''
+ else if t == "int" then builtins.toString v
+ else ''<${t}>'';
+ in mkOptionType {
+ name = "one of ${concatStringsSep ", " (map show values)}";
+ check = flip elem values;
+ merge = mergeOneOption;
+ };
+
+ rights = [
+ "apihighlimits" "applychangetags" "autoconfirmed" "autopatrol"
+ "bigdelete" "block" "blockemail" "bot" "browsearchive"
+ "changetags" "createaccount" "createpage" "createtalk"
+ "delete" "deletedhistory" "deletedtext" "deletelogentry"
+ "deleterevision" "edit" "editinterface" "editmyoptions"
+ "editmyprivateinfo" "editmyusercss" "editmyuserjs"
+ "editmywatchlist" "editprotected" "editsemiprotected"
+ "editusercss" "editusercssjs" "edituserjs" "hideuser" "import"
+ "importupload" "ipblock-exempt" "managechangetags" "markbotedits"
+ "mergehistory" "minoredit" "move" "move-categorypages"
+ "move-rootuserpages" "move-subpages" "movefile" "nominornewtalk"
+ "noratelimit" "override-export-depth" "pagelang" "passwordreset"
+ "patrol" "patrolmarks" "protect" "proxyunbannable" "purge"
+ "read" "reupload" "reupload-own" "reupload-shared" "rollback"
+ "sendemail" "siteadmin" "suppressionlog" "suppressredirect"
+ "suppressrevision" "unblockself" "undelete" "unwatchedpages"
+ "upload" "upload_by_url" "userrights" "userrights-interwiki"
+ "viewmyprivateinfo" "viewmywatchlist" "writeapi"
+ ]
+ ++ optionals extensions.UserPageEditProtection [ "editalluserpages" ]
+ ;
+
+ wgGroupPermissions = set ( genAttrs [
+ "*" "user" "autoconfirmed" "bot" "sysop" "bureaucrat"
+ ] (_:
+ set ( genAttrs rights (_: optional bool) )
+ )
+ );
+
+
+ wgDefaultUserOptions = set (
+ {
+ diffonly = optional bool;
+ disablemail = optional bool;
+ enotifminoredits = optional bool;
+ enotifrevealaddr = optional bool;
+ enotifusertalkpages = optional bool;
+ enotifwatchlistpages = optional bool;
+ fancysig = optional bool;
+ gender = optional (enum [ "female" "male" "unknown" ]);
+ hideminor = optional bool;
+ justify = optional bool;
+ minordefault = optional bool;
+ nickname = optional str;
+ previewontop = optional bool;
+ quickbar = optional (enum' [ 0 1 2 3 4 5 ]);
+ realname = optional str;
+ rememberpassword = optional bool;
+ underline = optional (enum' [0 1 2]);
+ math = optional (enum' [0 1]);
+ usenewrc = optional bool;
+ imagesize = optional int;
+ skin = optional str;
+ } // optionalAttrs extensions.WikiEditor
+ {
+ usebetatoolbar = optional bool;
+ usebetatoolbar-cgd = optional bool;
+ usenavigabletoc = optional bool;
+ wikieditor-preview = optional bool;
+ wikieditor-publish = optional bool;
+ }
+ );
+
+in {
+ options = {
+ inherit wgDefaultUserOptions;
+ inherit wgGroupPermissions;
+ wgAllowCopyUploads = optional bool;
+ wgArticlePath = optional path;
+ wgCheckFileExtensions = optional bool;
+ wgCopyUploadsDomains = default [] (listOf str);
+ wgCopyUploadsFromSpecialUpload = optional bool;
+ wgDBcompress = optional bool;
+ wgDBerrorLog = optional path;
+ wgDBname = default "mediawiki" str;
+ wgDBport = default "3456" int;
+ wgDBserver = default "" str;
+ wgDBssl = optional bool;
+ wgDBtype = default "postgres" (enum ["mysql" "postgres"]);
+ wgDBuser = default "mediawiki" str;
+ wgDebugLogFile = optional path;
+ wgEnableUploads = default false bool;
+ wgFileBlacklist = just (listOf str);
+ wgFileExtensions = just (listOf str);
+ wgLanguageCode = optional str;
+ wgMaxShellMemory = optional int;
+ wgMaxShellTime = optional int;
+ wgMimeTypeBlacklist = just (listOf str);
+ wgScriptPath = optional str;
+ wgServer = optional str;
+ wgShowDBErrorBacktrace = optional bool;
+ wgShowExceptionDetails = optional bool;
+ wgSitename = default "Wiki" str;
+ wgStrictFileExtensions = optional bool;
+ wgStyleDirectory = optional path;
+ wgStylePath = optional path;
+ wgUploadDirectory = default "/mediawiki" path;
+ wgUploadPath = default "/_files" str;
+ wgUrlProtocols = just (listOf str);
+ wgUsePrivateIPs = optional bool;
+ } // optionalAttrs (extensions.UserPageEditProtection)
+ {
+ wgOnlyUserEditUserPage = optional bool;
+ };
+
+ config = {
+ wgUrlProtocols = [
+ "//" "bitcoin:" "ftp://" "ftps://" "geo:" "git://" "gopher://"
+ "http://" "https://" "irc://" "ircs://" "magnet:" "mailto:"
+ "mms://" "news:" "nntp://" "redis://" "sftp://" "sip:"
+ "sips:" "sms:" "ssh://" "svn://" "tel:" "telnet://" "urn:"
+ "worldwind://" "xmpp:" ];
+ wgFileExtensions = [ "gif" "jpeg" "jpg" "png" ];
+ wgFileBlacklist = [
+ "bat" "cgi" "cmd" "com" "cpl" "dll" "exe" "htm" "html" "jhtml"
+ "js" "jsb" "mht" "mhtml" "msi" "php" "php3" "php4" "php5"
+ "phps" "phtml" "pif" "pl" "py" "scr" "shtml" "vbs" "vxd"
+ "xht" "xhtml" ];
+ wgMimeTypeBlacklist = [
+ "application/x-msdownload" "application/x-msmetafile"
+ "application/x-php" "application/x-shellscript" "text/html"
+ "text/javascript" "text/scriptlet" "text/x-bash" "text/x-csh"
+ "text/x-javascript" "text/x-perl" "text/x-php" "text/x-python"
+ "text/x-sh" ];
+ };
+}
+
diff --git a/modules/apps/mysqlbackup.nix b/modules/apps/mysqlbackup.nix
new file mode 100644
index 0000000..509e010
--- /dev/null
+++ b/modules/apps/mysqlbackup.nix
@@ -0,0 +1,428 @@
+{ config, pkgs, lib, ... }:
+let
+ inherit (lib) mkOption mkIf mkDefault mapAttrsToList flatten hasPrefix filter
+ concatMapStringsSep concatStringsSep optionalString filterAttrs
+ splitString removeSuffix;
+ inherit (lib.types) bool str int path either enum nullOr listOf attrsOf submodule;
+ inherit (builtins) isString isBool isInt isList isPath toString length;
+
+ cfg = config.nixsap.apps.mysqlbackup;
+ privateDir = "/run/mysqlbackup";
+
+ mysql = "${pkgs.mysql}/bin/mysql";
+ mysqldump = "${pkgs.mysql}/bin/mysqldump";
+ s3cmd = "${pkgs.s3cmd}/bin/s3cmd ${optionalString (cfg.s3cfg != null) "-c '${cfg.s3cfg}'"}";
+
+ gpgPubKeys = flatten [ cfg.encrypt ];
+ gpg = "${pkgs.gpg}/bin/gpg2";
+ pubring = pkgs.runCommand "pubring.gpg" {} ''
+ ${gpg} --homedir . --import ${toString gpgPubKeys}
+ cp pubring.gpg $out
+ '';
+
+ default = d: t: mkOption { type = t; default = d; };
+ explicit = filterAttrs (n: v: n != "_module" && v != null);
+ mandatory = type: mkOption { inherit type; };
+ optional = type: mkOption { type = nullOr type; default = null; };
+ sub = options: submodule { inherit options; } ;
+
+ connection = mkOption {
+ description = "Connection options used by mysqlbackup";
+ type = sub {
+ compress = default true bool;
+ host = mandatory str;
+ max-allowed-packet = optional int;
+ password-file = optional path;
+ port = optional int;
+ socket = optional path;
+ ssl = optional bool;
+ ssl-ca = optional path;
+ ssl-cert = optional path;
+ ssl-key = optional path;
+ ssl-verify-server-cert = optional bool;
+ user = optional str;
+ };
+ };
+
+ databases = mkOption {
+ description = "What to dump and what to ignore";
+ default = {};
+ type = sub {
+ like = mkOption {
+ description = ''
+ Databases to dump. MySQL wildcards (_ and %) are supported.
+ Logical OR is applied to all entries.
+ '';
+ type = either str (listOf str);
+ default = "%";
+ example = [ "%\\_live\\_%" ];
+ };
+ not-like = mkOption {
+ description = ''
+ Databases to skip. MySQL wildcards (_ and %) are supported.
+ You don't need to specify `performance_schema` or `information_schema`
+ here, they are always ignored. Logical AND is applied to all entries.
+ '';
+ type = either str (listOf str);
+ default = [];
+ example = [ "tmp\\_%" "snap\\_%" ];
+ };
+ empty-tables-like = mkOption {
+ description = ''
+ Tables to ignore. MySQL wildcards (_ and %) are supported.
+ Note that the schemas of these tables will be dumped anyway.
+ Each table template can be prefixed with a database template.
+ In that case it will be applied to matching databases only,
+ instead of all databases'';
+ type = either str (listOf str);
+ default = [];
+ example = [ "bob%.alice\\_message" ];
+ };
+ skip-tables-like = mkOption {
+ description = ''
+ Tables to ignore. MySQL wildcards (_ and %) are supported.
+ Each table template can be prefixed with a database template.
+ In that case it will be applied to matching databases only,
+ instead of all databases'';
+ type = either str (listOf str);
+ default = [];
+ example = [ "tmp%" "%\\_backup" ];
+ };
+ };
+ };
+
+ server = submodule ({ name, ... }:
+ {
+ options = { inherit connection databases; };
+ config.connection.host = mkDefault name;
+ }
+ );
+
+ connectionKeys = flatten (mapAttrsToList (_: s: with s.connection; [ password-file ssl-key ]) cfg.servers);
+ keys = filter (f: f != null && hasPrefix "/run/keys/" f) ( connectionKeys ++ [cfg.s3cfg] );
+
+ showDatabases = name: server: pkgs.writeText "show-databases-${name}.sql" ''
+ SHOW DATABASES WHERE `Database` NOT IN ('information_schema', 'performance_schema', 'tmp', 'innodb')
+ AND (${concatMapStringsSep " OR " (e: "`Database` LIKE '${e}'") (flatten [server.databases.like])})
+ ${concatMapStringsSep " " (e: "AND `Database` NOT LIKE '${e}'") (flatten [server.databases.not-like])}
+ ;
+ '';
+
+ defaultsFile = name: server:
+ let
+ inc = optionalString (server.connection.password-file != null)
+ "!include ${privateDir}/cnf/${name}";
+ show = n: v:
+ if isBool v then (if v then "1" else "0")
+ else if isInt v then toString v
+ else if isString v then "${v}"
+ else if isPath v then "'${v}'"
+ else abort "Unrecognized option ${n}";
+ in pkgs.writeText "my-${name}.cnf"
+ ( concatStringsSep "\n" (
+ [ "[client]" ]
+ ++ mapAttrsToList (k: v: "${k} = ${show k v}")
+ (filterAttrs (k: _: k != "password-file") (explicit server.connection))
+ ++ [ "${inc}\n" ]
+ )
+ );
+
+ listTables = name: server: tables:
+ let
+ anyDb = s: if 1 == length (splitString "." s)
+ then "%.${s}" else s;
+ query = optionalString (0 < length tables) ''
+ set -euo pipefail
+ db="$1"
+ cat <<SQL | ${mysql} --defaults-file=${defaultsFile name server} -N
+ SELECT CONCAT(table_schema, '.', table_name) AS tables
+ FROM information_schema.tables HAVING tables LIKE '$db.%'
+ AND ( ${concatMapStringsSep " OR " (e: "tables LIKE '${e}'")
+ (map anyDb tables)} );
+ SQL
+ '';
+ in pkgs.writeBashScript "list-tables-${name}" query;
+
+ job = name: server: pkgs.writeBashScript "job-${name}" ''
+ set -euo pipefail
+ db=$(basename "$0")
+ cd "${cfg.dumpDir}/$DATE"
+
+ dump="$db@${name},$DATE.mysql.xz"
+ ${if (gpgPubKeys != []) then ''
+ aim="$dump.gpg"
+ '' else ''
+ aim="$dump"
+ ''}
+
+ if ! [ -r "$aim" ]; then
+ {
+ empty=()
+
+ empty+=( $(${listTables name server server.databases.empty-tables-like} "$db") )
+ if [ ''${#empty[@]} -gt 0 ]; then
+ tables=( "''${empty[@]/#*./}" )
+ ${mysqldump} --defaults-file=${defaultsFile name server} \
+ --skip-comments --force --single-transaction \
+ --no-data "$db" "''${tables[@]}"
+ fi
+
+ empty+=( $(${listTables name server server.databases.skip-tables-like} "$db") )
+
+ if [ ''${#empty[@]} -gt 0 ]; then
+ ignoretables+=( "''${empty[@]/#/--ignore-table=}" )
+ fi
+
+ ${mysqldump} --defaults-file=${defaultsFile name server} \
+ --skip-comments --force --single-transaction \
+ "''${ignoretables[@]:+''${ignoretables[@]}}" \
+ "$db"
+ } | ${pkgs.pxz}/bin/pxz -2 -T2 > "$dump".tmp
+ ${pkgs.xz}/bin/xz -t -v "$dump".tmp
+ mv "$dump".tmp "$dump"
+
+ ${optionalString (gpgPubKeys != []) ''
+ recipient=( $(${gpg} --homedir '${privateDir}/gnupg' -k --with-colons --fast-list-mode | \
+ ${pkgs.gawk}/bin/awk -F: '/^pub/{print $5}') )
+ r=( "''${recipient[@]/#/-r}" )
+ ${gpg} --homedir '${privateDir}/gnupg' --batch --no-tty --yes \
+ "''${r[@]}" --trust-model always \
+ --compress-algo none \
+ -v -e "$dump"
+ rm -f "$dump"
+ ''}
+ else
+ echo "$aim exists. Not dumping." >&2
+ fi
+ ${optionalString (cfg.s3uri != null) ''
+ remote="${removeSuffix "/" cfg.s3uri}/$DATE/$aim"
+ if ! ${s3cmd} ls "$remote" | ${pkgs.gnugrep}/bin/grep -qF "/$aim"; then
+ ${s3cmd} put "$aim" "$remote"
+ else
+ echo "$remote exists. Not uploading." >&2
+ fi
+ ''}
+ '';
+
+ mkJobs = name: server: pkgs.writeBashScript "mkjobs-${name}" ''
+ set -euo pipefail
+ mkdir -p '${privateDir}/jobs/${name}'
+ for db in $(${mysql} --defaults-file=${defaultsFile name server} -N < ${showDatabases name server} | shuf)
+ do
+ ln -svf ${job name server} "${privateDir}/jobs/${name}/$db"
+ done
+ '';
+
+ preStart = ''
+ mkdir --mode=0750 -p '${cfg.dumpDir}'
+ chown -R ${cfg.user}:${cfg.user} '${cfg.dumpDir}'
+ chmod -R u=rwX,g=rX,o= ${cfg.dumpDir}
+
+ rm -rf '${privateDir}'
+ mkdir --mode=0700 -p '${privateDir}'
+ chown ${cfg.user}:${cfg.user} '${privateDir}'
+ '';
+
+ main = pkgs.writeBashScriptBin "mysqlbackup" ''
+ set -euo pipefail
+ umask 0027
+ DATE=$(date --iso-8601)
+ HOME='${privateDir}'
+ PARALLEL_SHELL=${pkgs.bash}/bin/bash
+ export DATE
+ export HOME
+ export PARALLEL_SHELL
+
+ clean() {
+ ${pkgs.findutils}/bin/find '${cfg.dumpDir}' -type f -name '*.tmp' -delete || true
+ }
+
+ listSets() {
+ ${pkgs.findutils}/bin/find '${cfg.dumpDir}' \
+ -maxdepth 1 -mindepth 1 -type d -name '????-??-??' \
+ | sort -V
+ }
+
+ enoughStorage() {
+ local n
+ local used
+ local total
+ local avg
+ local p
+ n=$(listSets | wc -l)
+ used=$(du -x -s --block-size=1M '${cfg.dumpDir}' | cut -f1)
+ total=$(df --output=size --block-size=1M '${cfg.dumpDir}' | tail -n 1)
+ if [ "$n" -eq 0 ]; then
+ echo "no sets" >&2
+ return 0
+ fi
+
+ avg=$(( used / n ))
+ p=$(( 100 * avg * (n + 1) / total ))
+ printf "estimated storage: %d of %d MiB (%d%%, max ${toString cfg.storage}%%)\n" \
+ "$((used + avg))" "$total" "$p" >&2
+ if [ "$p" -le ${toString cfg.storage} ]; then
+ return 0
+ else
+ return 1
+ fi
+ }
+
+ clean
+
+ listSets | head -n -${toString (cfg.slots - 1)} \
+ | ${pkgs.findutils}/bin/xargs --no-run-if-empty rm -rfv \
+ || true
+
+ while ! enoughStorage; do
+ listSets | head -n 1 \
+ | ${pkgs.findutils}/bin/xargs --no-run-if-empty rm -rfv \
+ || true
+ done
+
+ mkdir -p "${cfg.dumpDir}/$DATE"
+ mkdir -p '${privateDir}/cnf'
+ mkdir -p '${privateDir}/jobs'
+
+ ${optionalString (gpgPubKeys != []) ''
+ # shellcheck disable=SC2174
+ mkdir --mode=0700 -p '${privateDir}/gnupg'
+ ln -sf ${pubring} '${privateDir}/gnupg/pubring.gpg'
+ ''}
+
+ ${concatStringsSep "\n" (
+ mapAttrsToList (n: s: ''
+ printf '[client]\npassword=' > '${privateDir}/cnf/${n}'
+ cat '${s.connection.password-file}' >> '${privateDir}/cnf/${n}'
+ '') (filterAttrs (_: s: s.connection.password-file != null) cfg.servers)
+ )}
+
+ {
+ cat <<'LIST'
+ ${concatStringsSep "\n" (mapAttrsToList (mkJobs) cfg.servers)}
+ LIST
+ } | ${pkgs.parallel}/bin/parallel \
+ --halt-on-error 0 \
+ --jobs 100% \
+ --line-buffer \
+ --no-notice \
+ --no-run-if-empty \
+ --retries 2 \
+ --shuf \
+ --tagstr '* {}:' \
+ --timeout ${toString (10 * 60)} \
+ || true
+
+ failed=0
+ log="${cfg.dumpDir}/$DATE/joblog.txt"
+
+ {
+ cd '${privateDir}/jobs' && find -type l -printf '%P\n';
+ } | ${pkgs.parallel}/bin/parallel \
+ --halt-on-error 0 \
+ --joblog "$log" \
+ --jobs '${toString cfg.jobs}' \
+ --line-buffer \
+ --no-notice \
+ --no-run-if-empty \
+ --retries 2 \
+ --tagstr '* {}:' \
+ --timeout ${toString (6 * 60 * 60)} \
+ '${privateDir}/jobs/{}' || failed=$?
+
+ cat "$log"
+ clean
+
+ du -sh "${cfg.dumpDir}/$DATE" || true
+ exit "$failed"
+ '';
+
+in {
+ options.nixsap.apps.mysqlbackup = {
+ user = mkOption {
+ description = "User to run as";
+ default = "mysqlbackup";
+ type = str;
+ };
+
+ startAt = mkOption {
+ description = "Time to start (systemd format)";
+ default = "02:00";
+ type = str;
+ };
+
+ dumpDir = mkOption {
+ description = "Directory to save dumps in";
+ default = "/mysqlbackup";
+ type = path;
+ };
+
+ slots = mkOption {
+ description = ''
+ How many backup sets should be kept locally.
+ However, old sets will be removed anyway if storage
+ constraints apply.
+ '';
+ default = 60;
+ type = int;
+ };
+
+ storage = mkOption {
+ description = ''
+ Percent of storage backups can occupy.
+ '';
+ default = 75;
+ type = int;
+ };
+
+ encrypt = mkOption {
+ description = "Public GPG key(s) for encrypting the dumps";
+ default = [ ];
+ type = either path (listOf path);
+ };
+
+ servers = mkOption {
+ default = {};
+ type = attrsOf server;
+ };
+
+ jobs = mkOption {
+ description = ''
+ Number of jobs (mysqldump) to run in parallel.
+ In the format of GNU Parallel, e. g. "100%", -1. +3, 7, etc.
+ '';
+ default = "50%";
+ type = either int str;
+ };
+
+ s3cfg = mkOption {
+ description = "s3cmd config file (secret)";
+ type = nullOr path;
+ default = null;
+ };
+
+ s3uri = mkOption {
+ description = "S3 bucket URI with prefix in s3cmd format";
+ type = nullOr str;
+ default = null;
+ example = "s3://backups/nightly";
+ };
+ };
+
+ config = mkIf (cfg.servers != {}) {
+ nixsap.system.users.daemons = [ cfg.user ];
+ nixsap.deployment.keyrings.${cfg.user} = keys;
+ systemd.services.mysqlbackup = {
+ description = "MySQL backup";
+ after = [ "local-fs.target" "keys.target" "network.target" ];
+ wants = [ "keys.target" ];
+ startAt = cfg.startAt;
+ inherit preStart;
+ serviceConfig = {
+ ExecStart = "${main}/bin/mysqlbackup";
+ User = cfg.user;
+ PermissionsStartOnly = true;
+ };
+ };
+ };
+}
diff --git a/modules/apps/mywatch.nix b/modules/apps/mywatch.nix
new file mode 100644
index 0000000..732033c
--- /dev/null
+++ b/modules/apps/mywatch.nix
@@ -0,0 +1,61 @@
+{ config, pkgs, lib, ... }:
+
+let
+ inherit (builtins) filter toString;
+ inherit (lib) types mkOption mkEnableOption mkIf hasPrefix
+ concatStrings optionalString;
+ inherit (types) str path int nullOr;
+
+ cfg = config.nixsap.apps.mywatch;
+
+ ExecStart = concatStrings [
+ "${pkgs.mywatch}/bin/mywatch"
+ (if (cfg.port != null)
+ then " -p ${toString cfg.port}"
+ else " -s '${cfg.socket}'")
+ " '${cfg.myFile}'"
+ ];
+
+ keys = filter (f: f != null && hasPrefix "/run/keys/" f) [ cfg.myFile ];
+
+in {
+ options.nixsap.apps.mywatch = {
+ enable = mkEnableOption "MyWatch";
+ user = mkOption {
+ description = "User to run as";
+ default = "mywatch";
+ type = str;
+ };
+ port = mkOption {
+ description = "TCP port to listen on (insecure)";
+ default = null;
+ type = nullOr int;
+ };
+ socket = mkOption {
+ description = "UNIX socket to listen on. Ignored when TCP port is set";
+ default = "/tmp/mywatch.sock";
+ type = path;
+ };
+ myFile = mkOption {
+ description = "MySQL client configuration file";
+ type = path;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ nixsap.system.users.daemons = [ cfg.user ];
+ nixsap.deployment.keyrings.${cfg.user} = keys;
+ systemd.services.mywatch = {
+ description = "watch queries on multiple MySQL servers";
+ wantedBy = [ "multi-user.target" ];
+ wants = [ "keys.target" ];
+ after = [ "keys.target" "network.target" ];
+ serviceConfig = {
+ inherit ExecStart;
+ User = cfg.user;
+ Restart = "on-failure";
+ };
+ };
+ };
+}
+
diff --git a/modules/apps/nginx.nix b/modules/apps/nginx.nix
new file mode 100644
index 0000000..3765d67
--- /dev/null
+++ b/modules/apps/nginx.nix
@@ -0,0 +1,146 @@
+{ config, pkgs, lib, ... }:
+
+let
+
+ inherit (builtins)
+ filter isBool toString ;
+
+ inherit (lib)
+ concatMapStrings concatStringsSep filterAttrs mapAttrsToList mkEnableOption
+ mkIf mkOption ;
+
+ inherit (lib.types)
+ attrsOf bool either enum int lines nullOr path str submodule ;
+
+
+ cfg = config.nixsap.apps.nginx;
+ explicit = filterAttrs (n: v: n != "_module" && v != null);
+
+ attrs = opts: submodule { options = opts; };
+ default = d: t: mkOption { type = t; default = d; };
+ optional = t: mkOption { type = nullOr t; default = null; };
+
+ show = v: if isBool v then (if v then "on" else "off") else toString v;
+
+ format = indent: set:
+ let mkEntry = k: v: "${indent}${k} ${show v};";
+ in concatStringsSep "\n" (mapAttrsToList mkEntry (explicit set));
+
+ mkServer = name: text: pkgs.writeText "nginx-${name}.conf" ''
+ # ${name}:
+ server {
+ ${text}
+ }
+ '';
+
+ # Hardcode defaults that could be overriden in server context.
+ # Add options for http-only directives.
+ nginx-conf = pkgs.writeText "nginx.conf" ''
+ daemon off;
+ user ${cfg.user} ${cfg.user};
+ pid ${cfg.runDir}/nginx.pid;
+
+ ${format "" cfg.main}
+
+ events {
+ ${format " " cfg.events}
+ }
+
+ http {
+ include ${pkgs.nginx}/conf/mime.types;
+ default_type application/octet-stream;
+
+ access_log off;
+ error_log stderr;
+
+ gzip on;
+ keepalive_timeout 65;
+ sendfile on;
+ ssl_prefer_server_ciphers on;
+ ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ tcp_nodelay on;
+ tcp_nopush on;
+ types_hash_max_size 2048;
+
+ # https://www.nginx.com/blog/mitigating-the-httpoxy-vulnerability-with-nginx/
+ fastcgi_param HTTP_PROXY "";
+ proxy_set_header Proxy "";
+
+ ${concatMapStrings (s: "include ${s}\n;") (mapAttrsToList mkServer cfg.http.servers)}
+ }
+ '';
+
+ exec = "${pkgs.nginx}/bin/nginx -c ${nginx-conf} -p ${cfg.stateDir}";
+
+in {
+
+ options.nixsap.apps.nginx = {
+ user = mkOption {
+ description = "User to run as";
+ type = str;
+ default = "nginx";
+ };
+ stateDir = mkOption {
+ description = "Directory holding all state for nginx to run";
+ type = path;
+ default = "/nginx";
+ };
+ runDir = mkOption {
+ description = ''
+ Directory for sockets and PID-file.
+ UNIX-sockets created by nginx are world-writable.
+ So if you want some privacy, put sockets in this directory.
+ It is owned by nginx user and group, and has mode 0640.
+ '';
+ type = path;
+ readOnly = true;
+ default = "/run/nginx";
+ };
+
+ main = default {} (attrs {
+ pcre_jit = optional bool;
+ timer_resolution = optional int;
+ worker_cpu_affinity = optional str;
+ worker_priority = optional int;
+ worker_processes = default "auto" (either int (enum ["auto"]));
+ worker_rlimit_core = optional int;
+ worker_rlimit_nofile = optional int;
+ });
+
+ events = default {} (attrs {
+ accept_mutex = optional bool;
+ accept_mutex_delay = optional int;
+ multi_accept = optional bool;
+ worker_aio_requests = optional int;
+ worker_connections = optional int;
+ });
+
+ http = default {} (attrs {
+ servers = default {} (attrsOf lines);
+ });
+ };
+
+ config = mkIf ({} != explicit cfg.http.servers) {
+ nixsap.system.users.daemons = [ cfg.user ];
+ systemd.services.nginx = {
+ description = "web/proxy server";
+ wants = [ "keys.target" ];
+ after = [ "keys.target" "local-fs.target" "network.target" ];
+ wantedBy = [ "multi-user.target" ];
+ preStart = ''
+ rm -rf '${cfg.runDir}'
+ mkdir -p '${cfg.stateDir}/logs' '${cfg.runDir}'
+ chown -Rc '${cfg.user}:${cfg.user}' '${cfg.stateDir}' '${cfg.runDir}'
+ chmod -Rc u=rwX,g=rX,o= '${cfg.stateDir}' '${cfg.runDir}'
+ '';
+ serviceConfig = {
+ ExecStart = exec;
+ ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
+ RestartSec = "10s";
+ StartLimitInterval = "1min";
+ Restart = "always";
+ };
+ };
+ };
+}
+
diff --git a/modules/apps/pgbackup.nix b/modules/apps/pgbackup.nix
new file mode 100644
index 0000000..3428843
--- /dev/null
+++ b/modules/apps/pgbackup.nix
@@ -0,0 +1,337 @@
+{ config, pkgs, lib, ... }:
+let
+
+ inherit (builtins)
+ elem isBool isList isString toString ;
+ inherit (lib)
+ concatMapStringsSep concatStringsSep filter filterAttrs
+ findFirst flatten hasPrefix mapAttrsToList mkIf
+ mkOption optionalString removeSuffix ;
+ inherit (lib.types)
+ bool either enum int listOf nullOr path str submodule ;
+
+ cfg = config.nixsap.apps.pgbackup;
+ privateDir = "/run/pgbackup";
+
+ s3cmd = "${pkgs.s3cmd}/bin/s3cmd ${optionalString (cfg.s3cfg != null) "-c '${cfg.s3cfg}'"}";
+
+ gpgPubKeys = flatten [ cfg.encrypt ];
+ gpg = "${pkgs.gpg}/bin/gpg2";
+ pubring = pkgs.runCommand "pubring.gpg" {} ''
+ ${gpg} --homedir . --import ${toString gpgPubKeys}
+ cp pubring.gpg $out
+ '';
+
+ default = d: t: mkOption { type = t; default = d; };
+ optional = type: mkOption { type = nullOr type; default = null; };
+ sub = options: submodule { inherit options; } ;
+ concatMapAttrsSep = s: f: attrs: concatStringsSep s (mapAttrsToList f attrs);
+
+ command = sub
+ {
+ blobs = optional bool;
+ clean = optional bool;
+ compress = default 9 int;
+ create = optional bool;
+ data-only = optional bool;
+ dbname = optional str;
+ exclude-schema = optional (either str (listOf str));
+ exclude-table = optional (either str (listOf str));
+ exclude-table-data = optional (either str (listOf str));
+ format = default "plain" (enum ["plain" "custom" "directory" "tar"]);
+ host = optional str;
+ if-exists = optional bool;
+ inserts = optional bool;
+ jobs = default 2 int;
+ oids = optional bool;
+ port = optional int;
+ quote-all-identifiers = optional bool;
+ role = optional str;
+ schema = optional (either str (listOf str));
+ schema-only = optional bool;
+ serializable-deferrable = optional bool;
+ table = optional (either str (listOf str));
+ username = optional str;
+ };
+
+ job = o:
+ let
+ dbname = findFirst (n: n != null) cfg.user [ o.dbname o.username ];
+ name = "pg_dump"
+ + optionalString (o.host != null && o.host != "localhost") "-${o.host}"
+ + optionalString (o.port != null) "-${toString o.port}"
+ + "-${dbname}"
+ + "-${o.format}";
+
+ args = filterAttrs (n: v:
+ v != null && n != "_module"
+ && (n == "host" -> v != "localhost")
+ && (n == "jobs" -> o.format == "directory")
+ # XXX will use pigz for others:
+ && (n == "compress" -> elem o.format ["directory" "custom"])
+ ) o;
+
+ mkArg = k: v:
+ if isBool v then (optionalString v "--${k}")
+ else if isList v then concatMapStringsSep " " (i: "--${k}='${i}'") v
+ else if isString v then "--${k}='${v}'"
+ else "--${k}=${toString v}" ;
+
+ # XXX: Use the latest pg_dump:
+ pg_dump = pkgs.writeBashScript name ''
+ ${optionalString (cfg.pgpass != null) "export PGPASSFILE='${cfg.pgpass}'"}
+ exec ${pkgs.postgresql95}/bin/pg_dump \
+ ${concatMapAttrsSep " " mkArg args} \
+ "$@"
+ '';
+
+ compExt = optionalString (o.compress > 0) ".gz";
+ compPipe = optionalString (o.compress > 0)
+ "| ${pkgs.pigz}/bin/pigz -${toString o.compress} -p${toString o.jobs}";
+ suff = if o.format == "directory" then "dir.tar"
+ else if o.format == "tar" then "tar${compExt}"
+ else if o.format == "custom" then "pgdump"
+ else "pgsql${compExt}" ;
+
+ in pkgs.writeBashScript "${name}-job" ''
+ set -euo pipefail
+ cd "${cfg.dumpDir}/$DATE"
+ ${
+ if o.host != null && o.host != "localhost" then
+ "host='${o.host}'"
+ else
+ "host=$(${pkgs.nettools}/bin/hostname -f)"
+ }
+
+ dump="${dbname}@''${host}${optionalString (o.port != null) ":${toString o.port}"},$DATE.${suff}"
+ ${
+ if (gpgPubKeys != []) then
+ ''aim="$dump.gpg"''
+ else
+ ''aim="$dump"''
+ }
+
+ if ! [ -r "$aim" ]; then
+ ${
+ if o.format == "directory" then ''
+ rm -rf "$dump.tmp"
+ ${pg_dump} -f "$dump.tmp"
+ ${pkgs.gnutar}/bin/tar \
+ --owner=0 --group=0 --mode u=rwX,g=rX,o= \
+ --remove-files --transform 's,\.dir\.tar\.tmp,,' -c "$dump.tmp" -f "$dump"
+ rm -rf "$dump.tmp"
+ '' else if o.format == "custom" then ''
+ ${pg_dump} -f "$dump.tmp"
+ mv "$dump".tmp "$dump"
+ '' else ''
+ ${pg_dump} ${compPipe} > "$dump.tmp"
+ mv "$dump".tmp "$dump"
+ ''
+ }
+
+ ${optionalString (gpgPubKeys != []) ''
+ recipient=( $(${gpg} --homedir '${privateDir}/gnupg' -k --with-colons --fast-list-mode | \
+ ${pkgs.gawk}/bin/awk -F: '/^pub/{print $5}') )
+ r=( "''${recipient[@]/#/-r}" )
+ ${gpg} --homedir '${privateDir}/gnupg' --batch --no-tty --yes \
+ "''${r[@]}" --trust-model always \
+ -v -e "$dump"
+ rm -f "$dump"
+ ''}
+ else
+ echo "$aim exists. Not dumping." >&2
+ fi
+ ${optionalString (cfg.s3uri != null) ''
+ remote="${removeSuffix "/" cfg.s3uri}/$DATE/$aim"
+ if ! ${s3cmd} ls "$remote" | ${pkgs.gnugrep}/bin/grep -qF "/$aim"; then
+ ${s3cmd} put "$aim" "$remote"
+ else
+ echo "$remote exists. Not uploading." >&2
+ fi
+ ''}
+ '';
+
+ preStart = ''
+ mkdir --mode=0750 -p '${cfg.dumpDir}'
+ chown -R ${cfg.user}:${cfg.user} '${cfg.dumpDir}'
+ chmod -R u=rwX,g=rX,o= ${cfg.dumpDir}
+
+ rm -rf '${privateDir}'
+ mkdir --mode=0700 -p '${privateDir}'
+ chown ${cfg.user}:${cfg.user} '${privateDir}'
+ '';
+
+ main = pkgs.writeBashScriptBin "pgbackup" ''
+ set -euo pipefail
+ umask 0027
+ DATE=$(date --iso-8601)
+ HOME='${privateDir}'
+ PARALLEL_SHELL=${pkgs.bash}/bin/bash
+ export DATE
+ export HOME
+ export PARALLEL_SHELL
+
+ clean() {
+ ${pkgs.findutils}/bin/find '${cfg.dumpDir}' \
+ -name '*.tmp' -exec rm -rf {} + || true
+ }
+
+ listSets() {
+ ${pkgs.findutils}/bin/find '${cfg.dumpDir}' \
+ -maxdepth 1 -mindepth 1 -type d -name '????-??-??' \
+ | sort -V
+ }
+
+ enoughStorage() {
+ local n
+ local used
+ local total
+ local avg
+ local p
+ n=$(listSets | wc -l)
+ used=$(du -x -s --block-size=1M '${cfg.dumpDir}' | cut -f1)
+ total=$(df --output=size --block-size=1M '${cfg.dumpDir}' | tail -n 1)
+ if [ "$n" -eq 0 ]; then
+ echo "no sets" >&2
+ return 0
+ fi
+
+ avg=$(( used / n ))
+ p=$(( 100 * avg * (n + 1) / total ))
+ printf "estimated storage: %d of %d MiB (%d%%, max ${toString cfg.storage}%%)\n" \
+ "$((used + avg))" "$total" "$p" >&2
+ if [ "$p" -le ${toString cfg.storage} ]; then
+ return 0
+ else
+ return 1
+ fi
+ }
+
+ clean
+
+ listSets | head -n -${toString (cfg.slots - 1)} \
+ | ${pkgs.findutils}/bin/xargs --no-run-if-empty rm -rfv \
+ || true
+
+ while ! enoughStorage; do
+ listSets | head -n 1 \
+ | ${pkgs.findutils}/bin/xargs --no-run-if-empty rm -rfv \
+ || true
+ done
+
+ mkdir -p "${cfg.dumpDir}/$DATE"
+
+ ${optionalString (gpgPubKeys != []) ''
+ # shellcheck disable=SC2174
+ mkdir --mode=0700 -p '${privateDir}/gnupg'
+ ln -sf ${pubring} '${privateDir}/gnupg/pubring.gpg'
+ ''}
+
+ failed=0
+ log="${cfg.dumpDir}/$DATE/joblog.txt"
+
+ # shellcheck disable=SC2016
+ ${pkgs.parallel}/bin/parallel \
+ --halt-on-error 0 \
+ --joblog "$log" \
+ --jobs 50% \
+ --line-buffer \
+ --no-notice \
+ --no-run-if-empty \
+ --retries 2 \
+ --rpl '{nixbase} s:^/nix/store/[^-]+-pg_dump-(.+)-job$:$1:' \
+ --tagstr '* {nixbase}:' \
+ --timeout ${toString (6 * 60 * 60)} ::: \
+ ${concatMapStringsSep " " job cfg.pg_dump} \
+ || failed=$?
+
+ cat "$log"
+ clean
+
+ du -sh "${cfg.dumpDir}/$DATE" || true
+ exit "$failed"
+ '';
+
+ keys = filter (f: f != null && hasPrefix "/run/keys/" f) ( [cfg.pgpass cfg.s3cfg] );
+
+in {
+ options.nixsap.apps.pgbackup = {
+ user = mkOption {
+ description = "User to run as";
+ default = "pgbackup";
+ type = str;
+ };
+
+ dumpDir = mkOption {
+ description = "Directory to save dumps in";
+ default = "/pgbackup";
+ type = path;
+ };
+
+ slots = mkOption {
+ description = ''
+ How many backup sets should be kept locally.
+ However, old sets will be removed anyway if storage
+ constraints apply.
+ '';
+ default = 60;
+ type = int;
+ };
+
+ storage = mkOption {
+ description = ''
+ Percent of storage backups can occupy.
+ '';
+ default = 75;
+ type = int;
+ };
+
+ encrypt = mkOption {
+ description = "Public GPG key(s) for encrypting the dumps";
+ default = [ ];
+ type = either path (listOf path);
+ };
+
+ s3cfg = mkOption {
+ description = "s3cmd config file (secret)";
+ type = nullOr path;
+ default = null;
+ };
+
+ s3uri = mkOption {
+ description = "S3 bucket URI with prefix in s3cmd format";
+ type = nullOr str;
+ default = null;
+ example = "s3://backups/nightly";
+ };
+
+ pg_dump = mkOption {
+ description = "pg_dump commands";
+ default = [];
+ type = listOf command;
+ };
+
+ pgpass = mkOption {
+ description = "The Password File (secret)";
+ type = nullOr path;
+ default = null;
+ };
+ };
+
+ config = mkIf (cfg.pg_dump != []) {
+ nixsap.system.users.daemons = [ cfg.user ];
+ nixsap.deployment.keyrings.${cfg.user} = keys;
+ systemd.services.pgbackup = {
+ description = "PostgreSQL backup";
+ after = [ "local-fs.target" "keys.target" "network.target" ];
+ wants = [ "keys.target" ];
+ startAt = "02:00";
+ inherit preStart;
+ serviceConfig = {
+ ExecStart = "${main}/bin/pgbackup";
+ User = cfg.user;
+ PermissionsStartOnly = true;
+ };
+ };
+ };
+}
diff --git a/modules/apps/php-fpm.nix b/modules/apps/php-fpm.nix
new file mode 100644
index 0000000..e69be73
--- /dev/null
+++ b/modules/apps/php-fpm.nix
@@ -0,0 +1,139 @@
+{ config, pkgs, lib, ... }:
+
+let
+
+ inherit (builtins)
+ filter isAttrs isBool toString ;
+ inherit (lib)
+ concatStringsSep filterAttrs foldl hasPrefix
+ mapAttrsToList mkIf mkOption types ;
+ inherit (types)
+ attrsOf bool either enum int nullOr package path str
+ submodule ;
+
+ explicit = filterAttrs (n: v: n != "_module" && v != null);
+ concatNonEmpty = sep: list: concatStringsSep sep (filter (s: s != "") list);
+
+ attrs = opts: submodule { options = opts; };
+ default = d: t: mkOption { type = t; default = d; };
+ mandatory = t: mkOption { type = t; };
+ optional = t: mkOption { type = nullOr t; default = null; };
+
+ instances = explicit (config.nixsap.apps.php-fpm);
+
+ users = mapAttrsToList (_: v: v.pool.user) instances;
+
+ mkService = name: cfg:
+ let
+ show = v: if isBool v then (if v then "yes" else "no") else toString v;
+
+ mkGroup = group: opts: main:
+ let f = k: v: if k == main
+ then "${group} = ${show v}"
+ else "${group}.${k} = ${show v}";
+ in concatNonEmpty "\n" (mapAttrsToList f (explicit opts));
+
+ mkEnv = t: k: v: "${t}[${k}] = ${show v}";
+
+ mkPool = k: v:
+ if k == "listen" then mkGroup k v "socket"
+ else if k == "env" || hasPrefix "php_" k then concatNonEmpty "\n" (mapAttrsToList (mkEnv k) v)
+ else if k == "pm" then mkGroup k v "strategy"
+ else if isAttrs v then mkGroup k v ""
+ else "${k} = ${show v}";
+
+ mkGlobal = k: v:
+ if k == "php-ini" || k == "pool" || k == "package" then ""
+ else if isAttrs v then mkGroup k v ""
+ else "${k} = ${show v}";
+
+ conf = pkgs.writeText "php-fpm-${name}.conf" ''
+ [global]
+ daemonize = no
+ ${concatNonEmpty "\n" (mapAttrsToList mkGlobal (explicit cfg))}
+
+ [pool]
+ ${concatNonEmpty "\n" (mapAttrsToList mkPool (explicit cfg.pool))}
+ '';
+ exec = "${cfg.package}/bin/php-fpm --fpm-config ${conf} "
+ + ( if cfg.php-ini != null
+ then "--php-ini ${cfg.php-ini}"
+ else "--no-php-ini" );
+ in {
+ "php-fpm-${name}" = {
+ description = "PHP FastCGI Process Manager (${name})";
+ after = [ "local-fs.target" ];
+ wantedBy = [ "multi-user.target" ];
+ serviceConfig = {
+ ExecStart = exec;
+ Restart = "always";
+ };
+ };
+ };
+
+in {
+
+ options.nixsap.apps.php-fpm = default {}
+ (attrsOf (submodule( { config, name, ... }: {
+ options = {
+ package = default pkgs.php package;
+ emergency_restart_interval = optional int;
+ emergency_restart_threshold = optional int;
+ error_log = default "/var/log/php-fpm-${name}.log" path;
+ log_level = optional (enum ["alert" "error" "warning" "notice" "debug"]);
+ php-ini = optional path;
+ process_control_timeout = optional int;
+ rlimit_core = optional int;
+ rlimit_files = optional int;
+
+ process = optional (attrs {
+ max = optional int;
+ priority = optional int;
+ });
+
+ pool = default {} (submodule({
+ options = {
+ catch_workers_output = optional bool;
+ chdir = optional path;
+ clear_env = optional bool;
+ env = default {} (attrsOf str);
+ php_admin_flag = default {} (attrsOf bool);
+ php_admin_value = default {} (attrsOf (either str int));
+ php_flag = default {} (attrsOf bool);
+ php_value = default {} (attrsOf (either str int));
+ request_terminate_timeout = optional int;
+ rlimit_core = optional int;
+ rlimit_files = optional int;
+ user = default "php-fpm-${name}" str;
+ listen = default {} (attrs {
+ acl_groups = optional str;
+ backlog = optional int;
+ group = optional str;
+ mode = optional str;
+ owner = default config.pool.user str;
+ socket = default "/run/php-fpm-${name}.sock" path;
+ });
+ pm = mandatory (attrs {
+ max_children = mandatory int;
+ max_requests = optional int;
+ max_spare_servers = optional int;
+ min_spare_servers = optional int;
+ start_servers = optional int;
+ status_path = optional path;
+ strategy = mandatory (enum ["static" "ondemand" "dynamic"]);
+ });
+ ping = optional (attrs {
+ path = optional path;
+ response = optional str;
+ });
+ };
+ }));
+ };
+ })));
+
+ config = mkIf ({} != instances) {
+ nixsap.system.users.daemons = users;
+ systemd.services = foldl (a: b: a//b) {} (mapAttrsToList mkService instances);
+ };
+}
+
diff --git a/modules/apps/postgresql/default.nix b/modules/apps/postgresql/default.nix
new file mode 100644
index 0000000..847fc75
--- /dev/null
+++ b/modules/apps/postgresql/default.nix
@@ -0,0 +1,203 @@
+{ config, pkgs, lib, ... }:
+let
+
+ inherit (builtins)
+ match toString ;
+
+ inherit (lib)
+ concatMapStrings concatStringsSep filter filterAttrs foldl hasPrefix
+ isBool isInt isList isString length mapAttrs' mapAttrsToList mkDefault
+ mkIf mkOption nameValuePair types ;
+
+ inherit (types)
+ attrsOf lines listOf nullOr package path str submodule ;
+
+ concatNonEmpty = sep: list: concatStringsSep sep (filter (s: s != "") list);
+ explicit = filterAttrs (n: v: n != "_module" && v != null);
+
+ instances = explicit config.nixsap.apps.postgresql;
+ users = mapAttrsToList (_: v: v.user) instances;
+
+ isFloat = x: match "^[0-9]+(\\.[0-9]+)?$" (toString x) != null;
+ isKey = s: s != null && hasPrefix "/run/keys/" s;
+
+ keyrings = mapAttrs' (_: i: nameValuePair "${i.user}" [ i.server.ssl_key_file ]
+ ) (filterAttrs (_: i: isKey i.server.ssl_key_file) instances);
+
+ mkService = name: opts:
+ let
+ inherit (opts) user initdb;
+ inherit (opts.server) data_directory port hba_file ident_file;
+ ident_file_path = pkgs.writeText "${name}-ident_file" ''
+ postgres ${user} postgres
+ ${ident_file}
+ '';
+ hba_file_path = pkgs.writeText "${name}-hba_file" ''
+ local all postgres peer map=postgres
+ ${hba_file}
+ '';
+ show = n: v: if isBool v then (if v then "yes" else "no")
+ else if n == "ident_file" then "'${ident_file_path}'"
+ else if n == "hba_file" then "'${hba_file_path}'"
+ else if isFloat v then toString v
+ else if isString v then "'${v}'"
+ else if isList v then "'${concatStringsSep "," v}'"
+ else toString v;
+ conf = pkgs.writeText "pgsql-${name}.conf" (
+ concatStringsSep "\n" (mapAttrsToList (n: v: "${n} = ${show n v}") (explicit opts.server))
+ );
+
+ preStart = ''
+ mkdir -v -p '${data_directory}'
+ chown -R '${user}:${user}' '${data_directory}'
+ chmod -R u=rwX,g=,o= '${data_directory}'
+ '';
+
+ main = pkgs.writeBashScriptBin "pgsql-${name}" ''
+ set -euo pipefail
+ if [ ! -f '${data_directory}/PG_VERSION' ]; then
+ ${initdb} '${data_directory}'
+ rm -f '${data_directory}/'*hba.conf
+ rm -f '${data_directory}/'*ident.conf
+ rm -f '${data_directory}/postgresql.conf'
+ fi
+ exec '${opts.package}/bin/postgres' -c 'config_file=${conf}'
+ '';
+
+ psql = "${opts.package}/bin/psql -v ON_ERROR_STOP=1 -p${toString port} -U postgres";
+
+ configure =
+ let
+ create = pkgs.writeText "pgsql-${name}-create.sql" ''
+ ${concatMapStrings (r: ''
+ SELECT create_role_if_not_exists('${r}');
+ '') opts.roles}
+ ${concatMapStrings (d: ''
+ SELECT create_db_if_not_exists('${d}');
+ '') opts.databases}
+ '';
+ in pkgs.writeBashScriptBin "pgsql-${name}-conf" ''
+ set -euo pipefail
+ while ! ${psql} -c ';'; do
+ sleep 5s
+ done
+ ${psql} -f ${./functions.pgsql}
+ ${psql} -f ${create}
+ ${psql} -f ${pkgs.writeText "pgsql-${name}.sql" opts.configure}
+ '';
+
+ needConf = (opts.configure != "") || (opts.roles != []) || (opts.databases != []);
+
+ in {
+ "pgsql-${name}" = {
+ wantedBy = [ "multi-user.target" ];
+ wants = [ "keys.target" ];
+ after = [ "keys.target" "network.target" "local-fs.target" ];
+ inherit preStart;
+ serviceConfig = {
+ ExecStart = "${main}/bin/pgsql-${name}";
+ KillMode = "mixed";
+ KillSignal = "SIGINT";
+ PermissionsStartOnly = true;
+ TimeoutSec = 0;
+ User = user;
+ };
+ };
+ "pgsql-${name}-conf" = mkIf needConf {
+ wantedBy = [ "multi-user.target" ];
+ after = [ "pgsql-${name}.service" ];
+ requires = [ "pgsql-${name}.service" ];
+ serviceConfig = {
+ ExecStart = "${configure}/bin/pgsql-${name}-conf";
+ RemainAfterExit = true;
+ Type = "oneshot";
+ User = user;
+ };
+ };
+ };
+
+ instance = submodule ( { config, name, ... }: {
+ options = {
+ user = mkOption {
+ description = "User to run as. Default is instance name";
+ type = str;
+ default = "pgsql-${name}";
+ };
+ roles = mkOption {
+ description = ''
+ List of roles to be created. These roles will be created if do
+ not exist. That's it. You will have to ALTER these roles and GRANT
+ privileges using the `configure` option. Note that if you remove
+ roles from this list, they will NOT be deleted from the database.
+ You do not need this if this instance is a replica.
+ '';
+ type = listOf str;
+ default = [];
+ };
+ databases = mkOption {
+ description = ''
+ List of databases to be created. These databases will be created
+ if do not exist. You do not need this if this instance is a replica.
+ '';
+ type = listOf str;
+ default = [];
+ };
+ configure = mkOption {
+ description = ''
+ SQL statements to be executed. This should be idempotent.
+ May include creation of roles and databases, granting privileges.
+ Usage of PL/pgSQL is hightly encouraged.
+ You do not need this if this instance is a replica.
+ '';
+ type = lines;
+ default = "";
+ example = ''
+ SELECT create_role_if_not_exists('sproxy');
+ ALTER ROLE sproxy RESET ALL;
+ ALTER ROLE sproxy LOGIN;
+ SELECT create_db_if_not_exists('sproxy');
+ ALTER DATABASE sproxy OWNER TO sproxy;
+ '';
+ };
+ package = mkOption {
+ description = "PostgreSQL package";
+ type = package;
+ default = pkgs.postgresql;
+ };
+ server = mkOption {
+ description = "PostgreSQL server configuration";
+ type = submodule (import ./server.nix);
+ };
+ initdb = mkOption {
+ description = ''
+ Specifies the command to initialize data directory.
+ This command will be executed after the data directory is created.
+ The path to the data directory will be appended to this command.
+ '';
+ default = "${config.package}/bin/initdb -U postgres";
+ example = "\${pkgs.postgresql94}/bin/pg_basebackup ... -R -D";
+ type = path;
+ };
+ };
+ config = {
+ server = {
+ data_directory = mkDefault "/postgresql/${name}";
+ syslog_ident = mkDefault "pgsql-${name}";
+ };
+ };
+ });
+
+in {
+ options.nixsap.apps.postgresql = mkOption {
+ description = "Instances of PostgreSQL.";
+ type = attrsOf instance;
+ default = {};
+ };
+
+ config = {
+ nixsap.deployment.keyrings = keyrings;
+ environment.systemPackages = [ pkgs.postgresql ];
+ systemd.services = foldl (a: b: a//b) {} (mapAttrsToList mkService instances);
+ nixsap.system.users.daemons = users;
+ };
+}
diff --git a/modules/apps/postgresql/functions.pgsql b/modules/apps/postgresql/functions.pgsql
new file mode 100644
index 0000000..085cc5d
--- /dev/null
+++ b/modules/apps/postgresql/functions.pgsql
@@ -0,0 +1,25 @@
+CREATE EXTENSION IF NOT EXISTS dblink;
+
+DROP FUNCTION IF EXISTS create_role_if_not_exists(TEXT);
+CREATE FUNCTION create_role_if_not_exists(IN name TEXT)
+RETURNS VOID AS $$
+BEGIN
+IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = name) THEN
+ EXECUTE format('CREATE ROLE %I', name);
+END IF;
+END;
+$$ LANGUAGE PLPGSQL;
+
+DROP FUNCTION IF EXISTS create_db_if_not_exists(TEXT);
+CREATE FUNCTION create_db_if_not_exists(IN dbname TEXT)
+RETURNS VOID AS $$
+DECLARE port INT;
+DECLARE junk TEXT;
+BEGIN
+IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = dbname) THEN
+ SELECT setting FROM pg_settings WHERE name = 'port' INTO port;
+ SELECT dblink_exec('user=postgres dbname=postgres port=' || port, 'CREATE DATABASE ' || quote_ident(dbname)) INTO junk;
+END IF;
+END;
+$$ LANGUAGE PLPGSQL;
+
diff --git a/modules/apps/postgresql/server.nix b/modules/apps/postgresql/server.nix
new file mode 100644
index 0000000..864af5c
--- /dev/null
+++ b/modules/apps/postgresql/server.nix
@@ -0,0 +1,218 @@
+{ lib, ... }:
+let
+
+ inherit (lib) mkOption mkOptionType mkIf types isInt isString
+ all length splitString stringToCharacters filter;
+ inherit (types) either enum attrsOf nullOr listOf str path lines int bool;
+ inherit (builtins) toString match;
+
+ default = d: t: mkOption { type = t; default = d; };
+ mandatory = t: mkOption { type = t; };
+ optional = t: mkOption { type = nullOr t; default = null; };
+
+ isFloat = x: match "^[0-9]+(\\.[0-9]+)?$" (toString x) != null;
+
+ float = mkOptionType {
+ name = "positive float";
+ check = isFloat;
+ };
+
+in {
+ options = {
+ DateStyle = optional str;
+ IntervalStyle = optional (enum [ "sql_standard" "postgres_verbose" "iso_8601" ]);
+ TimeZone = optional str;
+ application_name = optional str;
+ archive_command = optional path;
+ archive_mode = optional bool;
+ archive_timeout = optional int;
+ array_nulls = optional bool;
+ authentication_timeout = optional int;
+ autovacuum = optional bool;
+ autovacuum_analyze_scale_factor = optional float;
+ autovacuum_analyze_threshold = optional int;
+ autovacuum_freeze_max_age = optional int;
+ autovacuum_max_workers = optional int;
+ autovacuum_multixact_freeze_max_age = optional int;
+ autovacuum_naptime = optional int;
+ autovacuum_vacuum_cost_delay = optional int;
+ autovacuum_vacuum_cost_limit = optional int;
+ autovacuum_vacuum_scale_factor = optional float;
+ autovacuum_vacuum_threshold = optional int;
+ autovacuum_work_mem = optional int;
+ backslash_quote = optional (enum [ "on" "off" "safe_encoding" ]);
+ bgwriter_delay = optional int;
+ bgwriter_lru_maxpages = optional int;
+ bgwriter_lru_multiplier = optional int;
+ bytea_output = optional (enum [ "hex" "escape" ]);
+ check_function_bodies = optional bool;
+ checkpoint_completion_target = optional float;
+ checkpoint_segments = optional int;
+ checkpoint_timeout = optional int;
+ checkpoint_warning = optional int;
+ client_encoding = optional str;
+ client_min_messages = optional (enum [ "DEBUG5" "DEBUG4" "DEBUG3" "DEBUG2" "DEBUG1" "LOG" "NOTICE" "WARNING" "ERROR" "FATAL" "PANIC" ]);
+ commit_delay = optional int;
+ commit_siblings = optional int;
+ constraint_exclusion = optional (enum [ "on" "partition" "off" ]);
+ cpu_index_tuple_cost = optional float;
+ cpu_operator_cost = optional float;
+ cpu_tuple_cost = optional float;
+ cursor_tuple_fraction = optional float;
+ data_directory = mandatory path;
+ deadlock_timeout = optional int;
+ debug_pretty_print = optional bool;
+ debug_print_parse = optional bool;
+ debug_print_plan = optional bool;
+ debug_print_rewritten = optional bool;
+ default_statistics_target = optional int;
+ default_tablespace = optional str;
+ default_text_search_config = optional str;
+ default_transaction_deferrable = optional bool;
+ default_transaction_isolation = optional (enum [ "read uncommitted" "read committed" "repeatable read" "serializable" ]);
+ default_transaction_read_only = optional bool;
+ default_with_oids = optional bool;
+ dynamic_shared_memory_type = optional (enum [ "posix" "sysv" "mmap" "none" ]);
+ effective_cache_size = optional int;
+ effective_io_concurrency = optional int;
+ enable_bitmapscan = optional bool;
+ enable_hashagg = optional bool;
+ enable_hashjoin = optional bool;
+ enable_indexonlyscan = optional bool;
+ enable_indexscan = optional bool;
+ enable_material = optional bool;
+ enable_mergejoin = optional bool;
+ enable_nestloop = optional bool;
+ enable_seqscan = optional bool;
+ enable_sort = optional bool;
+ enable_tidscan = optional bool;
+ escape_string_warning = optional bool;
+ exit_on_error = optional bool;
+ extra_float_digits = optional int;
+ from_collapse_limit = optional int;
+ fsync = optional bool;
+ full_page_writes = optional bool;
+ geqo = optional bool;
+ geqo_effort = optional int;
+ geqo_generations = optional int;
+ geqo_pool_size = optional int;
+ geqo_seed = optional float;
+ geqo_selection_bias = optional float;
+ geqo_threshold = optional int;
+ hba_file = default "" lines;
+ hot_standby = optional bool;
+ hot_standby_feedback = optional bool;
+ huge_pages = optional (enum [ "on" "off" "try" ]);
+ ident_file = default "" lines;
+ join_collapse_limit = optional int;
+ lc_messages = optional str;
+ lc_monetary = optional str;
+ lc_numeric = optional str;
+ lc_time = optional str;
+ listen_addresses = optional (either (listOf str) str);
+ lo_compat_privileges = optional bool;
+ lock_timeout = optional int;
+ log_autovacuum_min_duration = optional int;
+ log_checkpoints = optional bool;
+ log_connections = optional bool;
+ log_destination = optional (enum [ "stderr" "csvlog" "syslog" ]);
+ log_directory = optional path;
+ log_disconnections = optional bool;
+ log_duration = optional bool;
+ log_error_verbosity = optional (enum [ "TERSE" "DEFAULT" "VERBOSE" ]);
+ log_executor_stats = optional bool;
+ log_filename = optional str;
+ log_hostname = optional bool;
+ log_line_prefix = optional str;
+ log_lock_waits = optional bool;
+ log_min_duration_statement = optional int;
+ log_min_error_statement = optional (enum [ "DEBUG5" "DEBUG4" "DEBUG3" "DEBUG2" "DEBUG1" "LOG" "NOTICE" "WARNING" "ERROR" "FATAL" "PANIC" ]);
+ log_min_messages = optional (enum [ "DEBUG5" "DEBUG4" "DEBUG3" "DEBUG2" "DEBUG1" "LOG" "NOTICE" "WARNING" "ERROR" "FATAL" "PANIC" ]);
+ log_parser_stats = optional bool;
+ log_planner_stats = optional bool;
+ log_rotation_age = optional int;
+ log_rotation_size = optional int;
+ log_statement = optional (enum [ "none" "ddl" "mod" "all" ]);
+ log_statement_stats = optional bool;
+ log_temp_files = optional int;
+ log_timezone = optional str;
+ log_truncate_on_rotation = optional bool;
+ logging_collector = optional bool;
+ maintenance_work_mem = optional int;
+ max_connections = optional int;
+ max_files_per_process = optional int;
+ max_locks_per_transaction = optional int;
+ max_pred_locks_per_transaction = optional int;
+ max_prepared_transactions = optional int;
+ max_replication_slots = optional int;
+ max_stack_depth = optional int;
+ max_standby_archive_delay = optional int;
+ max_standby_streaming_delay = optional int;
+ max_wal_senders = optional int;
+ max_worker_processes = optional int;
+ password_encryption = optional bool;
+ port = default 5432 int;
+ quote_all_identifiers = optional bool;
+ random_page_cost = optional float;
+ restart_after_crash = optional bool;
+ search_path = optional (either (listOf str) str);
+ seq_page_cost = optional float;
+ session_replication_role = optional (enum [ "origin" "replica" "local" ]);
+ shared_buffers = optional int;
+ sql_inheritance = optional bool;
+ ssl = optional bool;
+ ssl_ca_file = optional path;
+ ssl_cert_file = optional path;
+ ssl_ciphers = optional str;
+ ssl_crl_file = optional path;
+ ssl_ecdh_curve = optional str;
+ ssl_key_file = optional path;
+ ssl_prefer_server_ciphers = optional bool;
+ ssl_renegotiation_limit = optional int;
+ standard_conforming_strings = optional bool;
+ statement_timeout = optional int;
+ stats_temp_directory = optional path;
+ superuser_reserved_connections = optional int;
+ synchronize_seqscans = optional bool;
+ synchronous_commit = optional (enum [ "on" "remote_write" "local" "off" ]);
+ synchronous_standby_names = optional (either (listOf str) str);
+ syslog_ident = optional str;
+ tcp_keepalives_count = optional int;
+ tcp_keepalives_idle = optional int;
+ tcp_keepalives_interval = optional int;
+ temp_buffers = optional int;
+ temp_file_limit = optional int;
+ temp_tablespaces = optional str;
+ timezone_abbreviations = optional str;
+ track_activities = optional bool;
+ track_activity_query_size = optional int;
+ track_counts = optional bool;
+ track_functions = optional (enum [ "none" "pl" "all" ]);
+ track_io_timing = optional bool;
+ transform_null_equals = optional bool;
+ update_process_title = optional bool;
+ vacuum_cost_delay = optional int;
+ vacuum_cost_limit = optional int;
+ vacuum_cost_page_dirty = optional int;
+ vacuum_cost_page_hit = optional int;
+ vacuum_cost_page_miss = optional int;
+ vacuum_defer_cleanup_age = optional int;
+ vacuum_freeze_min_age = optional int;
+ vacuum_freeze_table_age = optional int;
+ vacuum_multixact_freeze_min_age = optional int;
+ vacuum_multixact_freeze_table_age = optional int;
+ wal_buffers = optional int;
+ wal_keep_segments = optional int;
+ wal_level = optional (enum [ "minimal" "archive" "hot_standby" "logical" ]);
+ wal_log_hints = optional bool;
+ wal_receiver_status_interval = optional int;
+ wal_receiver_timeout = optional int;
+ wal_sender_timeout = optional int;
+ wal_sync_method = optional (enum [ "open_datasync" "fdatasync" "fsync" "fsync_writethrough" "open_sync" ]);
+ wal_writer_delay = optional int;
+ work_mem = optional int;
+ xmlbinary = optional (enum [ "base64" "hex" ]);
+ xmloption = optional (enum [ "DOCUMENT" "CONTENT" ]);
+ };
+}
+
diff --git a/modules/apps/sproxy-web.nix b/modules/apps/sproxy-web.nix
new file mode 100644
index 0000000..351e82d
--- /dev/null
+++ b/modules/apps/sproxy-web.nix
@@ -0,0 +1,71 @@
+{ config, pkgs, lib, ... }:
+
+let
+
+ inherit (builtins) toString ;
+ inherit (lib)
+ concatStrings filter hasPrefix mkEnableOption mkIf mkOption
+ optionalString types ;
+ inherit (types)
+ int nullOr path str ;
+
+ cfg = config.nixsap.apps.sproxy-web;
+
+ ExecStart = concatStrings [
+ "${pkgs.sproxy-web}/bin/sproxy-web"
+ (optionalString (cfg.connectionString != null) " -c '${cfg.connectionString}'")
+ (if (cfg.port != null)
+ then " -p ${toString cfg.port}"
+ else " -s '${cfg.socket}'")
+ ];
+
+ keys = filter (f: f != null && hasPrefix "/run/keys/" f) [ cfg.pgPassFile ];
+
+in {
+ options.nixsap.apps.sproxy-web = {
+ enable = mkEnableOption "Sproxy Web";
+ user = mkOption {
+ description = "User to run as";
+ default = "sproxy-web";
+ type = str;
+ };
+ connectionString = mkOption {
+ description = "PostgreSQL connection string";
+ type = str;
+ example = "user=sproxy-web dbname=sproxy port=6001";
+ };
+ pgPassFile = mkOption {
+ description = "postgreSQL password file (secret)";
+ default = null;
+ type = nullOr path;
+ };
+ socket = mkOption {
+ description = "UNIX socket to listen on. Ignored when TCP port is set";
+ default = "/tmp/sproxy-web.sock";
+ type = path;
+ };
+ port = mkOption {
+ description = "TCP port to listen on (insecure)";
+ type = nullOr int;
+ default = null;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ nixsap.system.users.daemons = [ cfg.user ];
+ nixsap.deployment.keyrings.${cfg.user} = keys;
+ systemd.services.sproxy-web = {
+ description = "Web interface to Sproxy database";
+ wantedBy = [ "multi-user.target" ];
+ wants = [ "keys.target" ];
+ after = [ "keys.target" "network.target" "local-fs.target" ];
+ serviceConfig = {
+ inherit ExecStart;
+ Restart = "on-failure";
+ User = cfg.user;
+ };
+ environment.PGPASSFILE = cfg.pgPassFile;
+ };
+ };
+}
+
diff --git a/modules/apps/sproxy.nix b/modules/apps/sproxy.nix
new file mode 100644
index 0000000..2c50554
--- /dev/null
+++ b/modules/apps/sproxy.nix
@@ -0,0 +1,144 @@
+{ config, pkgs, lib, ... }:
+
+let
+
+ inherit (builtins) toString;
+ inherit (lib)
+ filter filterAttrs hasPrefix mapAttrsToList
+ mkEnableOption concatStrings mkIf mkOption types ;
+ inherit (types)
+ enum int nullOr attrsOf path str submodule ;
+
+ explicit = filterAttrs (n: v: n != "_module" && v != null);
+
+ cfg = config.nixsap.apps.sproxy;
+
+ oauth2Options = concatStrings (mapAttrsToList (n: c:
+ if n == "google" then ''
+ client_id : ${c.client_id}
+ client_secret : ${c.client_secret_file}
+ '' else ''
+ ${n}_client_id : ${c.client_id}
+ ${n}_client_secret : ${c.client_secret_file}
+ ''
+ ) (explicit cfg.oauth2));
+
+ configFile = pkgs.writeText "sproxy.conf" ''
+ ${oauth2Options}
+ user : ${cfg.user}
+ cookie_domain : ${cfg.cookieDomain}
+ cookie_name : ${cfg.cookieName}
+ database : "${cfg.database}"
+ listen : 443
+ log_level : ${cfg.logLevel}
+ log_target : stderr
+ ssl_certs : ${cfg.sslCert}
+ ssl_key : ${cfg.sslKey}
+ session_shelf_life : ${toString cfg.sessionShelfLife}
+ ${if cfg.backendSocket != null then ''
+ backend_socket : ${cfg.backendSocket}
+ '' else ''
+ backend_address : ${cfg.backendAddress}
+ backend_port : ${toString cfg.backendPort}
+ ''}
+ '';
+
+ keys = filter (hasPrefix "/run/keys/")
+ ( [ cfg.sslKey ]
+ ++ mapAttrsToList (_: c: c.client_secret_file) (explicit cfg.oauth2)
+ );
+
+ oauth2 = mkOption {
+ type = attrsOf (submodule {
+ options = {
+ client_id = mkOption {
+ type = str;
+ description = "OAuth2 client id";
+ };
+ client_secret_file = mkOption {
+ type = path;
+ description = "File with OAuth2 client secret";
+ };
+ };
+ });
+ example = {
+ google.client_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com";
+ google.client_secret_file = "/run/keys/google_oauth2_secret";
+ };
+ };
+
+in {
+ options.nixsap.apps.sproxy = {
+ enable = mkEnableOption "SProxy";
+ inherit oauth2;
+ user = mkOption {
+ description = "User to run as";
+ default = "sproxy";
+ type = str;
+ };
+ cookieDomain = mkOption {
+ description = "Cookie domain";
+ type = str;
+ example = "example.com";
+ };
+ cookieName = mkOption {
+ description = "Cookie name";
+ type = str;
+ example = "sproxy";
+ };
+ logLevel = mkOption {
+ description = "Log level";
+ default = "info";
+ type = enum [ "info" "warn" "debug" ];
+ };
+ sslCert = mkOption {
+ description = "SSL certificate (in PEM format)";
+ type = path;
+ };
+ sslKey = mkOption {
+ description = "SSL key (in PEM format) - secret";
+ type = path;
+ };
+ backendAddress = mkOption {
+ description = "Backend TCP address";
+ type = str;
+ default = "127.0.0.1";
+ };
+ backendPort = mkOption {
+ description = "Backend TCP port";
+ type = int;
+ example = 8080;
+ };
+ backendSocket = mkOption {
+ description = "Backend UNIX socket. If set, other backend options are ignored";
+ type = nullOr path;
+ default = null;
+ };
+ database = mkOption {
+ description = "PostgreSQL connection string";
+ type = str;
+ example = "user=sproxy dbname=sproxy port=6001";
+ };
+ sessionShelfLife = mkOption {
+ description = "Session shelf life in seconds";
+ type = int;
+ default = 3600 * 24 * 14; # two weeks
+ };
+ };
+
+ config = mkIf cfg.enable {
+ nixsap.system.users.daemons = [ cfg.user ];
+ nixsap.deployment.keyrings.${cfg.user} = keys;
+ systemd.services.sproxy = {
+ description = "Sproxy secure proxy";
+ wantedBy = [ "multi-user.target" ];
+ wants = [ "keys.target" ];
+ after = [ "keys.target" "network.target" "local-fs.target" ];
+ serviceConfig = {
+ ExecStart = "${pkgs.sproxy}/bin/sproxy --config=${configFile}";
+ Restart = "on-failure";
+ };
+ };
+ };
+}
+
diff --git a/modules/apps/strongswan/default.nix b/modules/apps/strongswan/default.nix
new file mode 100644
index 0000000..d9a5034
--- /dev/null
+++ b/modules/apps/strongswan/default.nix
@@ -0,0 +1,101 @@
+{ config, pkgs, lib, ... }:
+
+let
+
+ inherit (lib) mkIf mkOption types filterAttrs hasPrefix
+ mapAttrsToList concatStringsSep concatMapStringsSep;
+ inherit (types) listOf submodule path attrsOf;
+ inherit (builtins) filter toString toFile isList isBool;
+
+ cfg = config.nixsap.apps.strongswan;
+ explicit = filterAttrs (n: v: n != "_module" && v != null);
+
+ ipsecSecrets = toFile "ipsec.secrets" ''
+ ${concatMapStringsSep "\n" (f: "include ${f}") cfg.secrets}
+ '';
+
+ ipsecConf =
+ let
+ show = k: v:
+ if k == "charondebug" then concatStringsSep ","
+ (mapAttrsToList (t: l: "${t} ${toString l}") (explicit v))
+ else if isList v then concatStringsSep "," v
+ else if isBool v then (if v then "yes" else "no")
+ else toString v;
+ makeSections = type: sections: concatStringsSep "\n\n" (
+ mapAttrsToList (sec: attrs:
+ "${type} ${sec}\n" +
+ (concatStringsSep "\n" (
+ mapAttrsToList (k: v: " ${k}=${show k v}") (explicit attrs)
+ ))
+ ) (explicit sections)
+ );
+ setupSec = makeSections "config" { inherit (cfg) setup; };
+ caSec = makeSections "ca" cfg.ca;
+ connSec = makeSections "conn" cfg.conn;
+ in toFile "ipsec.conf" ''
+ ${setupSec}
+ ${caSec}
+ ${connSec}
+ '';
+
+ strongswanConf = toFile "strongswan.conf" ''
+ charon { plugins { stroke { secrets_file = ${ipsecSecrets} } } }
+ starter { config_file = ${ipsecConf } }
+ '';
+
+in {
+ options.nixsap.apps.strongswan = {
+ secrets = mkOption {
+ description = ''
+ A list of paths to IPSec secret files. These files will be included into
+ the main ipsec.secrets file by the "include" directive
+ '';
+ type = listOf path;
+ default = [];
+ };
+ setup = mkOption {
+ description = ''
+ A set of options for the ‘config setup’ section of the
+ ipsec.conf file. Defines general configuration parameters
+ '';
+ type = submodule (import ./options/setup.nix);
+ default = {};
+ };
+ ca = mkOption {
+ description = ''
+ A set of CAs (certification authorities) and their options for
+ the ‘ca xxx’ sections of the ipsec.conf file
+ '';
+ type = attrsOf (submodule (import ./options/ca.nix));
+ default = {};
+ };
+ conn = mkOption {
+ description = ''
+ A set of connections and their options for the ‘conn xxx’
+ sections of the ipsec.conf file
+ '';
+ type = attrsOf (submodule (import ./options/conn.nix));
+ default = {};
+ };
+ };
+
+ config = mkIf ({} != explicit cfg.conn) {
+ nixsap.deployment.keyrings.root = filter (hasPrefix "/run/keys/") cfg.secrets;
+ environment.systemPackages = [ pkgs.strongswan ];
+ systemd.services.strongswan = {
+ description = "strongSwan IPSec Service";
+ wantedBy = [ "multi-user.target" ];
+ path = with pkgs; [ config.system.sbin.modprobe iproute iptables utillinux ];
+ wants = [ "keys.target" ];
+ after = [ "network.target" "keys.target" "local-fs.target" ];
+ environment = {
+ STRONGSWAN_CONF = strongswanConf;
+ };
+ serviceConfig = {
+ ExecStart = "${pkgs.strongswan}/sbin/ipsec start --nofork";
+ Restart = "always";
+ };
+ };
+ };
+}
diff --git a/modules/apps/strongswan/options/ca.nix b/modules/apps/strongswan/options/ca.nix
new file mode 100644
index 0000000..e52b088
--- /dev/null
+++ b/modules/apps/strongswan/options/ca.nix
@@ -0,0 +1,20 @@
+{ config, lib, ... }:
+
+let
+
+ inherit (lib) foldl;
+ inherit (lib.types) str path enum;
+ inherit (import ./lib.nix lib) optional;
+
+in {
+ options = foldl (a: b: a//b) {} [
+ { also = optional str; }
+ { auto = optional (enum [ "add" "ignore" ]); }
+ { cacert = optional path; }
+ { certuribase = optional str; }
+ { crluri = optional str; }
+ { crluri2 = optional str; }
+ { ocspuri = optional str; }
+ { ocspuri2 = optional str; }
+ ];
+}
diff --git a/modules/apps/strongswan/options/conn.nix b/modules/apps/strongswan/options/conn.nix
new file mode 100644
index 0000000..ac1d88c
--- /dev/null
+++ b/modules/apps/strongswan/options/conn.nix
@@ -0,0 +1,88 @@
+{ config, lib, ... }:
+
+let
+
+ inherit (lib) foldl attrNames head;
+ inherit (lib.types) int str path either listOf enum;
+ inherit (import ./lib.nix lib) boolean boolOr default optional;
+
+ leftright = map
+ (a: let n = head (attrNames a);
+ in {
+ "left${n}" = a."${n}";
+ "right${n}" = a."${n}";
+ })
+ [
+ { allowany = optional boolean; }
+ { auth = optional str; }
+ { auth2 = optional str; }
+ { ca = optional str; }
+ { ca2 = optional str; }
+ { cert = optional path; }
+ { cert2 = optional path; }
+ { dns = optional (either str (listOf str)); }
+ { firewall = optional boolean; }
+ { groups = optional (either str (listOf str)); }
+ { hostaccess = optional boolean; }
+ { id = optional str; }
+ { id2 = optional str; }
+ { policy = optional (either str (listOf str)); }
+ { sendcert = optional (boolOr [ "never" "always" "ifasked" ]); }
+ { sigkey = optional (either str path); }
+ { sourceip = optional str; }
+ { subnet = optional (either str (listOf str)); }
+ { updown = optional path; }
+ ];
+
+ conn = leftright ++ [
+ { aaa_identity = optional str; }
+ { aggressive = optional boolean; }
+ { ah = optional (either str (listOf str)); }
+ { also = optional str; }
+ { authby = optional (enum [ "pubkey" "rsasig" "ecdsasig" "psk" "secret" "xauthrsasig" "xauthpsk" "never" ]); }
+ { auto = optional (enum [ "ignore" "add" "route" "start" ]); }
+ { closeaction = optional (enum [ "none" "clear" "hold" "restart" ]); }
+ { compress = optional boolean; }
+ { dpdaction = optional (enum [ "none" "clear" "hold" "restart" ]); }
+ { dpddelay = optional int; }
+ { dpdtimeout = optional int; }
+ { eap_identity = optional str; }
+ { esp = optional (either str (listOf str)); }
+ { forceencaps = optional boolean; }
+ { fragmentation = optional (boolOr [ "force" ]); }
+ { ike = optional (either str (listOf str)); }
+ { ikedscp = optional str; }
+ { ikelifetime = optional int; }
+ { inactivity = optional int; }
+ { installpolicy = optional boolean; }
+ { keyexchange = optional (enum [ "ikev1" "ikev2" ]); }
+ { keyingtries = optional (either int (enum [ "%forever" ])); }
+ { left = optional str; }
+ { lifebytes = optional int; }
+ { lifepackets = optional int; }
+ { lifetime = optional int; }
+ { marginbytes = optional int; }
+ { marginpackets = optional int; }
+ { mark = optional str; }
+ { mark_in = optional str; }
+ { mark_out = optional str; }
+ { me_peerid = optional str; }
+ { mediated_by = optional str; }
+ { mediation = optional boolean; }
+ { mobike = optional boolean; }
+ { modeconfig = optional (enum [ "push" "pull" ]); }
+ { reauth = optional boolean; }
+ { rekey = optional boolean; }
+ { rekeyfuzz = optional int; }
+ { replay_window = optional int; }
+ { reqid = optional int; }
+ { right = optional str; }
+ { tfc = optional (either int (enum [ "%mtu" ])); }
+ { type = optional (enum [ "tunnel" "transport" "transport_proxy" "passthrough" "drop" ]); }
+ { xauth = optional (enum [ "client" "server" ]); }
+ { xauth_identity = optional str; }
+ ];
+
+in {
+ options = foldl (a: b: a//b) {} conn;
+}
diff --git a/modules/apps/strongswan/options/lib.nix b/modules/apps/strongswan/options/lib.nix
new file mode 100644
index 0000000..5b0808f
--- /dev/null
+++ b/modules/apps/strongswan/options/lib.nix
@@ -0,0 +1,26 @@
+lib:
+
+let
+ inherit (lib) mkOption mkOptionType mergeOneOption elem flip concatStringsSep;
+ inherit (lib.types) nullOr submodule bool either;
+
+in rec {
+ default = v: type: mkOption { type = type; default = v; };
+ optional = type: mkOption { type = nullOr type; default = null; };
+ set = opts: mkOption { type = nullOr (submodule { options = opts; }); default = null; };
+
+ # XXX https://github.com/NixOS/nixpkgs/issues/9826
+ enum' = values:
+ let show = v: let t = builtins.typeOf v;
+ in if t == "string" then ''"${v}"''
+ else if t == "int" then builtins.toString v
+ else ''<${t}>'';
+ in mkOptionType {
+ name = "one of ${concatStringsSep ", " (map show values)}";
+ check = flip elem values;
+ merge = mergeOneOption;
+ };
+
+ boolean = either bool (enum' [ "yes" "no" ]);
+ boolOr = l: either bool (enum' ([ "yes" "no" ] ++ l));
+}
diff --git a/modules/apps/strongswan/options/setup.nix b/modules/apps/strongswan/options/setup.nix
new file mode 100644
index 0000000..d60a2af
--- /dev/null
+++ b/modules/apps/strongswan/options/setup.nix
@@ -0,0 +1,24 @@
+{ config, lib, ... }:
+
+let
+
+ inherit (lib) foldl genAttrs;
+ inherit (import ./lib.nix lib) boolean boolOr default optional set enum';
+
+ charondebug = genAttrs [
+ "asn" "cfg" "chd" "dmn"
+ "enc" "esp" "ike" "imc"
+ "imv" "job" "knl" "lib"
+ "mgr" "net" "pts" "tls"
+ "tnc"
+ ] (_: optional (enum' [ (-1) 0 1 2 3 4 ]));
+
+in {
+ options = foldl (a: b: a//b) {} [
+ { cachecrls = optional boolean; }
+ { charondebug = set charondebug; }
+ { charonstart = optional boolean; }
+ { strictcrlpolicy = optional (boolOr [ "ifuri" ]); }
+ { uniqueids = optional (boolOr [ "never" "replace" "keep" ]); }
+ ];
+}