diff --git a/nixos/doc/manual/release-notes/rl-2511.section.md b/nixos/doc/manual/release-notes/rl-2511.section.md index 8b3a14483687..e00b4317fa0b 100644 --- a/nixos/doc/manual/release-notes/rl-2511.section.md +++ b/nixos/doc/manual/release-notes/rl-2511.section.md @@ -30,6 +30,8 @@ - [Overseerr](https://overseerr.dev), a request management and media discovery tool for the Plex ecosystem. Available as [services.overseerr](#opt-services.overseerr.enable). +- [services.rsync](options.html#opt-services.rsync) has been added to simplify periodic directory syncing. + - [gtklock](https://github.com/jovanlanik/gtklock), a GTK-based lockscreen for Wayland. Available as [programs.gtklock](#opt-programs.gtklock.enable). - [Chrysalis](https://github.com/keyboardio/Chrysalis), a graphical configurator for Kaleidoscope-powered keyboards. Available as [programs.chrysalis](#opt-programs.chrysalis.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 2b98c1cc23e1..2b178220e065 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -924,6 +924,7 @@ ./services/misc/rkvm.nix ./services/misc/rmfakecloud.nix ./services/misc/rshim.nix + ./services/misc/rsync.nix ./services/misc/safeeyes.nix ./services/misc/sdrplay.nix ./services/misc/servarr/lidarr.nix diff --git a/nixos/modules/services/misc/rsync.nix b/nixos/modules/services/misc/rsync.nix new file mode 100644 index 000000000000..0a262a076621 --- /dev/null +++ b/nixos/modules/services/misc/rsync.nix @@ -0,0 +1,203 @@ +{ + config, + lib, + pkgs, + utils, + ... +}: +let + cfg = config.services.rsync; + inherit (lib) types; + inherit (utils.systemdUtils.unitOptions) unitOption; +in +{ + options.services.rsync = { + enable = lib.mkEnableOption "periodic directory syncing via rsync"; + + package = lib.mkPackageOption pkgs "rsync" { }; + + jobs = lib.mkOption { + description = '' + Synchronization jobs to run. + ''; + default = { }; + type = types.attrsOf ( + types.submodule { + options = { + sources = lib.mkOption { + type = types.nonEmptyListOf types.str; + example = [ + "/srv/src1/" + "/srv/src2/" + ]; + description = '' + Source directories. + ''; + }; + + destination = lib.mkOption { + type = types.str; + example = "/srv/dst"; + description = '' + Destination directory. + ''; + }; + + settings = lib.mkOption { + type = + let + simples = [ + types.bool + types.str + types.int + types.float + ]; + in + types.attrsOf ( + types.oneOf ( + simples + ++ [ + (types.listOf (types.oneOf simples)) + ] + ) + ); + default = { }; + example = { + verbose = true; + archive = true; + delete = true; + mkpath = true; + }; + description = '' + Settings that should be passed to rsync via long options. + See {manpage}`rsync(1)` for available options. + ''; + }; + + user = lib.mkOption { + type = types.str; + default = "root"; + description = '' + The name of an existing user account under which the rsync process should run. + ''; + }; + + group = lib.mkOption { + type = types.str; + default = "root"; + description = '' + The name of an existing user group under which the rsync process should run. + ''; + }; + + timerConfig = lib.mkOption { + type = types.nullOr (types.attrsOf unitOption); + default = { + OnCalendar = "daily"; + Persistent = true; + }; + description = '' + When to run the job. + ''; + }; + + inhibit = lib.mkOption { + default = [ ]; + type = types.listOf (types.strMatching "^[^:]+$"); + example = [ + "sleep" + ]; + description = '' + Run the rsync process with an inhibition lock taken; + see {manpage}`systemd-inhibit(1)` for a list of possible operations. + ''; + }; + }; + } + ); + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = lib.all (job: job.sources != [ ]) (lib.attrValues cfg.jobs); + message = '' + At least one source directory must be provided to rsync. + ''; + } + ]; + + systemd = lib.mkMerge ( + lib.mapAttrsToList ( + jobName: job: + let + systemdName = "rsync-job-${jobName}"; + description = "Directory syncing via rsync job ${jobName}"; + in + { + timers.${systemdName} = { + wantedBy = [ + "timers.target" + ]; + inherit description; + inherit (job) timerConfig; + }; + + services.${systemdName} = { + inherit description; + + serviceConfig = { + Type = "oneshot"; + + ExecStart = + let + settingsToCommandLine = lib.cli.toCommandLineGNU { + isLong = _: true; + }; + + inhibitArgs = [ + (lib.getExe' config.systemd.package "systemd-inhibit") + "--mode" + "block" + "--who" + description + "--what" + (lib.concatStringsSep ":" job.inhibit) + "--why" + "Scheduled rsync job ${jobName}" + "--" + ]; + + args = + (lib.optionals (job.inhibit != [ ]) inhibitArgs) + ++ [ (lib.getExe cfg.package) ] + ++ (settingsToCommandLine job.settings) + ++ [ "--" ] + ++ job.sources + ++ [ job.destination ]; + in + utils.escapeSystemdExecArgs args; + + User = job.user; + Group = job.group; + + NoNewPrivileges = true; + PrivateDevices = true; + ProtectSystem = "full"; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + MemoryDenyWriteExecute = true; + LockPersonality = true; + }; + }; + } + ) cfg.jobs + ); + }; + + meta.maintainers = [ + lib.maintainers.lukaswrz + ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 3af4002a6aa2..b8def04f8295 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1322,6 +1322,7 @@ in rss-bridge = handleTest ./web-apps/rss-bridge { }; rss2email = handleTest ./rss2email.nix { }; rstudio-server = runTest ./rstudio-server.nix; + rsync = runTest ./rsync.nix; rsyncd = runTest ./rsyncd.nix; rsyslogd = handleTest ./rsyslogd.nix { }; rtkit = runTest ./rtkit.nix; diff --git a/nixos/tests/rsync.nix b/nixos/tests/rsync.nix new file mode 100644 index 000000000000..2a570adf20de --- /dev/null +++ b/nixos/tests/rsync.nix @@ -0,0 +1,64 @@ +{ + name = "rsync"; + + nodes.machine = { + users.users.test.isNormalUser = true; + + services.rsync = { + enable = true; + jobs = { + root = { + sources = [ "/root/src/" ]; + destination = "/root/dst"; + settings = { + archive = true; + delete = true; + mkpath = true; + }; + timerConfig = { + OnCalendar = "daily"; + Persistent = false; + }; + inhibit = [ "sleep" ]; + }; + user = { + sources = [ "/home/test/src/" ]; + destination = "/home/test/dst"; + settings = { + archive = true; + delete = true; + mkpath = true; + }; + timerConfig = { + OnCalendar = "daily"; + Persistent = false; + }; + user = "test"; + group = "users"; + }; + }; + }; + }; + + testScript = '' + machine.start() + + machine.wait_for_unit("multi-user.target") + + machine.succeed("mkdir --parents /root/src") + machine.succeed("echo test data > /root/src/file.txt") + machine.start_job("rsync-job-root.service") + machine.succeed("""[[ 'test data' == "$(< /root/dst/file.txt)" ]]""") + + machine.succeed("mkdir --parents /home/test/src") + machine.succeed("echo test data > /home/test/src/file.txt") + machine.start_job("rsync-job-user.service") + machine.succeed("""[[ 'test data' == "$(< /home/test/dst/file.txt)" ]]""") + + machine.wait_for_unit("timers.target") + machine.require_unit_state("rsync-job-root.timer", "active") + machine.require_unit_state("rsync-job-user.timer", "active") + + machine.shutdown() + ''; +}