]>
Commit | Line | Data |
---|---|---|
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, ... }: |
8 | with lib; | |
364c110c | 9 | let |
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 |
206 | in { |
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 | } |