]> git.scottworley.com Git - auto-upgrade-with-pinch/blame - modules/auto-upgrade.nix
When becoming other users, cd to / with pushd, not sudo -D
[auto-upgrade-with-pinch] / modules / auto-upgrade.nix
CommitLineData
15d7ea95
SW
1# auto-upgrade-with-pinch: Secure managed NixOS updates
2#
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.
6
901670f5
SW
7{ config, lib, pkgs, ... }:
8with lib;
364c110c 9let
edaaa0c0 10 local-pkgs = import ../. { inherit pkgs; };
364c110c 11 cfg = config.system.autoUpgradeWithPinch;
eff66a9c
SW
12 pull-repo-script = pkgs.writeShellScript "pull-repo" ''
13 set -eo pipefail
4cbd961f 14
eff66a9c
SW
15 path=$1
16 config=$2
4cbd961f 17
eff66a9c
SW
18 prop() {
19 ${pkgs.jq}/bin/jq -r ".$1" <<< "$config"
20 }
f1a53b29 21
eff66a9c 22 echo Pulling in "$path" >&2
f1a53b29 23
eff66a9c
SW
24 if [[ ! -e "$path" ]];then
25 d=$(mktemp -d)
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")"
31 mv "$d" "$path"
32 fi
4cbd961f 33
eff66a9c 34 cd "$path"
f1a53b29 35
eff66a9c
SW
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
40 abort) exit 1;;
41 update) echo Updating it >&2
42 ${pkgs.git}/bin/git -C "$d" remote set-url "$(prop remoteName)" "$(prop url)";;
43 esac
44 fi
4cbd961f 45
eff66a9c 46 ${pkgs.git}/bin/git fetch "$(prop remoteName)" "$(prop remoteBranch)"
4cbd961f 47
eff66a9c
SW
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
53 abort) exit 1;;
54 continue) exit 0;;
55 esac
56 fi
4cbd961f 57
eff66a9c
SW
58 if [[ "$(prop requireSignature)" == true ]]; then
59 ${pkgs.polite-merge}/bin/polite-merge \
edaaa0c0 60 -c gpg.program=${escapeShellArg (local-pkgs.keyed-gpg cfg.signingKeys)} \
eff66a9c
SW
61 merge --ff-only --verify-signatures
62 else
63 ${pkgs.polite-merge}/bin/polite-merge merge --ff-only
64 fi
65 '';
9dbfef33 66
364c110c 67 auto-upgrade-script = pkgs.writeShellScript "auto-upgrade" ''
c418324b
SW
68 ${pkgs.coreutils}/bin/nice -n 17 \
69 ${pkgs.util-linux}/bin/ionice -c 3 \
77278dee 70 ${pkgs.util-linux}/bin/flock /run/auto-upgrade-with-pinch ${
364c110c 71 pkgs.writeShellScript "auto-upgrade-with-lock-held" ''
4cbd961f 72 set -eo pipefail
eb0fa99c 73
f1a53b29
SW
74 dry_run=false
75 pinch_args=()
76 if [[ "$1" == --dry-run ]];then
77 dry_run=true
78 pinch_args=( --dry-run )
79 fi
80
81 hydrate() {
82 if "$dry_run";then
83 echo "Would run: $*"
84 else
85 "$@"
86 fi
87 }
88
89 die() {
90 echo "$*" >&2
91 exit 1
92 }
93
eb0fa99c 94 in_tmpdir() {
4cbd961f 95 d=$(${pkgs.coreutils}/bin/mktemp -d)
eb0fa99c
SW
96 pushd "$d"
97 "$@"
98 popd
4cbd961f 99 ${pkgs.coreutils}/bin/rm -r "$d"
eb0fa99c
SW
100 }
101
f1a53b29
SW
102 ${optionalString (cfg.upgradeConfigOwnershipPolicy != "any") (''
103 verify_ownership() {
104 if [[ "$1" != /* ]];then
105 die "Unexpected relative path: $1"
106 fi
107 if [[ "$1" != / ]];then
108 verify_ownership "$(${pkgs.coreutils}/bin/dirname "$1")"
109 fi
110 if [[ ! -e "$1" ]];then
111 die "Could not find upgrade config: $1 does not exist"
112 fi
113 if [[ -h "$1" ]];then
114 verify_ownership "$(
115 ${pkgs.coreutils}/bin/realpath --no-symlinks \
116 "$(${pkgs.coreutils}/bin/dirname "$1")/$(${pkgs.coreutils}/bin/readlink "$1")"
117 )"
118 fi
119 perms="$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%M")"
120 if [[ "$perms" == d*t ]];then
121 die "Will not use upgrade config in sticky directory $1"
122 fi
123 owner=$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%u")
124 if [[ "$owner" != root ]];then
125 die "Will not use upgrade config not owned by root in $1"
126 fi
127 if [[ "$perms" == l* ]];then
128 return 0 # Root-owned symlinks are fine
129 fi
130 if [[ "$perms" == *w? ]];then
131 die "Will not use world-writable upgrade config in $1"
132 fi
133 ${
134 {
135 root = ''
136 if [[ "$perms" == *w???? ]];then
137 die "Will not use group-writable upgrade config in $1"
138 fi
139 '';
140 wheel = ''
141 if [[ "$perms" == *w???? ]];then
142 group=$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%g")
143 if [[ "$group" != wheel ]];then
144 die "Will not use non-wheel-group group-writable upgrade config in $1"
145 fi
146 fi
147 '';
148 }."${cfg.upgradeConfigOwnershipPolicy}"
149 }
150 }
151 ''
152 + concatMapStringsSep "\n" (f: "verify_ownership ${escapeShellArg f}")
153 cfg.upgradeConfig)}
154
e830691a 155 config=$(${pkgs.nix}/bin/nix-instantiate --eval --strict --json -A config \
f1a53b29
SW
156 --arg upgradeConfig ${
157 escapeShellArg ("["
158 + lib.concatMapStringsSep " " lib.strings.escapeNixString
159 cfg.upgradeConfig + "]")
e830691a 160 } ${../upgrade-config.nix})
f1a53b29
SW
161
162 config_query() {
163 ${pkgs.jq}/bin/jq -r "$@" <<< "$config"
164 }
165
166 repo_query() {
167 config_query --arg path "$1" ".repos[\$ARGS.named.path]$2"
168 }
169
170 userenv_query() {
171 config_query --arg user "$1" ".userEnvironments[\$ARGS.named.user]$2"
172 }
173
9dbfef33 174 # Pull updates
f1a53b29
SW
175 while read path;do
176 hydrate /run/wrappers/bin/sudo -u "$(repo_query "$path" .user)" \
177 ${pull-repo-script} "$path" "$(repo_query "$path" "")"
178 done < <( config_query '.repos | keys []' )
364c110c 179
fae44c38 180 # Update channels
f1a53b29 181 config_query '.pinchFiles[]' | ${pkgs.findutils}/bin/xargs --no-run-if-empty --delimiter=\\n ${pkgs.pinch}/bin/pinch update "''${pinch_args[@]}"
fae44c38 182
eb0fa99c 183 # Build
f1a53b29
SW
184 in_tmpdir hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
185 while read user;do
13226f1c
SW
186 pushd /
187 hydrate /run/wrappers/bin/sudo -u "$user" \
f1a53b29 188 ${pkgs.nix}/bin/nix-build --no-out-link '<nixpkgs>' -A "$(userenv_query "$user" .package)"
13226f1c 189 popd
f1a53b29 190 done < <( config_query '.userEnvironments | keys []' )
eb0fa99c
SW
191
192 # Install
f1a53b29
SW
193 hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch
194 while read user;do
195 remove_arg=-r
196 if [[ "$(userenv_query "$user" .otherPackagesAction)" == keep ]];then
197 remove_arg=
198 fi
199 hydrate /run/wrappers/bin/sudo -u "$user" \
200 ${pkgs.nix}/bin/nix-env -f '<nixpkgs>' $remove_arg -iA "$(userenv_query "$user" .package)"
201 done < <( config_query '.userEnvironments | keys []' )
364c110c
SW
202 ''
203 }
204 '';
f1a53b29 205
901670f5
SW
206in {
207 options = {
208 system.autoUpgradeWithPinch = {
209
210 enable = mkOption {
211 type = types.bool;
212 default = false;
213 description = ''
214 Whether to periodically upgrade NixOS to the latest version.
215 Presumes that /etc/nixos is a git repo with a remote and
216 contains a pinch file called "channels".
217 '';
218 };
219
220 dates = mkOption {
221 default = "04:40";
222 type = types.str;
223 description = ''
224 Specification (in the format described by
225 <citerefentry><refentrytitle>systemd.time</refentrytitle>
226 <manvolnum>7</manvolnum></citerefentry>) of the time at
227 which the update will occur.
228 '';
229 };
d8537205 230
5ea02587
SW
231 signingKeys = mkOption {
232 type = types.listOf types.path;
233 description = ''
234 Files containing GPG keys that are authorized to sign updates.
235 Updates are only merged if the commit at the tip of the remote
236 ref is signed with one of these keys.
237 '';
238 };
239
f1a53b29
SW
240 upgradeConfig = mkOption {
241 type = types.listOf types.path;
d8537205 242 description = ''
f1a53b29
SW
243 Configuration files that specify what git repo paths to pull, what
244 pinch files to update from, and what user environments to update.
245 These are specified in separate configuration files processed at
246 update time so that changes to this configuration take effect in
247 the same update cycle.
d8537205 248 '';
4cbd961f 249 };
eb0fa99c 250
f1a53b29
SW
251 upgradeConfigOwnershipPolicy = mkOption {
252 type = types.enum [ "root" "wheel" "any" ];
253 default = "root";
4cbd961f 254 description = ''
f1a53b29
SW
255 Verify ownership of upgrade config files before using them for
256 system upgrades.
eb0fa99c 257
f1a53b29
SW
258 root = Config must be writable only by root.
259 wheel = Config must be writable only by root and wheel.
260 any = No checks. Not recommended.
4cbd961f 261 '';
eb0fa99c 262 };
901670f5
SW
263 };
264 };
265
266 config = lib.mkIf cfg.enable {
364c110c
SW
267
268 security.sudo.extraRules = lib.mkAfter [{
269 groups = [ "users" ];
270 commands = [{
271 command = "${auto-upgrade-script}";
272 options = [ "NOPASSWD" "NOSETENV" ];
273 }];
274 }];
275 # NOSETENV above still allows through ~17 vars, including PATH. Block those
276 # as well:
277 security.sudo.extraConfig = ''
278 Defaults!${auto-upgrade-script} !env_check
279 Defaults!${auto-upgrade-script} !env_keep
280 '';
281
d8537205 282 nixpkgs.overlays = [
d8537205 283 (import ../overlays/pinch.nix)
5048e8ce 284 (import ../overlays/polite-merge.nix)
5aaf4680
SW
285 (self: super: {
286 auto-upgrade = super.writeShellScriptBin "auto-upgrade" ''
4acf153c 287 /run/wrappers/bin/sudo ${auto-upgrade-script}
5aaf4680
SW
288 '';
289 })
d8537205 290 ];
5aaf4680
SW
291
292 environment.systemPackages = [ pkgs.auto-upgrade ];
293
901670f5
SW
294 systemd.services.nixos-upgrade = {
295 description = "NixOS Upgrade";
296 restartIfChanged = false;
297 unitConfig.X-StopOnRemoval = false;
298 serviceConfig.Type = "oneshot";
299 environment = config.nix.envVars // {
300 inherit (config.environment.sessionVariables) NIX_PATH;
301 HOME = "/root";
302 } // config.networking.proxy.envVars;
303
304 path = with pkgs; [
305 config.nix.package.out
306 coreutils
307 git
308 gitMinimal
309 gnutar
310 gzip
901670f5
SW
311 xz.bin
312 ];
313
314 script = ''
4cbd961f 315 set -eo pipefail
8569b965
SW
316
317 # Chill for awhile before applying updates. If applying an update
318 # badly breaks things, we want a window in which an operator can
319 # intervene either to fix the problem or disable automatic updates.
320 sleep 2h
321
364c110c 322 ${auto-upgrade-script}
901670f5
SW
323 '';
324
325 startAt = cfg.dates;
326 };
327 };
328}