diff --git a/modules/lib/maintainers.nix b/modules/lib/maintainers.nix
index 91ecb47be..2bd2db28a 100644
--- a/modules/lib/maintainers.nix
+++ b/modules/lib/maintainers.nix
@@ -138,6 +138,15 @@
github = "Janik-Haag";
githubId = 80165193;
};
+ jess = {
+ name = "Jessica";
+ email = "jess+hm@jessie.cafe";
+ githubId = 43591752;
+ keys = [{
+ longkeyid = "rsa3072/0xBA3350686C918606";
+ fingerprint = "8092 3BD1 ECD0 E436 671D C8E9 BA33 5068 6C91 8606";
+ }];
+ };
jkarlson = {
email = "jekarlson@gmail.com";
github = "jkarlson";
diff --git a/modules/modules.nix b/modules/modules.nix
index 75f8ac461..05cdfe669 100644
--- a/modules/modules.nix
+++ b/modules/modules.nix
@@ -205,6 +205,7 @@ let
./programs/qutebrowser.nix
./programs/ranger.nix
./programs/rbw.nix
+ ./programs/rclone.nix
./programs/readline.nix
./programs/rio.nix
./programs/ripgrep.nix
diff --git a/modules/programs/rclone.nix b/modules/programs/rclone.nix
new file mode 100644
index 000000000..d9b7e83d0
--- /dev/null
+++ b/modules/programs/rclone.nix
@@ -0,0 +1,142 @@
+{ config, lib, pkgs, ... }:
+
+let
+
+ cfg = config.programs.rclone;
+ iniFormat = pkgs.formats.ini { };
+
+in {
+ options = {
+ programs.rclone = {
+ enable = lib.mkEnableOption "rclone";
+
+ package = lib.mkPackageOption pkgs "rclone" { };
+
+ remotes = lib.mkOption {
+ type = lib.types.attrsOf (lib.types.submodule {
+ options = {
+ config = lib.mkOption {
+ type = with lib.types;
+ attrsOf (nullOr (oneOf [ bool int float str ]));
+ default = { };
+ description = ''
+ Regular configuration options as described in rclone's documentation
+ . When specifying options follow the formatting
+ process outlined here , namley:
+ - Remove the leading double-dash (--) from the rclone option name
+ - Replace hyphens (-) with underscores (_)
+ - Convert to lowercase
+ - Use the resulting string as your configuration key
+
+ For example, the rclone option "--mega-hard-delete" would use "hard_delete"
+ as the config key.
+
+ Security Note: Always use the {option}`secrets` option for sensitive data
+ instead of the {option}`config` option to prevent exposing credentials to
+ the world-readable Nix store.
+ '';
+ example = lib.literalExpression ''
+ {
+ type = "mega"; # Required - specifies the remote type
+ user = "you@example.com";
+ hard_delete = true;
+ }'';
+ };
+
+ secrets = lib.mkOption {
+ type = with lib.types; attrsOf str;
+ default = { };
+ description = ''
+ Sensitive configuration values such as passwords, API keys, and tokens. These
+ must be provided as file paths to the secrets, which will be read at activation
+ time.
+
+ Note: If using secret management solutions like agenix or sops-nix with
+ home-manager, you need to ensure their services are activated before switching
+ to this home-manager generation. Consider setting
+ {option}`systemd.user.startServices` to `"sd-switch"` for automatic service
+ startup.
+ '';
+ example = lib.literalExpression ''
+ {
+ password = "/run/secrets/password";
+ api_key = config.age.secrets.api-key.path;
+ }'';
+ };
+ };
+ });
+ default = { };
+ description = ''
+ An attribute set of remote configurations. Each remote consists of regular
+ configuration options and optional secrets.
+
+ See for more information on configuring specific
+ remotes.
+ '';
+ example = lib.literalExpression ''
+ {
+ b2 = {
+ config = {
+ type = "b2";
+ hard_delete = true;
+ };
+ secrets = {
+ # using sops
+ account = config.sops.secrets.b2-acc-id.path;
+ # using agenix
+ key = config.age.secrets.b2-key.path;
+ };
+ };
+
+ server.config = {
+ type = "sftp";
+ host = "server";
+ user = "backup";
+ key_file = "''${home.homeDirectory}/.ssh/id_ed25519";
+ };
+ }'';
+ };
+
+ writeAfter = lib.mkOption {
+ type = lib.types.str;
+ default = "reloadSystemd";
+ description = ''
+ Controls when the rclone configuration is written during Home Manager activation.
+ You should not need to change this unless you have very specific activation order
+ requirements.
+ '';
+ };
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ home = {
+ packages = [ cfg.package ];
+
+ activation.createRcloneConfig = let
+ safeConfig = lib.pipe cfg.remotes [
+ (lib.mapAttrs (_: v: v.config))
+ (iniFormat.generate "rclone.conf@pre-secrets")
+ ];
+
+ # https://github.com/rclone/rclone/issues/8190
+ injectSecret = remote:
+ lib.mapAttrsToList (secret: secretFile: ''
+ ${lib.getExe cfg.package} config update \
+ ${remote.name} config_refresh_token=false \
+ ${secret} $(cat ${secretFile}) \
+ --quiet > /dev/null
+ '') remote.value.secrets or { };
+
+ injectAllSecrets = lib.concatMap injectSecret
+ (lib.mapAttrsToList lib.nameValuePair cfg.remotes);
+ in lib.mkIf (cfg.remotes != { })
+ (lib.hm.dag.entryAfter [ "writeBoundary" cfg.writeAfter ] ''
+ run install $VERBOSE_ARG -D -m600 ${safeConfig} "${config.xdg.configHome}/rclone/rclone.conf"
+ ${lib.concatLines injectAllSecrets}
+ '');
+ };
+ };
+
+ meta.maintainers = with lib.hm.maintainers; [ jess ];
+}
diff --git a/tests/integration/default.nix b/tests/integration/default.nix
index 262f28faf..e8672f904 100644
--- a/tests/integration/default.nix
+++ b/tests/integration/default.nix
@@ -12,6 +12,7 @@ let
tests = {
kitty = runTest ./standalone/kitty.nix;
nixos-basics = runTest ./nixos/basics.nix;
+ rclone = runTest ./standalone/rclone;
standalone-flake-basics = runTest ./standalone/flake-basics.nix;
standalone-standard-basics = runTest ./standalone/standard-basics.nix;
};
diff --git a/tests/integration/standalone/rclone/default.nix b/tests/integration/standalone/rclone/default.nix
new file mode 100644
index 000000000..067be8c26
--- /dev/null
+++ b/tests/integration/standalone/rclone/default.nix
@@ -0,0 +1,91 @@
+{ pkgs, ... }:
+
+{
+ name = "rclone";
+
+ nodes.machine = { ... }: {
+ imports = [ "${pkgs.path}/nixos/modules/installer/cd-dvd/channel.nix" ];
+ virtualisation.memorySize = 2048;
+ users.users.alice = {
+ isNormalUser = true;
+ description = "Alice Foobar";
+ password = "foobar";
+ uid = 1000;
+ };
+ };
+
+ testScript = ''
+ start_all()
+ machine.wait_for_unit("network.target")
+ machine.wait_for_unit("multi-user.target")
+
+ home_manager = "${../../../..}"
+
+ def login_as_alice():
+ machine.wait_until_tty_matches("1", "login: ")
+ machine.send_chars("alice\n")
+ machine.wait_until_tty_matches("1", "Password: ")
+ machine.send_chars("foobar\n")
+ machine.wait_until_tty_matches("1", "alice\\@machine")
+
+ def logout_alice():
+ machine.send_chars("exit\n")
+
+ def alice_cmd(cmd):
+ return f"su -l alice --shell /bin/sh -c $'export XDG_RUNTIME_DIR=/run/user/$UID ; {cmd}'"
+
+ def succeed_as_alice(*cmds):
+ return machine.succeed(*map(alice_cmd,cmds))
+
+ def fail_as_alice(*cmds):
+ return machine.fail(*map(alice_cmd,cmds))
+
+ # Create a persistent login so that Alice has a systemd session.
+ login_as_alice()
+
+ # Set up a home-manager channel.
+ succeed_as_alice(" ; ".join([
+ "mkdir -p /home/alice/.nix-defexpr/channels",
+ f"ln -s {home_manager} /home/alice/.nix-defexpr/channels/home-manager"
+ ]))
+
+ with subtest("Home Manager installation"):
+ succeed_as_alice("nix-shell \"\" -A install")
+
+ succeed_as_alice("cp ${
+ ./home.nix
+ } /home/alice/.config/home-manager/home.nix")
+
+ with subtest("Generate with no secrets"):
+ succeed_as_alice("install -m644 ${
+ ./no-secrets.nix
+ } /home/alice/.config/home-manager/test-remote.nix")
+
+ actual = succeed_as_alice("home-manager switch")
+ expected = "Activating createRcloneConfig"
+ assert expected in actual, \
+ f"expected home-manager switch to contain {expected}, but got {actual}"
+
+ succeed_as_alice("diff -u ${
+ ./no-secrets.conf
+ } /home/alice/.config/rclone/rclone.conf")
+
+ with subtest("Generate with secrets from store"):
+ succeed_as_alice("install -m644 ${
+ ./with-secrets-in-store.nix
+ } /home/alice/.config/home-manager/test-remote.nix")
+
+ actual = succeed_as_alice("home-manager switch")
+ expected = "Activating createRcloneConfig"
+ assert expected in actual, \
+ f"expected home-manager switch to contain {expected}, but got {actual}"
+
+ succeed_as_alice("diff -u ${
+ ./with-secrets-in-store.conf
+ } /home/alice/.config/rclone/rclone.conf")
+
+ # TODO: verify correct activation order with the agenix and sops hm modules
+
+ logout_alice()
+ '';
+}
diff --git a/tests/integration/standalone/rclone/home.nix b/tests/integration/standalone/rclone/home.nix
new file mode 100644
index 000000000..0bc0f8a51
--- /dev/null
+++ b/tests/integration/standalone/rclone/home.nix
@@ -0,0 +1,13 @@
+{
+ imports = [ ./test-remote.nix ];
+
+ home.username = "alice";
+ home.homeDirectory = "/home/alice";
+
+ home.stateVersion = "24.05"; # Please read the comment before changing.
+
+ # Let Home Manager install and manage itself.
+ programs.home-manager.enable = true;
+
+ programs.rclone.enable = true;
+}
diff --git a/tests/integration/standalone/rclone/no-secrets.conf b/tests/integration/standalone/rclone/no-secrets.conf
new file mode 100644
index 000000000..d1fc379a1
--- /dev/null
+++ b/tests/integration/standalone/rclone/no-secrets.conf
@@ -0,0 +1,5 @@
+[alices-cool-remote]
+host=backup-server
+key_file=/key/path/foo
+type=sftp
+user=alice
diff --git a/tests/integration/standalone/rclone/no-secrets.nix b/tests/integration/standalone/rclone/no-secrets.nix
new file mode 100644
index 000000000..077a7ff15
--- /dev/null
+++ b/tests/integration/standalone/rclone/no-secrets.nix
@@ -0,0 +1,10 @@
+{
+ programs.rclone.remotes = {
+ alices-cool-remote.config = {
+ type = "sftp";
+ host = "backup-server";
+ user = "alice";
+ key_file = "/key/path/foo";
+ };
+ };
+}
diff --git a/tests/integration/standalone/rclone/with-secrets-in-store.conf b/tests/integration/standalone/rclone/with-secrets-in-store.conf
new file mode 100644
index 000000000..12e2777d1
--- /dev/null
+++ b/tests/integration/standalone/rclone/with-secrets-in-store.conf
@@ -0,0 +1,6 @@
+[alices-cool-remote-v2]
+hard_delete = true
+type = b2
+account = super-secret-account-id
+key = api-key-from-file
+
diff --git a/tests/integration/standalone/rclone/with-secrets-in-store.nix b/tests/integration/standalone/rclone/with-secrets-in-store.nix
new file mode 100644
index 000000000..4c47a60e2
--- /dev/null
+++ b/tests/integration/standalone/rclone/with-secrets-in-store.nix
@@ -0,0 +1,18 @@
+{ pkgs, ... }: {
+ programs.rclone.remotes = {
+ alices-cool-remote-v2 = {
+ config = {
+ type = "b2";
+ hard_delete = true;
+ };
+ secrets = {
+ account = "${pkgs.writeText "acc" ''
+ super-secret-account-id
+ ''}";
+ key = "${pkgs.writeText "key" ''
+ api-key-from-file
+ ''}";
+ };
+ };
+ };
+}