+# 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 = 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" ''
- ${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
+ 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"
${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
- ${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
- ${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
- 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
- ${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 = {
'';
};
- repos = mkOption {
+ signingKeys = mkOption {
+ type = types.listOf types.path;
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 = ''
- 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 = ''
- 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 = { }; };
};
};
};
'';
nixpkgs.overlays = [
- (import ../overlays/keyedgit.nix)
(import ../overlays/pinch.nix)
(import ../overlays/polite-merge.nix)
(self: super: {
# 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}
'';