]> git.scottworley.com Git - auto-upgrade-with-pinch/blobdiff - modules/auto-upgrade.nix
When becoming other users, cd to /
[auto-upgrade-with-pinch] / modules / auto-upgrade.nix
index ef705eb8ad30176d93202f6c99f1504e93c013ef..f3089bd7f75ff210017dd574d624b88ef79b9fb2 100644 (file)
@@ -1,82 +1,96 @@
+# 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
 { config, lib, pkgs, ... }:
 with lib;
 let
+  local-pkgs = import ../. { inherit pkgs; };
   cfg = config.system.autoUpgradeWithPinch;
   cfg = config.system.autoUpgradeWithPinch;
-  pull-repo-script = path: repo:
-    pkgs.writeShellScript "pull-repo" ''
-      set -eo pipefail
-
-      echo Pulling in ${escapeShellArg path} >&2
-
-      if [[ ! -e ${escapeShellArg path} ]];then
-        d=$(mktemp -d)
-        ${pkgs.git}/bin/git init "$d"
-        ${pkgs.git}/bin/git -C "$d" checkout -b \
-          ${escapeShellArg repo.localBranch}
-        ${pkgs.git}/bin/git -C "$d" remote add \
-          ${escapeShellArg repo.remoteName} \
-          ${escapeShellArg repo.url}
-        ${pkgs.git}/bin/git -C "$d" branch -u \
-          ${escapeShellArg repo.remoteBranch}
-        mkdir -p "$(dirname ${escapeShellArg path})"
-        mv "$d" ${escapeShellArg path}
-      fi
-
-      cd ${escapeShellArg path}
-
-      if [[ "$(${pkgs.git}/bin/git remote get-url \
-               ${escapeShellArg repo.remoteName})" != \
-            ${escapeShellArg repo.url} ]]
-      then
-        echo Expected git remote ${escapeShellArg repo.remoteName} \
-          to point at ${escapeShellArg repo.url} \
-          but it points at "$(${pkgs.git}/bin/git remote get-url \
-                              ${escapeShellArg repo.remoteName})" >&2
-        ${
-          {
-            abort = "exit 1";
-            update = ''
-              echo Updating it >&2
-              ${pkgs.git}/bin/git -C "$d" remote set-url \
-                ${escapeShellArg repo.remoteName} \
-                ${escapeShellArg repo.url}
-            '';
-          }."${repo.onRemoteURLMismatch}"
-        }
-      fi
-
-      ${pkgs.git}/bin/git fetch \
-        ${escapeShellArg repo.remoteName} \
-        ${escapeShellArg repo.remoteBranch}
-
-      if [[ "$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)" != \
-              ${escapeShellArg repo.localBranch} ]]
-      then
-        echo Could not merge because currently-checked-out \
-             \""$(${pkgs.git}/bin/git rev-parse --abbrev-ref HEAD)"\" is not \
-             \"${escapeShellArg repo.localBranch}\"
-        ${
-          {
-            abort = "exit 1";
-            continue = "exit 0";
-          }."${repo.onBranchMismatch}"
-        }
-      fi
+  pull-repo-script = pkgs.writeShellScript "pull-repo" ''
+    set -eo pipefail
 
 
+    path=$1
+    config=$2
 
 
-      ${if repo.requireSignature then ''
-        PATH="${pkgs.keyedgit repo.signingKeys}/bin:$PATH" \
-          ${pkgs.polite-merge}/bin/polite-merge --ff-only --verify-signatures
-      '' else ''
-        ${pkgs.polite-merge}/bin/polite-merge --ff-only
-      ''}
-    '';
+    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" ''
 
   auto-upgrade-script = pkgs.writeShellScript "auto-upgrade" ''
-    ${pkgs.utillinux}/bin/flock /run/auto-upgrade-with-pinch ${
+    ${pkgs.coreutils}/bin/nice -n 17 \
+    ${pkgs.util-linux}/bin/ionice -c 3 \
+    ${pkgs.util-linux}/bin/flock /run/auto-upgrade-with-pinch ${
       pkgs.writeShellScript "auto-upgrade-with-lock-held" ''
         set -eo pipefail
 
       pkgs.writeShellScript "auto-upgrade-with-lock-held" ''
         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=$(${pkgs.coreutils}/bin/mktemp -d)
           pushd "$d"
         in_tmpdir() {
           d=$(${pkgs.coreutils}/bin/mktemp -d)
           pushd "$d"
@@ -85,34 +99,108 @@ let
           ${pkgs.coreutils}/bin/rm -r "$d"
         }
 
           ${pkgs.coreutils}/bin/rm -r "$d"
         }
 
+        ${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
         # Pull updates
-        ${concatStringsSep "\n" (mapAttrsToList (path: repo: ''
-          /run/wrappers/bin/sudo -u ${escapeShellArg repo.user} \
-            ${pull-repo-script path repo}
-        '') cfg.repos)}
+        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
 
         # Update channels
-        ${pkgs.pinch}/bin/pinch update ${escapeShellArgs cfg.pinchFiles}
+        config_query '.pinchFiles[]' | ${pkgs.findutils}/bin/xargs --no-run-if-empty --delimiter=\\n ${pkgs.pinch}/bin/pinch update "''${pinch_args[@]}"
 
         # Build
 
         # Build
-        in_tmpdir ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
-        ${concatStringsSep "\n" (mapAttrsToList (user: env: ''
-          /run/wrappers/bin/sudo -u ${escapeShellArg user} \
-            nix-build --no-out-link '<nixpkgs>' \
-            -A ${escapeShellArg env.package}
-        '') cfg.userEnvironments)}
+        in_tmpdir hydrate ${config.system.build.nixos-rebuild}/bin/nixos-rebuild build
+        while read user;do
+          hydrate /run/wrappers/bin/sudo -u "$user" -D / \
+            ${pkgs.nix}/bin/nix-build --no-out-link '<nixpkgs>' -A "$(userenv_query "$user" .package)"
+        done < <( config_query '.userEnvironments | keys []' )
 
         # Install
 
         # Install
-        ${config.system.build.nixos-rebuild}/bin/nixos-rebuild switch
-        ${concatStringsSep "\n" (mapAttrsToList (user: env: ''
-          /run/wrappers/bin/sudo -u ${escapeShellArg user} \
-            nix-env -f '<nixpkgs>' \
-            ${optionalString (env.otherPackagesAction != "keep") "-r"} \
-            -iA ${escapeShellArg env.package}
-        '') cfg.userEnvironments)}
+        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 '<nixpkgs>' $remove_arg -iA "$(userenv_query "$user" .package)"
+        done < <( config_query '.userEnvironments | keys []' )
       ''
     }
   '';
       ''
     }
   '';
+
 in {
   options = {
     system.autoUpgradeWithPinch = {
 in {
   options = {
     system.autoUpgradeWithPinch = {
@@ -138,132 +226,37 @@ in {
         '';
       };
 
         '';
       };
 
-      repos = mkOption {
+      signingKeys = mkOption {
+        type = types.listOf types.path;
         description = ''
         description = ''
-          Git repositories to pull before running pinch.  These are maintained
-          as git checkouts at specified places in the filesystem with specified
-          ownership rather than kept read-only in the nix store so that humans
-          can use them both as points of intervention in the automation and to
-          author and push changes back up.
+          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.
         '';
         '';
-        type = types.attrsOf (types.submodule {
-          options = {
-            url = mkOption {
-              description = "Remote git repo.";
-              type = types.str;
-            };
-            remoteName = mkOption {
-              description = ''Name of the git remote.  Customarily "origin".'';
-              type = types.str;
-              default = "origin";
-            };
-            onRemoteURLMismatch = mkOption {
-              description = ''
-                What to do if the remote URL in the git repo doesn't match the
-                URL configured here.
-              '';
-              type = types.enum [ "update" "abort" ];
-              default = "update";
-            };
-            onBranchMismatch = mkOption {
-              description = ''
-                What to do if a different branch is currently checked out.
-
-                (Changes from <literal>remoteBranch</literal> are only ever
-                merged into <literal>localBranch</literal>, so if a different
-                branch is checked out, no remote changes will be merged.)
-              '';
-              type = types.enum [ "continue" "abort" ];
-              default = "continue";
-            };
-            user = mkOption {
-              description = "User as which to run 'git fetch'";
-              type = types.str;
-            };
-            localBranch = mkOption {
-              description = "";
-              type = types.str;
-              default = "master";
-            };
-            remoteBranch = mkOption {
-              type = types.str;
-              default = "master";
-            };
-            requireSignature = mkOption {
-              type = types.bool;
-              default = true;
-              description = ''
-                Only pull when the tip of the remote ref is signed by a key
-                specifed in <literal>signingKeys</literal>.
-              '';
-            };
-            signingKeys = mkOption {
-              type = types.either types.path (types.listOf types.path);
-              description = ''
-                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.
-              '';
-            };
-          };
-        });
-        example = {
-          "/etc/nixos" = {
-            url = "https://github.com/chkno/auto-upgrade-demo-nixos";
-            user = "root";
-            signingKeys = [ ./admins.asc ];
-          };
-          "/home/alice/.config/nixpkgs" = {
-            url = "https://github.com/chkno/auto-upgrade-demo-user-nixpkgs";
-            user = "alice";
-            signingKeys = [ ./admins.asc ./alice.asc ];
-          };
-        };
       };
 
       };
 
-      pinchFiles = mkOption {
+      upgradeConfig = mkOption {
+        type = types.listOf types.path;
         description = ''
         description = ''
-          Pinch files to use for channel updates.  Typically these are inside
-          <literal>repos</literal>' paths.
+          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.
         '';
         '';
-        type = types.listOf types.path;
-        example = [ "/etc/nixos/channels" ];
       };
 
       };
 
-      userEnvironments = mkOption {
+      upgradeConfigOwnershipPolicy = mkOption {
+        type = types.enum [ "root" "wheel" "any" ];
+        default = "root";
         description = ''
         description = ''
-          User environments to update as part of an upgrade run.
+          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.
         '';
         '';
-        type = types.attrsOf (types.submodule {
-          options = {
-            package = mkOption {
-              type = types.str;
-              default = "nixos.userPackages";
-              description = ''
-                The name of the single package that will be updated.  You'll
-                want to create 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
-              '';
-            };
-            otherPackagesAction = mkOption {
-              type = types.enum [ "remove" "keep" "abort" ];
-              default = "remove";
-              description = ''
-                What to do with packages other than <literal>package</literal>.
-
-                THIS DEFAULTS TO "remove", WHICH IS POTENTIALLY SOMEWHAT
-                DESTRUCTIVE!  This is the default because it is the recommended
-                setting -- This module recommends managing your environment
-                through your one entire-environment <literal>package</literal>.
-                This keeps your environment declarative and ensures that all
-                packages receive regular updates.
-              '';
-              # It seems like "upgrade" ought to be another choice here, powered
-              # by "nix-env --upgrade".  But when I tried this, it didn't work.
-            };
-          };
-        });
-        example = { alice = { }; };
       };
     };
   };
       };
     };
   };
@@ -285,7 +278,6 @@ in {
     '';
 
     nixpkgs.overlays = [
     '';
 
     nixpkgs.overlays = [
-      (import ../overlays/keyedgit.nix)
       (import ../overlays/pinch.nix)
       (import ../overlays/polite-merge.nix)
       (self: super: {
       (import ../overlays/pinch.nix)
       (import ../overlays/polite-merge.nix)
       (self: super: {
@@ -325,17 +317,6 @@ in {
         # intervene either to fix the problem or disable automatic updates.
         sleep 2h
 
         # 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}
       '';
 
         ${auto-upgrade-script}
       '';