WIP: Add atomic backups with restic
This commit is contained in:
		
							parent
							
								
									ab5e088016
								
							
						
					
					
						commit
						2f2292c376
					
				
					 9 changed files with 316 additions and 5 deletions
				
			
		|  | @ -14,6 +14,7 @@ | |||
|     "${modulesPath}/profiles/minimal.nix" | ||||
|     (import ../modules) | ||||
| 
 | ||||
|     ./services/backups.nix | ||||
|     ./services/conduit.nix | ||||
|     ./services/foundryvtt.nix | ||||
|     ./services/gitea.nix | ||||
|  |  | |||
							
								
								
									
										229
									
								
								configuration/services/backups.nix
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								configuration/services/backups.nix
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,229 @@ | |||
| { | ||||
|   config, | ||||
|   pkgs, | ||||
|   lib, | ||||
|   ... | ||||
| }: let | ||||
|   inherit (lib) types optional singleton; | ||||
|   mkShutdownScript = service: | ||||
|     pkgs.writeShellScript "backup-${service}-shutdown" '' | ||||
|       if systemctl is-active --quiet '${service}'; then | ||||
|         touch '/tmp/${service}-was-active' | ||||
|         systemctl stop '${service}' | ||||
|       fi | ||||
|     ''; | ||||
|   mkRestartScript = service: | ||||
|     pkgs.writeShellScript "backup-${service}-restart" '' | ||||
|       if [ -f '/tmp/${service}-was-active' ]; then | ||||
|         rm '/tmp/${service}-was-active' | ||||
|         systemctl start '${service}' | ||||
|       fi | ||||
|     ''; | ||||
|   writeScript = name: packages: text: | ||||
|     lib.getExe (pkgs.writeShellApplication { | ||||
|       inherit name text; | ||||
|       runtimeInputs = packages; | ||||
|     }); | ||||
| in { | ||||
|   options = { | ||||
|     services.backups = lib.mkOption { | ||||
|       description = lib.mdDoc '' | ||||
|         Configure restic backups with a specific tag. | ||||
|       ''; | ||||
|       type = types.attrsOf (types.submodule ({ | ||||
|         config, | ||||
|         name, | ||||
|         ... | ||||
|       }: { | ||||
|         options = { | ||||
|           user = lib.mkOption { | ||||
|             type = types.str; | ||||
|             description = '' | ||||
|               The user as which to run the backup. | ||||
|             ''; | ||||
|           }; | ||||
|           paths = lib.mkOption { | ||||
|             type = types.listOf types.str; | ||||
|             description = '' | ||||
|               The paths to back up. | ||||
|             ''; | ||||
|           }; | ||||
|           tag = lib.mkOption { | ||||
|             type = types.str; | ||||
|             description = '' | ||||
|               The restic tag to mark the backup with. | ||||
|             ''; | ||||
|             default = name; | ||||
|           }; | ||||
|           preparation = { | ||||
|             packages = lib.mkOption { | ||||
|               type = types.listOf types.package; | ||||
|               default = []; | ||||
|               description = '' | ||||
|                 The list of packages to make available in the | ||||
|                 preparation script. | ||||
|               ''; | ||||
|             }; | ||||
|             text = lib.mkOption { | ||||
|               type = types.nullOr types.str; | ||||
|               default = null; | ||||
|               description = '' | ||||
|                 The preparation script to run before the backup. | ||||
| 
 | ||||
|                 This should include things like database dumps and | ||||
|                 enabling maintenance modes. If a service needs to be | ||||
|                 shut down for backups, use `pauseServices` instead. | ||||
|               ''; | ||||
|             }; | ||||
|           }; | ||||
|           cleanup = { | ||||
|             packages = lib.mkOption { | ||||
|               type = types.listOf types.package; | ||||
|               default = []; | ||||
|               description = '' | ||||
|                 The list of packages to make available in the | ||||
|                 cleanup script. | ||||
|               ''; | ||||
|             }; | ||||
|             text = lib.mkOption { | ||||
|               type = types.nullOr types.str; | ||||
|               default = null; | ||||
|               description = '' | ||||
|                 The cleanup script to run after the backup. | ||||
| 
 | ||||
|                 This should do things like cleaning up database dumps | ||||
|                 and disabling maintenance modes. | ||||
|               ''; | ||||
|             }; | ||||
|           }; | ||||
|           pauseServices = lib.mkOption { | ||||
|             type = types.listOf types.str; | ||||
|             default = []; | ||||
|             description = '' | ||||
|               The systemd services that need to be shut down before | ||||
|               the backup can run. Services will be restarted after the | ||||
|               backup is complete. | ||||
| 
 | ||||
|               This is intended to be used for services that do not | ||||
|               support hot backups. | ||||
|             ''; | ||||
|           }; | ||||
|         }; | ||||
|       })); | ||||
|     }; | ||||
|   }; | ||||
| 
 | ||||
|   config = lib.mkIf (config.services.backups != {}) { | ||||
|     systemd.services = | ||||
|       { | ||||
|         restic-prune = { | ||||
|           # Doesn't hurt to finish the ongoing prune | ||||
|           restartIfChanged = false; | ||||
| 
 | ||||
|           environment = { | ||||
|             RESTIC_PASSWORD_FILE = config.sops.secrets."restic/local-backups".path; | ||||
|             RESTIC_REPOSITORY = "/var/lib/backups/"; | ||||
|             RESTIC_CACHE_DIR = "%C/restic-prune"; | ||||
|           }; | ||||
| 
 | ||||
|           path = with pkgs; [ | ||||
|             restic | ||||
|           ]; | ||||
| 
 | ||||
|           script = '' | ||||
|             # TODO(tlater): In an append-only setup, we should be | ||||
|             # careful with this; an attacker could delete backups by | ||||
|             # simply appending ad infinitum: | ||||
|             # https://restic.readthedocs.io/en/stable/060_forget.html#security-considerations-in-append-only-mode | ||||
|             restic forget --keep-last 3 --prune | ||||
|             restic check | ||||
|           ''; | ||||
| 
 | ||||
|           serviceConfig = { | ||||
|             DynamicUser = true; | ||||
|             Group = "backup"; | ||||
| 
 | ||||
|             CacheDirectory = "restic-prune"; | ||||
|             CacheDirectoryMode = "0700"; | ||||
|             ReadWritePaths = "/var/lib/backups/"; | ||||
| 
 | ||||
|             # Ensure we don't leave behind any files with the | ||||
|             # temporary UID of this service. | ||||
|             ExecStopPost = "+${pkgs.coreutils}/bin/chown -R root:backup /var/lib/backups/"; | ||||
|           }; | ||||
|         }; | ||||
|       } | ||||
|       // lib.mapAttrs' (name: backup: | ||||
|         lib.nameValuePair "backup-${name}" { | ||||
|           # Don't want to restart mid-backup | ||||
|           restartIfChanged = false; | ||||
| 
 | ||||
|           environment = { | ||||
|             RESTIC_CACHE_DIR = "%C/backup-${name}"; | ||||
|             RESTIC_PASSWORD_FILE = config.sops.secrets."restic/local-backups".path; | ||||
|             # TODO(tlater): If I ever add more than one repo, service | ||||
|             # shutdown/restarting will potentially break if multiple | ||||
|             # backups for the same service overlap. A more clever | ||||
|             # sentinel file with reference counts would probably solve | ||||
|             # this. | ||||
|             RESTIC_REPOSITORY = "/var/lib/backups/"; | ||||
|           }; | ||||
| 
 | ||||
|           serviceConfig = { | ||||
|             User = backup.user; | ||||
|             Group = "backup"; | ||||
|             RuntimeDirectory = "backup-${name}"; | ||||
|             CacheDirectory = "backup-${name}"; | ||||
|             CacheDirectoryMode = "0700"; | ||||
|             PrivateTmp = true; | ||||
| 
 | ||||
|             ExecStart = [ | ||||
|               (lib.concatStringsSep " " (["${pkgs.restic}/bin/restic" "backup" "--tag" name] ++ backup.paths)) | ||||
|             ]; | ||||
| 
 | ||||
|             ExecStartPre = | ||||
|               map (service: "+${mkShutdownScript service}") backup.pauseServices | ||||
|               ++ singleton (writeScript "backup-${name}-repo-init" [pkgs.restic pkgs.coreutils] '' | ||||
|                 restic snapshots || (restic init && chmod -R g+rwx "$RESTIC_REPOSITORY"/*) | ||||
|               '') | ||||
|               ++ optional (backup.preparation.text != null) | ||||
|               (writeScript "backup-${name}-prepare" backup.preparation.packages backup.preparation.text); | ||||
| 
 | ||||
|             # TODO(tlater): Add repo pruning/checking | ||||
|             ExecStopPost = | ||||
|               map (service: "+${mkRestartScript service}") backup.pauseServices | ||||
|               ++ optional (backup.cleanup.text != null) | ||||
|               (writeScript "backup-${name}-cleanup" backup.cleanup.packages backup.cleanup.text); | ||||
|           }; | ||||
|         }) | ||||
|       config.services.backups; | ||||
| 
 | ||||
|     systemd.timers = | ||||
|       { | ||||
|         restic-prune = { | ||||
|           wantedBy = ["timers.target"]; | ||||
|           timerConfig.OnCalendar = "Thursday 03:00:00 UTC"; | ||||
|           # Don't make this persistent, in case the server was offline | ||||
|           # for a while. This job cannot run at the same time as any | ||||
|           # of the backup jobs. | ||||
|         }; | ||||
|       } | ||||
|       // lib.mapAttrs' (name: backup: | ||||
|         lib.nameValuePair "backup-${name}" { | ||||
|           wantedBy = ["timers.target"]; | ||||
|           timerConfig = { | ||||
|             OnCalendar = "Wednesday 02:30:00 UTC"; | ||||
|             RandomizedDelaySec = "1h"; | ||||
|             FixedRandomDelay = true; | ||||
|             Persistent = true; | ||||
|           }; | ||||
|         }) | ||||
|       config.services.backups; | ||||
| 
 | ||||
|     users.groups.backup = {}; | ||||
| 
 | ||||
|     systemd.tmpfiles.rules = [ | ||||
|       "d /var/lib/backups/ 0770 root backup" | ||||
|     ]; | ||||
|   }; | ||||
| } | ||||
|  | @ -231,4 +231,14 @@ in { | |||
|       }; | ||||
|     }; | ||||
|   }; | ||||
| 
 | ||||
|   services.backups.conduit = { | ||||
|     user = "root"; | ||||
|     paths = [ | ||||
|       "/var/lib/private/matrix-conduit/" | ||||
|     ]; | ||||
|     # Other services store their data in conduit, so no other services | ||||
|     # need to be shut down currently. | ||||
|     pauseServices = ["conduit.service"]; | ||||
|   }; | ||||
| } | ||||
|  |  | |||
|  | @ -1,4 +1,8 @@ | |||
| {config, ...}: let | ||||
| { | ||||
|   pkgs, | ||||
|   config, | ||||
|   ... | ||||
| }: let | ||||
|   domain = "gitea.${config.services.nginx.domain}"; | ||||
| in { | ||||
|   services.gitea = { | ||||
|  | @ -52,4 +56,24 @@ in { | |||
|       enabled = true | ||||
|     ''; | ||||
|   }; | ||||
| 
 | ||||
|   services.backups.gitea = { | ||||
|     user = "gitea"; | ||||
|     paths = [ | ||||
|       "/var/lib/gitea/gitea-db.sql" | ||||
|       "/var/lib/gitea/repositories/" | ||||
|       "/var/lib/gitea/data/" | ||||
|       "/var/lib/gitea/custom/" | ||||
|       # Conf is backed up via nix | ||||
|     ]; | ||||
|     preparation = { | ||||
|       packages = [config.services.postgresql.package]; | ||||
|       text = "pg_dump ${config.services.gitea.database.name} --file=/var/lib/gitea/gitea-db.sql"; | ||||
|     }; | ||||
|     cleanup = { | ||||
|       packages = [pkgs.coreutils]; | ||||
|       text = "rm /var/lib/gitea/gitea-db.sql"; | ||||
|     }; | ||||
|     pauseServices = ["gitea.service"]; | ||||
|   }; | ||||
| } | ||||
|  |  | |||
|  | @ -74,4 +74,33 @@ in { | |||
|       bantime = 86400 | ||||
|     ''; | ||||
|   }; | ||||
| 
 | ||||
|   services.backups.nextcloud = { | ||||
|     user = "nextcloud"; | ||||
|     paths = [ | ||||
|       "/var/lib/nextcloud/nextcloud-db.sql" | ||||
|       "/var/lib/nextcloud/data/" | ||||
|       "/var/lib/nextcloud/config/config.php" | ||||
|     ]; | ||||
|     preparation = { | ||||
|       packages = [ | ||||
|         config.services.postgresql.package | ||||
|         config.services.nextcloud.occ | ||||
|       ]; | ||||
|       text = '' | ||||
|         nextcloud-occ maintenance:mode --on | ||||
|         pg_dump ${config.services.nextcloud.config.dbname} --file=/var/lib/nextcloud/nextcloud-db.sql | ||||
|       ''; | ||||
|     }; | ||||
|     cleanup = { | ||||
|       packages = [ | ||||
|         pkgs.coreutils | ||||
|         config.services.nextcloud.occ | ||||
|       ]; | ||||
|       text = '' | ||||
|         rm /var/lib/nextcloud/nextcloud-db.sql | ||||
|         nextcloud-occ maintenance:mode --off | ||||
|       ''; | ||||
|     }; | ||||
|   }; | ||||
| } | ||||
|  |  | |||
|  | @ -110,4 +110,12 @@ in { | |||
|       # ProtectHome = "read-only"; # See further up | ||||
|     }; | ||||
|   }; | ||||
| 
 | ||||
|   services.backups.starbound = { | ||||
|     user = "root"; | ||||
|     paths = [ | ||||
|       "/var/lib/private/starbound/storage/universe/" | ||||
|     ]; | ||||
|     pauseServices = ["starbound.service"]; | ||||
|   }; | ||||
| } | ||||
|  |  | |||
|  | @ -19,6 +19,12 @@ | |||
|         mode = "0440"; | ||||
|       }; | ||||
| 
 | ||||
|       "restic/local-backups" = { | ||||
|         owner = "root"; | ||||
|         group = "backup"; | ||||
|         mode = "0440"; | ||||
|       }; | ||||
| 
 | ||||
|       "turn/env" = {}; | ||||
|       "turn/secret" = { | ||||
|         owner = "turnserver"; | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue