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

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

View File

@@ -0,0 +1,168 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
concatMapStringsSep
concatStringsSep
isInt
isList
literalExpression
;
inherit (lib)
mapAttrs
mapAttrsToList
mkDefault
mkEnableOption
mkIf
mkOption
mkRenamedOptionModule
optional
types
;
cfg = config.services.automysqlbackup;
pkg = pkgs.automysqlbackup;
user = "automysqlbackup";
group = "automysqlbackup";
toStr =
val:
if isList val then
"( ${concatMapStringsSep " " (val: "'${val}'") val} )"
else if isInt val then
toString val
else if true == val then
"'yes'"
else if false == val then
"'no'"
else
"'${toString val}'";
configFile = pkgs.writeText "automysqlbackup.conf" ''
#version=${pkg.version}
# DONT'T REMOVE THE PREVIOUS VERSION LINE!
#
${concatStringsSep "\n" (mapAttrsToList (name: value: "CONFIG_${name}=${toStr value}") cfg.config)}
'';
in
{
imports = [
(mkRenamedOptionModule
[ "services" "automysqlbackup" "config" ]
[ "services" "automysqlbackup" "settings" ]
)
];
# interface
options = {
services.automysqlbackup = {
enable = mkEnableOption "AutoMySQLBackup";
calendar = mkOption {
type = types.str;
default = "01:15:00";
description = ''
Configured when to run the backup service systemd unit (DayOfWeek Year-Month-Day Hour:Minute:Second).
'';
};
settings = mkOption {
type =
with types;
attrsOf (oneOf [
str
int
bool
(listOf str)
]);
default = { };
description = ''
automysqlbackup configuration. Refer to
{file}`''${pkgs.automysqlbackup}/etc/automysqlbackup.conf`
for details on supported values.
'';
example = literalExpression ''
{
db_names = [ "nextcloud" "matomo" ];
table_exclude = [ "nextcloud.oc_users" "nextcloud.oc_whats_new" ];
mailcontent = "log";
mail_address = "admin@example.org";
}
'';
};
};
};
# implementation
config = mkIf cfg.enable {
assertions = [
{
assertion = !config.services.mysqlBackup.enable;
message = "Please choose one of services.mysqlBackup or services.automysqlbackup.";
}
];
services.automysqlbackup.config = mapAttrs (name: mkDefault) {
mysql_dump_username = user;
mysql_dump_host = "localhost";
mysql_dump_socket = "/run/mysqld/mysqld.sock";
backup_dir = "/var/backup/mysql";
db_exclude = [
"information_schema"
"performance_schema"
];
mailcontent = "stdout";
mysql_dump_single_transaction = true;
};
systemd.timers.automysqlbackup = {
description = "automysqlbackup timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.calendar;
AccuracySec = "5m";
};
};
systemd.services.automysqlbackup = {
description = "automysqlbackup service";
serviceConfig = {
User = user;
Group = group;
ExecStart = "${pkg}/bin/automysqlbackup ${configFile}";
};
};
environment.systemPackages = [ pkg ];
users.users.${user} = {
group = group;
isSystemUser = true;
};
users.groups.${group} = { };
systemd.tmpfiles.rules = [
"d '${cfg.config.backup_dir}' 0750 ${user} ${group} - -"
];
services.mysql.ensureUsers =
optional (config.services.mysql.enable && cfg.config.mysql_dump_host == "localhost")
{
name = user;
ensurePermissions = {
"*.*" = "SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT";
};
};
};
}

View File

@@ -0,0 +1,764 @@
{
config,
lib,
pkgs,
...
}:
# TODO: test configuration when building nixexpr (use -t parameter)
# TODO: support sqlite3 (it's deprecate?) and mysql
let
inherit (lib)
concatStringsSep
literalExpression
mapAttrsToList
mkIf
mkOption
optional
optionalString
types
;
libDir = "/var/lib/bacula";
yes_no = bool: if bool then "yes" else "no";
tls_conf =
tls_cfg:
optionalString tls_cfg.enable (
concatStringsSep "\n" (
[ "TLS Enable = yes;" ]
++ optional (tls_cfg.require != null) "TLS Require = ${yes_no tls_cfg.require};"
++ optional (tls_cfg.certificate != null) ''TLS Certificate = "${tls_cfg.certificate}";''
++ [ ''TLS Key = "${tls_cfg.key}";'' ]
++ optional (tls_cfg.verifyPeer != null) "TLS Verify Peer = ${yes_no tls_cfg.verifyPeer};"
++ optional (
tls_cfg.allowedCN != [ ]
) "TLS Allowed CN = ${concatStringsSep " " (tls_cfg.allowedCN)};"
++ optional (
tls_cfg.caCertificateFile != null
) ''TLS CA Certificate File = "${tls_cfg.caCertificateFile}";''
)
);
fd_cfg = config.services.bacula-fd;
fd_conf = pkgs.writeText "bacula-fd.conf" ''
Client {
Name = "${fd_cfg.name}";
FDPort = ${toString fd_cfg.port};
WorkingDirectory = ${libDir};
Pid Directory = /run;
${fd_cfg.extraClientConfig}
${tls_conf fd_cfg.tls}
}
${concatStringsSep "\n" (
mapAttrsToList (name: value: ''
Director {
Name = "${name}";
Password = ${value.password};
Monitor = ${value.monitor};
${tls_conf value.tls}
}
'') fd_cfg.director
)}
Messages {
Name = Standard;
syslog = all, !skipped, !restored
${fd_cfg.extraMessagesConfig}
}
'';
sd_cfg = config.services.bacula-sd;
sd_conf = pkgs.writeText "bacula-sd.conf" ''
Storage {
Name = "${sd_cfg.name}";
SDPort = ${toString sd_cfg.port};
WorkingDirectory = ${libDir};
Pid Directory = /run;
${sd_cfg.extraStorageConfig}
${tls_conf sd_cfg.tls}
}
${concatStringsSep "\n" (
mapAttrsToList (name: value: ''
Autochanger {
Name = "${name}";
Device = ${concatStringsSep ", " (map (a: "\"${a}\"") value.devices)};
Changer Device = ${value.changerDevice};
Changer Command = ${value.changerCommand};
${value.extraAutochangerConfig}
}
'') sd_cfg.autochanger
)}
${concatStringsSep "\n" (
mapAttrsToList (name: value: ''
Device {
Name = "${name}";
Archive Device = ${value.archiveDevice};
Media Type = ${value.mediaType};
${value.extraDeviceConfig}
}
'') sd_cfg.device
)}
${concatStringsSep "\n" (
mapAttrsToList (name: value: ''
Director {
Name = "${name}";
Password = ${value.password};
Monitor = ${value.monitor};
${tls_conf value.tls}
}
'') sd_cfg.director
)}
Messages {
Name = Standard;
syslog = all, !skipped, !restored
${sd_cfg.extraMessagesConfig}
}
'';
dir_cfg = config.services.bacula-dir;
dir_conf = pkgs.writeText "bacula-dir.conf" ''
Director {
Name = "${dir_cfg.name}";
Password = ${dir_cfg.password};
DirPort = ${toString dir_cfg.port};
Working Directory = ${libDir};
Pid Directory = /run/;
QueryFile = ${pkgs.bacula}/etc/query.sql;
${tls_conf dir_cfg.tls}
${dir_cfg.extraDirectorConfig}
}
Catalog {
Name = PostgreSQL;
dbname = bacula;
user = bacula;
}
Messages {
Name = Standard;
syslog = all, !skipped, !restored
${dir_cfg.extraMessagesConfig}
}
${dir_cfg.extraConfig}
'';
linkOption =
name: destination: "[${name}](#opt-${builtins.replaceStrings [ "<" ">" ] [ "_" "_" ] destination})";
tlsLink =
destination: submodulePath:
linkOption "${submodulePath}.${destination}" "${submodulePath}.${destination}";
tlsOptions =
submodulePath:
{ ... }:
{
options = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Specifies if TLS should be enabled.
If this set to `false` TLS will be completely disabled, even if ${tlsLink "tls.require" submodulePath} is true.
'';
};
require = mkOption {
type = types.nullOr types.bool;
default = null;
description = ''
Require TLS or TLS-PSK encryption.
This directive is ignored unless one of ${tlsLink "tls.enable" submodulePath} is true or TLS PSK Enable is set to `yes`.
If TLS is not required while TLS or TLS-PSK are enabled, then the Bacula component
will connect with other components either with or without TLS or TLS-PSK
If ${tlsLink "tls.enable" submodulePath} or TLS-PSK is enabled and TLS is required, then the Bacula
component will refuse any connection request that does not use TLS.
'';
};
certificate = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
The full path to the PEM encoded TLS certificate.
It will be used as either a client or server certificate,
depending on the connection direction.
This directive is required in a server context, but it may
not be specified in a client context if ${tlsLink "tls.verifyPeer" submodulePath} is
`false` in the corresponding server context.
'';
};
key = mkOption {
type = types.path;
description = ''
The path of a PEM encoded TLS private key.
It must correspond to the TLS certificate.
'';
};
verifyPeer = mkOption {
type = types.nullOr types.bool;
default = null;
description = ''
Verify peer certificate.
Instructs server to request and verify the client's X.509 certificate.
Any client certificate signed by a known-CA will be accepted.
Additionally, the client's X509 certificate Common Name must meet the value of the Address directive.
If ${tlsLink "tls.allowedCN" submodulePath} is used,
the client's x509 certificate Common Name must also correspond to
one of the CN specified in the ${tlsLink "tls.allowedCN" submodulePath} directive.
This directive is valid only for a server and not in client context.
Standard from Bacula is `true`.
'';
};
allowedCN = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
Common name attribute of allowed peer certificates.
This directive is valid for a server and in a client context.
If this directive is specified, the peer certificate will be verified against this list.
In the case this directive is configured on a server side, the allowed
CN list will not be checked if ${tlsLink "tls.verifyPeer" submodulePath} is false.
'';
};
caCertificateFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
The path specifying a PEM encoded TLS CA certificate(s).
Multiple certificates are permitted in the file.
One of TLS CA Certificate File or TLS CA Certificate Dir are required in a server context, unless
${tlsLink "tls.verifyPeer" submodulePath} is false, and are always required in a client context.
'';
};
};
};
directorOptions =
submodulePath:
{ ... }:
{
options = {
password = mkOption {
type = types.str;
# TODO: required?
description = ''
Specifies the password that must be supplied for the default Bacula
Console to be authorized. The same password must appear in the
Director resource of the Console configuration file. For added
security, the password is never passed across the network but instead
a challenge response hash code created with the password. This
directive is required. If you have either /dev/random or bc on your
machine, Bacula will generate a random password during the
configuration process, otherwise it will be left blank and you must
manually supply it.
The password is plain text. It is not generated through any special
process but as noted above, it is better to use random text for
security reasons.
'';
};
monitor = mkOption {
type = types.enum [
"no"
"yes"
];
default = "no";
example = "yes";
description = ''
If Monitor is set to `no`, this director will have
full access to this Storage daemon. If Monitor is set to
`yes`, this director will only be able to fetch the
current status of this Storage daemon.
Please note that if this director is being used by a Monitor, we
highly recommend to set this directive to yes to avoid serious
security problems.
'';
};
tls = mkOption {
type = types.submodule (tlsOptions "${submodulePath}.director.<name>");
description = ''
TLS Options for the Director in this Configuration.
'';
};
};
};
autochangerOptions =
{ ... }:
{
options = {
changerDevice = mkOption {
type = types.str;
description = ''
The specified name-string must be the generic SCSI device name of the
autochanger that corresponds to the normal read/write Archive Device
specified in the Device resource. This generic SCSI device name
should be specified if you have an autochanger or if you have a
standard tape drive and want to use the Alert Command (see below).
For example, on Linux systems, for an Archive Device name of
`/dev/nst0`, you would specify
`/dev/sg0` for the Changer Device name. Depending
on your exact configuration, and the number of autochangers or the
type of autochanger, what you specify here can vary. This directive
is optional. See the Using AutochangersAutochangersChapter chapter of
this manual for more details of using this and the following
autochanger directives.
'';
};
changerCommand = mkOption {
type = types.str;
description = ''
The name-string specifies an external program to be called that will
automatically change volumes as required by Bacula. Normally, this
directive will be specified only in the AutoChanger resource, which
is then used for all devices. However, you may also specify the
different Changer Command in each Device resource. Most frequently,
you will specify the Bacula supplied mtx-changer script as follows:
`"/path/mtx-changer %c %o %S %a %d"`
and you will install the mtx on your system (found in the depkgs
release). An example of this command is in the default bacula-sd.conf
file. For more details on the substitution characters that may be
specified to configure your autochanger please see the
AutochangersAutochangersChapter chapter of this manual. For FreeBSD
users, you might want to see one of the several chio scripts in
examples/autochangers.
'';
default = "/etc/bacula/mtx-changer %c %o %S %a %d";
};
devices = mkOption {
description = "";
type = types.listOf types.str;
};
extraAutochangerConfig = mkOption {
default = "";
type = types.lines;
description = ''
Extra configuration to be passed in Autochanger directive.
'';
example = ''
'';
};
};
};
deviceOptions =
{ ... }:
{
options = {
archiveDevice = mkOption {
# TODO: required?
type = types.str;
description = ''
The specified name-string gives the system file name of the storage
device managed by this storage daemon. This will usually be the
device file name of a removable storage device (tape drive), for
example `/dev/nst0` or
`/dev/rmt/0mbn`. For a DVD-writer, it will be for
example `/dev/hdc`. It may also be a directory name
if you are archiving to disk storage. In this case, you must supply
the full absolute path to the directory. When specifying a tape
device, it is preferable that the "non-rewind" variant of the device
file name be given.
'';
};
mediaType = mkOption {
# TODO: required?
type = types.str;
description = ''
The specified name-string names the type of media supported by this
device, for example, `DLT7000`. Media type names are
arbitrary in that you set them to anything you want, but they must be
known to the volume database to keep track of which storage daemons
can read which volumes. In general, each different storage type
should have a unique Media Type associated with it. The same
name-string must appear in the appropriate Storage resource
definition in the Director's configuration file.
Even though the names you assign are arbitrary (i.e. you choose the
name you want), you should take care in specifying them because the
Media Type is used to determine which storage device Bacula will
select during restore. Thus you should probably use the same Media
Type specification for all drives where the Media can be freely
interchanged. This is not generally an issue if you have a single
Storage daemon, but it is with multiple Storage daemons, especially
if they have incompatible media.
For example, if you specify a Media Type of `DDS-4`
then during the restore, Bacula will be able to choose any Storage
Daemon that handles `DDS-4`. If you have an
autochanger, you might want to name the Media Type in a way that is
unique to the autochanger, unless you wish to possibly use the
Volumes in other drives. You should also ensure to have unique Media
Type names if the Media is not compatible between drives. This
specification is required for all devices.
In addition, if you are using disk storage, each Device resource will
generally have a different mount point or directory. In order for
Bacula to select the correct Device resource, each one must have a
unique Media Type.
'';
};
extraDeviceConfig = mkOption {
default = "";
type = types.lines;
description = ''
Extra configuration to be passed in Device directive.
'';
example = ''
LabelMedia = yes
Random Access = no
AutomaticMount = no
RemovableMedia = no
MaximumOpenWait = 60
AlwaysOpen = no
'';
};
};
};
in
{
options = {
services.bacula-fd = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Whether to enable the Bacula File Daemon.
'';
};
name = mkOption {
default = "${config.networking.hostName}-fd";
defaultText = literalExpression ''"''${config.networking.hostName}-fd"'';
type = types.str;
description = ''
The client name that must be used by the Director when connecting.
Generally, it is a good idea to use a name related to the machine so
that error messages can be easily identified if you have multiple
Clients. This directive is required.
'';
};
port = mkOption {
default = 9102;
type = types.port;
description = ''
This specifies the port number on which the Client listens for
Director connections. It must agree with the FDPort specified in
the Client resource of the Director's configuration file.
'';
};
director = mkOption {
default = { };
description = ''
This option defines director resources in Bacula File Daemon.
'';
type = types.attrsOf (types.submodule (directorOptions "services.bacula-fd"));
};
tls = mkOption {
type = types.submodule (tlsOptions "services.bacula-fd");
default = { };
description = ''
TLS Options for the File Daemon.
Important notice: The backup won't be encrypted.
'';
};
extraClientConfig = mkOption {
default = "";
type = types.lines;
description = ''
Extra configuration to be passed in Client directive.
'';
example = ''
Maximum Concurrent Jobs = 20;
Heartbeat Interval = 30;
'';
};
extraMessagesConfig = mkOption {
default = "";
type = types.lines;
description = ''
Extra configuration to be passed in Messages directive.
'';
example = ''
console = all
'';
};
};
services.bacula-sd = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Whether to enable Bacula Storage Daemon.
'';
};
name = mkOption {
default = "${config.networking.hostName}-sd";
defaultText = literalExpression ''"''${config.networking.hostName}-sd"'';
type = types.str;
description = ''
Specifies the Name of the Storage daemon.
'';
};
port = mkOption {
default = 9103;
type = types.port;
description = ''
Specifies port number on which the Storage daemon listens for
Director connections.
'';
};
director = mkOption {
default = { };
description = ''
This option defines Director resources in Bacula Storage Daemon.
'';
type = types.attrsOf (types.submodule (directorOptions "services.bacula-sd"));
};
device = mkOption {
default = { };
description = ''
This option defines Device resources in Bacula Storage Daemon.
'';
type = types.attrsOf (types.submodule deviceOptions);
};
autochanger = mkOption {
default = { };
description = ''
This option defines Autochanger resources in Bacula Storage Daemon.
'';
type = types.attrsOf (types.submodule autochangerOptions);
};
extraStorageConfig = mkOption {
default = "";
type = types.lines;
description = ''
Extra configuration to be passed in Storage directive.
'';
example = ''
Maximum Concurrent Jobs = 20;
Heartbeat Interval = 30;
'';
};
extraMessagesConfig = mkOption {
default = "";
type = types.lines;
description = ''
Extra configuration to be passed in Messages directive.
'';
example = ''
console = all
'';
};
tls = mkOption {
type = types.submodule (tlsOptions "services.bacula-sd");
default = { };
description = ''
TLS Options for the Storage Daemon.
Important notice: The backup won't be encrypted.
'';
};
};
services.bacula-dir = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Whether to enable Bacula Director Daemon.
'';
};
name = mkOption {
default = "${config.networking.hostName}-dir";
defaultText = literalExpression ''"''${config.networking.hostName}-dir"'';
type = types.str;
description = ''
The director name used by the system administrator. This directive is
required.
'';
};
port = mkOption {
default = 9101;
type = types.port;
description = ''
Specify the port (a positive integer) on which the Director daemon
will listen for Bacula Console connections. This same port number
must be specified in the Director resource of the Console
configuration file. The default is 9101, so normally this directive
need not be specified. This directive should not be used if you
specify DirAddresses (N.B plural) directive.
'';
};
password = mkOption {
# TODO: required?
type = types.str;
description = ''
Specifies the password that must be supplied for a Director.
'';
};
extraMessagesConfig = mkOption {
default = "";
type = types.lines;
description = ''
Extra configuration to be passed in Messages directive.
'';
example = ''
console = all
'';
};
extraDirectorConfig = mkOption {
default = "";
type = types.lines;
description = ''
Extra configuration to be passed in Director directive.
'';
example = ''
Maximum Concurrent Jobs = 20;
Heartbeat Interval = 30;
'';
};
extraConfig = mkOption {
default = "";
type = types.lines;
description = ''
Extra configuration for Bacula Director Daemon.
'';
example = ''
TODO
'';
};
tls = mkOption {
type = types.submodule (tlsOptions "services.bacula-dir");
default = { };
description = ''
TLS Options for the Director.
Important notice: The backup won't be encrypted.
'';
};
};
};
config = mkIf (fd_cfg.enable || sd_cfg.enable || dir_cfg.enable) {
systemd.slices.system-bacula = {
description = "Bacula Backup System Slice";
documentation = [
"man:bacula(8)"
"https://www.bacula.org/"
];
};
systemd.services.bacula-fd = mkIf fd_cfg.enable {
after = [ "network.target" ];
description = "Bacula File Daemon";
wantedBy = [ "multi-user.target" ];
path = [ pkgs.bacula ];
serviceConfig = {
ExecStart = "${pkgs.bacula}/sbin/bacula-fd -f -u root -g bacula -c ${fd_conf}";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
LogsDirectory = "bacula";
StateDirectory = "bacula";
Slice = "system-bacula.slice";
};
};
systemd.services.bacula-sd = mkIf sd_cfg.enable {
after = [ "network.target" ];
description = "Bacula Storage Daemon";
wantedBy = [ "multi-user.target" ];
path = [ pkgs.bacula ];
serviceConfig = {
ExecStart = "${pkgs.bacula}/sbin/bacula-sd -f -u bacula -g bacula -c ${sd_conf}";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
LogsDirectory = "bacula";
StateDirectory = "bacula";
Slice = "system-bacula.slice";
};
};
services.postgresql.enable = lib.mkIf dir_cfg.enable true;
systemd.services.bacula-dir = mkIf dir_cfg.enable {
after = [
"network.target"
"postgresql.target"
];
description = "Bacula Director Daemon";
wantedBy = [ "multi-user.target" ];
path = [ pkgs.bacula ];
serviceConfig = {
ExecStart = "${pkgs.bacula}/sbin/bacula-dir -f -u bacula -g bacula -c ${dir_conf}";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
LogsDirectory = "bacula";
StateDirectory = "bacula";
Slice = "system-bacula.slice";
};
preStart = ''
if ! test -e "${libDir}/db-created"; then
${pkgs.postgresql}/bin/createuser --no-superuser --no-createdb --no-createrole bacula
#${pkgs.postgresql}/bin/createdb --owner bacula bacula
# populate DB
${pkgs.bacula}/etc/create_bacula_database postgresql
${pkgs.bacula}/etc/make_bacula_tables postgresql
${pkgs.bacula}/etc/grant_bacula_privileges postgresql
touch "${libDir}/db-created"
else
${pkgs.bacula}/etc/update_bacula_tables postgresql || true
fi
'';
};
environment.systemPackages = [ pkgs.bacula ];
users.users.bacula = {
group = "bacula";
uid = config.ids.uids.bacula;
home = "${libDir}";
createHome = true;
description = "Bacula Daemons user";
shell = "${pkgs.bash}/bin/bash";
};
users.groups.bacula.gid = config.ids.gids.bacula;
};
}

View File

@@ -0,0 +1,167 @@
# BorgBackup {#module-borgbase}
*Source:* {file}`modules/services/backup/borgbackup.nix`
*Upstream documentation:* <https://borgbackup.readthedocs.io/>
[BorgBackup](https://www.borgbackup.org/) (short: Borg)
is a deduplicating backup program. Optionally, it supports compression and
authenticated encryption.
The main goal of Borg is to provide an efficient and secure way to backup
data. The data deduplication technique used makes Borg suitable for daily
backups since only changes are stored. The authenticated encryption technique
makes it suitable for backups to not fully trusted targets.
## Configuring {#module-services-backup-borgbackup-configuring}
A complete list of options for the Borgbase module may be found
[here](#opt-services.borgbackup.jobs).
## Basic usage for a local backup {#opt-services-backup-borgbackup-local-directory}
A very basic configuration for backing up to a locally accessible directory is:
```nix
{
services.borgbackup.jobs = {
rootBackup = {
paths = "/";
exclude = [
"/nix"
"/path/to/local/repo"
];
repo = "/path/to/local/repo";
doInit = true;
encryption = {
mode = "repokey";
passphrase = "secret";
};
compression = "auto,lzma";
startAt = "weekly";
};
};
}
```
::: {.warning}
If you do not want the passphrase to be stored in the world-readable
Nix store, use passCommand. You find an example below.
:::
## Create a borg backup server {#opt-services-backup-create-server}
You should use a different SSH key for each repository you write to,
because the specified keys are restricted to running borg serve and can only
access this single repository. You need the output of the generate pub file.
```ShellSession
# sudo ssh-keygen -N '' -t ed25519 -f /run/keys/id_ed25519_my_borg_repo
# cat /run/keys/id_ed25519_my_borg_repo
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID78zmOyA+5uPG4Ot0hfAy+sLDPU1L4AiIoRYEIVbbQ/ root@nixos
```
Add the following snippet to your NixOS configuration:
```nix
{
services.borgbackup.repos = {
my_borg_repo = {
authorizedKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID78zmOyA+5uPG4Ot0hfAy+sLDPU1L4AiIoRYEIVbbQ/ root@nixos"
];
path = "/var/lib/my_borg_repo";
};
};
}
```
## Backup to the borg repository server {#opt-services-backup-borgbackup-remote-server}
The following NixOS snippet creates an hourly backup to the service
(on the host nixos) as created in the section above. We assume
that you have stored a secret passphrasse in the file
{file}`/run/keys/borgbackup_passphrase`, which should be only
accessible by root
```nix
{
services.borgbackup.jobs = {
backupToLocalServer = {
paths = [ "/etc/nixos" ];
doInit = true;
repo = "borg@nixos:.";
encryption = {
mode = "repokey-blake2";
passCommand = "cat /run/keys/borgbackup_passphrase";
};
environment = {
BORG_RSH = "ssh -i /run/keys/id_ed25519_my_borg_repo";
};
compression = "auto,lzma";
startAt = "hourly";
};
};
}
```
The following few commands (run as root) let you test your backup.
```
> nixos-rebuild switch
...restarting the following units: polkit.service
> systemctl restart borgbackup-job-backupToLocalServer
> sleep 10
> systemctl restart borgbackup-job-backupToLocalServer
> export BORG_PASSPHRASE=topSecret
> borg list --rsh='ssh -i /run/keys/id_ed25519_my_borg_repo' borg@nixos:.
nixos-backupToLocalServer-2020-03-30T21:46:17 Mon, 2020-03-30 21:46:19 [84feb97710954931ca384182f5f3cb90665f35cef214760abd7350fb064786ac]
nixos-backupToLocalServer-2020-03-30T21:46:30 Mon, 2020-03-30 21:46:32 [e77321694ecd160ca2228611747c6ad1be177d6e0d894538898de7a2621b6e68]
```
## Backup to a hosting service {#opt-services-backup-borgbackup-borgbase}
Several companies offer [(paid) hosting services](https://www.borgbackup.org/support/commercial.html)
for Borg repositories.
To backup your home directory to borgbase you have to:
- Generate a SSH key without a password, to access the remote server. E.g.
sudo ssh-keygen -N '' -t ed25519 -f /run/keys/id_ed25519_borgbase
- Create the repository on the server by following the instructions for your
hosting server.
- Initialize the repository on the server. Eg.
sudo borg init --encryption=repokey-blake2 \
--rsh "ssh -i /run/keys/id_ed25519_borgbase" \
zzz2aaaaa@zzz2aaaaa.repo.borgbase.com:repo
- Add it to your NixOS configuration, e.g.
{
services.borgbackup.jobs = {
my_Remote_Backup = {
paths = [ "/" ];
exclude = [ "/nix" "'**/.cache'" ];
repo = "zzz2aaaaa@zzz2aaaaa.repo.borgbase.com:repo";
encryption = {
mode = "repokey-blake2";
passCommand = "cat /run/keys/borgbackup_passphrase";
};
environment = { BORG_RSH = "ssh -i /run/keys/id_ed25519_borgbase"; };
compression = "auto,lzma";
startAt = "daily";
};
};
}}
## Vorta backup client for the desktop {#opt-services-backup-borgbackup-vorta}
Vorta is a backup client for macOS and Linux desktops. It integrates the
mighty BorgBackup with your desktop environment to protect your data from
disk failure, ransomware and theft.
It can be installed in NixOS e.g. by adding `pkgs.vorta`
to [](#opt-environment.systemPackages).
Details about using Vorta can be found under
[https://vorta.borgbase.com](https://vorta.borgbase.com/usage) .

View File

@@ -0,0 +1,913 @@
{
config,
lib,
pkgs,
...
}:
let
isLocalPath =
x:
builtins.substring 0 1 x == "/" # absolute path
|| builtins.substring 0 1 x == "." # relative path
|| builtins.match "[.*:.*]" == null; # not machine:path
mkExcludeFile =
cfg:
# Write each exclude pattern to a new line
pkgs.writeText "excludefile" (lib.concatMapStrings (s: s + "\n") cfg.exclude);
mkPatternsFile =
cfg:
# Write each pattern to a new line
pkgs.writeText "patternsfile" (lib.concatMapStrings (s: s + "\n") cfg.patterns);
mkKeepArgs =
cfg:
# If cfg.prune.keep e.g. has a yearly attribute,
# its content is passed on as --keep-yearly
lib.concatStringsSep " " (lib.mapAttrsToList (x: y: "--keep-${x}=${toString y}") cfg.prune.keep);
mkExtraArgs =
cfg:
# Create BASH arrays of extra args
lib.concatLines (
lib.mapAttrsToList
(name: values: ''
${name}=(${values})
'')
{
inherit (cfg)
extraArgs
extraInitArgs
extraCreateArgs
extraPruneArgs
extraCompactArgs
;
}
);
mkBackupScript =
name: cfg:
pkgs.writeShellScript "${name}-script" (
''
set -e
${mkExtraArgs cfg}
on_exit()
{
exitStatus=$?
${cfg.postHook}
exit $exitStatus
}
trap on_exit EXIT
borgWrapper () {
local result
borg "$@" && result=$? || result=$?
if [[ -z "${toString cfg.failOnWarnings}" ]] && [[ "$result" == 1 || ("$result" -ge 100 && "$result" -le 127) ]]; then
echo "ignoring warning return value $result"
return 0
else
return "$result"
fi
}
archiveName="${
lib.optionalString (cfg.archiveBaseName != null) (cfg.archiveBaseName + "-")
}$(date ${cfg.dateFormat})"
archiveSuffix="${lib.optionalString cfg.appendFailedSuffix ".failed"}"
${cfg.preHook}
''
+ lib.optionalString cfg.doInit ''
# Run borg init if the repo doesn't exist yet
if ! borgWrapper list "''${extraArgs[@]}" > /dev/null; then
borgWrapper init "''${extraArgs[@]}" \
--encryption ${cfg.encryption.mode} \
"''${extraInitArgs[@]}"
${cfg.postInit}
fi
''
+ ''
(
set -o pipefail
${lib.optionalString (cfg.dumpCommand != null) ''${lib.escapeShellArg cfg.dumpCommand} | \''}
borgWrapper create "''${extraArgs[@]}" \
--compression ${cfg.compression} \
--exclude-from ${mkExcludeFile cfg} \
--patterns-from ${mkPatternsFile cfg} \
"''${extraCreateArgs[@]}" \
"::$archiveName$archiveSuffix" \
${if cfg.paths == null then "-" else lib.escapeShellArgs cfg.paths}
)
''
+ lib.optionalString cfg.appendFailedSuffix ''
borgWrapper rename "''${extraArgs[@]}" \
"::$archiveName$archiveSuffix" "$archiveName"
''
+ ''
${cfg.postCreate}
''
+ lib.optionalString (cfg.prune.keep != { }) ''
borgWrapper prune "''${extraArgs[@]}" \
${mkKeepArgs cfg} \
${
lib.optionalString (
cfg.prune.prefix != null
) "--glob-archives ${lib.escapeShellArg "${cfg.prune.prefix}*"}"
} \
"''${extraPruneArgs[@]}"
borgWrapper compact "''${extraArgs[@]}" "''${extraCompactArgs[@]}"
${cfg.postPrune}
''
);
mkPassEnv =
cfg:
with cfg.encryption;
if passCommand != null then
{ BORG_PASSCOMMAND = passCommand; }
else if passphrase != null then
{ BORG_PASSPHRASE = passphrase; }
else
{ };
mkBackupService =
name: cfg:
let
userHome = config.users.users.${cfg.user}.home;
backupJobName = "borgbackup-job-${name}";
backupScript = mkBackupScript backupJobName cfg;
in
lib.nameValuePair backupJobName {
description = "BorgBackup job ${name}";
path = [
config.services.borgbackup.package
pkgs.openssh
];
script =
"exec "
+ lib.optionalString cfg.inhibitsSleep ''
${pkgs.systemd}/bin/systemd-inhibit \
--who="borgbackup" \
--what="sleep" \
--why="Scheduled backup" \
''
+ backupScript;
unitConfig = lib.optionalAttrs (isLocalPath cfg.repo) {
RequiresMountsFor = [ cfg.repo ];
};
serviceConfig = {
User = cfg.user;
Group = cfg.group;
# Only run when no other process is using CPU or disk
CPUSchedulingPolicy = "idle";
IOSchedulingClass = "idle";
ProtectSystem = "strict";
ReadWritePaths = [
"${userHome}/.config/borg"
"${userHome}/.cache/borg"
]
++ cfg.readWritePaths
# Borg needs write access to repo if it is not remote
++ lib.optional (isLocalPath cfg.repo) cfg.repo;
PrivateTmp = cfg.privateTmp;
};
environment = {
BORG_REPO = cfg.repo;
}
// (mkPassEnv cfg)
// cfg.environment;
};
mkBackupTimers =
name: cfg:
lib.nameValuePair "borgbackup-job-${name}" {
description = "BorgBackup job ${name} timer";
wantedBy = [ "timers.target" ];
timerConfig = {
Persistent = cfg.persistentTimer;
OnCalendar = cfg.startAt;
};
# if remote-backup wait for network
after = lib.optional (cfg.persistentTimer && !isLocalPath cfg.repo) "network-online.target";
wants = lib.optional (cfg.persistentTimer && !isLocalPath cfg.repo) "network-online.target";
};
# utility function around makeWrapper
mkWrapperDrv =
{
original,
name,
set ? { },
}:
pkgs.runCommand "${name}-wrapper"
{
nativeBuildInputs = [ pkgs.makeWrapper ];
}
''
makeWrapper "${original}" "$out/bin/${name}" \
${lib.concatStringsSep " \\\n " (
lib.mapAttrsToList (name: value: ''--set ${name} "${value}"'') set
)}
'';
# Returns a singleton list, due to usage of lib.optional
mkBorgWrapper =
name: cfg:
lib.optional (cfg.wrapper != "" && cfg.wrapper != null) (mkWrapperDrv {
original = lib.getExe config.services.borgbackup.package;
name = cfg.wrapper;
set = {
BORG_REPO = cfg.repo;
}
// (mkPassEnv cfg)
// cfg.environment;
});
# Paths listed in ReadWritePaths must exist before service is started
mkTmpfiles =
name: cfg:
let
settings = { inherit (cfg) user group; };
in
lib.nameValuePair "borgbackup-job-${name}" (
{
# Create parent dirs separately, to ensure correct ownership.
"${config.users.users."${cfg.user}".home}/.config".d = settings;
"${config.users.users."${cfg.user}".home}/.cache".d = settings;
"${config.users.users."${cfg.user}".home}/.config/borg".d = settings;
"${config.users.users."${cfg.user}".home}/.cache/borg".d = settings;
}
// lib.optionalAttrs (isLocalPath cfg.repo && !cfg.removableDevice) {
"${cfg.repo}".d = settings;
}
);
mkPassAssertion = name: cfg: {
assertion = with cfg.encryption; mode != "none" -> passCommand != null || passphrase != null;
message =
"passCommand or passphrase has to be specified because"
+ " borgbackup.jobs.${name}.encryption != \"none\"";
};
mkRepoService =
name: cfg:
lib.nameValuePair "borgbackup-repo-${name}" {
description = "Create BorgBackup repository ${name} directory";
script = ''
mkdir -p ${lib.escapeShellArg cfg.path}
chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg cfg.path}
'';
serviceConfig = {
# The service's only task is to ensure that the specified path exists
Type = "oneshot";
};
wantedBy = [ "multi-user.target" ];
};
mkAuthorizedKey =
cfg: appendOnly: key:
let
# Because of the following line, clients do not need to specify an absolute repo path
cdCommand = "cd ${lib.escapeShellArg cfg.path}";
restrictedArg = "--restrict-to-${if cfg.allowSubRepos then "path" else "repository"} .";
appendOnlyArg = lib.optionalString appendOnly "--append-only";
quotaArg = lib.optionalString (cfg.quota != null) "--storage-quota ${cfg.quota}";
serveCommand = "borg serve ${restrictedArg} ${appendOnlyArg} ${quotaArg}";
in
''command="${cdCommand} && ${serveCommand}",restrict ${key}'';
mkUsersConfig = name: cfg: {
users.${cfg.user} = {
openssh.authorizedKeys.keys = (
map (mkAuthorizedKey cfg false) cfg.authorizedKeys
++ map (mkAuthorizedKey cfg true) cfg.authorizedKeysAppendOnly
);
useDefaultShell = true;
group = cfg.group;
isSystemUser = true;
};
groups.${cfg.group} = { };
};
mkKeysAssertion = name: cfg: {
assertion = cfg.authorizedKeys != [ ] || cfg.authorizedKeysAppendOnly != [ ];
message = "borgbackup.repos.${name} does not make sense" + " without at least one public key";
};
mkSourceAssertions = name: cfg: {
assertion =
lib.count isNull [
cfg.dumpCommand
cfg.paths
] == 1;
message = ''
Exactly one of borgbackup.jobs.${name}.paths or borgbackup.jobs.${name}.dumpCommand
must be set.
'';
};
mkRemovableDeviceAssertions = name: cfg: {
assertion = !(isLocalPath cfg.repo) -> !cfg.removableDevice;
message = ''
borgbackup.repos.${name}: repo isn't a local path, thus it can't be a removable device!
'';
};
in
{
meta.maintainers = with lib.maintainers; [
dotlambda
Scrumplex
];
meta.doc = ./borgbackup.md;
###### interface
options.services.borgbackup.package = lib.mkPackageOption pkgs "borgbackup" { };
options.services.borgbackup.jobs = lib.mkOption {
description = ''
Deduplicating backups using BorgBackup.
Adding a job will cause a borg-job-NAME wrapper to be added
to your system path, so that you can perform maintenance easily.
See also the chapter about BorgBackup in the NixOS manual.
'';
default = { };
example = lib.literalExpression ''
{ # for a local backup
rootBackup = {
paths = "/";
exclude = [ "/nix" ];
repo = "/path/to/local/repo";
encryption = {
mode = "repokey";
passphrase = "secret";
};
compression = "auto,lzma";
startAt = "weekly";
};
}
{ # Root backing each day up to a remote backup server. We assume that you have
# * created a password less key: ssh-keygen -N "" -t ed25519 -f /path/to/ssh_key
# best practices are: use -t ed25519, /path/to = /run/keys
# * the passphrase is in the file /run/keys/borgbackup_passphrase
# * you have initialized the repository manually
paths = [ "/etc" "/home" ];
exclude = [ "/nix" "'**/.cache'" ];
doInit = false;
repo = "user3@arep.repo.borgbase.com:repo";
encryption = {
mode = "repokey-blake2";
passCommand = "cat /path/to/passphrase";
};
environment = { BORG_RSH = "ssh -i /path/to/ssh_key"; };
compression = "auto,lzma";
startAt = "daily";
};
'';
type = lib.types.attrsOf (
lib.types.submodule (
let
globalConfig = config;
in
{ name, config, ... }:
{
options = {
paths = lib.mkOption {
type = with lib.types; nullOr (coercedTo str lib.singleton (listOf str));
default = null;
description = ''
Path(s) to back up.
Mutually exclusive with {option}`dumpCommand`.
'';
example = "/home/user";
};
dumpCommand = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = ''
Backup the stdout of this program instead of filesystem paths.
Mutually exclusive with {option}`paths`.
'';
example = "/path/to/createZFSsend.sh";
};
repo = lib.mkOption {
type = lib.types.str;
description = "Remote or local repository to back up to.";
example = "user@machine:/path/to/repo";
};
removableDevice = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether the repo (which must be local) is a removable device.";
};
archiveBaseName = lib.mkOption {
type = lib.types.nullOr (lib.types.strMatching "[^/{}]+");
default = "${globalConfig.networking.hostName}-${name}";
defaultText = lib.literalExpression ''"''${config.networking.hostName}-<name>"'';
description = ''
How to name the created archives. A timestamp, whose format is
determined by {option}`dateFormat`, will be appended. The full
name can be modified at runtime (`$archiveName`).
Placeholders like `{hostname}` must not be used.
Use `null` for no base name.
'';
};
dateFormat = lib.mkOption {
type = lib.types.str;
description = ''
Arguments passed to {command}`date`
to create a timestamp suffix for the archive name.
'';
default = "+%Y-%m-%dT%H:%M:%S";
example = "-u +%s";
};
startAt = lib.mkOption {
type = with lib.types; either str (listOf str);
default = "daily";
description = ''
When or how often the backup should run.
Must be in the format described in
{manpage}`systemd.time(7)`.
If you do not want the backup to start
automatically, use `[ ]`.
It will generate a systemd service borgbackup-job-NAME.
You may trigger it manually via systemctl restart borgbackup-job-NAME.
'';
};
persistentTimer = lib.mkOption {
default = false;
type = lib.types.bool;
example = true;
description = ''
Set the `Persistent` option for the
{manpage}`systemd.timer(5)`
which triggers the backup immediately if the last trigger
was missed (e.g. if the system was powered down).
'';
};
inhibitsSleep = lib.mkOption {
default = false;
type = lib.types.bool;
example = true;
description = ''
Prevents the system from sleeping while backing up.
'';
};
user = lib.mkOption {
type = lib.types.str;
description = ''
The user {command}`borg` is run as.
User or group need read permission
for the specified {option}`paths`.
'';
default = "root";
};
group = lib.mkOption {
type = lib.types.str;
description = ''
The group borg is run as. User or group needs read permission
for the specified {option}`paths`.
'';
default = "root";
};
wrapper = lib.mkOption {
type = with lib.types; nullOr str;
description = ''
Name of the wrapper that is installed into {env}`PATH`.
Set to `null` or `""` to disable it altogether.
'';
default = "borg-job-${name}";
defaultText = "borg-job-<name>";
};
encryption.mode = lib.mkOption {
type = lib.types.enum [
"repokey"
"keyfile"
"repokey-blake2"
"keyfile-blake2"
"authenticated"
"authenticated-blake2"
"none"
];
description = ''
Encryption mode to use. Setting a mode
other than `"none"` requires
you to specify a {option}`passCommand`
or a {option}`passphrase`.
'';
example = "repokey-blake2";
};
encryption.passCommand = lib.mkOption {
type = with lib.types; nullOr str;
description = ''
A command which prints the passphrase to stdout.
Mutually exclusive with {option}`passphrase`.
'';
default = null;
example = "cat /path/to/passphrase_file";
};
encryption.passphrase = lib.mkOption {
type = with lib.types; nullOr str;
description = ''
The passphrase the backups are encrypted with.
Mutually exclusive with {option}`passCommand`.
If you do not want the passphrase to be stored in the
world-readable Nix store, use {option}`passCommand`.
'';
default = null;
};
compression = lib.mkOption {
# "auto" is optional,
# compression mode must be given,
# compression level is optional
type = lib.types.strMatching "none|(auto,)?(lz4|zstd|zlib|lzma)(,[[:digit:]]{1,2})?";
description = ''
Compression method to use. Refer to
{command}`borg help compression`
for all available options.
'';
default = "lz4";
example = "auto,lzma";
};
exclude = lib.mkOption {
type = with lib.types; listOf str;
description = ''
Exclude paths matching any of the given patterns. See
{command}`borg help patterns` for pattern syntax.
'';
default = [ ];
example = [
"/home/*/.cache"
"/nix"
];
};
patterns = lib.mkOption {
type = with lib.types; listOf str;
description = ''
Include/exclude paths matching the given patterns. The first
matching patterns is used, so if an include pattern (prefix `+`)
matches before an exclude pattern (prefix `-`), the file is
backed up. See [{command}`borg help patterns`](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-patterns) for pattern syntax.
'';
default = [ ];
example = [
"+ /home/susan"
"- /home/*"
];
};
readWritePaths = lib.mkOption {
type = with lib.types; listOf path;
description = ''
By default, borg cannot write anywhere on the system but
`$HOME/.config/borg` and `$HOME/.cache/borg`.
If, for example, your preHook script needs to dump files
somewhere, put those directories here.
'';
default = [ ];
example = [
"/var/backup/mysqldump"
];
};
privateTmp = lib.mkOption {
type = lib.types.bool;
description = ''
Set the `PrivateTmp` option for
the systemd-service. Set to false if you need sockets
or other files from global /tmp.
'';
default = true;
};
failOnWarnings = lib.mkOption {
type = lib.types.bool;
description = ''
Fail the whole backup job if any borg command returns a warning
(exit code 1), for example because a file changed during backup.
'';
default = true;
};
doInit = lib.mkOption {
type = lib.types.bool;
description = ''
Run {command}`borg init` if the
specified {option}`repo` does not exist.
You should set this to `false`
if the repository is located on an external drive
that might not always be mounted.
'';
default = true;
};
appendFailedSuffix = lib.mkOption {
type = lib.types.bool;
description = ''
Append a `.failed` suffix
to the archive name, which is only removed if
{command}`borg create` has a zero exit status.
'';
default = true;
};
prune.keep = lib.mkOption {
# Specifying e.g. `prune.keep.yearly = -1`
# means there is no limit of yearly archives to keep
# The regex is for use with e.g. --keep-within 1y
type = with lib.types; attrsOf (either int (strMatching "[[:digit:]]+[Hdwmy]"));
description = ''
Prune a repository by deleting all archives not matching any of the
specified retention options. See {command}`borg help prune`
for the available options.
'';
default = { };
example = lib.literalExpression ''
{
within = "1d"; # Keep all archives from the last day
daily = 7;
weekly = 4;
monthly = -1; # Keep at least one archive for each month
}
'';
};
prune.prefix = lib.mkOption {
type = lib.types.nullOr (lib.types.str);
description = ''
Only consider archive names starting with this prefix for pruning.
By default, only archives created by this job are considered.
Use `""` or `null` to consider all archives.
'';
default = config.archiveBaseName;
defaultText = lib.literalExpression "archiveBaseName";
};
environment = lib.mkOption {
type = with lib.types; attrsOf str;
description = ''
Environment variables passed to the backup script.
You can for example specify which SSH key to use.
'';
default = { };
example = {
BORG_RSH = "ssh -i /path/to/key";
};
};
preHook = lib.mkOption {
type = lib.types.lines;
description = ''
Shell commands to run before the backup.
This can for example be used to mount file systems.
'';
default = "";
example = ''
# To add excluded paths at runtime
extraCreateArgs+=("--exclude" "/some/path")
'';
};
postInit = lib.mkOption {
type = lib.types.lines;
description = ''
Shell commands to run after {command}`borg init`.
'';
default = "";
};
postCreate = lib.mkOption {
type = lib.types.lines;
description = ''
Shell commands to run after {command}`borg create`. The name
of the created archive is stored in `$archiveName`.
'';
default = "";
};
postPrune = lib.mkOption {
type = lib.types.lines;
description = ''
Shell commands to run after {command}`borg prune`.
'';
default = "";
};
postHook = lib.mkOption {
type = lib.types.lines;
description = ''
Shell commands to run just before exit. They are executed
even if a previous command exits with a non-zero exit code.
The latter is available as `$exitStatus`.
'';
default = "";
};
extraArgs = lib.mkOption {
type = with lib.types; coercedTo (listOf str) lib.escapeShellArgs str;
description = ''
Additional arguments for all {command}`borg` calls the
service has. Handle with care.
'';
default = [ ];
example = [ "--remote-path=/path/to/borg" ];
};
extraInitArgs = lib.mkOption {
type = with lib.types; coercedTo (listOf str) lib.escapeShellArgs str;
description = ''
Additional arguments for {command}`borg init`.
Can also be set at runtime using `$extraInitArgs`.
'';
default = [ ];
example = [ "--append-only" ];
};
extraCreateArgs = lib.mkOption {
type = with lib.types; coercedTo (listOf str) lib.escapeShellArgs str;
description = ''
Additional arguments for {command}`borg create`.
Can also be set at runtime using `$extraCreateArgs`.
'';
default = [ ];
example = [
"--stats"
"--checkpoint-interval 600"
];
};
extraPruneArgs = lib.mkOption {
type = with lib.types; coercedTo (listOf str) lib.escapeShellArgs str;
description = ''
Additional arguments for {command}`borg prune`.
Can also be set at runtime using `$extraPruneArgs`.
'';
default = [ ];
example = [ "--save-space" ];
};
extraCompactArgs = lib.mkOption {
type = with lib.types; coercedTo (listOf str) lib.escapeShellArgs str;
description = ''
Additional arguments for {command}`borg compact`.
Can also be set at runtime using `$extraCompactArgs`.
'';
default = [ ];
example = [ "--cleanup-commits" ];
};
};
}
)
);
};
options.services.borgbackup.repos = lib.mkOption {
description = ''
Serve BorgBackup repositories to given public SSH keys,
restricting their access to the repository only.
See also the chapter about BorgBackup in the NixOS manual.
Also, clients do not need to specify the absolute path when accessing the repository,
i.e. `user@machine:.` is enough. (Note colon and dot.)
'';
default = { };
type = lib.types.attrsOf (
lib.types.submodule (
{ ... }:
{
options = {
path = lib.mkOption {
type = lib.types.path;
description = ''
Where to store the backups. Note that the directory
is created automatically, with correct permissions.
'';
default = "/var/lib/borgbackup";
};
user = lib.mkOption {
type = lib.types.str;
description = ''
The user {command}`borg serve` is run as.
User or group needs write permission
for the specified {option}`path`.
'';
default = "borg";
};
group = lib.mkOption {
type = lib.types.str;
description = ''
The group {command}`borg serve` is run as.
User or group needs write permission
for the specified {option}`path`.
'';
default = "borg";
};
authorizedKeys = lib.mkOption {
type = with lib.types; listOf str;
description = ''
Public SSH keys that are given full write access to this repository.
You should use a different SSH key for each repository you write to, because
the specified keys are restricted to running {command}`borg serve`
and can only access this single repository.
'';
default = [ ];
};
authorizedKeysAppendOnly = lib.mkOption {
type = with lib.types; listOf str;
description = ''
Public SSH keys that can only be used to append new data (archives) to the repository.
Note that archives can still be marked as deleted and are subsequently removed from disk
upon accessing the repo with full write access, e.g. when pruning.
'';
default = [ ];
};
allowSubRepos = lib.mkOption {
type = lib.types.bool;
description = ''
Allow clients to create repositories in subdirectories of the
specified {option}`path`. These can be accessed using
`user@machine:path/to/subrepo`. Note that a
{option}`quota` applies to repositories independently.
Therefore, if this is enabled, clients can create multiple
repositories and upload an arbitrary amount of data.
'';
default = false;
};
quota = lib.mkOption {
# See the definition of parse_file_size() in src/borg/helpers/parseformat.py
type = with lib.types; nullOr (strMatching "[[:digit:].]+[KMGTP]?");
description = ''
Storage quota for the repository. This quota is ensured for all
sub-repositories if {option}`allowSubRepos` is enabled
but not for the overall storage space used.
'';
default = null;
example = "100G";
};
};
}
)
);
};
###### implementation
config = lib.mkIf (with config.services.borgbackup; jobs != { } || repos != { }) (
with config.services.borgbackup;
{
assertions =
lib.mapAttrsToList mkPassAssertion jobs
++ lib.mapAttrsToList mkKeysAssertion repos
++ lib.mapAttrsToList mkSourceAssertions jobs
++ lib.mapAttrsToList mkRemovableDeviceAssertions jobs;
systemd.tmpfiles.settings = lib.mapAttrs' mkTmpfiles jobs;
systemd.services =
# A job named "foo" is mapped to systemd.services.borgbackup-job-foo
lib.mapAttrs' mkBackupService jobs
# A repo named "foo" is mapped to systemd.services.borgbackup-repo-foo
// lib.mapAttrs' mkRepoService repos;
# A job named "foo" is mapped to systemd.timers.borgbackup-job-foo
# only generate the timer if interval (startAt) is set
systemd.timers = lib.mapAttrs' mkBackupTimers (lib.filterAttrs (_: cfg: cfg.startAt != [ ]) jobs);
users = lib.mkMerge (lib.mapAttrsToList mkUsersConfig repos);
environment.systemPackages = [
config.services.borgbackup.package
]
++ (lib.flatten (lib.mapAttrsToList mkBorgWrapper jobs));
}
);
}

View File

@@ -0,0 +1,199 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.borgmatic;
settingsFormat = pkgs.formats.yaml { };
postgresql = config.services.postgresql.package;
mysql = config.services.mysql.package;
requireSudo =
s:
s ? postgresql_databases
&& lib.any (d: d ? username && !(d ? password) && !(d ? pg_dump_command)) s.postgresql_databases;
addRequiredBinaries =
s:
s
// (lib.optionalAttrs (s ? postgresql_databases && s.postgresql_databases != [ ]) {
postgresql_databases = map (
d:
let
as_user = if d ? username && !(d ? password) then "${pkgs.sudo}/bin/sudo -u ${d.username} " else "";
in
{
pg_dump_command =
if d.name == "all" && (!(d ? format) || isNull d.format) then
"${as_user}${postgresql}/bin/pg_dumpall"
else
"${as_user}${postgresql}/bin/pg_dump";
pg_restore_command = "${as_user}${postgresql}/bin/pg_restore";
psql_command = "${as_user}${postgresql}/bin/psql";
}
// d
) s.postgresql_databases;
})
// (lib.optionalAttrs (s ? mariadb_databases && s.mariadb_databases != [ ]) {
mariadb_databases = map (
d:
{
mariadb_dump_command = "${mysql}/bin/mariadb-dump";
mariadb_command = "${mysql}/bin/mariadb";
}
// d
) s.mariadb_databases;
})
// (lib.optionalAttrs (s ? mysql_databases && s.mysql_databases != [ ]) {
mysql_databases = map (
d:
{
mysql_dump_command = "${mysql}/bin/mysqldump";
mysql_command = "${mysql}/bin/mysql";
}
// d
) s.mysql_databases;
});
repository =
with lib.types;
submodule {
options = {
path = lib.mkOption {
type = str;
description = ''
Path to the repository
'';
};
label = lib.mkOption {
type = str;
description = ''
Label to the repository
'';
};
};
};
cfgType =
with lib.types;
submodule {
freeformType = settingsFormat.type;
options = {
source_directories = lib.mkOption {
type = listOf str;
default = [ ];
description = ''
List of source directories and files to backup. Globs and tildes are
expanded. Do not backslash spaces in path names.
'';
example = [
"/home"
"/etc"
"/var/log/syslog*"
"/home/user/path with spaces"
];
};
repositories = lib.mkOption {
type = listOf repository;
default = [ ];
description = ''
A required list of local or remote repositories with paths and
optional labels (which can be used with the --repository flag to
select a repository). Tildes are expanded. Multiple repositories are
backed up to in sequence. Borg placeholders can be used. See the
output of "borg help placeholders" for details. See ssh_command for
SSH options like identity file or port. If systemd service is used,
then add local repository paths in the systemd service file to the
ReadWritePaths list.
'';
example = [
{
path = "ssh://user@backupserver/./sourcehostname.borg";
label = "backupserver";
}
{
path = "/mnt/backup";
label = "local";
}
];
};
};
};
cfgfile = settingsFormat.generate "config.yaml" (addRequiredBinaries cfg.settings);
anycfgRequiresSudo =
requireSudo cfg.settings || lib.any requireSudo (lib.attrValues cfg.configurations);
in
{
options.services.borgmatic = {
enable = lib.mkEnableOption "borgmatic";
settings = lib.mkOption {
description = ''
See <https://torsion.org/borgmatic/docs/reference/configuration/>
'';
default = null;
type = lib.types.nullOr cfgType;
};
configurations = lib.mkOption {
description = ''
Set of borgmatic configurations, see <https://torsion.org/borgmatic/docs/reference/configuration/>
'';
default = { };
type = lib.types.attrsOf cfgType;
};
enableConfigCheck = lib.mkEnableOption "checking all configurations during build time" // {
default = true;
};
};
config =
let
configFiles =
(lib.optionalAttrs (cfg.settings != null) {
"borgmatic/config.yaml".source = cfgfile;
})
// lib.mapAttrs' (
name: value:
lib.nameValuePair "borgmatic.d/${name}.yaml" {
source = settingsFormat.generate "${name}.yaml" (addRequiredBinaries value);
}
) cfg.configurations;
borgmaticCheck =
name: f:
pkgs.runCommandCC "${name} validation" { } ''
${pkgs.borgmatic}/bin/borgmatic -c ${f.source} config validate
touch $out
'';
in
lib.mkIf cfg.enable {
warnings =
[ ]
++
lib.optional (cfg.settings != null && cfg.settings ? location)
"`services.borgmatic.settings.location` is deprecated, please move your options out of sections to the global scope"
++
lib.optional (lib.catAttrs "location" (lib.attrValues cfg.configurations) != [ ])
"`services.borgmatic.configurations.<name>.location` is deprecated, please move your options out of sections to the global scope";
environment.systemPackages = [ pkgs.borgmatic ];
environment.etc = configFiles;
systemd.packages = [ pkgs.borgmatic ];
systemd.services.borgmatic.path = [ pkgs.coreutils ];
systemd.services.borgmatic.serviceConfig = lib.optionalAttrs anycfgRequiresSudo {
NoNewPrivileges = false;
CapabilityBoundingSet = "CAP_DAC_READ_SEARCH CAP_NET_RAW CAP_SETUID CAP_SETGID";
};
# Workaround: https://github.com/NixOS/nixpkgs/issues/81138
systemd.timers.borgmatic.wantedBy = [ "timers.target" ];
system.checks = lib.mkIf cfg.enableConfigCheck (lib.mapAttrsToList borgmaticCheck configFiles);
};
}

View File

@@ -0,0 +1,392 @@
{
config,
pkgs,
lib,
...
}:
let
inherit (lib)
concatLists
concatMap
concatMapStringsSep
concatStringsSep
filterAttrs
flatten
getAttr
isAttrs
literalExpression
mapAttrs'
mapAttrsToList
mkIf
mkOption
optional
optionalString
sortOn
types
;
# The priority of an option or section.
# The configurations format are order-sensitive. Pairs are added as children of
# the last sections if possible, otherwise, they start a new section.
# We sort them in topological order:
# 1. Leaf pairs.
# 2. Sections that may contain (1).
# 3. Sections that may contain (1) or (2).
# 4. Etc.
prioOf =
{ name, value }:
if !isAttrs value then
0 # Leaf options.
else
{
target = 1; # Contains: options.
subvolume = 2; # Contains: options, target.
volume = 3; # Contains: options, target, subvolume.
}
.${name} or (throw "Unknow section '${name}'");
genConfig' = set: concatStringsSep "\n" (genConfig set);
genConfig =
set:
let
pairs = mapAttrsToList (name: value: { inherit name value; }) set;
sortedPairs = sortOn prioOf pairs;
in
concatMap genPair sortedPairs;
genSection =
sec: secName: value:
[ "${sec} ${secName}" ] ++ map (x: " " + x) (genConfig value);
genPair =
{ name, value }:
if !isAttrs value then
[ "${name} ${value}" ]
else
concatLists (mapAttrsToList (genSection name) value);
sudoRule = {
users = [ "btrbk" ];
commands = [
{
command = "${pkgs.btrfs-progs}/bin/btrfs";
options = [ "NOPASSWD" ];
}
{
command = "${pkgs.coreutils}/bin/mkdir";
options = [ "NOPASSWD" ];
}
{
command = "${pkgs.coreutils}/bin/readlink";
options = [ "NOPASSWD" ];
}
# for ssh, they are not the same than the one hard coded in ${pkgs.btrbk}
{
command = "/run/current-system/sw/bin/btrfs";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/mkdir";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/readlink";
options = [ "NOPASSWD" ];
}
];
};
sudo_doas =
if config.security.sudo.enable || config.security.sudo-rs.enable then
"sudo"
else if config.security.doas.enable then
"doas"
else
throw "The btrbk nixos module needs either sudo or doas enabled in the configuration";
addDefaults = settings: { backend = "btrfs-progs-${sudo_doas}"; } // settings;
mkConfigFile =
name: settings:
pkgs.writeTextFile {
name = "btrbk-${name}.conf";
text = genConfig' (addDefaults settings);
checkPhase = ''
set +e
${pkgs.btrbk}/bin/btrbk -c $out dryrun
# According to btrbk(1), exit status 2 means parse error
# for CLI options or the config file.
if [[ $? == 2 ]]; then
echo "Btrbk configuration is invalid:"
cat $out
exit 1
fi
set -e
'';
};
streamCompressMap = {
gzip = pkgs.gzip;
pigz = pkgs.pigz;
bzip2 = pkgs.bzip2;
pbzip2 = pkgs.pbzip2;
bzip3 = pkgs.bzip3;
xz = pkgs.xz;
lzo = pkgs.lzo;
lz4 = pkgs.lz4;
zstd = pkgs.zstd;
};
cfg = config.services.btrbk;
sshEnabled = cfg.sshAccess != [ ];
serviceEnabled = cfg.instances != { };
in
{
meta.maintainers = with lib.maintainers; [ oxalica ];
options = {
services.btrbk = {
extraPackages = mkOption {
description = ''
Extra packages for btrbk, like compression utilities for `stream_compress`.
**Note**: This option will get deprecated in future releases.
Required compression programs will get automatically provided to btrbk
depending on configured compression method in
`services.btrbk.instances.<name>.settings` option.
'';
type = types.listOf types.package;
default = [ ];
example = literalExpression "[ pkgs.xz ]";
};
niceness = mkOption {
description = "Niceness for local instances of btrbk. Also applies to remote ones connecting via ssh when positive.";
type = types.ints.between (-20) 19;
default = 10;
};
ioSchedulingClass = mkOption {
description = "IO scheduling class for btrbk (see {manpage}`ionice(1)` for a quick description). Applies to local instances, and remote ones connecting by ssh if set to idle.";
type = types.enum [
"idle"
"best-effort"
"realtime"
];
default = "best-effort";
};
instances = mkOption {
description = "Set of btrbk instances. The instance named `btrbk` is the default one.";
type =
with types;
attrsOf (submodule {
options = {
onCalendar = mkOption {
type = types.nullOr types.str;
default = "daily";
description = ''
How often this btrbk instance is started. See {manpage}`systemd.time(7)` for more information about the format.
Setting it to null disables the timer, thus this instance can only be started manually.
'';
};
snapshotOnly = mkOption {
type = types.bool;
default = false;
description = ''
Whether to run in snapshot only mode. This skips backup creation and deletion steps.
Useful when you want to manually backup to an external drive that might not always be connected.
Use `btrbk -c /path/to/conf resume` to trigger manual backups.
More examples [here](https://github.com/digint/btrbk#example-backups-to-usb-disk).
See also `snapshot` subcommand in {manpage}`btrbk(1)`.
'';
};
settings = mkOption {
type = types.submodule {
freeformType =
let
t = types.attrsOf (
types.either types.str (t // { description = "instances of this type recursively"; })
);
in
t;
options = {
stream_compress = mkOption {
description = ''
Compress the btrfs send stream before transferring it from/to remote locations using a
compression command.
'';
type = types.enum [
"gzip"
"pigz"
"bzip2"
"pbzip2"
"bzip3"
"xz"
"lzo"
"lz4"
"zstd"
"no"
];
default = "no";
};
};
};
default = { };
example = {
snapshot_preserve_min = "2d";
snapshot_preserve = "14d";
volume = {
"/mnt/btr_pool" = {
target = "/mnt/btr_backup/mylaptop";
subvolume = {
"rootfs" = { };
"home" = {
snapshot_create = "always";
};
};
};
};
};
description = "configuration options for btrbk. Nested attrsets translate to subsections.";
};
};
});
default = { };
};
sshAccess = mkOption {
description = "SSH keys that should be able to make or push snapshots on this system remotely with btrbk";
type =
with types;
listOf (submodule {
options = {
key = mkOption {
type = str;
description = "SSH public key allowed to login as user `btrbk` to run remote backups.";
};
roles = mkOption {
type = listOf (enum [
"info"
"source"
"target"
"delete"
"snapshot"
"send"
"receive"
]);
example = [
"source"
"info"
"send"
];
description = "What actions can be performed with this SSH key. See ssh_filter_btrbk(1) for details";
};
};
});
default = [ ];
};
};
};
config = mkIf (sshEnabled || serviceEnabled) {
environment.systemPackages = [ pkgs.btrbk ] ++ cfg.extraPackages;
security.sudo.extraRules = mkIf (sudo_doas == "sudo") [ sudoRule ];
security.sudo-rs.extraRules = mkIf (sudo_doas == "sudo") [ sudoRule ];
security.doas = mkIf (sudo_doas == "doas") {
extraRules =
let
doasCmdNoPass = cmd: {
users = [ "btrbk" ];
cmd = cmd;
noPass = true;
};
in
[
(doasCmdNoPass "${pkgs.btrfs-progs}/bin/btrfs")
(doasCmdNoPass "${pkgs.coreutils}/bin/mkdir")
(doasCmdNoPass "${pkgs.coreutils}/bin/readlink")
# for ssh, they are not the same than the one hard coded in ${pkgs.btrbk}
(doasCmdNoPass "/run/current-system/sw/bin/btrfs")
(doasCmdNoPass "/run/current-system/sw/bin/mkdir")
(doasCmdNoPass "/run/current-system/sw/bin/readlink")
# doas matches command, not binary
(doasCmdNoPass "btrfs")
(doasCmdNoPass "mkdir")
(doasCmdNoPass "readlink")
];
};
users.users.btrbk = {
isSystemUser = true;
# ssh needs a home directory
home = "/var/lib/btrbk";
createHome = true;
shell = "${pkgs.bash}/bin/bash";
group = "btrbk";
openssh.authorizedKeys.keys = map (
v:
let
options = concatMapStringsSep " " (x: "--" + x) v.roles;
ioniceClass =
{
"idle" = 3;
"best-effort" = 2;
"realtime" = 1;
}
.${cfg.ioSchedulingClass};
sudo_doas_flag = "--${sudo_doas}";
in
''command="${pkgs.util-linux}/bin/ionice -t -c ${toString ioniceClass} ${
optionalString (cfg.niceness >= 1) "${pkgs.coreutils}/bin/nice -n ${toString cfg.niceness}"
} ${pkgs.btrbk}/share/btrbk/scripts/ssh_filter_btrbk.sh ${sudo_doas_flag} ${options}" ${v.key}''
) cfg.sshAccess;
};
users.groups.btrbk = { };
systemd.tmpfiles.rules = [
"d /var/lib/btrbk 0750 btrbk btrbk"
"d /var/lib/btrbk/.ssh 0700 btrbk btrbk"
"f /var/lib/btrbk/.ssh/config 0700 btrbk btrbk - StrictHostKeyChecking=accept-new"
];
environment.etc = mapAttrs' (name: instance: {
name = "btrbk/${name}.conf";
value.source = mkConfigFile name instance.settings;
}) cfg.instances;
systemd.services = mapAttrs' (name: instance: {
name = "btrbk-${name}";
value = {
description = "Takes BTRFS snapshots and maintains retention policies.";
unitConfig.Documentation = "man:btrbk(1)";
path = [
"/run/wrappers"
]
++ cfg.extraPackages
++ optional (instance.settings.stream_compress != "no") (
getAttr instance.settings.stream_compress streamCompressMap
);
serviceConfig = {
User = "btrbk";
Group = "btrbk";
Type = "oneshot";
ExecStart = "${pkgs.btrbk}/bin/btrbk -c /etc/btrbk/${name}.conf ${
if instance.snapshotOnly then "snapshot" else "run"
}";
Nice = cfg.niceness;
IOSchedulingClass = cfg.ioSchedulingClass;
StateDirectory = "btrbk";
};
};
}) cfg.instances;
systemd.timers = mapAttrs' (name: instance: {
name = "btrbk-${name}";
value = {
description = "Timer to take BTRFS snapshots and maintain retention policies.";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = instance.onCalendar;
AccuracySec = "10min";
Persistent = true;
};
};
}) (filterAttrs (name: instance: instance.onCalendar != null) cfg.instances);
};
}

View File

@@ -0,0 +1,131 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.duplicati;
parametersFile =
if cfg.parametersFile != null then
cfg.parametersFile
else
pkgs.writeText "duplicati-parameters" cfg.parameters;
in
{
options = {
services.duplicati = {
enable = lib.mkEnableOption "Duplicati";
package = lib.mkPackageOption pkgs "duplicati" { };
port = lib.mkOption {
default = 8200;
type = lib.types.port;
description = ''
Port serving the web interface
'';
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/duplicati";
description = ''
The directory where Duplicati stores its data files.
::: {.note}
If left as the default value this directory will automatically be created
before the Duplicati server starts, otherwise you are responsible for ensuring
the directory exists with appropriate ownership and permissions.
:::
'';
};
interface = lib.mkOption {
default = "127.0.0.1";
type = lib.types.str;
description = ''
Listening interface for the web UI
Set it to "any" to listen on all available interfaces
'';
};
user = lib.mkOption {
default = "duplicati";
type = lib.types.str;
description = ''
Duplicati runs as it's own user. It will only be able to backup world-readable files.
Run as root with special care.
'';
};
parameters = lib.mkOption {
default = "";
type = lib.types.lines;
example = ''
--webservice-allowedhostnames=*
'';
description = ''
This option can be used to store some or all of the options given to the
commandline client.
Each line in this option should be of the format --option=value.
The options in this file take precedence over the options provided
through command line arguments.
<link xlink:href="https://duplicati.readthedocs.io/en/latest/06-advanced-options/#parameters-file">Duplicati docs: parameters-file</link>
'';
};
parametersFile = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.path;
description = ''
This file can be used to store some or all of the options given to the
commandline client.
Each line in the file option should be of the format --option=value.
The options in this file take precedence over the options provided
through command line arguments.
<link xlink:href="https://duplicati.readthedocs.io/en/latest/06-advanced-options/#parameters-file">Duplicati docs: parameters-file</link>
'';
};
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
assertions = [
{
assertion = !(cfg.parametersFile != null && cfg.parameters != "");
message = "cannot set both services.duplicati.parameters and services.duplicati.parametersFile at the same time";
}
];
systemd.services.duplicati = {
description = "Duplicati backup";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = lib.mkMerge [
{
User = cfg.user;
Group = "duplicati";
ExecStart = "${cfg.package}/bin/duplicati-server --webservice-interface=${cfg.interface} --webservice-port=${toString cfg.port} --server-datafolder=${cfg.dataDir} --parameters-file=${parametersFile}";
Restart = "on-failure";
}
(lib.mkIf (cfg.dataDir == "/var/lib/duplicati") {
StateDirectory = "duplicati";
})
];
};
users.users = lib.optionalAttrs (cfg.user == "duplicati") {
duplicati = {
uid = config.ids.uids.duplicati;
home = cfg.dataDir;
group = "duplicati";
};
};
users.groups.duplicati.gid = config.ids.gids.duplicati;
};
}

View File

@@ -0,0 +1,254 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.duplicity;
stateDirectory = "/var/lib/duplicity";
localTarget =
if lib.hasPrefix "file://" cfg.targetUrl then lib.removePrefix "file://" cfg.targetUrl else null;
in
{
options.services.duplicity = {
enable = lib.mkEnableOption "backups with duplicity";
root = lib.mkOption {
type = lib.types.path;
default = "/";
description = ''
Root directory to backup.
'';
};
include = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "/home" ];
description = ''
List of paths to include into the backups. See the FILE SELECTION
section in {manpage}`duplicity(1)` for details on the syntax.
'';
};
exclude = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
List of paths to exclude from backups. See the FILE SELECTION section in
{manpage}`duplicity(1)` for details on the syntax.
'';
};
includeFileList = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = /path/to/fileList.txt;
description = ''
File containing newline-separated list of paths to include into the
backups. See the FILE SELECTION section in {manpage}`duplicity(1)` for
details on the syntax.
'';
};
excludeFileList = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = /path/to/fileList.txt;
description = ''
File containing newline-separated list of paths to exclude into the
backups. See the FILE SELECTION section in {manpage}`duplicity(1)` for
details on the syntax.
'';
};
targetUrl = lib.mkOption {
type = lib.types.str;
example = "s3://host:port/prefix";
description = ''
Target url to backup to. See the URL FORMAT section in
{manpage}`duplicity(1)` for supported urls.
'';
};
secretFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path of a file containing secrets (gpg passphrase, access key...) in
the format of EnvironmentFile as described by
{manpage}`systemd.exec(5)`. For example:
```
PASSPHRASE=«...»
AWS_ACCESS_KEY_ID=«...»
AWS_SECRET_ACCESS_KEY=«...»
```
'';
};
frequency = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "daily";
description = ''
Run duplicity with the given frequency (see
{manpage}`systemd.time(7)` for the format).
If null, do not run automatically.
'';
};
extraFlags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"--backend-retry-delay"
"100"
];
description = ''
Extra command-line flags passed to duplicity. See
{manpage}`duplicity(1)`.
'';
};
fullIfOlderThan = lib.mkOption {
type = lib.types.str;
default = "never";
example = "1M";
description = ''
If `"never"` (the default) always do incremental
backups (the first backup will be a full backup, of course). If
`"always"` always do full backups. Otherwise, this
must be a string representing a duration. Full backups will be made
when the latest full backup is older than this duration. If this is not
the case, an incremental backup is performed.
'';
};
cleanup = {
maxAge = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "6M";
description = ''
If non-null, delete all backup sets older than the given time. Old backup sets
will not be deleted if backup sets newer than time depend on them.
'';
};
maxFull = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
example = 2;
description = ''
If non-null, delete all backups sets that are older than the count:th last full
backup (in other words, keep the last count full backups and
associated incremental sets).
'';
};
maxIncr = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
example = 1;
description = ''
If non-null, delete incremental sets of all backups sets that are
older than the count:th last full backup (in other words, keep only
old full backups and not their increments).
'';
};
};
};
config = lib.mkIf cfg.enable {
systemd = {
services.duplicity = {
description = "backup files with duplicity";
environment.HOME = stateDirectory;
script =
let
target = lib.escapeShellArg cfg.targetUrl;
extra = lib.escapeShellArgs (
[
"--archive-dir"
stateDirectory
]
++ cfg.extraFlags
);
dup = "${pkgs.duplicity}/bin/duplicity";
in
''
set -x
${dup} cleanup ${target} --force ${extra}
${lib.optionalString (
cfg.cleanup.maxAge != null
) "${dup} remove-older-than ${lib.escapeShellArg cfg.cleanup.maxAge} ${target} --force ${extra}"}
${lib.optionalString (
cfg.cleanup.maxFull != null
) "${dup} remove-all-but-n-full ${toString cfg.cleanup.maxFull} ${target} --force ${extra}"}
${lib.optionalString (
cfg.cleanup.maxIncr != null
) "${dup} remove-all-inc-of-but-n-full ${toString cfg.cleanup.maxIncr} ${target} --force ${extra}"}
exec ${dup} ${if cfg.fullIfOlderThan == "always" then "full" else "incr"} ${
lib.escapeShellArgs (
[
cfg.root
cfg.targetUrl
]
++ lib.optionals (cfg.includeFileList != null) [
"--include-filelist"
cfg.includeFileList
]
++ lib.optionals (cfg.excludeFileList != null) [
"--exclude-filelist"
cfg.excludeFileList
]
++ lib.concatMap (p: [
"--include"
p
]) cfg.include
++ lib.concatMap (p: [
"--exclude"
p
]) cfg.exclude
++ (lib.optionals (cfg.fullIfOlderThan != "never" && cfg.fullIfOlderThan != "always") [
"--full-if-older-than"
cfg.fullIfOlderThan
])
)
} ${extra}
'';
serviceConfig = {
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = "read-only";
StateDirectory = baseNameOf stateDirectory;
}
// lib.optionalAttrs (localTarget != null) {
ReadWritePaths = localTarget;
}
// lib.optionalAttrs (cfg.secretFile != null) {
EnvironmentFile = cfg.secretFile;
};
}
// lib.optionalAttrs (cfg.frequency != null) {
startAt = cfg.frequency;
};
tmpfiles.rules = lib.optional (localTarget != null) "d ${localTarget} 0700 root root -";
};
assertions = lib.singleton {
# Duplicity will fail if the last file selection option is an include. It
# is not always possible to detect but this simple case can be caught.
assertion = cfg.include != [ ] -> cfg.exclude != [ ] || cfg.extraFlags != [ ];
message = ''
Duplicity will fail if you only specify included paths ("Because the
default is to include all files, the expression is redundant. Exiting
because this probably isn't what you meant.")
'';
};
};
}

View File

@@ -0,0 +1,260 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.libvirtd.autoSnapshot;
# Function to get VM config with defaults
getVMConfig =
vm:
if lib.isString vm then
{
name = vm;
inherit (cfg) snapshotType keep;
}
else
{
inherit (vm) name;
snapshotType = if vm.snapshotType != null then vm.snapshotType else cfg.snapshotType;
keep = if vm.keep != null then vm.keep else cfg.keep;
};
# Main backup script combining all VM scripts
backupScript = ''
set -eo pipefail
# Initialize failure tracking
failed=""
# Define the VM snapshot function
function snap_vm() {
local vmName="$1"
local snapshotType="$2"
local keep="$3"
# Add validation for VM name
if ! echo "$vmName" | ${pkgs.gnugrep}/bin/grep -qE '^[a-zA-Z0-9_.-]+$'; then
echo "Invalid VM name: '$vmName'"
failed="$failed $vmName"
return
fi
echo "Processing VM: $vmName"
# Check if VM exists
if ! ${pkgs.libvirt}/bin/virsh dominfo "$vmName" >/dev/null 2>&1; then
echo "VM '$vmName' does not exist, skipping"
return
fi
# Create new snapshot
local snapshot_name
snapshot_name="${cfg.prefix}_$(date +%Y-%m-%d_%H%M%S)"
local snapshot_opts=""
[[ "$snapshotType" == "external" ]] && snapshot_opts="--disk-only"
if ! ${pkgs.libvirt}/bin/virsh snapshot-create-as \
"$vmName" \
"$snapshot_name" \
"Automatic backup snapshot" \
$snapshot_opts \
--atomic; then
echo "Failed to create snapshot for $vmName"
failed="$failed $vmName"
return
fi
# List all automatic snapshots for this VM
readarray -t SNAPSHOTS < <(${pkgs.libvirt}/bin/virsh snapshot-list "$vmName" --name | ${pkgs.gnugrep}/bin/grep "^${cfg.prefix}_")
# Count snapshots
local snapshot_count=''${#SNAPSHOTS[@]}
# Delete old snapshots if we have more than the keep limit
if [[ $snapshot_count -gt $keep ]]; then
# Sort snapshots by date (they're named with date prefix)
readarray -t TO_DELETE < <(printf '%s\n' "''${SNAPSHOTS[@]}" | ${pkgs.coreutils}/bin/sort | ${pkgs.coreutils}/bin/head -n -$keep)
for snap in "''${TO_DELETE[@]}"; do
echo "Removing old snapshot $snap from $vmName"
# Check if snapshot is internal or external
local snapshot_location
snapshot_location=$(${pkgs.libvirt}/bin/virsh snapshot-info "$vmName" --snapshotname "$snap" | ${pkgs.gnugrep}/bin/grep "Location:" | ${pkgs.gawk}/bin/awk '{print $2}')
local delete_opts=""
[[ "$snapshot_location" == "internal" ]] && delete_opts="--metadata"
if ! ${pkgs.libvirt}/bin/virsh snapshot-delete "$vmName" "$snap" $delete_opts; then
echo "Failed to remove snapshot $snap from $vmName"
failed="$failed $vmName(cleanup)"
fi
done
fi
}
${
if cfg.vms == null then
''
# Process all VMs
${pkgs.libvirt}/bin/virsh list --all --name | while read -r vm; do
# Skip empty lines
[ -z "$vm" ] && continue
# Call snap_vm function with default settings
snap_vm "$vm" ${cfg.snapshotType} ${toString cfg.keep}
done
''
else
''
# Process specific VMs from the list
${lib.concatMapStrings (
vm: with getVMConfig vm; "snap_vm '${name}' ${snapshotType} ${toString keep}\n"
) cfg.vms}
''
}
# Report any failures
if [ -n "$failed" ]; then
echo "Snapshot operation failed for:$failed"
exit 1
fi
exit 0
'';
in
{
options = {
services.libvirtd.autoSnapshot = {
enable = lib.mkEnableOption "LibVirt VM snapshots";
calendar = lib.mkOption {
type = lib.types.str;
default = "04:15:00";
description = ''
When to create snapshots (systemd calendar format).
Default is 4:15 AM.
'';
};
prefix = lib.mkOption {
type = lib.types.str;
default = "autosnap";
description = ''
Prefix for automatic snapshot names.
This is used to identify and manage automatic snapshots
separately from manual ones.
'';
};
keep = lib.mkOption {
type = lib.types.int;
default = 2;
description = "Default number of snapshots to keep for VMs that don't specify a keep value.";
};
snapshotType = lib.mkOption {
type = lib.types.enum [
"internal"
"external"
];
default = "internal";
description = "Type of snapshot to create (internal or external).";
};
vms = lib.mkOption {
type = lib.types.nullOr (
lib.types.listOf (
lib.types.oneOf [
lib.types.str
(lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Name of the VM";
};
snapshotType = lib.mkOption {
type = lib.types.nullOr (
lib.types.enum [
"internal"
"external"
]
);
default = null;
description = ''
Type of snapshot to create (internal or external).
If not specified, uses global snapshotType (${toString cfg.snapshotType}).
'';
};
keep = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = ''
Number of snapshots to keep for this VM.
If not specified, uses global keep (${toString cfg.keep}).
'';
};
};
})
]
)
);
default = null;
description = ''
If specified only the list of VMs will be snapshotted else all existing one. Each entry can be either:
- A string (VM name, uses default settings)
- An attribute set with VM configuration
'';
example = lib.literalExpression ''
[
"myvm1" # Uses defaults
{
name = "myvm2";
keep = 30; # Override retention
}
]
'';
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = (cfg.vms == null) || (lib.isList cfg.vms && cfg.vms != [ ]);
message = "'services.libvirtd.autoSnapshot.vms' must either be null for all VMs or a non-empty list of VM configurations";
}
{
assertion = config.virtualisation.libvirtd.enable;
message = "virtualisation.libvirtd must be enabled to use services.libvirtd.autoSnapshot";
}
];
systemd = {
timers.libvirtd-autosnapshot = {
description = "LibVirt VM snapshot timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.calendar;
AccuracySec = "5m";
Unit = "libvirtd-autosnapshot.service";
};
};
services.libvirtd-autosnapshot = {
description = "LibVirt VM snapshot service";
after = [ "libvirtd.service" ];
requires = [ "libvirtd.service" ];
serviceConfig = {
Type = "oneshot";
User = "root";
};
script = backupScript;
};
};
};
meta.maintainers = [ lib.maintainers._6543 ];
}

View File

@@ -0,0 +1,239 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.mysqlBackup;
defaultUser = "mysqlbackup";
# Newer mariadb versions warn of the usage of 'mysqldump' and recommend 'mariadb-dump' (https://mariadb.com/kb/en/mysqldump/)
dumpBinary =
if
(
lib.getName config.services.mysql.package == lib.getName pkgs.mariadb
&& lib.versionAtLeast config.services.mysql.package.version "11.0.0"
)
then
"${config.services.mysql.package}/bin/mariadb-dump"
else
"${config.services.mysql.package}/bin/mysqldump";
compressionAlgs = {
gzip = rec {
pkg = pkgs.gzip;
ext = ".gz";
minLevel = 1;
maxLevel = 9;
cmd = compressionLevelFlag: "${pkg}/bin/gzip -c ${cfg.gzipOptions} ${compressionLevelFlag}";
};
xz = rec {
pkg = pkgs.xz;
ext = ".xz";
minLevel = 0;
maxLevel = 9;
cmd = compressionLevelFlag: "${pkg}/bin/xz -z -c ${compressionLevelFlag} -";
};
zstd = rec {
pkg = pkgs.zstd;
ext = ".zst";
minLevel = 1;
maxLevel = 19;
cmd = compressionLevelFlag: "${pkg}/bin/zstd ${compressionLevelFlag} -";
};
};
compressionLevelFlag = lib.optionalString (cfg.compressionLevel != null) (
"-" + toString cfg.compressionLevel
);
selectedAlg = compressionAlgs.${cfg.compressionAlg};
compressionCmd = selectedAlg.cmd compressionLevelFlag;
shouldUseSingleTransaction =
db:
if lib.isBool cfg.singleTransaction then
cfg.singleTransaction
else
lib.elem db cfg.singleTransaction;
backupScript = ''
set -o pipefail
failed=""
${lib.concatMapStringsSep "\n" backupDatabaseScript cfg.databases}
if [ -n "$failed" ]; then
echo "Backup of database(s) failed:$failed"
exit 1
fi
'';
backupDatabaseScript = db: ''
dest="${cfg.location}/${db}${selectedAlg.ext}"
if ${dumpBinary} ${lib.optionalString (shouldUseSingleTransaction db) "--single-transaction"} ${db} | ${compressionCmd} > $dest.tmp; then
mv $dest.tmp $dest
echo "Backed up to $dest"
else
echo "Failed to back up to $dest"
rm -f $dest.tmp
failed="$failed ${db}"
fi
'';
in
{
options = {
services.mysqlBackup = {
enable = lib.mkEnableOption "MySQL backups";
calendar = lib.mkOption {
type = lib.types.str;
default = "01:15:00";
description = ''
Configured when to run the backup service systemd unit (DayOfWeek Year-Month-Day Hour:Minute:Second).
'';
};
compressionAlg = lib.mkOption {
type = lib.types.enum (lib.attrNames compressionAlgs);
default = "gzip";
description = ''
Compression algorithm to use for database dumps.
'';
};
compressionLevel = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = ''
Compression level to use for ${lib.concatStringsSep ", " (lib.init (lib.attrNames compressionAlgs))} or ${lib.last (lib.attrNames compressionAlgs)}.
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: algo: "- For ${name}: ${toString algo.minLevel}-${toString algo.maxLevel}"
) compressionAlgs
)}
:::{.note}
If compression level is also specified in gzipOptions, the gzipOptions value will be overwritten
:::
'';
};
user = lib.mkOption {
type = lib.types.str;
default = defaultUser;
description = ''
User to be used to perform backup.
'';
};
databases = lib.mkOption {
default = [ ];
type = lib.types.listOf lib.types.str;
description = ''
List of database names to dump.
'';
};
location = lib.mkOption {
type = lib.types.path;
default = "/var/backup/mysql";
description = ''
Location to put the compressed MySQL database dumps.
'';
};
singleTransaction = lib.mkOption {
default = false;
type = lib.types.oneOf [
lib.types.bool
(lib.types.listOf lib.types.str)
];
description = ''
Whether to create database dump in a single transaction.
Can be either a boolean for all databases or a list of database names.
'';
};
gzipOptions = lib.mkOption {
default = "--no-name --rsyncable";
type = lib.types.str;
description = ''
Command line options to use when invoking `gzip`.
Only used when compression is set to "gzip".
If compression level is specified both here and in compressionLevel, the compressionLevel value will take precedence.
'';
};
};
};
config = lib.mkIf cfg.enable {
# assert config to be correct
assertions = [
{
assertion =
cfg.compressionLevel == null
|| selectedAlg.minLevel <= cfg.compressionLevel && cfg.compressionLevel <= selectedAlg.maxLevel;
message = "${cfg.compressionAlg} compression level must be between ${toString selectedAlg.minLevel} and ${toString selectedAlg.maxLevel}";
}
{
assertion =
!(lib.isList cfg.singleTransaction)
|| lib.all (db: lib.elem db cfg.databases) cfg.singleTransaction;
message = "All databases in singleTransaction must be included in the databases option";
}
];
# ensure unix user to be used to perform backup exist.
users.users = lib.optionalAttrs (cfg.user == defaultUser) {
${defaultUser} = {
isSystemUser = true;
createHome = false;
home = cfg.location;
group = "nogroup";
};
};
# add the compression tool to the system environment.
environment.systemPackages = [ selectedAlg.pkg ];
# ensure database user to be used to perform backup exist.
services.mysql.ensureUsers = [
{
name = cfg.user;
ensurePermissions =
let
privs = "SELECT, SHOW VIEW, TRIGGER, LOCK TABLES";
grant = db: lib.nameValuePair "\\`${db}\\`.*" privs;
in
lib.listToAttrs (map grant cfg.databases);
}
];
systemd = {
timers.mysql-backup = {
description = "Mysql backup timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.calendar;
AccuracySec = "5m";
Unit = "mysql-backup.service";
};
};
services.mysql-backup = {
description = "MySQL backup service";
enable = true;
serviceConfig = {
Type = "oneshot";
User = cfg.user;
};
script = backupScript;
};
tmpfiles.rules = [
"d ${cfg.location} 0700 ${cfg.user} - - -"
];
};
};
meta.maintainers = [ lib.maintainers._6543 ];
}

View File

@@ -0,0 +1,479 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.pgbackrest;
settingsFormat = pkgs.formats.ini {
listsAsDuplicateKeys = true;
};
# pgBackRest "options"
settingsType =
with lib.types;
attrsOf (oneOf [
bool
ints.unsigned
str
(attrsOf str)
(listOf str)
]);
# Applied to both repoNNN-* and pgNNN-* options in global and stanza sections.
flattenWithIndex =
attrs: prefix:
lib.concatMapAttrs (
name:
let
index = lib.lists.findFirstIndex (n: n == name) null (lib.attrNames attrs);
index1 = index + 1;
in
lib.mapAttrs' (option: lib.nameValuePair "${prefix}${toString index1}-${option}")
) attrs;
# Remove nulls, turn attrsets into lists and bools into y/n
normalize =
x:
lib.pipe x [
(lib.filterAttrs (_: v: v != null))
(lib.mapAttrs (_: v: if lib.isAttrs v then lib.mapAttrsToList (n': v': "${n'}=${v'}") v else v))
(lib.mapAttrs (
_: v:
if v == true then
"y"
else if v == false then
"n"
else
v
))
];
fullConfig = {
global = normalize (cfg.settings // flattenWithIndex cfg.repos "repo");
}
// lib.mapAttrs' (
cmd: settings: lib.nameValuePair "global:${cmd}" (normalize settings)
) cfg.commands
// lib.mapAttrs (
_: cfg': normalize (cfg'.settings // flattenWithIndex cfg'.instances "pg")
) cfg.stanzas;
namedJobs = lib.listToAttrs (
lib.flatten (
lib.mapAttrsToList (
stanza:
{ jobs, ... }:
lib.mapAttrsToList (
job: attrs: lib.nameValuePair "pgbackrest-${stanza}-${job}" (attrs // { inherit stanza job; })
) jobs
) cfg.stanzas
)
);
disabledOption = lib.mkOption {
default = null;
readOnly = true;
internal = true;
};
secretPathOption =
with lib.types;
lib.mkOption {
type = nullOr (pathWith {
inStore = false;
absolute = true;
});
default = null;
internal = true;
};
in
{
meta = {
maintainers = with lib.maintainers; [ wolfgangwalther ];
};
# TODO: Add enableServer option and corresponding pgBackRest TLS server service.
# TODO: Write wrapper around pgbackrest to turn --repo=<name> into --repo=<number>
# The following two are dependent on improvements upstream:
# https://github.com/pgbackrest/pgbackrest/issues/2621
# TODO: Add support for more repository types
# TODO: Support passing encryption key safely
options.services.pgbackrest = {
enable = lib.mkEnableOption "pgBackRest";
repos = lib.mkOption {
type =
with lib.types;
attrsOf (
submodule (
{ config, name, ... }:
let
setHostForType =
type:
if name == "localhost" then
null
# "posix" is the default repo type, which uses the -host option.
# Other types use prefixed options, for example -sftp-host.
else if config.type or "posix" != type then
null
else
name;
in
{
freeformType = settingsType;
options.host = lib.mkOption {
type = nullOr str;
default = setHostForType "posix";
defaultText = lib.literalExpression "name";
description = "Repository host when operating remotely";
};
options.sftp-host = lib.mkOption {
type = nullOr str;
default = setHostForType "sftp";
defaultText = lib.literalExpression "name";
description = "SFTP repository host";
};
options.sftp-private-key-file = lib.mkOption {
type = nullOr (pathWith {
inStore = false;
absolute = true;
});
default = null;
description = ''
SFTP private key file.
The file must be accessible by both the pgbackrest and the postgres users.
'';
};
# The following options should not be used; they would store secrets in the store.
options.azure-key = disabledOption;
options.cipher-pass = disabledOption;
options.s3-key = disabledOption;
options.s3-key-secret = disabledOption;
options.s3-kms-key-id = disabledOption; # unsure whether that's a secret or not
options.s3-sse-customer-key = disabledOption; # unsure whether that's a secret or not
options.s3-token = disabledOption;
options.sftp-private-key-passphrase = disabledOption;
# The following options are not fully supported / tested, yet, but point to files with secrets.
# Users can already set those options, but we'll force non-store paths.
options.gcs-key = secretPathOption;
options.host-cert-file = secretPathOption;
options.host-key-file = secretPathOption;
}
)
);
default = { };
description = ''
An attribute set of repositories as described in:
<https://pgbackrest.org/configuration.html#section-repository>
Each repository defaults to set `repo-host` to the attribute's name.
The special value "localhost" will unset `repo-host`.
::: {.note}
The prefix `repoNNN-` is added automatically.
Example: Use `path` instead of `repo1-path`.
:::
'';
example = lib.literalExpression ''
{
localhost.path = "/var/lib/backup";
"backup.example.com".host-type = "tls";
}
'';
};
stanzas = lib.mkOption {
type =
with lib.types;
attrsOf (submodule {
options = {
jobs = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options.schedule = lib.mkOption {
type = lib.types.str;
description = ''
When or how often the backup should run.
Must be in the format described in {manpage}`systemd.time(7)`.
'';
};
options.type = lib.mkOption {
type = lib.types.str;
description = ''
Backup type as described in:
<https://pgbackrest.org/command.html#command-backup/category-command/option-type>
'';
};
}
);
default = { };
description = ''
Backups jobs to schedule for this stanza as described in:
<https://pgbackrest.org/user-guide.html#quickstart/schedule-backup>
'';
example = lib.literalExpression ''
{
weekly = { schedule = "Sun, 6:30"; type = "full"; };
daily = { schedule = "Mon..Sat, 6:30"; type = "diff"; };
}
'';
};
instances = lib.mkOption {
type =
with lib.types;
attrsOf (
submodule (
{ name, ... }:
{
freeformType = settingsType;
options.host = lib.mkOption {
type = nullOr str;
default = if name == "localhost" then null else name;
defaultText = lib.literalExpression ''if name == "localhost" then null else name'';
description = "PostgreSQL host for operating remotely.";
};
# The following options are not fully supported / tested, yet, but point to files with secrets.
# Users can already set those options, but we'll force non-store paths.
options.host-cert-file = secretPathOption;
options.host-key-file = secretPathOption;
}
)
);
default = { };
description = ''
An attribute set of database instances as described in:
<https://pgbackrest.org/configuration.html#section-stanza>
Each instance defaults to set `pg-host` to the attribute's name.
The special value "localhost" will unset `pg-host`.
::: {.note}
The prefix `pgNNN-` is added automatically.
Example: Use `user` instead of `pg1-user`.
:::
'';
example = lib.literalExpression ''
{
localhost.database = "app";
"postgres.example.com".port = "5433";
}
'';
};
settings = lib.mkOption {
type = lib.types.submodule {
freeformType = settingsType;
# The following options are not fully supported / tested, yet, but point to files with secrets.
# Users can already set those options, but we'll force non-store paths.
options.tls-server-cert-file = secretPathOption;
options.tls-server-key-file = secretPathOption;
};
default = { };
description = ''
An attribute set of options as described in:
<https://pgbackrest.org/configuration.html>
All options can be used.
Repository options should be set via [`repos`](#opt-services.pgbackrest.repos) instead.
Stanza options should be set via [`instances`](#opt-services.pgbackrest.stanzas._name_.instances) instead.
'';
example = lib.literalExpression ''
{
process-max = 2;
}
'';
};
};
});
default = { };
description = ''
An attribute set of stanzas as described in:
<https://pgbackrest.org/user-guide.html#quickstart/configure-stanza>
'';
};
settings = lib.mkOption {
type = lib.types.submodule {
freeformType = settingsType;
# The following options are not fully supported / tested, yet, but point to files with secrets.
# Users can already set those options, but we'll force non-store paths.
options.tls-server-cert-file = secretPathOption;
options.tls-server-key-file = secretPathOption;
};
default = { };
description = ''
An attribute set of options as described in:
<https://pgbackrest.org/configuration.html>
All globally available options, i.e. all except stanza options, can be used.
Repository options should be set via [`repos`](#opt-services.pgbackrest.repos) instead.
'';
example = lib.literalExpression ''
{
process-max = 2;
}
'';
};
commands =
lib.genAttrs
[
# List of commands from https://pgbackrest.org/command.html:
"annotate"
"archive-get"
"archive-push"
"backup"
"check"
"expire"
"help"
"info"
"repo-get"
"repo-ls"
"restore"
"server"
"server-ping"
"stanza-create"
"stanza-delete"
"stanza-upgrade"
"start"
"stop"
"verify"
"version"
]
(
command:
lib.mkOption {
type = lib.types.submodule {
freeformType = settingsType;
# The following options are not fully supported / tested, yet, but point to files with secrets.
# Users can already set those options, but we'll force non-store paths.
options.tls-server-cert-file = secretPathOption;
options.tls-server-key-file = secretPathOption;
};
default = { };
description = ''
Options for the '${command}' command.
An attribute set of options as described in:
<https://pgbackrest.org/configuration.html>
All globally available options, i.e. all except stanza options, can be used.
Repository options should be set via [`repos`](#opt-services.pgbackrest.repos) instead.
'';
}
);
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
services.pgbackrest.settings = {
log-level-console = lib.mkDefault "info";
log-level-file = lib.mkDefault "off";
cmd-ssh = lib.getExe pkgs.openssh;
};
environment.systemPackages = [ pkgs.pgbackrest ];
environment.etc."pgbackrest/pgbackrest.conf".source =
settingsFormat.generate "pgbackrest.conf" fullConfig;
users.users.pgbackrest = {
name = "pgbackrest";
group = "pgbackrest";
description = "pgBackRest service user";
isSystemUser = true;
useDefaultShell = true;
createHome = true;
home = cfg.repos.localhost.path or "/var/lib/pgbackrest";
};
users.groups.pgbackrest = { };
systemd.services = lib.mapAttrs (
_:
{
stanza,
job,
type,
...
}:
{
description = "pgBackRest job ${job} for stanza ${stanza}";
serviceConfig = {
User = "pgbackrest";
Group = "pgbackrest";
Type = "oneshot";
# stanza-create is idempotent, so safe to always run
ExecStartPre = "${lib.getExe pkgs.pgbackrest} --stanza='${stanza}' stanza-create";
ExecStart = "${lib.getExe pkgs.pgbackrest} --stanza='${stanza}' backup --type='${type}'";
};
}
) namedJobs;
systemd.timers = lib.mapAttrs (
name:
{
stanza,
job,
schedule,
...
}:
{
description = "pgBackRest job ${job} for stanza ${stanza}";
wantedBy = [ "timers.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
timerConfig = {
OnCalendar = schedule;
Persistent = true;
Unit = "${name}.service";
};
}
) namedJobs;
}
# The default stanza is set up for the local postgresql instance.
# It does not backup automatically, the systemd timer still needs to be set.
(lib.mkIf config.services.postgresql.enable {
services.pgbackrest.stanzas.default = {
settings.cmd = lib.getExe pkgs.pgbackrest;
instances.localhost = {
path = config.services.postgresql.dataDir;
user = "postgres";
};
};
# If PostgreSQL runs on the same machine, any restore will have to be done with that user.
# Keeping the lock file in a directory writeable by the postgres user prevents errors.
services.pgbackrest.commands.restore.lock-path = "/tmp/postgresql";
services.postgresql.identMap = ''
postgres pgbackrest postgres
'';
services.postgresql.initdbArgs = [ "--allow-group-access" ];
users.users.pgbackrest.extraGroups = [ "postgres" ];
services.postgresql.settings = {
archive_command = ''${lib.getExe pkgs.pgbackrest} --stanza=default archive-push "%p"'';
archive_mode = lib.mkDefault "on";
};
users.groups.pgbackrest.members = [ "postgres" ];
})
]
);
}

View File

@@ -0,0 +1,211 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.postgresqlBackup;
postgresqlBackupService =
db: dumpCmd:
let
compressSuffixes = {
"none" = "";
"gzip" = ".gz";
"zstd" = ".zstd";
};
compressSuffix = lib.getAttr cfg.compression compressSuffixes;
compressCmd = lib.getAttr cfg.compression {
"none" = "cat";
"gzip" = "${pkgs.gzip}/bin/gzip -c -${toString cfg.compressionLevel} --rsyncable";
"zstd" = "${pkgs.zstd}/bin/zstd -c -${toString cfg.compressionLevel} --rsyncable";
};
mkSqlPath = prefix: suffix: "${cfg.location}/${db}${prefix}.sql${suffix}";
curFile = mkSqlPath "" compressSuffix;
prevFile = mkSqlPath ".prev" compressSuffix;
prevFiles = map (mkSqlPath ".prev") (lib.attrValues compressSuffixes);
inProgressFile = mkSqlPath ".in-progress" compressSuffix;
in
{
enable = true;
description = "Backup of ${db} database(s)";
requires = [ "postgresql.target" ];
path = [
pkgs.coreutils
config.services.postgresql.package
];
script = ''
set -e -o pipefail
umask 0077 # ensure backup is only readable by postgres user
if [ -e ${curFile} ]; then
rm -f ${toString prevFiles}
mv ${curFile} ${prevFile}
fi
${dumpCmd} \
| ${compressCmd} \
> ${inProgressFile}
mv ${inProgressFile} ${curFile}
'';
serviceConfig = {
Type = "oneshot";
User = "postgres";
};
startAt = cfg.startAt;
};
in
{
imports = [
(lib.mkRemovedOptionModule [ "services" "postgresqlBackup" "period" ] ''
A systemd timer is now used instead of cron.
The starting time can be configured via <literal>services.postgresqlBackup.startAt</literal>.
'')
];
options = {
services.postgresqlBackup = {
enable = lib.mkEnableOption "PostgreSQL dumps";
startAt = lib.mkOption {
default = "*-*-* 01:15:00";
type = with lib.types; either (listOf str) str;
description = ''
This option defines (see `systemd.time` for format) when the
databases should be dumped.
The default is to update at 01:15 (at night) every day.
'';
};
backupAll = lib.mkOption {
default = cfg.databases == [ ];
defaultText = lib.literalExpression "services.postgresqlBackup.databases == []";
type = lib.types.bool;
description = ''
Backup all databases using pg_dumpall.
This option is mutual exclusive to
`services.postgresqlBackup.databases`.
The resulting backup dump will have the name all.sql.gz.
This option is the default if no databases are specified.
'';
};
databases = lib.mkOption {
default = [ ];
type = lib.types.listOf lib.types.str;
description = ''
List of database names to dump.
'';
};
location = lib.mkOption {
default = "/var/backup/postgresql";
type = lib.types.path;
description = ''
Path of directory where the PostgreSQL database dumps will be placed.
'';
};
pgdumpOptions = lib.mkOption {
type = lib.types.separatedString " ";
default = "-C";
description = ''
Command line options for pg_dump. This options is not used if
`config.services.postgresqlBackup.backupAll` is enabled. Note that
config.services.postgresqlBackup.backupAll is also active, when no
databases where specified.
'';
};
pgdumpAllOptions = lib.mkOption {
type = lib.types.separatedString " ";
default = "";
description = ''
Command line options for pg_dumpall. This options is not used if
`config.services.postgresqlBackup.backupAll` is disabled.
'';
};
compression = lib.mkOption {
type = lib.types.enum [
"none"
"gzip"
"zstd"
];
default = "gzip";
description = ''
The type of compression to use on the generated database dump.
'';
};
compressionLevel = lib.mkOption {
type = lib.types.ints.between 1 19;
default = 6;
description = ''
The compression level used when compression is enabled.
gzip accepts levels 1 to 9. zstd accepts levels 1 to 19.
'';
};
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
assertions = [
{
assertion = cfg.backupAll -> cfg.databases == [ ];
message = "config.services.postgresqlBackup.backupAll cannot be used together with config.services.postgresqlBackup.databases";
}
{
assertion =
cfg.compression == "none"
|| (cfg.compression == "gzip" && cfg.compressionLevel >= 1 && cfg.compressionLevel <= 9)
|| (cfg.compression == "zstd" && cfg.compressionLevel >= 1 && cfg.compressionLevel <= 19);
message = "config.services.postgresqlBackup.compressionLevel must be set between 1 and 9 for gzip and 1 and 19 for zstd";
}
];
systemd.tmpfiles.rules = [
"d '${cfg.location}' 0700 postgres - - -"
];
}
(lib.mkIf cfg.backupAll {
systemd.services.postgresqlBackup = postgresqlBackupService "all" "pg_dumpall ${cfg.pgdumpAllOptions}";
})
(lib.mkIf (!cfg.backupAll) {
systemd.services = lib.listToAttrs (
map (
db:
let
cmd = "pg_dump ${cfg.pgdumpOptions} ${db}";
in
{
name = "postgresqlBackup-${db}";
value = postgresqlBackupService db cmd;
}
) cfg.databases
);
})
]
);
meta.maintainers = with lib.maintainers; [ Scrumplex ];
}

View File

@@ -0,0 +1,214 @@
{
config,
lib,
pkgs,
...
}:
let
receiverSubmodule = {
options = {
postgresqlPackage = lib.mkPackageOption pkgs "postgresql" {
example = "postgresql_15";
};
directory = lib.mkOption {
type = lib.types.path;
example = lib.literalExpression "/mnt/pg_wal/main/";
description = ''
Directory to write the output to.
'';
};
statusInterval = lib.mkOption {
type = lib.types.int;
default = 10;
description = ''
Specifies the number of seconds between status packets sent back to the server.
This allows for easier monitoring of the progress from server.
A value of zero disables the periodic status updates completely,
although an update will still be sent when requested by the server, to avoid timeout disconnect.
'';
};
slot = lib.mkOption {
type = lib.types.str;
default = "";
example = "some_slot_name";
description = ''
Require {command}`pg_receivewal` to use an existing replication slot (see
[Section 26.2.6 of the PostgreSQL manual](https://www.postgresql.org/docs/current/warm-standby.html#STREAMING-REPLICATION-SLOTS)).
When this option is used, {command}`pg_receivewal` will report a flush position to the server,
indicating when each segment has been synchronized to disk so that the server can remove that segment if it is not otherwise needed.
When the replication client of {command}`pg_receivewal` is configured on the server as a synchronous standby,
then using a replication slot will report the flush position to the server, but only when a WAL file is closed.
Therefore, that configuration will cause transactions on the primary to wait for a long time and effectively not work satisfactorily.
The option {option}`synchronous` must be specified in addition to make this work correctly.
'';
};
synchronous = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Flush the WAL data to disk immediately after it has been received.
Also send a status packet back to the server immediately after flushing, regardless of {option}`statusInterval`.
This option should be specified if the replication client of {command}`pg_receivewal` is configured on the server as a synchronous standby,
to ensure that timely feedback is sent to the server.
'';
};
compress = lib.mkOption {
type = lib.types.ints.between 0 9;
default = 0;
description = ''
Enables gzip compression of write-ahead logs, and specifies the compression level
(`0` through `9`, `0` being no compression and `9` being best compression).
The suffix `.gz` will automatically be added to all filenames.
This option requires PostgreSQL >= 10.
'';
};
connection = lib.mkOption {
type = lib.types.str;
example = "postgresql://user@somehost";
description = ''
Specifies parameters used to connect to the server, as a connection string.
See [Section 34.1.1 of the PostgreSQL manual](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING) for more information.
Because {command}`pg_receivewal` doesn't connect to any particular database in the cluster,
database name in the connection string will be ignored.
'';
};
extraArgs = lib.mkOption {
type = with lib.types; listOf str;
default = [ ];
example = lib.literalExpression ''
[
"--no-sync"
]
'';
description = ''
A list of extra arguments to pass to the {command}`pg_receivewal` command.
'';
};
environment = lib.mkOption {
type = with lib.types; attrsOf str;
default = { };
example = lib.literalExpression ''
{
PGPASSFILE = "/private/passfile";
PGSSLMODE = "require";
}
'';
description = ''
Environment variables passed to the service.
Usable parameters are listed in [Section 34.14 of the PostgreSQL manual](https://www.postgresql.org/docs/current/libpq-envars.html).
'';
};
};
};
in
{
options = {
services.postgresqlWalReceiver = {
receivers = lib.mkOption {
type = with lib.types; attrsOf (submodule receiverSubmodule);
default = { };
example = lib.literalExpression ''
{
main = {
postgresqlPackage = pkgs.postgresql_15;
directory = /mnt/pg_wal/main/;
slot = "main_wal_receiver";
connection = "postgresql://user@somehost";
};
}
'';
description = ''
PostgreSQL WAL receivers.
Stream write-ahead logs from a PostgreSQL server using {command}`pg_receivewal` (formerly {command}`pg_receivexlog`).
See [the man page](https://www.postgresql.org/docs/current/app-pgreceivewal.html) for more information.
'';
};
};
};
config =
let
receivers = config.services.postgresqlWalReceiver.receivers;
in
lib.mkIf (receivers != { }) {
users = {
users.postgres = {
uid = config.ids.uids.postgres;
group = "postgres";
description = "PostgreSQL server user";
};
groups.postgres = {
gid = config.ids.gids.postgres;
};
};
assertions = lib.concatLists (
lib.attrsets.mapAttrsToList (name: config: [
{
assertion = config.compress > 0 -> lib.versionAtLeast config.postgresqlPackage.version "10";
message = "Invalid configuration for WAL receiver \"${name}\": compress requires PostgreSQL version >= 10.";
}
]) receivers
);
systemd.tmpfiles.rules = lib.mapAttrsToList (name: config: ''
d ${lib.escapeShellArg config.directory} 0750 postgres postgres - -
'') receivers;
systemd.services = lib.mapAttrs' (
name: config:
lib.nameValuePair "postgresql-wal-receiver-${name}" {
description = "PostgreSQL WAL receiver (${name})";
wantedBy = [ "multi-user.target" ];
startLimitIntervalSec = 0; # retry forever, useful in case of network disruption
serviceConfig = {
User = "postgres";
Group = "postgres";
KillSignal = "SIGINT";
Restart = "always";
RestartSec = 60;
};
inherit (config) environment;
script =
let
receiverCommand =
postgresqlPackage:
if (lib.versionAtLeast postgresqlPackage.version "10") then
"${postgresqlPackage}/bin/pg_receivewal"
else
"${postgresqlPackage}/bin/pg_receivexlog";
in
''
${receiverCommand config.postgresqlPackage} \
--no-password \
--directory=${lib.escapeShellArg config.directory} \
--status-interval=${toString config.statusInterval} \
--dbname=${lib.escapeShellArg config.connection} \
${lib.optionalString (config.compress > 0) "--compress=${toString config.compress}"} \
${lib.optionalString (config.slot != "") "--slot=${lib.escapeShellArg config.slot}"} \
${lib.optionalString config.synchronous "--synchronous"} \
${lib.concatStringsSep " " config.extraArgs}
'';
}
) receivers;
};
meta.maintainers = with lib.maintainers; [ euxane ];
}

View File

@@ -0,0 +1,152 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.restic.server;
in
{
meta.maintainers = [ lib.maintainers.bachp ];
options.services.restic.server = {
enable = lib.mkEnableOption "Restic REST Server";
listenAddress = lib.mkOption {
default = "8000";
example = "127.0.0.1:8080";
type = lib.types.str;
description = "Listen on a specific IP address and port or unix socket.";
};
dataDir = lib.mkOption {
default = "/var/lib/restic";
type = lib.types.path;
description = "The directory for storing the restic repository.";
};
appendOnly = lib.mkOption {
default = false;
type = lib.types.bool;
description = ''
Enable append only mode.
This mode allows creation of new backups but prevents deletion and modification of existing backups.
This can be useful when backing up systems that have a potential of being hacked.
'';
};
htpasswd-file = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.path;
description = "The path to the servers .htpasswd file. Defaults to `\${dataDir}/.htpasswd`.";
};
privateRepos = lib.mkOption {
default = false;
type = lib.types.bool;
description = ''
Enable private repos.
Grants access only when a subdirectory with the same name as the user is specified in the repository URL.
'';
};
prometheus = lib.mkOption {
default = false;
type = lib.types.bool;
description = "Enable Prometheus metrics at /metrics.";
};
extraFlags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Extra commandline options to pass to Restic REST server.
'';
};
package = lib.mkPackageOption pkgs "restic-rest-server" { };
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = lib.substring 0 1 cfg.listenAddress != ":";
message = "The restic-rest-server now uses systemd socket activation, which expects only the Port number: services.restic.server.listenAddress = \"${
lib.substring 1 6 cfg.listenAddress
}\";";
}
];
systemd.services.restic-rest-server = {
description = "Restic REST Server";
after = [
"network.target"
"restic-rest-server.socket"
];
requires = [ "restic-rest-server.socket" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = ''
${cfg.package}/bin/rest-server \
--path ${cfg.dataDir} \
${lib.optionalString (cfg.htpasswd-file != null) "--htpasswd-file ${cfg.htpasswd-file}"} \
${lib.optionalString cfg.appendOnly "--append-only"} \
${lib.optionalString cfg.privateRepos "--private-repos"} \
${lib.optionalString cfg.prometheus "--prometheus"} \
${lib.escapeShellArgs cfg.extraFlags} \
'';
Type = "simple";
User = "restic";
Group = "restic";
# Security hardening
CapabilityBoundingSet = "";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateNetwork = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
PrivateDevices = true;
ReadWritePaths = [ cfg.dataDir ];
ReadOnlyPaths = lib.optional (cfg.htpasswd-file != null) cfg.htpasswd-file;
RemoveIPC = true;
RestrictAddressFamilies = "none";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = "@system-service";
UMask = 27;
};
};
systemd.sockets.restic-rest-server = {
listenStreams = [ cfg.listenAddress ];
wantedBy = [ "sockets.target" ];
};
systemd.tmpfiles.rules = lib.mkIf cfg.privateRepos [
"f ${cfg.dataDir}/.htpasswd 0700 restic restic -"
];
users.users.restic = {
group = "restic";
home = cfg.dataDir;
createHome = true;
uid = config.ids.uids.restic;
};
users.groups.restic.gid = config.ids.uids.restic;
};
}

View File

@@ -0,0 +1,533 @@
{
config,
lib,
pkgs,
utils,
...
}:
let
# Type for a valid systemd unit option. Needed for correctly passing "timerConfig" to "systemd.timers"
inherit (utils.systemdUtils.unitOptions) unitOption;
in
{
options.services.restic.backups = lib.mkOption {
description = ''
Periodic backups to create with Restic.
'';
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
passwordFile = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = ''
Read the repository password from a file.
'';
example = "/etc/nixos/restic-password";
};
environmentFile = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = ''
file containing the credentials to access the repository, in the
format of an EnvironmentFile as described by {manpage}`systemd.exec(5)`
'';
};
rcloneOptions = lib.mkOption {
type =
with lib.types;
nullOr (
attrsOf (oneOf [
str
bool
])
);
default = null;
description = ''
Options to pass to rclone to control its behavior.
See <https://rclone.org/docs/#options> for
available options. When specifying option names, strip the
leading `--`. To set a flag such as
`--drive-use-trash`, which does not take a value,
set the value to the Boolean `true`.
'';
example = {
bwlimit = "10M";
drive-use-trash = "true";
};
};
rcloneConfig = lib.mkOption {
type =
with lib.types;
nullOr (
attrsOf (oneOf [
str
bool
])
);
default = null;
description = ''
Configuration for the rclone remote being used for backup.
See the remote's specific options under rclone's docs at
<https://rclone.org/docs/>. When specifying
option names, use the "config" name specified in the docs.
For example, to set `--b2-hard-delete` for a B2
remote, use `hard_delete = true` in the
attribute set.
Warning: Secrets set in here will be world-readable in the Nix
store! Consider using the `rcloneConfigFile`
option instead to specify secret values separately. Note that
options set here will override those set in the config file.
'';
example = {
type = "b2";
account = "xxx";
key = "xxx";
hard_delete = true;
};
};
rcloneConfigFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = ''
Path to the file containing rclone configuration. This file
must contain configuration for the remote specified in this backup
set and also must be readable by root. Options set in
`rcloneConfig` will override those set in this
file.
'';
};
inhibitsSleep = lib.mkOption {
default = false;
type = lib.types.bool;
example = true;
description = ''
Prevents the system from sleeping while backing up.
'';
};
repository = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = ''
repository to backup to.
'';
example = "sftp:backup@192.168.1.100:/backups/${name}";
};
repositoryFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = ''
Path to the file containing the repository location to backup to.
'';
};
paths = lib.mkOption {
# This is nullable for legacy reasons only. We should consider making it a pure listOf
# after some time has passed since this comment was added.
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = [ ];
description = ''
Which paths to backup, in addition to ones specified via
`dynamicFilesFrom`. If null or an empty array and
`dynamicFilesFrom` is also null, no backup command will be run.
This can be used to create a prune-only job.
'';
example = [
"/var/lib/postgresql"
"/home/user/backup"
];
};
command = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Command to pass to --stdin-from-command. If null or an empty array, and `paths`/`dynamicFilesFrom`
are also null, no backup command will be run.
'';
example = [
"sudo"
"-u"
"postgres"
"pg_dumpall"
];
};
exclude = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Patterns to exclude when backing up. See
https://restic.readthedocs.io/en/latest/040_backup.html#excluding-files for
details on syntax.
'';
example = [
"/var/cache"
"/home/*/.cache"
".git"
];
};
timerConfig = lib.mkOption {
type = lib.types.nullOr (lib.types.attrsOf unitOption);
default = {
OnCalendar = "daily";
Persistent = true;
};
description = ''
When to run the backup. See {manpage}`systemd.timer(5)` for
details. If null no timer is created and the backup will only
run when explicitly started.
'';
example = {
OnCalendar = "00:05";
RandomizedDelaySec = "5h";
Persistent = true;
};
};
user = lib.mkOption {
type = lib.types.str;
default = "root";
description = ''
As which user the backup should run.
'';
example = "postgresql";
};
extraBackupArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Extra arguments passed to restic backup.
'';
example = [
"--cleanup-cache"
"--exclude-file=/etc/nixos/restic-ignore"
];
};
extraOptions = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Extra extended options to be passed to the restic --option flag.
'';
example = [
"sftp.command='ssh backup@192.168.1.100 -i /home/user/.ssh/id_rsa -s sftp'"
];
};
initialize = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Create the repository if it doesn't exist.
'';
};
pruneOpts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
A list of options (--keep-\* et al.) for 'restic forget
--prune', to automatically prune old snapshots. The
'forget' command is run *after* the 'backup' command, so
keep that in mind when constructing the --keep-\* options.
'';
example = [
"--keep-daily 7"
"--keep-weekly 5"
"--keep-monthly 12"
"--keep-yearly 75"
];
};
runCheck = lib.mkOption {
type = lib.types.bool;
default = builtins.length config.services.restic.backups.${name}.checkOpts > 0;
defaultText = lib.literalExpression ''builtins.length config.services.backups.${name}.checkOpts > 0'';
description = "Whether to run the `check` command with the provided `checkOpts` options.";
example = true;
};
checkOpts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
A list of options for 'restic check'.
'';
example = [
"--with-cache"
];
};
dynamicFilesFrom = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = ''
A script that produces a list of files to back up. The
results of this command are given to the '--files-from'
option. The result is merged with paths specified via `paths`.
'';
example = "find /home/matt/git -type d -name .git";
};
backupPrepareCommand = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = ''
A script that must run before starting the backup process.
'';
};
backupCleanupCommand = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = ''
A script that must run after finishing the backup process.
'';
};
package = lib.mkPackageOption pkgs "restic" { };
createWrapper = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to generate and add a script to the system path, that has the same environment variables set
as the systemd service. This can be used to e.g. mount snapshots or perform other opterations, without
having to manually specify most options.
'';
};
progressFps = lib.mkOption {
type = with lib.types; nullOr numbers.nonnegative;
default = null;
description = ''
Controls the frequency of progress reporting.
'';
example = 0.1;
};
};
}
)
);
default = { };
example = {
localbackup = {
paths = [ "/home" ];
exclude = [ "/home/*/.cache" ];
repository = "/mnt/backup-hdd";
passwordFile = "/etc/nixos/secrets/restic-password";
initialize = true;
};
remotebackup = {
paths = [ "/home" ];
repository = "sftp:backup@host:/backups/home";
passwordFile = "/etc/nixos/secrets/restic-password";
extraOptions = [
"sftp.command='ssh backup@host -i /etc/nixos/secrets/backup-private-key -s sftp'"
];
timerConfig = {
OnCalendar = "00:05";
RandomizedDelaySec = "5h";
};
};
commandbackup = {
command = [
"\${lib.getExe pkgs.sudo}"
"-u postgres"
"\${pkgs.postgresql}/bin/pg_dumpall"
];
extraBackupArgs = [ "--tag database" ];
repository = "s3:example.com/mybucket";
passwordFile = "/etc/nixos/secrets/restic-password";
environmentFile = "/etc/nixos/secrets/restic-environment";
pruneOpts = [
"--keep-daily 14"
"--keep-weekly 4"
"--keep-monthly 2"
"--group-by tags"
];
};
};
};
config = {
assertions = lib.flatten (
lib.mapAttrsToList (name: backup: [
{
assertion =
((backup.repository == null) != (backup.repositoryFile == null))
|| (backup.environmentFile != null);
message = "services.restic.backups.${name}: exactly one of repository, repositoryFile or environmentFile should be set";
}
{
assertion =
let
fileBackup = (backup.paths != null && backup.paths != [ ]) || backup.dynamicFilesFrom != null;
commandBackup = backup.command != [ ];
in
!(fileBackup && commandBackup);
message = "services.restic.backups.${name}: cannot do both a command backup and a file backup at the same time.";
}
{
assertion = (backup.passwordFile != null) || (backup.environmentFile != null);
message = "services.restic.backups.${name}: passwordFile or environmentFile must be set";
}
]) config.services.restic.backups
);
systemd.services = lib.mapAttrs' (
name: backup:
let
extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions;
inhibitCmd = lib.concatStringsSep " " [
"${pkgs.systemd}/bin/systemd-inhibit"
"--mode='block'"
"--who='restic'"
"--what='sleep'"
"--why=${lib.escapeShellArg "Scheduled backup ${name}"} "
];
resticCmd = "${lib.optionalString backup.inhibitsSleep inhibitCmd}${lib.getExe backup.package}${extraOptions}";
excludeFlags = lib.optional (
backup.exclude != [ ]
) "--exclude-file=${pkgs.writeText "exclude-patterns" (lib.concatStringsSep "\n" backup.exclude)}";
filesFromTmpFile = "/run/restic-backups-${name}/includes";
fileBackup = (backup.dynamicFilesFrom != null) || (backup.paths != null && backup.paths != [ ]);
commandBackup = backup.command != [ ];
doBackup = fileBackup || commandBackup;
pruneCmd = lib.optionals (builtins.length backup.pruneOpts > 0) [
(resticCmd + " unlock")
(resticCmd + " forget --prune " + (lib.concatStringsSep " " backup.pruneOpts))
];
checkCmd = lib.optionals backup.runCheck [
(resticCmd + " check " + (lib.concatStringsSep " " backup.checkOpts))
];
# Helper functions for rclone remotes
rcloneRemoteName = builtins.elemAt (lib.splitString ":" backup.repository) 1;
rcloneAttrToOpt = v: "RCLONE_" + lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v);
rcloneAttrToConf = v: "RCLONE_CONFIG_" + lib.toUpper (rcloneRemoteName + "_" + v);
toRcloneVal = v: if lib.isBool v then lib.boolToString v else v;
in
lib.nameValuePair "restic-backups-${name}" (
{
environment = {
# not %C, because that wouldn't work in the wrapper script
RESTIC_CACHE_DIR = "/var/cache/restic-backups-${name}";
RESTIC_PASSWORD_FILE = backup.passwordFile;
RESTIC_REPOSITORY = backup.repository;
RESTIC_REPOSITORY_FILE = backup.repositoryFile;
}
// lib.optionalAttrs (backup.rcloneOptions != null) (
lib.mapAttrs' (
name: value: lib.nameValuePair (rcloneAttrToOpt name) (toRcloneVal value)
) backup.rcloneOptions
)
// lib.optionalAttrs (backup.rcloneConfigFile != null) {
RCLONE_CONFIG = backup.rcloneConfigFile;
}
// lib.optionalAttrs (backup.rcloneConfig != null) (
lib.mapAttrs' (
name: value: lib.nameValuePair (rcloneAttrToConf name) (toRcloneVal value)
) backup.rcloneConfig
)
// lib.optionalAttrs (backup.progressFps != null) {
RESTIC_PROGRESS_FPS = toString backup.progressFps;
};
path = [ config.programs.ssh.package ];
restartIfChanged = false;
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart =
lib.optionals doBackup [
"${resticCmd} backup ${
lib.concatStringsSep " " (
backup.extraBackupArgs
++ lib.optionals fileBackup (excludeFlags ++ [ "--files-from=${filesFromTmpFile}" ])
++ lib.optionals commandBackup ([ "--stdin-from-command=true --" ] ++ backup.command)
)
}"
]
++ pruneCmd
++ checkCmd;
User = backup.user;
RuntimeDirectory = "restic-backups-${name}";
CacheDirectory = "restic-backups-${name}";
CacheDirectoryMode = "0700";
PrivateTmp = true;
}
// lib.optionalAttrs (backup.environmentFile != null) {
EnvironmentFile = backup.environmentFile;
};
}
// lib.optionalAttrs (backup.initialize || doBackup || backup.backupPrepareCommand != null) {
preStart = ''
${lib.optionalString (backup.backupPrepareCommand != null) ''
${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand}
''}
${lib.optionalString backup.initialize ''
${resticCmd} cat config > /dev/null || ${resticCmd} init
''}
${lib.optionalString (backup.paths != null && backup.paths != [ ]) ''
cat ${pkgs.writeText "staticPaths" (lib.concatLines backup.paths)} >> ${filesFromTmpFile}
''}
${lib.optionalString (backup.dynamicFilesFrom != null) ''
${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} >> ${filesFromTmpFile}
''}
'';
}
// lib.optionalAttrs (doBackup || backup.backupCleanupCommand != null) {
postStop = ''
${lib.optionalString (backup.backupCleanupCommand != null) ''
${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand}
''}
${lib.optionalString fileBackup ''
rm ${filesFromTmpFile}
''}
'';
}
)
) config.services.restic.backups;
systemd.timers = lib.mapAttrs' (
name: backup:
lib.nameValuePair "restic-backups-${name}" {
wantedBy = [ "timers.target" ];
inherit (backup) timerConfig;
}
) (lib.filterAttrs (_: backup: backup.timerConfig != null) config.services.restic.backups);
# generate wrapper scripts, as described in the createWrapper option
environment.systemPackages = lib.mapAttrsToList (
name: backup:
let
extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions;
resticCmd = "${lib.getExe backup.package}${extraOptions}";
in
pkgs.writeShellScriptBin "restic-${name}" ''
set -a # automatically export variables
${lib.optionalString (backup.environmentFile != null) "source ${backup.environmentFile}"}
# set same environment variables as the systemd service
${lib.pipe config.systemd.services."restic-backups-${name}".environment [
(lib.filterAttrs (n: v: v != null && n != "PATH"))
(lib.mapAttrs (_: v: "${v}"))
lib.toShellVars
]}
PATH=${config.systemd.services."restic-backups-${name}".environment.PATH}:$PATH
exec ${resticCmd} "$@"
''
) (lib.filterAttrs (_: v: v.createWrapper) config.services.restic.backups);
};
}

View File

@@ -0,0 +1,83 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.rsnapshot;
cfgfile = pkgs.writeText "rsnapshot.conf" ''
config_version 1.2
cmd_cp ${pkgs.coreutils}/bin/cp
cmd_rm ${pkgs.coreutils}/bin/rm
cmd_rsync ${pkgs.rsync}/bin/rsync
cmd_ssh ${pkgs.openssh}/bin/ssh
cmd_logger ${pkgs.inetutils}/bin/logger
cmd_du ${pkgs.coreutils}/bin/du
cmd_rsnapshot_diff ${pkgs.rsnapshot}/bin/rsnapshot-diff
lockfile /run/rsnapshot.pid
link_dest 1
${cfg.extraConfig}
'';
in
{
options = {
services.rsnapshot = {
enable = lib.mkEnableOption "rsnapshot backups";
enableManualRsnapshot = lib.mkOption {
description = "Whether to enable manual usage of the rsnapshot command with this module.";
default = true;
type = lib.types.bool;
};
extraConfig = lib.mkOption {
default = "";
example = ''
retains hourly 24
retain daily 365
backup /home/ localhost/
'';
type = lib.types.lines;
description = ''
rsnapshot configuration option in addition to the defaults from
rsnapshot and this module.
Note that tabs are required to separate option arguments, and
directory names require trailing slashes.
The "extra" in the option name might be a little misleading right
now, as it is required to get a functional configuration.
'';
};
cronIntervals = lib.mkOption {
default = { };
example = {
hourly = "0 * * * *";
daily = "50 21 * * *";
};
type = lib.types.attrsOf lib.types.str;
description = ''
Periodicity at which intervals should be run by cron.
Note that the intervals also have to exist in configuration
as retain options.
'';
};
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
services.cron.systemCronJobs = lib.mapAttrsToList (
interval: time: "${time} root ${pkgs.rsnapshot}/bin/rsnapshot -c ${cfgfile} ${interval}"
) cfg.cronIntervals;
}
(lib.mkIf cfg.enableManualRsnapshot {
environment.systemPackages = [ pkgs.rsnapshot ];
environment.etc."rsnapshot.conf".source = cfgfile;
})
]
);
}

View File

@@ -0,0 +1,296 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.sanoid;
datasetSettingsType =
with lib.types;
(attrsOf (
nullOr (oneOf [
str
int
bool
(listOf str)
])
))
// {
description = "dataset/template options";
};
commonOptions = {
hourly = lib.mkOption {
description = "Number of hourly snapshots.";
type = with lib.types; nullOr ints.unsigned;
default = null;
};
daily = lib.mkOption {
description = "Number of daily snapshots.";
type = with lib.types; nullOr ints.unsigned;
default = null;
};
monthly = lib.mkOption {
description = "Number of monthly snapshots.";
type = with lib.types; nullOr ints.unsigned;
default = null;
};
yearly = lib.mkOption {
description = "Number of yearly snapshots.";
type = with lib.types; nullOr ints.unsigned;
default = null;
};
autoprune = lib.mkOption {
description = "Whether to automatically prune old snapshots.";
type = with lib.types; nullOr bool;
default = null;
};
autosnap = lib.mkOption {
description = "Whether to automatically take snapshots.";
type = with lib.types; nullOr bool;
default = null;
};
pre_snapshot_script = lib.mkOption {
description = "Script to run before taking snapshot.";
type = with lib.types; nullOr str;
default = null;
};
post_snapshot_script = lib.mkOption {
description = "Script to run after taking snapshot.";
type = with lib.types; nullOr str;
default = null;
};
pruning_script = lib.mkOption {
description = "Script to run after pruning snapshot.";
type = with lib.types; nullOr str;
default = null;
};
no_inconsistent_snapshot = lib.mkOption {
description = "Whether to take a snapshot if the pre script fails";
type = with lib.types; nullOr bool;
default = null;
};
force_post_snapshot_script = lib.mkOption {
description = "Whether to run the post script if the pre script fails";
type = with lib.types; nullOr bool;
default = null;
};
script_timeout = lib.mkOption {
description = "Time limit for pre/post/pruning script execution time (<=0 for infinite).";
type = with lib.types; nullOr int;
default = null;
};
};
datasetOptions = rec {
use_template = lib.mkOption {
description = "Names of the templates to use for this dataset.";
type = lib.types.listOf (
lib.types.str
// {
check = (lib.types.enum (lib.attrNames cfg.templates)).check;
description = "configured template name";
}
);
default = [ ];
};
useTemplate = use_template;
recursive = lib.mkOption {
description = ''
Whether to recursively snapshot dataset children.
You can also set this to `"zfs"` to handle datasets
recursively in an atomic way without the possibility to
override settings for child datasets.
'';
type =
with lib.types;
oneOf [
bool
(enum [ "zfs" ])
];
default = false;
};
process_children_only = lib.mkOption {
description = "Whether to only snapshot child datasets if recursing.";
type = lib.types.bool;
default = false;
};
processChildrenOnly = process_children_only;
};
# Extract unique dataset names
datasets = lib.unique (lib.attrNames cfg.datasets);
# Function to build "zfs allow" and "zfs unallow" commands for the
# filesystems we've delegated permissions to.
buildAllowCommand =
zfsAction: permissions: dataset:
lib.escapeShellArgs [
# Here we explicitly use the booted system to guarantee the stable API needed by ZFS
"-+/run/booted-system/sw/bin/zfs"
zfsAction
"sanoid"
(lib.concatStringsSep "," permissions)
dataset
];
configFile =
let
mkValueString =
v: if lib.isList v then lib.concatStringsSep "," v else lib.generators.mkValueStringDefault { } v;
mkKeyValue =
k: v:
if v == null then
""
else if k == "processChildrenOnly" then
""
else if k == "useTemplate" then
""
else
lib.generators.mkKeyValueDefault { inherit mkValueString; } "=" k v;
in
lib.generators.toINI { inherit mkKeyValue; } cfg.settings;
in
{
# Interface
options.services.sanoid = {
enable = lib.mkEnableOption "Sanoid ZFS snapshotting service";
package = lib.mkPackageOption pkgs "sanoid" { };
interval = lib.mkOption {
type = lib.types.str;
default = "hourly";
example = "daily";
description = ''
Run sanoid at this interval. The default is to run hourly.
The format is described in
{manpage}`systemd.time(7)`.
'';
};
datasets = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
{ config, options, ... }:
{
freeformType = datasetSettingsType;
options = commonOptions // datasetOptions;
config.use_template = lib.modules.mkAliasAndWrapDefsWithPriority lib.id (
options.useTemplate or { }
);
config.process_children_only = lib.modules.mkAliasAndWrapDefsWithPriority lib.id (
options.processChildrenOnly or { }
);
}
)
);
default = { };
description = "Datasets to snapshot.";
};
templates = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
freeformType = datasetSettingsType;
options = commonOptions;
}
);
default = { };
description = "Templates for datasets.";
};
settings = lib.mkOption {
type = lib.types.attrsOf datasetSettingsType;
description = ''
Free-form settings written directly to the config file. See
<https://github.com/jimsalterjrs/sanoid/blob/master/sanoid.defaults.conf>
for allowed values.
'';
};
extraArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"--verbose"
"--readonly"
"--debug"
];
description = ''
Extra arguments to pass to sanoid. See
<https://github.com/jimsalterjrs/sanoid/#sanoid-command-line-options>
for allowed options.
'';
};
};
# Implementation
config = lib.mkIf cfg.enable {
services.sanoid.settings = lib.mkMerge [
(lib.mapAttrs' (d: v: lib.nameValuePair ("template_" + d) v) cfg.templates)
(lib.mapAttrs (d: v: v) cfg.datasets)
];
systemd.services.sanoid = {
description = "Sanoid snapshot service";
serviceConfig = {
ExecStartPre = (
map (buildAllowCommand "allow" [
"snapshot"
"mount"
"destroy"
]) datasets
);
ExecStopPost = (
map (buildAllowCommand "unallow" [
"snapshot"
"mount"
"destroy"
]) datasets
);
ExecStart = lib.escapeShellArgs (
[
"${cfg.package}/bin/sanoid"
"--cron"
"--configdir"
(pkgs.writeTextDir "sanoid.conf" configFile)
]
++ cfg.extraArgs
);
User = "sanoid";
Group = "sanoid";
DynamicUser = true;
RuntimeDirectory = "sanoid";
CacheDirectory = "sanoid";
};
# Prevents missing snapshots during DST changes
environment.TZ = "UTC";
after = [ "zfs.target" ];
startAt = cfg.interval;
};
};
meta.maintainers = with lib.maintainers; [ lopsided98 ];
}

View File

@@ -0,0 +1,240 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.snapraid;
in
{
imports = [
# Should have never been on the top-level.
(lib.mkRenamedOptionModule [ "snapraid" ] [ "services" "snapraid" ])
];
options.services.snapraid = with lib.types; {
enable = lib.mkEnableOption "SnapRAID";
dataDisks = lib.mkOption {
default = { };
example = {
d1 = "/mnt/disk1/";
d2 = "/mnt/disk2/";
d3 = "/mnt/disk3/";
};
description = "SnapRAID data disks.";
type = attrsOf str;
};
parityFiles = lib.mkOption {
default = [ ];
example = [
"/mnt/diskp/snapraid.parity"
"/mnt/diskq/snapraid.2-parity"
"/mnt/diskr/snapraid.3-parity"
"/mnt/disks/snapraid.4-parity"
"/mnt/diskt/snapraid.5-parity"
"/mnt/disku/snapraid.6-parity"
];
description = "SnapRAID parity files.";
type = listOf str;
};
contentFiles = lib.mkOption {
default = [ ];
example = [
"/var/snapraid.content"
"/mnt/disk1/snapraid.content"
"/mnt/disk2/snapraid.content"
];
description = "SnapRAID content list files.";
type = listOf str;
};
exclude = lib.mkOption {
default = [ ];
example = [
"*.unrecoverable"
"/tmp/"
"/lost+found/"
];
description = "SnapRAID exclude directives.";
type = listOf str;
};
touchBeforeSync = lib.mkOption {
default = true;
example = false;
description = "Whether {command}`snapraid touch` should be run before {command}`snapraid sync`.";
type = bool;
};
sync.interval = lib.mkOption {
default = "01:00";
example = "daily";
description = "How often to run {command}`snapraid sync`.";
type = str;
};
scrub = {
interval = lib.mkOption {
default = "Mon *-*-* 02:00:00";
example = "weekly";
description = "How often to run {command}`snapraid scrub`.";
type = str;
};
plan = lib.mkOption {
default = 8;
example = 5;
description = "Percent of the array that should be checked by {command}`snapraid scrub`.";
type = int;
};
olderThan = lib.mkOption {
default = 10;
example = 20;
description = "Number of days since data was last scrubbed before it can be scrubbed again.";
type = int;
};
};
extraConfig = lib.mkOption {
default = "";
example = ''
nohidden
blocksize 256
hashsize 16
autosave 500
pool /pool
'';
description = "Extra config options for SnapRAID.";
type = lines;
};
};
config =
let
nParity = builtins.length cfg.parityFiles;
mkPrepend = pre: s: pre + s;
in
lib.mkIf cfg.enable {
assertions = [
{
assertion = nParity <= 6;
message = "You can have no more than six SnapRAID parity files.";
}
{
assertion = builtins.length cfg.contentFiles >= nParity + 1;
message = "There must be at least one SnapRAID content file for each SnapRAID parity file plus one.";
}
];
environment = {
systemPackages = with pkgs; [ snapraid ];
etc."snapraid.conf" = {
text =
with cfg;
let
prependData = mkPrepend "data ";
prependContent = mkPrepend "content ";
prependExclude = mkPrepend "exclude ";
in
lib.concatStringsSep "\n" (
map prependData ((lib.mapAttrsToList (name: value: name + " " + value)) dataDisks)
++ lib.zipListsWith (a: b: a + b) (
[ "parity " ] ++ map (i: toString i + "-parity ") (lib.range 2 6)
) parityFiles
++ map prependContent contentFiles
++ map prependExclude exclude
)
+ "\n"
+ extraConfig;
};
};
systemd.services = with cfg; {
snapraid-scrub = {
description = "Scrub the SnapRAID array";
startAt = scrub.interval;
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.snapraid}/bin/snapraid scrub -p ${toString scrub.plan} -o ${toString scrub.olderThan}";
Nice = 19;
IOSchedulingPriority = 7;
CPUSchedulingPolicy = "batch";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
RestrictAddressFamilies = "none";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = "@system-service";
SystemCallErrorNumber = "EPERM";
CapabilityBoundingSet = "CAP_DAC_OVERRIDE";
ProtectSystem = "strict";
ProtectHome = "read-only";
ReadWritePaths =
# scrub requires access to directories containing content files
# to remove them if they are stale
let
contentDirs = map dirOf contentFiles;
in
lib.unique (lib.attrValues dataDisks ++ contentDirs);
};
unitConfig.After = "snapraid-sync.service";
};
snapraid-sync = {
description = "Synchronize the state of the SnapRAID array";
startAt = sync.interval;
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.snapraid}/bin/snapraid sync";
Nice = 19;
IOSchedulingPriority = 7;
CPUSchedulingPolicy = "batch";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
RestrictAddressFamilies = "none";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = "@system-service";
SystemCallErrorNumber = "EPERM";
CapabilityBoundingSet = "CAP_DAC_OVERRIDE" + lib.optionalString cfg.touchBeforeSync " CAP_FOWNER";
ProtectSystem = "strict";
ProtectHome = "read-only";
ReadWritePaths =
# sync requires access to directories containing content files
# to remove them if they are stale
let
contentDirs = map dirOf contentFiles;
# Multiple "split" parity files can be specified in a single
# "parityFile", separated by a comma.
# https://www.snapraid.it/manual#7.1
splitParityFiles = map (s: lib.splitString "," s) parityFiles;
in
lib.unique (lib.attrValues dataDisks ++ splitParityFiles ++ contentDirs);
}
// lib.optionalAttrs touchBeforeSync {
ExecStartPre = "${pkgs.snapraid}/bin/snapraid touch";
};
};
};
};
}

View File

@@ -0,0 +1,481 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.syncoid;
# Extract local dasaset names (so no datasets containing "@")
localDatasetName =
d:
lib.optionals (d != null) (
let
m = builtins.match "([^/@]+[^@]*)" d;
in
lib.optionals (m != null) m
);
# Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
escapeUnitName =
name:
lib.concatMapStrings (s: if lib.isList s then "-" else s) (
builtins.split "[^a-zA-Z0-9_.\\-]+" name
);
# Function to build "zfs allow" commands for the filesystems we've delegated
# permissions to. It also checks if the target dataset exists before
# delegating permissions, if it doesn't exist we delegate it to the parent
# dataset (if it exists). This should solve the case of provisoning new
# datasets.
buildAllowCommand =
permissions: dataset:
"-+${pkgs.writeShellScript "zfs-allow-${dataset}" ''
# Here we explicitly use the booted system to guarantee the stable API needed by ZFS
# Run a ZFS list on the dataset to check if it exists
if ${
lib.escapeShellArgs [
"/run/booted-system/sw/bin/zfs"
"list"
dataset
]
} 2> /dev/null; then
${lib.escapeShellArgs [
"/run/booted-system/sw/bin/zfs"
"allow"
cfg.user
(lib.concatStringsSep "," permissions)
dataset
]}
${lib.optionalString ((builtins.dirOf dataset) != ".") ''
else
${lib.escapeShellArgs [
"/run/booted-system/sw/bin/zfs"
"allow"
cfg.user
(lib.concatStringsSep "," permissions)
# Remove the last part of the path
(builtins.dirOf dataset)
]}
''}
fi
''}";
# Function to build "zfs unallow" commands for the filesystems we've
# delegated permissions to. Here we unallow both the target but also
# on the parent dataset because at this stage we have no way of
# knowing if the allow command did execute on the parent dataset or
# not in the pre-hook. We can't run the same if in the post hook
# since the dataset should have been created at this point.
buildUnallowCommand =
permissions: dataset:
"-+${pkgs.writeShellScript "zfs-unallow-${dataset}" ''
# Here we explicitly use the booted system to guarantee the stable API needed by ZFS
${lib.escapeShellArgs [
"/run/booted-system/sw/bin/zfs"
"unallow"
cfg.user
(lib.concatStringsSep "," permissions)
dataset
]}
${lib.optionalString ((builtins.dirOf dataset) != ".") (
lib.escapeShellArgs [
"/run/booted-system/sw/bin/zfs"
"unallow"
cfg.user
(lib.concatStringsSep "," permissions)
# Remove the last part of the path
(builtins.dirOf dataset)
]
)}
''}";
in
{
# Interface
options.services.syncoid = {
enable = lib.mkEnableOption "Syncoid ZFS synchronization service";
package = lib.mkPackageOption pkgs "sanoid" { };
interval = lib.mkOption {
type = with lib.types; either str (listOf str);
default = "hourly";
example = "*-*-* *:15:00";
description = ''
Run syncoid at this interval. The default is to run hourly.
Must be in the format described in {manpage}`systemd.time(7)`. This is
equivalent to adding a corresponding timer unit with
{option}`OnCalendar` set to the value given here.
Set to an empty list to avoid starting syncoid automatically.
'';
};
user = lib.mkOption {
type = lib.types.str;
default = "syncoid";
example = "backup";
description = ''
The user for the service. ZFS privilege delegation will be
automatically configured for any local pools used by syncoid if this
option is set to a user other than root. The user will be given the
"hold" and "send" privileges on any pool that has datasets being sent
and the "create", "mount", "receive", and "rollback" privileges on
any pool that has datasets being received.
'';
};
group = lib.mkOption {
type = lib.types.str;
default = "syncoid";
example = "backup";
description = "The group for the service.";
};
sshKey = lib.mkOption {
type = with lib.types; nullOr (coercedTo path toString str);
default = null;
description = ''
SSH private key file to use to login to the remote system. Can be
overridden in individual commands.
'';
};
localSourceAllow = lib.mkOption {
type = lib.types.listOf lib.types.str;
# Permissions snapshot and destroy are in case --no-sync-snap is not used
default = [
"bookmark"
"hold"
"send"
"snapshot"
"destroy"
"mount"
];
description = ''
Permissions granted for the {option}`services.syncoid.user` user
for local source datasets. See
<https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
for available permissions.
'';
};
localTargetAllow = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"change-key"
"compression"
"create"
"mount"
"mountpoint"
"receive"
"rollback"
];
example = [
"create"
"mount"
"receive"
"rollback"
];
description = ''
Permissions granted for the {option}`services.syncoid.user` user
for local target datasets. See
<https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
for available permissions.
Make sure to include the `change-key` permission if you send raw encrypted datasets,
the `compression` permission if you send raw compressed datasets, and so on.
For remote target datasets you'll have to set your remote user permissions by yourself.
'';
};
commonArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "--no-sync-snap" ];
description = ''
Arguments to add to every syncoid command, unless disabled for that
command. See
<https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options>
for available options.
'';
};
service = lib.mkOption {
type = lib.types.attrs;
default = { };
description = ''
Systemd configuration common to all syncoid services.
'';
};
commands = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
source = lib.mkOption {
type = lib.types.str;
example = "pool/dataset";
description = ''
Source ZFS dataset. Can be either local or remote. Defaults to
the attribute name.
'';
};
target = lib.mkOption {
type = lib.types.str;
example = "user@server:pool/dataset";
description = ''
Target ZFS dataset. Can be either local
(«pool/dataset») or remote
(«user@server:pool/dataset»).
'';
};
recursive = lib.mkEnableOption ''the transfer of child datasets'';
sshKey = lib.mkOption {
type = with lib.types; nullOr (coercedTo path toString str);
description = ''
SSH private key file to use to login to the remote system.
Defaults to {option}`services.syncoid.sshKey` option.
'';
};
localSourceAllow = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Permissions granted for the {option}`services.syncoid.user` user
for local source datasets. See
<https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
for available permissions.
Defaults to {option}`services.syncoid.localSourceAllow` option.
'';
};
localTargetAllow = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Permissions granted for the {option}`services.syncoid.user` user
for local target datasets. See
<https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
for available permissions.
Make sure to include the `change-key` permission if you send raw encrypted datasets,
the `compression` permission if you send raw compressed datasets, and so on.
For remote target datasets you'll have to set your remote user permissions by yourself.
'';
};
sendOptions = lib.mkOption {
type = lib.types.separatedString " ";
default = "";
example = "Lc e";
description = ''
Advanced options to pass to zfs send. Options are specified
without their leading dashes and separated by spaces.
'';
};
recvOptions = lib.mkOption {
type = lib.types.separatedString " ";
default = "";
example = "ux recordsize o compression=lz4";
description = ''
Advanced options to pass to zfs recv. Options are specified
without their leading dashes and separated by spaces.
'';
};
useCommonArgs = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to add the configured common arguments to this command.
'';
};
service = lib.mkOption {
type = lib.types.attrs;
default = { };
description = ''
Systemd configuration specific to this syncoid service.
'';
};
extraArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "--sshport 2222" ];
description = "Extra syncoid arguments for this command.";
};
};
config = {
source = lib.mkDefault name;
sshKey = lib.mkDefault cfg.sshKey;
localSourceAllow = lib.mkDefault cfg.localSourceAllow;
localTargetAllow = lib.mkDefault cfg.localTargetAllow;
};
}
)
);
default = { };
example = lib.literalExpression ''
{
"pool/test".target = "root@target:pool/test";
}
'';
description = "Syncoid commands to run.";
};
};
# Implementation
config = lib.mkIf cfg.enable {
users = {
users = lib.mkIf (cfg.user == "syncoid") {
syncoid = {
group = cfg.group;
isSystemUser = true;
# For syncoid to be able to create /var/lib/syncoid/.ssh/
# and to use custom ssh_config or known_hosts.
home = "/var/lib/syncoid";
createHome = false;
};
};
groups = lib.mkIf (cfg.group == "syncoid") {
syncoid = { };
};
};
systemd.services = lib.mapAttrs' (
name: c:
lib.nameValuePair "syncoid-${escapeUnitName name}" (
lib.mkMerge [
{
description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}";
after = [ "zfs.target" ];
startAt = cfg.interval;
# syncoid may need zpool to get feature@extensible_dataset
path = [ "/run/booted-system/sw/bin/" ];
serviceConfig = {
ExecStartPre =
(map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source))
++ (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target));
ExecStopPost =
(map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source))
++ (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target));
ExecStart = lib.escapeShellArgs (
[ "${cfg.package}/bin/syncoid" ]
++ lib.optionals c.useCommonArgs cfg.commonArgs
++ lib.optional c.recursive "-r"
++ lib.optionals (c.sshKey != null) [
"--sshkey"
c.sshKey
]
++ c.extraArgs
++ [
"--sendoptions"
c.sendOptions
"--recvoptions"
c.recvOptions
"--no-privilege-elevation"
c.source
c.target
]
);
User = cfg.user;
Group = cfg.group;
StateDirectory = [ "syncoid" ];
StateDirectoryMode = "700";
# Prevent SSH control sockets of different syncoid services from interfering
PrivateTmp = true;
# Permissive access to /proc because syncoid
# calls ps(1) to detect ongoing `zfs receive`.
ProcSubset = "all";
ProtectProc = "default";
# The following options are only for optimizing:
# systemd-analyze security | grep syncoid-'*'
AmbientCapabilities = "";
CapabilityBoundingSet = "";
DeviceAllow = [ "/dev/zfs" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateNetwork = lib.mkDefault false;
PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RootDirectory = "/run/syncoid/${escapeUnitName name}";
RootDirectoryStartOnly = true;
BindPaths = [ "/dev/zfs" ];
BindReadOnlyPaths = [
builtins.storeDir
"/etc"
"/run"
"/bin/sh"
];
# Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
MountAPIVFS = true;
# Create RootDirectory= in the host's mount namespace.
RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
RuntimeDirectoryMode = "700";
SystemCallFilter = [
"@system-service"
# Groups in @system-service which do not contain a syscall listed by:
# perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
# awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
# systemd-analyze syscall-filter | grep -v -e '#' | sed -e ':loop; /^[^ ]/N; s/\n //; t loop' | grep $(printf ' -e \\<%s\\>' $(cat perf.syscalls)) | cut -f 1 -d ' '
"~@aio"
"~@chown"
"~@keyring"
"~@memlock"
"~@privileged"
"~@resources"
"~@setuid"
"~@timer"
];
SystemCallArchitectures = "native";
# This is for BindPaths= and BindReadOnlyPaths=
# to allow traversal of directories they create in RootDirectory=.
UMask = "0066";
};
}
cfg.service
c.service
]
)
) cfg.commands;
};
meta.maintainers = with lib.maintainers; [
julm
lopsided98
];
}

View File

@@ -0,0 +1,451 @@
{
config,
lib,
options,
pkgs,
utils,
...
}:
let
gcfg = config.services.tarsnap;
opt = options.services.tarsnap;
configFile = name: cfg: ''
keyfile ${cfg.keyfile}
${lib.optionalString (cfg.cachedir != null) "cachedir ${cfg.cachedir}"}
${lib.optionalString cfg.nodump "nodump"}
${lib.optionalString cfg.printStats "print-stats"}
${lib.optionalString cfg.printStats "humanize-numbers"}
${lib.optionalString (cfg.checkpointBytes != null) ("checkpoint-bytes " + cfg.checkpointBytes)}
${lib.optionalString cfg.aggressiveNetworking "aggressive-networking"}
${lib.concatStringsSep "\n" (map (v: "exclude ${v}") cfg.excludes)}
${lib.concatStringsSep "\n" (map (v: "include ${v}") cfg.includes)}
${lib.optionalString cfg.lowmem "lowmem"}
${lib.optionalString cfg.verylowmem "verylowmem"}
${lib.optionalString (cfg.maxbw != null) "maxbw ${toString cfg.maxbw}"}
${lib.optionalString (cfg.maxbwRateUp != null) "maxbw-rate-up ${toString cfg.maxbwRateUp}"}
${lib.optionalString (cfg.maxbwRateDown != null) "maxbw-rate-down ${toString cfg.maxbwRateDown}"}
'';
in
{
imports = [
(lib.mkRemovedOptionModule [
"services"
"tarsnap"
"cachedir"
] "Use services.tarsnap.archives.<name>.cachedir")
];
options = {
services.tarsnap = {
enable = lib.mkEnableOption "periodic tarsnap backups";
package = lib.mkPackageOption pkgs "tarsnap" { };
keyfile = lib.mkOption {
type = lib.types.str;
default = "/root/tarsnap.key";
description = ''
The keyfile which associates this machine with your tarsnap
account.
Create the keyfile with {command}`tarsnap-keygen`.
Note that each individual archive (specified below) may also have its
own individual keyfile specified. Tarsnap does not allow multiple
concurrent backups with the same cache directory and key (starting a
new backup will cause another one to fail). If you have multiple
archives specified, you should either spread out your backups to be
far apart, or specify a separate key for each archive. By default
every archive defaults to using
`"/root/tarsnap.key"`.
It's recommended for backups that you generate a key for every archive
using {manpage}`tarsnap-keygen(1)`, and then generate a
write-only tarsnap key using {manpage}`tarsnap-keymgmt(1)`,
and keep your master key(s) for a particular machine off-site.
The keyfile name should be given as a string and not a path, to
avoid the key being copied into the Nix store.
'';
};
archives = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
{ config, options, ... }:
{
options = {
keyfile = lib.mkOption {
type = lib.types.str;
default = gcfg.keyfile;
defaultText = lib.literalExpression "config.${opt.keyfile}";
description = ''
Set a specific keyfile for this archive. This defaults to
`"/root/tarsnap.key"` if left unspecified.
Use this option if you want to run multiple backups
concurrently - each archive must have a unique key. You can
generate a write-only key derived from your master key (which
is recommended) using {manpage}`tarsnap-keymgmt(1)`.
Note: every archive must have an individual master key. You
must generate multiple keys with
{manpage}`tarsnap-keygen(1)`, and then generate write
only keys from those.
The keyfile name should be given as a string and not a path, to
avoid the key being copied into the Nix store.
'';
};
cachedir = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = "/var/cache/tarsnap/${utils.escapeSystemdPath config.keyfile}";
defaultText = lib.literalExpression ''
"/var/cache/tarsnap/''${utils.escapeSystemdPath config.${options.keyfile}}"
'';
description = ''
The cache allows tarsnap to identify previously stored data
blocks, reducing archival time and bandwidth usage.
Should the cache become desynchronized or corrupted, tarsnap
will refuse to run until you manually rebuild the cache with
{command}`tarsnap --fsck`.
Set to `null` to disable caching.
'';
};
nodump = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Exclude files with the `nodump` flag.
'';
};
printStats = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Print global archive statistics upon completion.
The output is available via
{command}`systemctl status tarsnap-archive-name`.
'';
};
checkpointBytes = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "1GB";
description = ''
Create a checkpoint every `checkpointBytes`
of uploaded data (optionally specified using an SI prefix).
1GB is the minimum value. A higher value is recommended,
as checkpointing is expensive.
Set to `null` to disable checkpointing.
'';
};
period = lib.mkOption {
type = lib.types.str;
default = "01:15";
example = "hourly";
description = ''
Create archive at this interval.
The format is described in
{manpage}`systemd.time(7)`.
'';
};
aggressiveNetworking = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Upload data over multiple TCP connections, potentially
increasing tarsnap's bandwidth utilisation at the cost
of slowing down all other network traffic. Not
recommended unless TCP congestion is the dominant
limiting factor.
'';
};
directories = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
description = "List of filesystem paths to archive.";
};
excludes = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Exclude files and directories matching these patterns.
'';
};
includes = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Include only files and directories matching these
patterns (the empty list includes everything).
Exclusions have precedence over inclusions.
'';
};
lowmem = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Reduce memory consumption by not caching small files.
Possibly beneficial if the average file size is smaller
than 1 MB and the number of files is lower than the
total amount of RAM in KB.
'';
};
verylowmem = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Reduce memory consumption by a factor of 2 beyond what
`lowmem` does, at the cost of significantly
slowing down the archiving process.
'';
};
maxbw = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = ''
Abort archival if upstream bandwidth usage in bytes
exceeds this threshold.
'';
};
maxbwRateUp = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
example = lib.literalExpression "25 * 1000";
description = ''
Upload bandwidth rate limit in bytes.
'';
};
maxbwRateDown = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
example = lib.literalExpression "50 * 1000";
description = ''
Download bandwidth rate limit in bytes.
'';
};
verbose = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to produce verbose logging output.
'';
};
explicitSymlinks = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to follow symlinks specified as archives.
'';
};
followSymlinks = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to follow all symlinks in archive trees.
'';
};
};
}
)
);
default = { };
example = lib.literalExpression ''
{
nixos =
{ directories = [ "/home" "/root/ssl" ];
};
gamedata =
{ directories = [ "/var/lib/minecraft" ];
period = "*:30";
};
}
'';
description = ''
Tarsnap archive configurations. Each attribute names an archive
to be created at a given time interval, according to the options
associated with it. When uploading to the tarsnap server,
archive names are suffixed by a 1 second resolution timestamp,
with the format `%Y%m%d%H%M%S`.
For each member of the set is created a timer which triggers the
instanced `tarsnap-archive-name` service unit. You may use
{command}`systemctl start tarsnap-archive-name` to
manually trigger creation of `archive-name` at
any time.
'';
};
};
};
config = lib.mkIf gcfg.enable {
assertions =
(lib.mapAttrsToList (name: cfg: {
assertion = cfg.directories != [ ];
message = "Must specify paths for tarsnap to back up";
}) gcfg.archives)
++ (lib.mapAttrsToList (name: cfg: {
assertion = !(cfg.lowmem && cfg.verylowmem);
message = "You cannot set both lowmem and verylowmem";
}) gcfg.archives);
systemd.services =
(lib.mapAttrs' (
name: cfg:
lib.nameValuePair "tarsnap-${name}" {
description = "Tarsnap archive '${name}'";
requires = [ "network-online.target" ];
after = [ "network-online.target" ];
path = with pkgs; [
iputils
gcfg.package
util-linux
];
# In order for the persistent tarsnap timer to work reliably, we have to
# make sure that the tarsnap server is reachable after systemd starts up
# the service - therefore we sleep in a loop until we can ping the
# endpoint.
preStart = ''
while ! ping -4 -q -c 1 v1-0-0-server.tarsnap.com &> /dev/null; do sleep 3; done
'';
script =
let
tarsnap = ''${lib.getExe gcfg.package} --configfile "/etc/tarsnap/${name}.conf"'';
run = ''
${tarsnap} -c -f "${name}-$(date +"%Y%m%d%H%M%S")" \
${lib.optionalString cfg.verbose "-v"} \
${lib.optionalString cfg.explicitSymlinks "-H"} \
${lib.optionalString cfg.followSymlinks "-L"} \
${lib.concatStringsSep " " cfg.directories}'';
cachedir = lib.escapeShellArg cfg.cachedir;
in
if (cfg.cachedir != null) then
''
mkdir -p ${cachedir}
chmod 0700 ${cachedir}
( flock 9
if [ ! -e ${cachedir}/firstrun ]; then
( flock 10
flock -u 9
${tarsnap} --fsck
flock 9
) 10>${cachedir}/firstrun
fi
) 9>${cachedir}/lockf
exec flock ${cachedir}/firstrun ${run}
''
else
"exec ${run}";
serviceConfig = {
Type = "oneshot";
IOSchedulingClass = "idle";
NoNewPrivileges = "true";
CapabilityBoundingSet = [ "CAP_DAC_READ_SEARCH" ];
PermissionsStartOnly = "true";
};
}
) gcfg.archives)
//
(lib.mapAttrs' (
name: cfg:
lib.nameValuePair "tarsnap-restore-${name}" {
description = "Tarsnap restore '${name}'";
requires = [ "network-online.target" ];
path = with pkgs; [
iputils
gcfg.package
util-linux
];
script =
let
tarsnap = ''${lib.getExe gcfg.package} --configfile "/etc/tarsnap/${name}.conf"'';
lastArchive = "$(${tarsnap} --list-archives | sort | tail -1)";
run = ''${tarsnap} -x -f "${lastArchive}" ${lib.optionalString cfg.verbose "-v"}'';
cachedir = lib.escapeShellArg cfg.cachedir;
in
if (cfg.cachedir != null) then
''
mkdir -p ${cachedir}
chmod 0700 ${cachedir}
( flock 9
if [ ! -e ${cachedir}/firstrun ]; then
( flock 10
flock -u 9
${tarsnap} --fsck
flock 9
) 10>${cachedir}/firstrun
fi
) 9>${cachedir}/lockf
exec flock ${cachedir}/firstrun ${run}
''
else
"exec ${run}";
serviceConfig = {
Type = "oneshot";
IOSchedulingClass = "idle";
NoNewPrivileges = "true";
CapabilityBoundingSet = [ "CAP_DAC_READ_SEARCH" ];
PermissionsStartOnly = "true";
};
}
) gcfg.archives);
# Note: the timer must be Persistent=true, so that systemd will start it even
# if e.g. your laptop was asleep while the latest interval occurred.
systemd.timers = lib.mapAttrs' (
name: cfg:
lib.nameValuePair "tarsnap-${name}" {
timerConfig.OnCalendar = cfg.period;
timerConfig.Persistent = "true";
wantedBy = [ "timers.target" ];
}
) gcfg.archives;
environment.etc = lib.mapAttrs' (
name: cfg:
lib.nameValuePair "tarsnap/${name}.conf" {
text = configFile name cfg;
}
) gcfg.archives;
environment.systemPackages = [ gcfg.package ];
};
}

View File

@@ -0,0 +1,124 @@
{ config, lib, ... }:
let
inherit (lib.attrsets) hasAttr;
inherit (lib.meta) getExe';
inherit (lib.modules) mkDefault mkIf;
inherit (lib.options) mkEnableOption mkOption;
inherit (lib.types) nonEmptyStr nullOr;
options.services.tsmBackup = {
enable = mkEnableOption ''
automatic backups with the
IBM Storage Protect (Tivoli Storage Manager, TSM) client.
This also enables
{option}`programs.tsmClient.enable`
'';
command = mkOption {
type = nonEmptyStr;
default = "backup";
example = "incr";
description = ''
The actual command passed to the
`dsmc` executable to start the backup.
'';
};
servername = mkOption {
type = nonEmptyStr;
example = "mainTsmServer";
description = ''
Create a systemd system service
`tsm-backup.service` that starts
a backup based on the given servername's stanza.
Note that this server's
{option}`passwdDir` will default to
{file}`/var/lib/tsm-backup/password`
(but may be overridden);
also, the service will use
{file}`/var/lib/tsm-backup` as
`HOME` when calling
`dsmc`.
'';
};
autoTime = mkOption {
type = nullOr nonEmptyStr;
default = null;
example = "12:00";
description = ''
The backup service will be invoked
automatically at the given date/time,
which must be in the format described in
{manpage}`systemd.time(5)`.
The default `null`
disables automatic backups.
'';
};
};
cfg = config.services.tsmBackup;
cfgPrg = config.programs.tsmClient;
assertions = [
{
assertion = hasAttr cfg.servername cfgPrg.servers;
message = "TSM service servername not found in list of servers";
}
{
assertion = cfgPrg.servers.${cfg.servername}.genPasswd;
message = "TSM service requires automatic password generation";
}
];
in
{
inherit options;
config = mkIf cfg.enable {
inherit assertions;
programs.tsmClient.enable = true;
programs.tsmClient.servers.${cfg.servername}.passworddir = mkDefault "/var/lib/tsm-backup/password";
systemd.services.tsm-backup = {
description = "IBM Storage Protect (Tivoli Storage Manager) Backup";
# DSM_LOG needs a trailing slash to have it treated as a directory.
# `/var/log` would be littered with TSM log files otherwise.
environment.DSM_LOG = "/var/log/tsm-backup/";
# TSM needs a HOME dir to store certificates.
environment.HOME = "/var/lib/tsm-backup";
serviceConfig = {
# for exit status description see
# https://www.ibm.com/docs/en/storage-protect/8.1.27?topic=clients-client-return-codes
SuccessExitStatus = "4 8";
# The `-se` option must come after the command.
# The `-optfile` option suppresses a `dsm.opt`-not-found warning.
ExecStart = "${getExe' cfgPrg.wrappedPackage "dsmc"} ${cfg.command} -se='${cfg.servername}' -optfile=/dev/null";
LogsDirectory = "tsm-backup";
StateDirectory = "tsm-backup";
StateDirectoryMode = "0750";
# systemd sandboxing
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
#PrivateTmp = true; # would break backup of {/var,}/tmp
#PrivateUsers = true; # would block backup of /home/*
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = "read-only";
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "noaccess";
ProtectSystem = "strict";
RestrictNamespaces = true;
RestrictSUIDSGID = true;
};
startAt = mkIf (cfg.autoTime != null) cfg.autoTime;
};
};
meta.maintainers = [ lib.maintainers.yarny ];
}

View File

@@ -0,0 +1,110 @@
{
lib,
pkgs,
config,
...
}:
let
cfg = config.services.zfs.autoReplication;
in
{
options = {
services.zfs.autoReplication = {
enable = lib.mkEnableOption "ZFS snapshot replication";
package = lib.mkPackageOption pkgs "zfs-replicate" { };
followDelete = lib.mkOption {
description = "Remove remote snapshots that don't have a local correspondent.";
default = true;
type = lib.types.bool;
};
host = lib.mkOption {
description = "Remote host where snapshots should be sent. `lz4` is expected to be installed on this host.";
example = "example.com";
type = lib.types.str;
};
identityFilePath = lib.mkOption {
description = "Path to SSH key used to login to host.";
example = "/home/username/.ssh/id_rsa";
type = lib.types.path;
};
localFilesystem = lib.mkOption {
description = "Local ZFS filesystem from which snapshots should be sent. Defaults to the attribute name.";
example = "pool/file/path";
type = lib.types.str;
};
remoteFilesystem = lib.mkOption {
description = "Remote ZFS filesystem where snapshots should be sent.";
example = "pool/file/path";
type = lib.types.str;
};
recursive = lib.mkOption {
description = "Recursively discover snapshots to send.";
default = true;
type = lib.types.bool;
};
username = lib.mkOption {
description = "Username used by SSH to login to remote host.";
example = "username";
type = lib.types.str;
};
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [
pkgs.lz4
];
systemd.services.zfs-replication = {
after = [
"zfs-snapshot-daily.service"
"zfs-snapshot-frequent.service"
"zfs-snapshot-hourly.service"
"zfs-snapshot-monthly.service"
"zfs-snapshot-weekly.service"
];
description = "ZFS Snapshot Replication";
documentation = [
"https://github.com/alunduil/zfs-replicate"
];
restartIfChanged = false;
serviceConfig.ExecStart =
let
args = lib.map lib.escapeShellArg (
[
"--verbose"
"--user"
cfg.username
"--identity-file"
cfg.identityFilePath
cfg.host
cfg.remoteFilesystem
cfg.localFilesystem
]
++ (lib.optional cfg.recursive "--recursive")
++ (lib.optional cfg.followDelete "--follow-delete")
);
in
"${lib.getExe cfg.package} ${lib.concatStringsSep " " args}";
wantedBy = [
"zfs-snapshot-daily.service"
"zfs-snapshot-frequent.service"
"zfs-snapshot-hourly.service"
"zfs-snapshot-monthly.service"
"zfs-snapshot-weekly.service"
];
};
};
meta = {
maintainers = with lib.maintainers; [ alunduil ];
};
}

View File

@@ -0,0 +1,530 @@
{
config,
lib,
pkgs,
...
}:
let
planDescription = ''
The znapzend backup plan to use for the source.
The plan specifies how often to backup and for how long to keep the
backups. It consists of a series of retention periods to interval
associations:
```
retA=>intA,retB=>intB,...
```
Both intervals and retention periods are expressed in standard units
of time or multiples of them. You can use both the full name or a
shortcut according to the following listing:
```
second|sec|s, minute|min, hour|h, day|d, week|w, month|mon|m, year|y
```
See {manpage}`znapzendzetup(1)` for more info.
'';
planExample = "1h=>10min,1d=>1h,1w=>1d,1m=>1w,1y=>1m";
# A type for a string of the form number{b|k|M|G}
mbufferSizeType = lib.types.str // {
check = x: lib.types.str.check x && builtins.isList (builtins.match "^[0-9]+[bkMG]$" x);
description = "string of the form number{b|k|M|G}";
};
enabledFeatures = lib.concatLists (
lib.mapAttrsToList (name: enabled: lib.optional enabled name) cfg.features
);
# Type for a string that must contain certain other strings (the list parameter).
# Note that these would need regex escaping.
stringContainingStrings =
list:
let
matching = s: map (str: builtins.match ".*${str}.*" s) list;
in
lib.types.str
// {
check = x: lib.types.str.check x && lib.all lib.isList (matching x);
description = "string containing all of the characters ${lib.concatStringsSep ", " list}";
};
timestampType = stringContainingStrings [
"%Y"
"%m"
"%d"
"%H"
"%M"
"%S"
];
destType =
srcConfig:
lib.types.submodule (
{ name, ... }:
{
options = {
label = lib.mkOption {
type = lib.types.str;
description = "Label for this destination. Defaults to the attribute name.";
};
plan = lib.mkOption {
type = lib.types.str;
description = planDescription;
example = planExample;
};
dataset = lib.mkOption {
type = lib.types.str;
description = "Dataset name to send snapshots to.";
example = "tank/main";
};
host = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Host to use for the destination dataset. Can be prefixed with
`user@` to specify the ssh user.
'';
default = null;
example = "john@example.com";
};
presend = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Command to run before sending the snapshot to the destination.
Intended to run a remote script via {command}`ssh` on the
destination, e.g. to bring up a backup disk or server or to put a
zpool online/offline. See also {option}`postsend`.
'';
default = null;
example = "ssh root@bserv zpool import -Nf tank";
};
postsend = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Command to run after sending the snapshot to the destination.
Intended to run a remote script via {command}`ssh` on the
destination, e.g. to bring up a backup disk or server or to put a
zpool online/offline. See also {option}`presend`.
'';
default = null;
example = "ssh root@bserv zpool export tank";
};
};
config = {
label = lib.mkDefault name;
plan = lib.mkDefault srcConfig.plan;
};
}
);
srcType = lib.types.submodule (
{ name, config, ... }:
{
options = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Whether to enable this source.";
default = true;
};
recursive = lib.mkOption {
type = lib.types.bool;
description = "Whether to do recursive snapshots.";
default = false;
};
mbuffer = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Whether to use {command}`mbuffer`.";
default = false;
};
port = lib.mkOption {
type = lib.types.nullOr lib.types.ints.u16;
description = ''
Port to use for {command}`mbuffer`.
If this is null, it will run {command}`mbuffer` through
ssh.
If this is not null, it will run {command}`mbuffer`
directly through TCP, which is not encrypted but faster. In that
case the given port needs to be open on the destination host.
'';
default = null;
};
size = lib.mkOption {
type = mbufferSizeType;
description = ''
The size for {command}`mbuffer`.
Supports the units b, k, M, G.
'';
default = "1G";
example = "128M";
};
};
presnap = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Command to run before snapshots are taken on the source dataset,
e.g. for database locking/flushing. See also
{option}`postsnap`.
'';
default = null;
example = lib.literalExpression ''
'''''${pkgs.mariadb}/bin/mysql -e "set autocommit=0;flush tables with read lock;\\! ''${pkgs.coreutils}/bin/sleep 600" & ''${pkgs.coreutils}/bin/echo $! > /tmp/mariadblock.pid ; sleep 10'''
'';
};
postsnap = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Command to run after snapshots are taken on the source dataset,
e.g. for database unlocking. See also {option}`presnap`.
'';
default = null;
example = lib.literalExpression ''
"''${pkgs.coreutils}/bin/kill `''${pkgs.coreutils}/bin/cat /tmp/mariadblock.pid`;''${pkgs.coreutils}/bin/rm /tmp/mariadblock.pid"
'';
};
timestampFormat = lib.mkOption {
type = timestampType;
description = ''
The timestamp format to use for constructing snapshot names.
The syntax is `strftime`-like. The string must
consist of the mandatory `%Y %m %d %H %M %S`.
Optionally `- _ . :` characters as well as any
alphanumeric character are allowed. If suffixed by a
`Z`, times will be in UTC.
'';
default = "%Y-%m-%d-%H%M%S";
example = "znapzend-%m.%d.%Y-%H%M%SZ";
};
sendDelay = lib.mkOption {
type = lib.types.int;
description = ''
Specify delay (in seconds) before sending snaps to the destination.
May be useful if you want to control sending time.
'';
default = 0;
example = 60;
};
plan = lib.mkOption {
type = lib.types.str;
description = planDescription;
example = planExample;
};
dataset = lib.mkOption {
type = lib.types.str;
description = "The dataset to use for this source.";
example = "tank/home";
};
destinations = lib.mkOption {
type = lib.types.attrsOf (destType config);
description = "Additional destinations.";
default = { };
example = lib.literalExpression ''
{
local = {
dataset = "btank/backup";
presend = "zpool import -N btank";
postsend = "zpool export btank";
};
remote = {
host = "john@example.com";
dataset = "tank/john";
};
};
'';
};
};
config = {
dataset = lib.mkDefault name;
};
}
);
### Generating the configuration from here
cfg = config.services.znapzend;
onOff = b: if b then "on" else "off";
nullOff = b: if b == null then "off" else toString b;
stripSlashes = lib.replaceStrings [ "/" ] [ "." ];
attrsToFile =
config: lib.concatStringsSep "\n" (builtins.attrValues (lib.mapAttrs (n: v: "${n}=${v}") config));
mkDestAttrs =
dst:
with dst;
lib.mapAttrs' (n: v: lib.nameValuePair "dst_${label}${n}" v) (
{
"" = lib.optionalString (host != null) "${host}:" + dataset;
_plan = plan;
}
// lib.optionalAttrs (presend != null) {
_precmd = presend;
}
// lib.optionalAttrs (postsend != null) {
_pstcmd = postsend;
}
);
mkSrcAttrs =
srcCfg:
with srcCfg;
{
enabled = onOff enable;
# mbuffer is not referenced by its full path to accommodate non-NixOS systems or differing mbuffer versions between source and target
mbuffer =
with mbuffer;
if enable then "mbuffer" + lib.optionalString (port != null) ":${toString port}" else "off";
mbuffer_size = mbuffer.size;
post_znap_cmd = nullOff postsnap;
pre_znap_cmd = nullOff presnap;
recursive = onOff recursive;
src = dataset;
src_plan = plan;
tsformat = timestampFormat;
zend_delay = toString sendDelay;
}
// lib.foldr (a: b: a // b) { } (map mkDestAttrs (builtins.attrValues destinations));
files = lib.mapAttrs' (
n: srcCfg:
let
fileText = attrsToFile (mkSrcAttrs srcCfg);
in
{
name = srcCfg.dataset;
value = pkgs.writeText (stripSlashes srcCfg.dataset) fileText;
}
) cfg.zetup;
in
{
options = {
services.znapzend = {
enable = lib.mkEnableOption "ZnapZend ZFS backup daemon";
logLevel = lib.mkOption {
default = "debug";
example = "warning";
type = lib.types.enum [
"debug"
"info"
"warning"
"err"
"alert"
];
description = ''
The log level when logging to file. Any of debug, info, warning, err,
alert. Default in daemonized form is debug.
'';
};
logTo = lib.mkOption {
type = lib.types.str;
default = "syslog::daemon";
example = "/var/log/znapzend.log";
description = ''
Where to log to (syslog::\<facility\> or \<filepath\>).
'';
};
mailErrorSummaryTo = lib.mkOption {
type = lib.types.singleLineStr;
default = "";
description = ''
Email address to send a summary to if "send task(s) failed".
'';
};
noDestroy = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Does all changes to the filesystem except destroy.";
};
autoCreation = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Automatically create the destination dataset if it does not exist.";
};
zetup = lib.mkOption {
type = lib.types.attrsOf srcType;
description = "Znapzend configuration.";
default = { };
example = lib.literalExpression ''
{
"tank/home" = {
# Make snapshots of tank/home every hour, keep those for 1 day,
# keep every days snapshot for 1 month, etc.
plan = "1d=>1h,1m=>1d,1y=>1m";
recursive = true;
# Send all those snapshots to john@example.com:rtank/john as well
destinations.remote = {
host = "john@example.com";
dataset = "rtank/john";
};
};
};
'';
};
pure = lib.mkOption {
type = lib.types.bool;
description = ''
Do not persist any stateful znapzend setups. If this option is
enabled, your previously set znapzend setups will be cleared and only
the ones defined with this module will be applied.
'';
default = false;
};
features.oracleMode = lib.mkEnableOption ''
destroying snapshots one by one instead of using one long argument list.
If source and destination are out of sync for a long time, you may have
so many snapshots to destroy that the argument gets is too long and the
command fails
'';
features.recvu = lib.mkEnableOption ''
recvu feature which uses `-u` on the receiving end to keep the destination
filesystem unmounted
'';
features.compressed = lib.mkEnableOption ''
compressed feature which adds the options `-Lce` to
the {command}`zfs send` command. When this is enabled, make
sure that both the sending and receiving pool have the same relevant
features enabled. Using `-c` will skip unnecessary
decompress-compress stages, `-L` is for large block
support and -e is for embedded data support. see
{manpage}`znapzend(1)`
and {manpage}`zfs(8)`
for more info
'';
features.sendRaw = lib.mkEnableOption ''
sendRaw feature which adds the options `-w` to the
{command}`zfs send` command. For encrypted source datasets this
instructs zfs not to decrypt before sending which results in a remote
backup that can't be read without the encryption key/passphrase, useful
when the remote isn't fully trusted or not physically secure. This
option must be used consistently, raw incrementals cannot be based on
non-raw snapshots and vice versa
'';
features.skipIntermediates = lib.mkEnableOption ''
the skipIntermediates feature to send a single increment
between latest common snapshot and the newly made one. It may skip
several source snaps if the destination was offline for some time, and
it should skip snapshots not managed by znapzend. Normally for online
destinations, the new snapshot is sent as soon as it is created on the
source, so there are no automatic increments to skip
'';
features.lowmemRecurse = lib.mkEnableOption ''
use lowmemRecurse on systems where you have too many datasets, so a
recursive listing of attributes to find backup plans exhausts the
memory available to {command}`znapzend`: instead, go the slower
way to first list all impacted dataset names, and then query their
configs one by one
'';
features.zfsGetType = lib.mkEnableOption ''
using zfsGetType if your {command}`zfs get` supports a
`-t` argument for filtering by dataset type at all AND
lists properties for snapshots by default when recursing, so that there
is too much data to process while searching for backup plans.
If these two conditions apply to your system, the time needed for a
`--recursive` search for backup plans can literally
differ by hundreds of times (depending on the amount of snapshots in
that dataset tree... and a decent backup plan will ensure you have a lot
of those), so you would benefit from requesting this feature
'';
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ pkgs.znapzend ];
systemd.services = {
znapzend = {
description = "ZnapZend - ZFS Backup System";
wantedBy = [ "zfs.target" ];
after = [ "zfs.target" ];
path = with pkgs; [
config.boot.zfs.package
mbuffer
openssh
];
preStart =
lib.optionalString cfg.pure ''
echo Resetting znapzend zetups
${pkgs.znapzend}/bin/znapzendzetup list \
| grep -oP '(?<=\*\*\* backup plan: ).*(?= \*\*\*)' \
| xargs -I{} ${pkgs.znapzend}/bin/znapzendzetup delete "{}"
''
+ lib.concatStringsSep "\n" (
lib.mapAttrsToList (dataset: config: ''
echo Importing znapzend zetup ${config} for dataset ${dataset}
${pkgs.znapzend}/bin/znapzendzetup import --write ${dataset} ${config} &
'') files
)
+ ''
wait
'';
serviceConfig = {
# znapzendzetup --import apparently tries to connect to the backup
# host 3 times with a timeout of 30 seconds, leading to a startup
# delay of >90s when the host is down, which is just above the default
# service timeout of 90 seconds. Increase the timeout so it doesn't
# make the service fail in that case.
TimeoutStartSec = 180;
# Needs to have write access to ZFS
User = "root";
ExecStart =
let
args = lib.concatStringsSep " " [
"--logto=${cfg.logTo}"
"--loglevel=${cfg.logLevel}"
(lib.optionalString cfg.noDestroy "--nodestroy")
(lib.optionalString cfg.autoCreation "--autoCreation")
(lib.optionalString (cfg.mailErrorSummaryTo != "") "--mailErrorSummaryTo=${cfg.mailErrorSummaryTo}")
(lib.optionalString (
enabledFeatures != [ ]
) "--features=${lib.concatStringsSep "," enabledFeatures}")
];
in
"${pkgs.znapzend}/bin/znapzend ${args}";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
Restart = "on-failure";
};
};
};
};
meta.maintainers = with lib.maintainers; [ SlothOfAnarchy ];
}

View File

@@ -0,0 +1,61 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.zrepl;
format = pkgs.formats.yaml { };
configFile = format.generate "zrepl.yml" cfg.settings;
in
{
meta.maintainers = with lib.maintainers; [ cole-h ];
options = {
services.zrepl = {
enable = lib.mkEnableOption "zrepl";
package = lib.mkPackageOption pkgs "zrepl" { };
settings = lib.mkOption {
default = { };
description = ''
Configuration for zrepl. See <https://zrepl.github.io/configuration.html>
for more information.
'';
type = lib.types.submodule {
freeformType = format.type;
};
};
};
};
### Implementation ###
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
# zrepl looks for its config in this location by default. This
# allows the use of e.g. `zrepl signal wakeup <job>` without having
# to specify the storepath of the config.
environment.etc."zrepl/zrepl.yml".source = configFile;
systemd.packages = [ cfg.package ];
# Note that pkgs.zrepl copies and adapts the upstream systemd unit, and
# the fields defined here only override certain fields from that unit.
systemd.services.zrepl = {
requires = [ "local-fs.target" ];
wantedBy = [ "zfs.target" ];
after = [ "zfs.target" ];
path = [ config.boot.zfs.package ];
restartTriggers = [ configFile ];
serviceConfig = {
Restart = "on-failure";
};
};
};
}