X-Git-Url: http://git.scottworley.com/auto-upgrade-with-pinch/blobdiff_plain/b972908a3b28dc5038f716bf46075221d0845045..15d7ea954e9c4ca687f2175630724cf60346f758:/modules/auto-upgrade.nix diff --git a/modules/auto-upgrade.nix b/modules/auto-upgrade.nix index cc98ad0..e41716b 100644 --- a/modules/auto-upgrade.nix +++ b/modules/auto-upgrade.nix @@ -1,51 +1,204 @@ +# auto-upgrade-with-pinch: Secure managed NixOS updates +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, version 3. + { config, lib, pkgs, ... }: with lib; let + local-pkgs = import ../. { inherit pkgs; }; cfg = config.system.autoUpgradeWithPinch; + pull-repo-script = pkgs.writeShellScript "pull-repo" '' + set -eo pipefail + + path=$1 + config=$2 + + prop() { + ${pkgs.jq}/bin/jq -r ".$1" <<< "$config" + } + + echo Pulling in "$path" >&2 + + if [[ ! -e "$path" ]];then + d=$(mktemp -d) + ${pkgs.git}/bin/git init "$d" + ${pkgs.git}/bin/git -C "$d" checkout -b "$(prop localBranch)" + ${pkgs.git}/bin/git -C "$d" remote add "$(prop remoteName)" "$(prop url)" + ${pkgs.git}/bin/git -C "$d" branch -u "$(prop remoteBranch)" + mkdir -p "$(${pkgs.coreutils}/bin/dirname "$path")" + mv "$d" "$path" + fi + + cd "$path" + + if [[ "$(${pkgs.git}/bin/git remote get-url "$(prop remoteName)")" != "$(prop url)" ]]; then + echo Expected git remote "$(prop remoteName)" to point at "$(prop url)" \ + but it points at "$(${pkgs.git}/bin/git remote get-url "$(prop remoteName)")" >&2 + case "$(prop onRemoteURLMismatch)" in + abort) exit 1;; + update) echo Updating it >&2 + ${pkgs.git}/bin/git -C "$d" remote set-url "$(prop remoteName)" "$(prop url)";; + esac + fi + + ${pkgs.git}/bin/git fetch "$(prop remoteName)" "$(prop remoteBranch)" + + if [[ "$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)" != "$(prop localBranch)" ]];then + echo Could not merge because currently-checked-out \ + \""$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)"\" is not \ + \""$(prop localBranch)"\" + case "$(prop onBranchMismatch)" in + abort) exit 1;; + continue) exit 0;; + esac + fi + + if [[ "$(prop requireSignature)" == true ]]; then + ${pkgs.polite-merge}/bin/polite-merge \ + -c gpg.program=${escapeShellArg (local-pkgs.keyed-gpg cfg.signingKeys)} \ + merge --ff-only --verify-signatures + else + ${pkgs.polite-merge}/bin/polite-merge merge --ff-only + fi + ''; + auto-upgrade-script = pkgs.writeShellScript "auto-upgrade" '' - flock /run/auto-upgrade-with-pinch ${ + ${pkgs.util-linux}/bin/flock /run/auto-upgrade-with-pinch ${ pkgs.writeShellScript "auto-upgrade-with-lock-held" '' - set -e + set -eo pipefail + + dry_run=false + pinch_args=() + if [[ "$1" == --dry-run ]];then + dry_run=true + pinch_args=( --dry-run ) + fi + + hydrate() { + if "$dry_run";then + echo "Would run: $*" + else + "$@" + fi + } + + die() { + echo "$*" >&2 + exit 1 + } in_tmpdir() { - d=$(mktemp -d) + d=$(${pkgs.coreutils}/bin/mktemp -d) pushd "$d" "$@" popd - rm -r "$d" + ${pkgs.coreutils}/bin/rm -r "$d" } - as_user() { - ${ - if cfg.userEnvironment.enable then '' - sudo -u ${escapeShellArg cfg.userEnvironment.user} "$@" - '' else '' - : - '' + ${optionalString (cfg.upgradeConfigOwnershipPolicy != "any") ('' + verify_ownership() { + if [[ "$1" != /* ]];then + die "Unexpected relative path: $1" + fi + if [[ "$1" != / ]];then + verify_ownership "$(${pkgs.coreutils}/bin/dirname "$1")" + fi + if [[ ! -e "$1" ]];then + die "Could not find upgrade config: $1 does not exist" + fi + if [[ -h "$1" ]];then + verify_ownership "$( + ${pkgs.coreutils}/bin/realpath --no-symlinks \ + "$(${pkgs.coreutils}/bin/dirname "$1")/$(${pkgs.coreutils}/bin/readlink "$1")" + )" + fi + perms="$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%M")" + if [[ "$perms" == d*t ]];then + die "Will not use upgrade config in sticky directory $1" + fi + owner=$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%u") + if [[ "$owner" != root ]];then + die "Will not use upgrade config not owned by root in $1" + fi + if [[ "$perms" == l* ]];then + return 0 # Root-owned symlinks are fine + fi + if [[ "$perms" == *w? ]];then + die "Will not use world-writable upgrade config in $1" + fi + ${ + { + root = '' + if [[ "$perms" == *w???? ]];then + die "Will not use group-writable upgrade config in $1" + fi + ''; + wheel = '' + if [[ "$perms" == *w???? ]];then + group=$(${pkgs.findutils}/bin/find "$1" -maxdepth 0 -printf "%g") + if [[ "$group" != wheel ]];then + die "Will not use non-wheel-group group-writable upgrade config in $1" + fi + fi + ''; + }."${cfg.upgradeConfigOwnershipPolicy}" + } } + '' + + concatMapStringsSep "\n" (f: "verify_ownership ${escapeShellArg f}") + cfg.upgradeConfig)} + + config=$(${pkgs.nix}/bin/nix-instantiate --eval --strict --json -A config \ + --arg upgradeConfig ${ + escapeShellArg ("[" + + lib.concatMapStringsSep " " lib.strings.escapeNixString + cfg.upgradeConfig + "]") + } ${../upgrade-config.nix}) + + config_query() { + ${pkgs.jq}/bin/jq -r "$@" <<< "$config" + } + + repo_query() { + config_query --arg path "$1" ".repos[\$ARGS.named.path]$2" + } + + userenv_query() { + config_query --arg user "$1" ".userEnvironments[\$ARGS.named.user]$2" } + # Pull updates + while read path;do + hydrate /run/wrappers/bin/sudo -u "$(repo_query "$path" .user)" \ + ${pull-repo-script} "$path" "$(repo_query "$path" "")" + done < <( config_query '.repos | keys []' ) + # Update channels - ( - cd /etc/nixos - ${pkgs.keyedgit cfg.key}/bin/git pull --ff-only --verify-signatures - ${pkgs.pinch}/bin/pinch update channels - ) + config_query '.pinchFiles[]' | ${pkgs.findutils}/bin/xargs --no-run-if-empty --delimiter=\\n ${pkgs.pinch}/bin/pinch update "''${pinch_args[@]}" # Build - in_tmpdir ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build - as_user nix-build --no-out-link '' -A ${ - escapeShellArg cfg.userEnvironment.package - } + in_tmpdir hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build + while read user;do + hydrate /run/wrappers/bin/sudo -u "$user" \ + ${pkgs.nix}/bin/nix-build --no-out-link '' -A "$(userenv_query "$user" .package)" + done < <( config_query '.userEnvironments | keys []' ) # Install - ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch - as_user nix-env -f '' -riA ${ - escapeShellArg cfg.userEnvironment.package - } + hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch + while read user;do + remove_arg=-r + if [[ "$(userenv_query "$user" .otherPackagesAction)" == keep ]];then + remove_arg= + fi + hydrate /run/wrappers/bin/sudo -u "$user" \ + ${pkgs.nix}/bin/nix-env -f '' $remove_arg -iA "$(userenv_query "$user" .package)" + done < <( config_query '.userEnvironments | keys []' ) '' } ''; + in { options = { system.autoUpgradeWithPinch = { @@ -71,47 +224,37 @@ in { ''; }; - key = mkOption { - type = types.path; + signingKeys = mkOption { + type = types.listOf types.path; description = '' - GPG key that signs updates. Updates are only merged if the commit - at the tip of the remote branch is signed with this key. + Files containing GPG keys that are authorized to sign updates. + Updates are only merged if the commit at the tip of the remote + ref is signed with one of these keys. ''; }; - userEnvironment = { - enable = mkOption { - type = types.bool; - default = false; - description = '' - Whether to update a user-environment as well. This update is done - with nix-env -riA. Note the -r! I.e., ALL OTHER PACKAGES INSTALLED - WITH nix-env WILL BE DELETED! - - This presumes that you have configured an "entire user environment" - package as shown in - https://nixos.wiki/wiki/FAQ#How_can_I_manage_software_with_nix-env_like_with_configuration.nix.3F - - To check if you're set up for this, run "nix-env --query". If it - only lists one package, you're good to go. - ''; - }; - - user = mkOption { - type = types.str; - description = '' - The username of the user whose environment should be updated. - ''; - }; - - package = mkOption { - type = types.str; - example = "nixos.userPackages"; - description = '' - The name of the single package that is the user's entire environment. - ''; - }; + upgradeConfig = mkOption { + type = types.listOf types.path; + description = '' + Configuration files that specify what git repo paths to pull, what + pinch files to update from, and what user environments to update. + These are specified in separate configuration files processed at + update time so that changes to this configuration take effect in + the same update cycle. + ''; + }; + upgradeConfigOwnershipPolicy = mkOption { + type = types.enum [ "root" "wheel" "any" ]; + default = "root"; + description = '' + Verify ownership of upgrade config files before using them for + system upgrades. + + root = Config must be writable only by root. + wheel = Config must be writable only by root and wheel. + any = No checks. Not recommended. + ''; }; }; }; @@ -133,11 +276,11 @@ in { ''; nixpkgs.overlays = [ - (import ../overlays/keyedgit.nix) (import ../overlays/pinch.nix) + (import ../overlays/polite-merge.nix) (self: super: { auto-upgrade = super.writeShellScriptBin "auto-upgrade" '' - sudo ${auto-upgrade-script} + /run/wrappers/bin/sudo ${auto-upgrade-script} ''; }) ]; @@ -165,34 +308,17 @@ in { ]; script = '' - set -e + set -eo pipefail # Chill for awhile before applying updates. If applying an update # badly breaks things, we want a window in which an operator can # intervene either to fix the problem or disable automatic updates. sleep 2h - # Wait until outside business hours - now=$(date +%s) - day_of_week=$(date +%u) - business_start=$(date -d 8:00 +%s) - business_end=$( date -d 17:00 +%s) - if (( day_of_week <= 5 && now > business_start && now < business_end ));then - delay=$((business_end - now)) - echo "Waiting $delay seconds so we don't upgrade during business hours" >&2 - sleep "$delay" - fi - ${auto-upgrade-script} ''; startAt = cfg.dates; }; - - assertions = [{ - assertion = cfg.userEnvironment.enable -> cfg.enable; - message = - "User environment upgrades cannot yet be enabled separately from system upgrades."; - }]; }; }