1 # auto-upgrade-with-pinch: Secure managed NixOS updates
3 # This program is free software: you can redistribute it and/or modify it
4 # under the terms of the GNU General Public License as published by the
5 # Free Software Foundation, version 3.
7 { config, lib, pkgs, ... }:
10 local-pkgs = import ../. { inherit pkgs; };
11 cfg = config.system.autoUpgradeWithPinch;
12 pull-repo-script = pkgs.writeShellScript "pull-repo" ''
19 ${pkgs.jq}/bin/jq -r ".$1" <<< "$config"
22 echo Pulling in "$path" >&2
24 if [[ ! -e "$path" ]];then
26 ${pkgs.git}/bin/git init "$d"
27 ${pkgs.git}/bin/git -C "$d" checkout -b "$(prop localBranch)"
28 ${pkgs.git}/bin/git -C "$d" remote add "$(prop remoteName)" "$(prop url)"
29 ${pkgs.git}/bin/git -C "$d" branch -u "$(prop remoteBranch)"
30 mkdir -p "$(${pkgs.coreutils}/bin/dirname "$path")"
36 if [[ "$(${pkgs.git}/bin/git remote get-url "$(prop remoteName)")" != "$(prop url)" ]]; then
37 echo Expected git remote "$(prop remoteName)" to point at "$(prop url)" \
38 but it points at "$(${pkgs.git}/bin/git remote get-url "$(prop remoteName)")" >&2
39 case "$(prop onRemoteURLMismatch)" in
41 update) echo Updating it >&2
42 ${pkgs.git}/bin/git -C "$d" remote set-url "$(prop remoteName)" "$(prop url)";;
46 ${pkgs.git}/bin/git fetch "$(prop remoteName)" "$(prop remoteBranch)"
48 if [[ "$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)" != "$(prop localBranch)" ]];then
49 echo Could not merge because currently-checked-out \
50 \""$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)"\" is not \
51 \""$(prop localBranch)"\"
52 case "$(prop onBranchMismatch)" in
58 if [[ "$(prop requireSignature)" == true ]]; then
59 ${pkgs.polite-merge}/bin/polite-merge \
60 -c gpg.program=${escapeShellArg (local-pkgs.keyed-gpg cfg.signingKeys)} \
61 merge --ff-only --verify-signatures
63 ${pkgs.polite-merge}/bin/polite-merge merge --ff-only
67 auto-upgrade-script = pkgs.writeShellScript "auto-upgrade" ''
68 ${pkgs.util-linux}/bin/flock /run/auto-upgrade-with-pinch ${
69 pkgs.writeShellScript "auto-upgrade-with-lock-held" ''
74 if [[ "$1" == --dry-run ]];then
76 pinch_args=( --dry-run )
93 d=$(${pkgs.coreutils}/bin/mktemp -d)
97 ${pkgs.coreutils}/bin/rm -r "$d"
100 ${optionalString (cfg.upgradeConfigOwnershipPolicy != "any") (''
102 if [[ "$1" != /* ]];then
103 die "Unexpected relative path: $1"
105 if [[ "$1" != / ]];then
106 verify_ownership "$(${pkgs.coreutils}/bin/dirname "$1")"
108 if [[ ! -e "$1" ]];then
109 die "Could not find upgrade config: $1 does not exist"
111 if [[ -h "$1" ]];then
113 ${pkgs.coreutils}/bin/realpath --no-symlinks \
114 "$(${pkgs.coreutils}/bin/dirname "$1")/$(${pkgs.coreutils}/bin/readlink "$1")"
117 perms="$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%M")"
118 if [[ "$perms" == d*t ]];then
119 die "Will not use upgrade config in sticky directory $1"
121 owner=$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%u")
122 if [[ "$owner" != root ]];then
123 die "Will not use upgrade config not owned by root in $1"
125 if [[ "$perms" == l* ]];then
126 return 0 # Root-owned symlinks are fine
128 if [[ "$perms" == *w? ]];then
129 die "Will not use world-writable upgrade config in $1"
134 if [[ "$perms" == *w???? ]];then
135 die "Will not use group-writable upgrade config in $1"
139 if [[ "$perms" == *w???? ]];then
140 group=$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%g")
141 if [[ "$group" != wheel ]];then
142 die "Will not use non-wheel-group group-writable upgrade config in $1"
146 }."${cfg.upgradeConfigOwnershipPolicy}"
150 + concatMapStringsSep "\n" (f: "verify_ownership ${escapeShellArg f}")
153 config=$(${pkgs.nix}/bin/nix-instantiate --eval --strict --json -A config \
154 --arg upgradeConfig ${
156 + lib.concatMapStringsSep " " lib.strings.escapeNixString
157 cfg.upgradeConfig + "]")
158 } ${../upgrade-config.nix})
161 ${pkgs.jq}/bin/jq -r "$@" <<< "$config"
165 config_query --arg path "$1" ".repos[\$ARGS.named.path]$2"
169 config_query --arg user "$1" ".userEnvironments[\$ARGS.named.user]$2"
174 hydrate /run/wrappers/bin/sudo -u "$(repo_query "$path" .user)" \
175 ${pull-repo-script} "$path" "$(repo_query "$path" "")"
176 done < <( config_query '.repos | keys []' )
179 config_query '.pinchFiles[]' | ${pkgs.findutils}/bin/xargs --no-run-if-empty --delimiter=\\n ${pkgs.pinch}/bin/pinch update "''${pinch_args[@]}"
182 in_tmpdir hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
184 hydrate /run/wrappers/bin/sudo -u "$user" \
185 ${pkgs.nix}/bin/nix-build --no-out-link '<nixpkgs>' -A "$(userenv_query "$user" .package)"
186 done < <( config_query '.userEnvironments | keys []' )
189 hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch
192 if [[ "$(userenv_query "$user" .otherPackagesAction)" == keep ]];then
195 hydrate /run/wrappers/bin/sudo -u "$user" \
196 ${pkgs.nix}/bin/nix-env -f '<nixpkgs>' $remove_arg -iA "$(userenv_query "$user" .package)"
197 done < <( config_query '.userEnvironments | keys []' )
204 system.autoUpgradeWithPinch = {
210 Whether to periodically upgrade NixOS to the latest version.
211 Presumes that /etc/nixos is a git repo with a remote and
212 contains a pinch file called "channels".
220 Specification (in the format described by
221 <citerefentry><refentrytitle>systemd.time</refentrytitle>
222 <manvolnum>7</manvolnum></citerefentry>) of the time at
223 which the update will occur.
227 signingKeys = mkOption {
228 type = types.listOf types.path;
230 Files containing GPG keys that are authorized to sign updates.
231 Updates are only merged if the commit at the tip of the remote
232 ref is signed with one of these keys.
236 upgradeConfig = mkOption {
237 type = types.listOf types.path;
239 Configuration files that specify what git repo paths to pull, what
240 pinch files to update from, and what user environments to update.
241 These are specified in separate configuration files processed at
242 update time so that changes to this configuration take effect in
243 the same update cycle.
247 upgradeConfigOwnershipPolicy = mkOption {
248 type = types.enum [ "root" "wheel" "any" ];
251 Verify ownership of upgrade config files before using them for
254 root = Config must be writable only by root.
255 wheel = Config must be writable only by root and wheel.
256 any = No checks. Not recommended.
262 config = lib.mkIf cfg.enable {
264 security.sudo.extraRules = lib.mkAfter [{
265 groups = [ "users" ];
267 command = "${auto-upgrade-script}";
268 options = [ "NOPASSWD" "NOSETENV" ];
271 # NOSETENV above still allows through ~17 vars, including PATH. Block those
273 security.sudo.extraConfig = ''
274 Defaults!${auto-upgrade-script} !env_check
275 Defaults!${auto-upgrade-script} !env_keep
279 (import ../overlays/pinch.nix)
280 (import ../overlays/polite-merge.nix)
282 auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
283 /run/wrappers/bin/sudo ${auto-upgrade-script}
288 environment.systemPackages = [ pkgs.auto-upgrade ];
290 systemd.services.nixos-upgrade = {
291 description = "NixOS Upgrade";
292 restartIfChanged = false;
293 unitConfig.X-StopOnRemoval = false;
294 serviceConfig.Type = "oneshot";
295 environment = config.nix.envVars // {
296 inherit (config.environment.sessionVariables) NIX_PATH;
298 } // config.networking.proxy.envVars;
301 config.nix.package.out
313 # Chill for awhile before applying updates. If applying an update
314 # badly breaks things, we want a window in which an operator can
315 # intervene either to fix the problem or disable automatic updates.
318 ${auto-upgrade-script}