Unverified Commit 6c4f93e1 authored by Maximilian Bosch's avatar Maximilian Bosch Committed by GitHub
Browse files

Merge: nixos/nextcloud: use LoadCredential to read secrets (#367433)

parents 092bfe6a 432d274c
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -310,6 +310,8 @@

- `hardware.pulseaudio` has been renamed to `services.pulseaudio`.  The deprecated option names will continue to work, but causes a warning.

- `services.nextcloud` now uses systemd's credential mechanism to read in secret files. The `nextcloud-occ` wrapper script implements this using `systemd-run`, as such it now also requires root privileges or `$CREDENTIALS_DIRECTORY` set where running it as user `nextcloud` was enough previously.

- `minetest` has been renamed to `luanti` to match the upstream name change but aliases have been added. The new name hasn't resulted in many changes as of yet but older references to minetest should be sunset. See the [new name announcement](https://blog.minetest.net/2024/10/13/Introducing-Our-New-Name/) for more details.

- `poac` has been renamed to `cabinpkg` to match the upstream name change but an alias has been added. See the [new name announcement](https://github.com/orgs/cabinpkg/discussions/1052) for more details.
+68 −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,23 @@ 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;
          RestartMode = "direct";
          Restart = "on-failure";
        };
      };
    };

+141 −65
Original line number Diff line number Diff line
@@ -85,18 +85,60 @@ let
    "-dmemory_limit=${cfg.cli.memoryLimit}"
  ]);

  occ = pkgs.writeScriptBin "nextcloud-occ" ''
    #! ${pkgs.runtimeShell}
    cd ${webroot}
    sudo=exec
    if [[ "$USER" != nextcloud ]]; then
      sudo='exec /run/wrappers/bin/sudo -u nextcloud'
    fi
    $sudo ${pkgs.coreutils}/bin/env \
  # 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 = 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;

@@ -120,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'),"}
        ],
      ]
    '';
@@ -143,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)) {
@@ -176,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}
    ];
@@ -490,9 +537,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 = {
@@ -530,8 +576,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 {
@@ -592,8 +637,6 @@ in {
              openssl rand 32 | base64
              ```

              Must be readable by user `nextcloud`.

              [1]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html
            '';
          };
@@ -944,11 +987,6 @@ in {
      services.nextcloud.finalPackage = webroot;

      systemd.services = {
        # When upgrading the Nextcloud package, Nextcloud can report errors such as
        # "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).
        phpfpm-nextcloud.restartTriggers = [ webroot overrideConfig ];

        nextcloud-setup = let
          c = cfg.config;
          occInstallCmd = let
@@ -959,12 +997,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}") {
@@ -983,12 +1021,12 @@ in {
          in ''
            ${mkExport dbpass}
            ${mkExport adminpass}
            ${occ}/bin/nextcloud-occ maintenance:install \
            ${lib.getExe occ} maintenance:install \
                ${installFlags}
          '';
          occSetTrustedDomainsCmd = concatStringsSep "\n" (imap0
            (i: v: ''
              ${occ}/bin/nextcloud-occ config:system:set trusted_domains \
              ${lib.getExe occ} config:system:set trusted_domains \
                ${toString i} --value="${toString v}"
            '') ([ cfg.hostName ] ++ cfg.settings.trusted_domains));

@@ -1002,20 +1040,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
@@ -1032,56 +1062,101 @@ in {
              ${occInstallCmd}
            fi

            ${occ}/bin/nextcloud-occ upgrade
            ${lib.getExe occ} upgrade

            ${occ}/bin/nextcloud-occ config:system:delete trusted_domains
            ${lib.getExe occ} config:system:delete trusted_domains

            ${optionalString (cfg.extraAppsEnable && cfg.extraApps != { }) ''
                # Try to enable apps
                ${occ}/bin/nextcloud-occ app:enable ${concatStringsSep " " (attrNames cfg.extraApps)}
                ${lib.getExe occ} app:enable ${concatStringsSep " " (attrNames cfg.extraApps)}
            ''}

            ${occSetTrustedDomainsCmd}
          '';
          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 {
          after = [ "nextcloud-setup.service" ];
          serviceConfig = {
            Type = "oneshot";
            ExecStart = "${occ}/bin/nextcloud-occ app:update --all";
            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 = ''
            ${occ}/bin/nextcloud-occ db:add-missing-columns
            ${occ}/bin/nextcloud-occ db:add-missing-indices
            ${occ}/bin/nextcloud-occ db:add-missing-primary-keys
            # 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
          '';
          serviceConfig = {
            Type = "exec";
            User = "nextcloud";
            ExecCondition = "${phpCli} -f ${webroot}/occ status -e";
            LoadCredential = runtimeSystemdCredentials;
          };
        };

        phpfpm-nextcloud = {
          # When upgrading the Nextcloud package, Nextcloud can report errors such as
          # "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/
          '';
        };
      };

@@ -1091,6 +1166,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";
          };
+2 −2
Original line number Diff line number Diff line
@@ -8,10 +8,10 @@

with import ../../lib/testing-python.nix { inherit system pkgs; };
runTest (
  { config, ... }:
  { config, lib, ... }:
  {
    inherit name;
    meta = with pkgs.lib.maintainers; {
    meta = with lib.maintainers; {
      maintainers = [
        globin
        eqyiel
+17 −4
Original line number Diff line number Diff line
@@ -8,13 +8,13 @@

with import ../../lib/testing-python.nix { inherit system pkgs; };
runTest (
  { config, ... }:
  { config, lib, ... }:
  let
    inherit (config) adminuser;
  in
  {
    inherit name;
    meta = with pkgs.lib.maintainers; {
    meta = with lib.maintainers; {
      maintainers = [
        eqyiel
        ma27
@@ -34,6 +34,13 @@ runTest (
              redis = true;
              memcached = false;
            };
            notify_push = {
              enable = true;
              bendDomainToLocalhost = true;
              logLevel = "debug";
            };
            extraAppsEnable = true;
            extraApps.notify_push = config.services.nextcloud.package.packages.apps.notify_push;
            # This test also validates that we can use an "external" database
            database.createLocally = false;
            config = {
@@ -70,7 +77,7 @@ runTest (
          services.postgresql = {
            enable = true;
          };
          systemd.services.postgresql.postStart = pkgs.lib.mkAfter ''
          systemd.services.postgresql.postStart = lib.mkAfter ''
            password=$(cat ${config.services.nextcloud.config.dbpassFile})
            ${config.services.postgresql.package}/bin/psql <<EOF
              CREATE ROLE ${adminuser} WITH LOGIN PASSWORD '$password' CREATEDB;
@@ -95,7 +102,13 @@ runTest (
    test-helpers.extraTests = ''
      with subtest("non-empty redis cache"):
          # redis cache should not be empty
          nextcloud.fail('test 0 -lt "$(redis-cli --pass secret --json KEYS "*" | jq "len")"')
          assert nextcloud.succeed('redis-cli --pass secret --json KEYS "*" | jq length').strip() != "0", """
            redis-cli for keys * returned 0 entries
          """

      with subtest("notify-push"):
          client.execute("${lib.getExe pkgs.nextcloud-notify_push.passthru.test_client} http://nextcloud ${config.adminuser} ${config.adminpass} >&2 &")
          nextcloud.wait_until_succeeds("journalctl -u nextcloud-notify_push | grep -q \"Sending ping to ${config.adminuser}\"")
    '';
  }
)
Loading