Unverified Commit 2ce1e841 authored by networkException's avatar networkException
Browse files

nixos/nextcloud: use LoadCredential to read secrets



This patch adds support for using systemd's LoadCredential
feature to read various secret files used by nextcloud service
units.

Previously credentials had to be readable by the nextcloud user,
this is now no longer required.

The nextcloud-occ wrapper script has been adjusted to use
systemd-run for loading credentials when being called from
outside a service.

In detail this change touches various details of the module:

- The nix_read_secret() php function now takes the name of a
  file relative to the path specified in the CREDENTIALS_DIRECTORY
  environment variable.
- The nix_read_secret() now exits with error code 1 instead of
  throwing a RuntimeException as this will properly error out
  the nextcloud-occ script
- Only the nextcloud-setup service unit has the adminpass credential
  added in addition to the other credentials
- Uses of ExecCondition= in nextcloud-cron and nextcloud-update-db
  have been replaced by a shell conditional as ExecCondition currently
  doesn't support credentials
- The phpfpm-nextcloud service now runs a preStart script to make
  the credentials it gets readable by the nextcloud user as the
  unit runs as root but the php process itself as nextcloud.
- To invoke occ notify_push:setup when using nextcloud notify_push
  a new service has been added that replaces the preStart script
  in nextcloud-notify_push.service. This has been done as the
  main executable only needs the database password credential.

Co-authored-by: default avatarlassulus <lassulus@lassul.us>
parent e6b07898
Loading
Loading
Loading
Loading
+66 −53
Original line number Diff line number Diff line
@@ -78,7 +78,8 @@ in
    );

  config = lib.mkIf cfg.enable {
    systemd.services.nextcloud-notify_push = {
    systemd.services = {
      nextcloud-notify_push = {
        description = "Push daemon for Nextcloud clients";
        documentation = [ "https://github.com/nextcloud/notify_push" ];
        after = [
@@ -92,9 +93,6 @@ in
          DATABASE_PREFIX = cfg.dbtableprefix;
          LOG = cfg.logLevel;
        };
      postStart = ''
        ${cfgN.occ}/bin/nextcloud-occ notify_push:setup ${cfg.nextcloudUrl}/push
      '';
        script =
          let
            dbType = if cfg.dbtype == "pgsql" then "postgresql" else cfg.dbtype;
@@ -118,8 +116,8 @@ in
            dbName = lib.optionalString (cfg.dbname != null) "/${cfg.dbname}";
            dbUrl = "${dbType}://${dbUser}${dbPass}${dbHost}${dbName}${dbOpts}";
          in
        lib.optionalString (dbPass != "") ''
          export DATABASE_PASSWORD="$(<"${cfg.dbpassFile}")"
          lib.optionalString (cfg.dbpassFile != null) ''
            export DATABASE_PASSWORD="$(<"$CREDENTIALS_DIRECTORY/dbpass")"
          ''
          + ''
            export DATABASE_URL="${dbUrl}"
@@ -132,6 +130,21 @@ in
          Restart = "on-failure";
          RestartSec = "5s";
          Type = "notify";
          LoadCredential = lib.optional (cfg.dbpassFile != null) "dbpass:${cfg.dbpassFile}";
        };
      };

      nextcloud-notify_push_setup = {
        wantedBy = [ "multi-user.target" ];
        requiredBy = [ "nextcloud-notify_push.service" ];
        after = [ "nextcloud-notify_push.service" ];
        serviceConfig = {
          Type = "oneshot";
          User = "nextcloud";
          Group = "nextcloud";
          ExecStart = "${lib.getExe cfgN.occ} notify_push:setup ${cfg.nextcloudUrl}/push";
          LoadCredential = config.systemd.services.nextcloud-cron.serviceConfig.LoadCredential;
        };
      };
    };

+118 −47
Original line number Diff line number Diff line
@@ -85,20 +85,59 @@ let
    "-dmemory_limit=${cfg.cli.memoryLimit}"
  ]);

  # NOTE: The credentials required by all services at runtime, not including things like the
  #       admin password which is only needed by the setup service.
  runtimeSystemdCredentials = []
    ++ (lib.optional (cfg.config.dbpassFile != null) "dbpass:${cfg.config.dbpassFile}")
    ++ (lib.optional (cfg.config.objectstore.s3.enable) "s3_secret:${cfg.config.objectstore.s3.secretFile}")
    ++ (lib.optional (cfg.config.objectstore.s3.sseCKeyFile != null) "s3_sse_c_key:${cfg.config.objectstore.s3.sseCKeyFile}");

  requiresRuntimeSystemdCredentials = (lib.length runtimeSystemdCredentials) != 0;

  occ = pkgs.writeShellApplication {
    name = "nextcloud-occ";

    text = ''
      cd ${webroot}
      sudo="exec"
      if [[ "$USER" != nextcloud ]]; then
        sudo='exec /run/wrappers/bin/sudo -u nextcloud'
      fi
      $sudo ${pkgs.coreutils}/bin/env \
    text = let
      command = ''
        ${lib.getExe' pkgs.coreutils "env"} \
          NEXTCLOUD_CONFIG_DIR="${datadir}/config" \
          ${phpCli} \
          occ "$@"
      '';
    in ''
      cd ${webroot}

      # NOTE: This is templated at eval time
      requiresRuntimeSystemdCredentials=${lib.boolToString requiresRuntimeSystemdCredentials}

      # NOTE: This wrapper is both used in the internal nextcloud service units
      #       and by users outside a service context for administration. As such,
      #       when there's an existing CREDENTIALS_DIRECTORY, we inherit it for use
      #       in the nix_read_secret() php function.
      #       When there's no CREDENTIALS_DIRECTORY we try to use systemd-run to
      #       load the credentials just as in a service unit.
      # NOTE: If there are no credentials that are required at runtime then there's no need
      #       to load any credentials.
      if [[ $requiresRuntimeSystemdCredentials == true && -z "''${CREDENTIALS_DIRECTORY:-}" ]]; then
        exec ${lib.getExe' config.systemd.package "systemd-run"} \
          ${lib.escapeShellArgs (map (credential: "--property=LoadCredential=${credential}") runtimeSystemdCredentials)} \
          --uid=nextcloud \
          --same-dir \
          --pty \
          --wait \
          --collect \
          --service-type=exec \
          --quiet \
          ${command}
      elif [[ "$USER" != nextcloud ]]; then
        exec /run/wrappers/bin/sudo \
          --preserve-env=CREDENTIALS_DIRECTORY \
          --user=nextcloud \
          ${command}
      else
        exec ${command}
      fi
    '';
  };

  inherit (config.system) stateVersion;
@@ -123,13 +162,13 @@ let
          'bucket' => '${s3.bucket}',
          'autocreate' => ${boolToString s3.autocreate},
          'key' => '${s3.key}',
          'secret' => nix_read_secret('${s3.secretFile}'),
          'secret' => nix_read_secret('s3_secret'),
          ${optionalString (s3.hostname != null) "'hostname' => '${s3.hostname}',"}
          ${optionalString (s3.port != null) "'port' => ${toString s3.port},"}
          'use_ssl' => ${boolToString s3.useSsl},
          ${optionalString (s3.region != null) "'region' => '${s3.region}',"}
          'use_path_style' => ${boolToString s3.usePathStyle},
          ${optionalString (s3.sseCKeyFile != null) "'sse_c_key' => nix_read_secret('${s3.sseCKeyFile}'),"}
          ${optionalString (s3.sseCKeyFile != null) "'sse_c_key' => nix_read_secret('s3_sse_c_key'),"}
        ],
      ]
    '';
@@ -146,16 +185,26 @@ let
  in pkgs.writeText "nextcloud-config.php" ''
    <?php
    ${optionalString requiresReadSecretFunction ''
      function nix_read_secret($file) {
        if (!file_exists($file)) {
          throw new \RuntimeException(sprintf(
            "Cannot start Nextcloud, secret file %s set by NixOS doesn't seem to "
            . "exist! Please make sure that the file exists and has appropriate "
            . "permissions for user & group 'nextcloud'!",
            $file
      function nix_read_secret($credential_name) {
        $credentials_directory = getenv("CREDENTIALS_DIRECTORY");
        if (!$credentials_directory) {
          error_log(sprintf(
            "Cannot read credential '%s' passed by NixOS, \$CREDENTIALS_DIRECTORY is not set!",
            $credential_name
          ));
          exit(1);
        }

        $credential_path = $credentials_directory . "/" . $credential_name;
        if (!is_readable($credential_path)) {
          error_log(sprintf(
            "Cannot read credential '%s' passed by NixOS, it does not exist or is not readable!",
            $credential_path,
          ));
          exit(1);
        }
        return trim(file_get_contents($file));

        return trim(file_get_contents($credential_path));
      }''}
    function nix_decode_json_file($file, $error) {
      if (!file_exists($file)) {
@@ -179,12 +228,7 @@ let
      ${optionalString (c.dbhost != null) "'dbhost' => '${c.dbhost}',"}
      ${optionalString (c.dbuser != null) "'dbuser' => '${c.dbuser}',"}
      ${optionalString (c.dbtableprefix != null) "'dbtableprefix' => '${toString c.dbtableprefix}',"}
      ${optionalString (c.dbpassFile != null) ''
          'dbpassword' => nix_read_secret(
            "${c.dbpassFile}"
          ),
        ''
      }
      ${optionalString (c.dbpassFile != null) "'dbpassword' => nix_read_secret('dbpass'),"}
      'dbtype' => '${c.dbtype}',
      ${objectstoreConfig}
    ];
@@ -480,9 +524,8 @@ in {
      adminpassFile = mkOption {
        type = types.str;
        description = ''
          The full path to a file that contains the admin's password. Must be
          readable by user `nextcloud`. The password is set only in the initial
          setup of Nextcloud by the systemd service `nextcloud-setup.service`.
          The full path to a file that contains the admin's password. The password is
          set only in the initial setup of Nextcloud by the systemd service `nextcloud-setup.service`.
        '';
      };
      objectstore = {
@@ -520,8 +563,7 @@ in {
            type = types.str;
            example = "/var/nextcloud-objectstore-s3-secret";
            description = ''
              The full path to a file that contains the access secret. Must be
              readable by user `nextcloud`.
              The full path to a file that contains the access secret.
            '';
          };
          hostname = mkOption {
@@ -582,8 +624,6 @@ in {
              openssl rand 32 | base64
              ```

              Must be readable by user `nextcloud`.

              [1]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html
            '';
          };
@@ -939,12 +979,12 @@ in {
            dbpass = {
              arg = "DBPASS";
              value = if c.dbpassFile != null
                then ''"$(<"${toString c.dbpassFile}")"''
                then ''"$(<"$CREDENTIALS_DIRECTORY/dbpass")"''
                else ''""'';
            };
            adminpass = {
              arg = "ADMINPASS";
              value = ''"$(<"${toString c.adminpassFile}")"'';
              value = ''"$(<"$CREDENTIALS_DIRECTORY/adminpass")"'';
            };
            installFlags = concatStringsSep " \\\n    "
              (mapAttrsToList (k: v: "${k} ${toString v}") {
@@ -982,20 +1022,12 @@ in {
          restartTriggers = [ overrideConfig ];
          script = ''
            ${optionalString (c.dbpassFile != null) ''
              if [ ! -r "${c.dbpassFile}" ]; then
                echo "dbpassFile ${c.dbpassFile} is not readable by nextcloud:nextcloud! Aborting..."
                exit 1
              fi
              if [ -z "$(<${c.dbpassFile})" ]; then
              if [ -z "$(<$CREDENTIALS_DIRECTORY/dbpass)" ]; then
                echo "dbpassFile ${c.dbpassFile} is empty!"
                exit 1
              fi
            ''}
            if [ ! -r "${c.adminpassFile}" ]; then
              echo "adminpassFile ${c.adminpassFile} is not readable by nextcloud:nextcloud! Aborting..."
              exit 1
            fi
            if [ -z "$(<${c.adminpassFile})" ]; then
            if [ -z "$(<$CREDENTIALS_DIRECTORY/adminpass)" ]; then
              echo "adminpassFile ${c.adminpassFile} is empty!"
              exit 1
            fi
@@ -1025,19 +1057,32 @@ in {
          '';
          serviceConfig.Type = "oneshot";
          serviceConfig.User = "nextcloud";
          serviceConfig.LoadCredential = [ "adminpass:${cfg.config.adminpassFile}" ] ++ runtimeSystemdCredentials;
          # On Nextcloud ≥ 26, it is not necessary to patch the database files to prevent
          # an automatic creation of the database user.
          environment.NC_setup_create_db_user = lib.mkIf (nextcloudGreaterOrEqualThan "26") "false";
        };
        nextcloud-cron = {
          after = [ "nextcloud-setup.service" ];
          # NOTE: In contrast to the occ wrapper script running phpCli directly will not
          #       set NEXTCLOUD_CONFIG_DIR by itself currently.
          environment.NEXTCLOUD_CONFIG_DIR = "${datadir}/config";
          script = ''
            # NOTE: This early returns the script when nextcloud is in maintenance mode
            #       or needs `occ upgrade`. Using ExecCondition= is not possible here
            #       because it doesn't work with systemd credentials.
            if [[ $(${lib.getExe occ} status --output=json | ${lib.getExe pkgs.jq} '. | if .maintenance or .needsDbUpgrade then "skip" else "" end' --raw-output) == "skip" ]]; then
              echo "Nextcloud is in maintenance mode or needs DB upgrade, exiting."
              exit 0
            fi

            ${phpCli} -f ${webroot}/cron.php
          '';
          serviceConfig = {
            Type = "exec";
            User = "nextcloud";
            ExecCondition = "${phpCli} -f ${webroot}/occ status -e";
            ExecStart = "${phpCli} -f ${webroot}/cron.php";
            KillMode = "process";
            LoadCredential = runtimeSystemdCredentials;
          };
        };
        nextcloud-update-plugins = mkIf cfg.autoUpdateApps.enable {
@@ -1046,13 +1091,21 @@ in {
            Type = "oneshot";
            ExecStart = "${lib.getExe occ} app:update --all";
            User = "nextcloud";
            LoadCredential = runtimeSystemdCredentials;
          };
          startAt = cfg.autoUpdateApps.startAt;
        };
        nextcloud-update-db = {
          after = [ "nextcloud-setup.service" ];
          environment.NEXTCLOUD_CONFIG_DIR = "${datadir}/config";
          script = ''
            # NOTE: This early returns the script when nextcloud is in maintenance mode
            #       or needs `occ upgrade`. Using ExecCondition= is not possible here
            #       because it doesn't work with systemd credentials.
            if [[ $(${lib.getExe occ} status --output=json | ${lib.getExe pkgs.jq} '. | if .maintenance or .needsDbUpgrade then "skip" else "" end' --raw-output) == "skip" ]]; then
              echo "Nextcloud is in maintenance mode or needs DB upgrade, exiting."
              exit 0
            fi

            ${lib.getExe occ} db:add-missing-columns
            ${lib.getExe occ} db:add-missing-indices
            ${lib.getExe occ} db:add-missing-primary-keys
@@ -1060,7 +1113,7 @@ in {
          serviceConfig = {
            Type = "exec";
            User = "nextcloud";
            ExecCondition = "${phpCli} -f ${webroot}/occ status -e";
            LoadCredential = runtimeSystemdCredentials;
          };
        };

@@ -1069,6 +1122,23 @@ in {
          # "The files of the app [all apps in /var/lib/nextcloud/apps] were not replaced correctly"
          # Restarting phpfpm on Nextcloud package update fixes these issues (but this is a workaround).
          restartTriggers = [ webroot overrideConfig ];
        } // lib.optionalAttrs requiresRuntimeSystemdCredentials {
          serviceConfig.LoadCredential = runtimeSystemdCredentials;

          # FIXME: We use a hack to make the credential files readable by the nextcloud
          #        user by copying them somewhere else and overriding CREDENTIALS_DIRECTORY
          #        for php. This is currently necessary as the unit runs as root.
          serviceConfig.RuntimeDirectory = lib.mkForce "phpfpm phpfpm-nextcloud";
          preStart = ''
            umask 0077

            # NOTE: Runtime directories for this service are currently preserved
            #       between restarts.
            rm -rf /run/phpfpm-nextcloud/credentials/
            mkdir -p /run/phpfpm-nextcloud/credentials/
            cp "$CREDENTIALS_DIRECTORY"/* /run/phpfpm-nextcloud/credentials/
            chown -R nextcloud:nextcloud /run/phpfpm-nextcloud/credentials/
          '';
        };
      };

@@ -1078,6 +1148,7 @@ in {
          group = "nextcloud";
          phpPackage = phpPackage;
          phpEnv = {
            CREDENTIALS_DIRECTORY = "/run/phpfpm-nextcloud/credentials/";
            NEXTCLOUD_CONFIG_DIR = "${datadir}/config";
            PATH = "/run/wrappers/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin:/usr/bin:/bin";
          };