{ description = "Restic"; inputs = { nixpkgs-stable = { url = "nixpkgs/nixos-24.05"; }; flake-utils = { url = "github:numtide/flake-utils"; }; }; outputs = { self, nixpkgs-stable, flake-utils, ... }: flake-utils.lib.eachDefaultSystem ( system: let pkgs = import nixpkgs-stable { inherit system; }; in { packages = { restic = let pname = "restic"; version = "0.17.3"; hash = "sha256-PTy/YcojJGrYQhdp98e3rEMqHIWDMR5jiSC6BdzBT/M="; vendorHash = "sha256-tU2msDHktlU0SvvxLQCU64p8DpL8B0QiliVCuHlLTHQ="; in pkgs.buildGoModule { inherit pname version vendorHash; src = pkgs.fetchFromGitHub { owner = "restic"; repo = "restic"; rev = "v${version}"; hash = hash; }; # env = { # RESTIC_TEST_FUSE = "false"; # }; patches = [ # The TestRestoreWithPermissionFailure test fails in Nix's build sandbox ./0001-Skip-testing-restore-with-permission-failure.patch ]; subPackages = ["cmd/restic"]; nativeBuildInputs = [ pkgs.installShellFiles pkgs.makeWrapper ]; nativeCheckInputs = [pkgs.python3]; passthru.tests.restic = pkgs.nixosTests.restic; postPatch = '' rm cmd/restic/cmd_mount_integration_test.go ''; postInstall = '' wrapProgram $out/bin/restic --prefix PATH : '${pkgs.rclone}/bin' '' + pkgs.lib.optionalString (pkgs.stdenv.hostPlatform == pkgs.stdenv.buildPlatform) '' $out/bin/restic generate \ --bash-completion restic.bash \ --fish-completion restic.fish \ --zsh-completion restic.zsh \ --man . installShellCompletion restic.{bash,fish,zsh} installManPage *.1 ''; meta = with pkgs.lib; { homepage = "https://restic.net"; description = "A backup program that is fast, efficient and secure"; platforms = platforms.linux ++ platforms.darwin; license = licenses.bsd2; maintainers = [maintainers.mbrgm]; }; }; entrypoint = pkgs.writeTextFile { name = "entrypoint"; destination = "/bin/entrypoint"; text = pkgs.lib.concatStringsSep "\n" [ "#!${pkgs.nushell}/bin/nu" (builtins.readFile ./entrypoint.nu) ]; executable = true; }; docker = pkgs.dockerTools.buildLayeredImage { name = "restic"; tag = "latest"; maxLayers = 2; contents = [ ]; config = { Cmd = [ "${self.packages.${system}.entrypoint}/bin/entrypoint" "--rclone" "${pkgs.rclone}/bin/rclone" "--restic" "${self.packages.${pkgs.system}.restic}/bin/restic" "--cache-dir" "/cache" ]; Env = [ "TZ=UTC" "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ]; ExposedPorts = {}; Volumes = { "/cache" = {}; }; }; }; }; } ) // { nixosModules = { restic = { config, lib, pkgs, ... }: let cfg = config.restic; package = self.packages.${pkgs.system}.restic; in { options = { restic = lib.options.mkOption { type = lib.types.submodule { options = { enable = lib.options.mkEnableOption "Restic"; passwordFile = lib.options.mkOption { type = lib.types.path; }; storage = lib.options.mkOption { type = lib.types.enum [ "azure" "b2" ]; }; healthcheck = lib.options.mkOption { type = lib.types.submodule { options = { enable = lib.options.mkEnableOption "use healthcheck"; apiUrl = lib.options.mkOption { type = lib.types.str; }; apiKeyFile = lib.options.mkOption { type = lib.types.str; }; timeout = lib.options.mkOption { type = lib.types.int; default = 86400; }; grace = lib.options.mkOption { type = lib.types.int; default = 14400; }; }; }; }; b2 = lib.options.mkOption { type = lib.types.submodule { options = { bucket = lib.options.mkOption { type = lib.types.str; }; accountId = lib.options.mkOption { type = lib.types.str; }; accountKeyFile = lib.options.mkOption { type = lib.types.str; }; }; }; }; azure = lib.options.mkOption { type = lib.types.submodule { options = { accountName = lib.options.mkOption { type = lib.types.str; }; accountKeyFile = lib.options.mkOption { type = lib.types.nullOr lib.types.str; default = null; }; }; }; }; backups = lib.options.mkOption { type = lib.types.listOf ( lib.types.submodule { options = { oneFileSystem = lib.options.mkOption { type = lib.types.bool; default = false; }; excludes = lib.options.mkOption { type = lib.types.listOf lib.types.str; default = []; }; paths = lib.options.mkOption { type = lib.types.listOf lib.types.str; default = []; }; preCommand = lib.options.mkOption { type = lib.types.lines; default = ""; }; postCommand = lib.options.mkOption { type = lib.types.lines; default = ""; }; }; } ); }; }; }; }; }; config = let bucket = { "b2" = cfg.b2.bucket; "azure" = builtins.replaceStrings ["."] ["-"] "${config.networking.hostName}.${config.networking.domain}"; } .${cfg.storage}; directory = { "b2" = "${config.networking.hostName}.${config.networking.domain}"; "azure" = "/"; } .${cfg.storage}; in lib.mkIf cfg.enable { environment.systemPackages = [ package ]; systemd.services.restic = let curlOptions = "--fail --silent --show-error --max-time 10 --retry 5"; hcPayload = if cfg.healthcheck.enable then ( pkgs.writeText "healthcheck-payload" ( builtins.toJSON { name = "${config.networking.hostName}.${config.networking.domain}"; timeout = cfg.healthcheck.timeout; grace = cfg.healthcheck.grace; unique = ["name"]; channels = "*"; } ) ) else null; hcSetup = if cfg.healthcheck.enable then '' HC_URL=''$(${pkgs.curl}/bin/curl ${curlOptions} --request POST --header 'Content-Type: application/json' --header "X-Api-Key: ''$(<${cfg.healthcheck.apiKeyFile})" --data @${hcPayload} "${cfg.healthcheck.apiUrl}" | ${pkgs.jq}/bin/jq -r .ping_url) '' else ""; hcStart = if cfg.healthcheck.enable then '' if [ ! -z "''${HC_URL}" ] then ${pkgs.curl}/bin/curl ${curlOptions} --output /dev/null "''${HC_URL}/start" || true fi '' else ""; hcStop = if cfg.healthcheck.enable then '' if [ ! -z "''${HC_URL}" ] then ${pkgs.curl}/bin/curl ${curlOptions} --output /dev/null "''${HC_URL}" || true fi '' else ""; repositoryConfig = { "b2" = "b2:${bucket}:${directory}"; "azure" = "azure:${bucket}:${directory}"; } .${cfg.storage}; resticConfig = '' export RESTIC_CACHE_DIR=/var/cache/restic export RESTIC_PASSWORD_FILE=${cfg.passwordFile} export RESTIC_REPOSITORY=${repositoryConfig} '' + { "b2" = '' export B2_ACCOUNT_ID='${cfg.b2.accountId}' export B2_ACCOUNT_KEY="''$(<${cfg.b2.accountKeyFile})" ''; "azure" = '' export AZURE_ACCOUNT_NAME='${cfg.azure.accountName}' export AZURE_ACCOUNT_KEY="''$(<${cfg.azure.accountKeyFile})" ''; } .${cfg.storage}; backupCommands = lib.strings.concatStringsSep "\n" ( map ( backup: let oneFileSystem = if backup.oneFileSystem then ["--one-file-system"] else []; excludes = map ( exclude: ''--exclude="${exclude}"'' ) backup.excludes; paths = map ( path: ''"${path}"'' ) backup.paths; arguments = lib.strings.concatStringsSep " " ( oneFileSystem ++ excludes ++ paths ); in '' ${backup.preCommand} ${package}/bin/restic backup ${arguments} ${backup.postCommand} '' ) cfg.backups ); initCheck = { "b2" = '' export RCLONE_CONFIG=/dev/null export RCLONE_CONFIG_B2_TYPE=b2 export RCLONE_CONFIG_B2_ACCOUNT='${cfg.b2.accountId}' export RCLONE_CONFIG_B2_KEY="''$(<${cfg.b2.accountKeyFile})" config=''$(${pkgs.rclone}/bin/rclone lsjson 'b2:${bucket}/${directory}/config' | ${pkgs.jq}/bin/jq -r '. | length | . > 0') if [ "''${config}" != 'true' ] then ${package}/bin/restic init fi ''; "azure" = '' export RCLONE_CONFIG=/dev/null export RCLONE_CONFIG_AZURE_TYPE=azureblob export RCLONE_CONFIG_AZURE_ACCOUNT='${cfg.azure.accountName}' export RCLONE_CONFIG_AZURE_KEY="''$(<${cfg.azure.accountKeyFile})" container=''$(${pkgs.rclone}/bin/rclone lsjson 'azure:' | ${pkgs.jq}/bin/jq -r 'map(select(.Path=="${bucket}" and .IsBucket)) | length | . > 0') if [ "''${container}" != 'true' ] then ${pkgs.rclone}/bin/rclone mkdir 'azure:${bucket}' fi config=$(${pkgs.rclone}/bin/rclone lsjson 'azure:${bucket}/config' | ${pkgs.jq}/bin/jq -r '. | length | . > 0') if [ "''${config}" != 'true' ] then ${package}/bin/restic init fi ''; } .${cfg.storage}; in { serviceConfig = { Type = "oneshot"; CacheDirectory = "restic"; }; script = '' ${hcSetup} ${hcStart} ${resticConfig} ${initCheck} ${backupCommands} ${package}/bin/restic forget --prune --keep-daily 14 ${package}/bin/restic check ${package}/bin/restic cache --cleanup ${hcStop} ''; }; systemd.timers.restic = { timerConfig = { OnCalendar = "*-*-* 02:00:00"; RandomizedDelaySec = "60m"; }; wantedBy = ["multi-user.target"]; }; }; }; }; }; }