Skip to content

Commit 020c163

Browse files
committed
feat(core): add support for encrypted db connection in maintenance:install
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent e30b771 commit 020c163

4 files changed

Lines changed: 148 additions & 18 deletions

File tree

core/Command/Maintenance/Install.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@
2727
use function get_class;
2828

2929
class Install extends Command {
30+
/**
31+
* SSL/TLS command line options and the installer options they provide. The database
32+
* setup translates those, see \OC\Setup\AbstractDatabase::ENCRYPTION_OPTIONS.
33+
* `--database-ssl-no-verify` is handled separately as it takes no value.
34+
*/
35+
private const array SSL_OPTIONS = [
36+
'database-ssl-mode' => 'dbsslmode',
37+
'database-ssl-ca' => 'dbsslca',
38+
'database-ssl-cert' => 'dbsslcert',
39+
'database-ssl-key' => 'dbsslkey',
40+
'database-ssl-crl' => 'dbsslcrl',
41+
];
42+
3043
public function __construct(
3144
private SystemConfig $config,
3245
private IniGetWrapper $iniGetWrapper,
@@ -46,6 +59,12 @@ protected function configure(): void {
4659
->addOption('database-user', null, InputOption::VALUE_REQUIRED, 'Login to connect to the database')
4760
->addOption('database-pass', null, InputOption::VALUE_OPTIONAL, 'Password of the database user', null)
4861
->addOption('database-table-space', null, InputOption::VALUE_OPTIONAL, 'Table space of the database (oci only)', null)
62+
->addOption('database-ssl-mode', null, InputOption::VALUE_REQUIRED, 'Encryption mode for the database connection, e.g. "require" or "verify-full" (pgsql only)')
63+
->addOption('database-ssl-ca', null, InputOption::VALUE_REQUIRED, 'Path to the CA certificate the database server is verified against (mysql and pgsql only)')
64+
->addOption('database-ssl-cert', null, InputOption::VALUE_REQUIRED, 'Path to the client certificate used to authenticate against the database (mysql and pgsql only)')
65+
->addOption('database-ssl-key', null, InputOption::VALUE_REQUIRED, 'Path to the private key of the client certificate (mysql and pgsql only)')
66+
->addOption('database-ssl-crl', null, InputOption::VALUE_REQUIRED, 'Path to the certificate revocation list (pgsql only)')
67+
->addOption('database-ssl-no-verify', null, InputOption::VALUE_NONE, 'Do not verify that the database server certificate matches the hostname used to connect (mysql only)')
4968
->addOption('disable-admin-user', null, InputOption::VALUE_NONE, 'Disable the creation of an admin user')
5069
->addOption('admin-user', null, InputOption::VALUE_REQUIRED, 'Login of the admin account', 'admin')
5170
->addOption('admin-pass', null, InputOption::VALUE_REQUIRED, 'Password of the admin account')
@@ -184,6 +203,19 @@ protected function validateInput(InputInterface $input, OutputInterface $output,
184203
if ($db === 'oci') {
185204
$options['dbtablespace'] = $input->getParameterOption('--database-table-space', '');
186205
}
206+
// The database setup translates these into the system config values that configure
207+
// an encrypted connection, and rejects the ones it does not support,
208+
// see \OC\Setup\AbstractDatabase::getEncryptionConfig()
209+
foreach (self::SSL_OPTIONS as $option => $installerOption) {
210+
$value = $input->getOption($option);
211+
if ($value !== null) {
212+
$options[$installerOption] = (string)$value;
213+
}
214+
}
215+
if ($input->getOption('database-ssl-no-verify')) {
216+
$options['dbsslnoverify'] = true;
217+
}
218+
187219
return $options;
188220
}
189221

lib/private/Setup/MySQL.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ protected function getEncryptionConfig(array $config): array {
111111
private function getSslAttributes(): array {
112112
// TODO: simplify once we only support PHP 8.5+.
113113
if (PHP_VERSION_ID >= 80500 && class_exists(\Pdo\Mysql::class)) {
114-
/** @psalm-suppress UndefinedClass */
114+
/** @psalm-suppress UndefinedConstant Psalm resolves the non-mysqlnd variant of the symfony polyfill class, which lacks this constant */
115115
return [
116116
'ca' => \Pdo\Mysql::ATTR_SSL_CA,
117117
'cert' => \Pdo\Mysql::ATTR_SSL_CERT,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace Tests\Core\Command\Maintenance;
10+
11+
use bantu\IniGetWrapper\IniGetWrapper;
12+
use OC\Core\Command\Maintenance\Install;
13+
use OC\SystemConfig;
14+
use Symfony\Component\Console\Input\ArrayInput;
15+
use Symfony\Component\Console\Output\NullOutput;
16+
use Test\TestCase;
17+
18+
class InstallTest extends TestCase {
19+
private Install $command;
20+
21+
#[\Override]
22+
protected function setUp(): void {
23+
parent::setUp();
24+
25+
$this->command = new Install(
26+
$this->createMock(SystemConfig::class),
27+
$this->createMock(IniGetWrapper::class),
28+
);
29+
}
30+
31+
/**
32+
* @param array<string, string|bool> $parameters
33+
* @return array The installer options built from the command line input
34+
*/
35+
private function validateInput(array $parameters): array {
36+
$input = new ArrayInput(array_merge([
37+
'--database-name' => 'nextcloud',
38+
'--database-user' => 'admin',
39+
'--database-pass' => 'admin-password',
40+
'--admin-pass' => 'admin-password',
41+
], $parameters), $this->command->getDefinition());
42+
43+
return self::invokePrivate($this->command, 'validateInput', [$input, new NullOutput(), ['sqlite', 'mysql', 'pgsql', 'oci']]);
44+
}
45+
46+
public static function encryptionOptions(): array {
47+
return [
48+
'--database-ssl-mode' => ['--database-ssl-mode', 'verify-full', 'dbsslmode', 'verify-full'],
49+
'--database-ssl-ca' => ['--database-ssl-ca', '/ca.pem', 'dbsslca', '/ca.pem'],
50+
'--database-ssl-cert' => ['--database-ssl-cert', '/client.crt', 'dbsslcert', '/client.crt'],
51+
'--database-ssl-key' => ['--database-ssl-key', '/client.key', 'dbsslkey', '/client.key'],
52+
'--database-ssl-crl' => ['--database-ssl-crl', '/crl.pem', 'dbsslcrl', '/crl.pem'],
53+
'--database-ssl-no-verify' => ['--database-ssl-no-verify', true, 'dbsslnoverify', true],
54+
];
55+
}
56+
57+
/**
58+
* The command only forwards the options, the database setup translates them into the
59+
* system config values and rejects the ones it does not support.
60+
*/
61+
#[\PHPUnit\Framework\Attributes\DataProvider('encryptionOptions')]
62+
public function testForwardsEncryptionOptions(string $parameter, string|bool $value, string $option, string|bool $expected): void {
63+
$options = $this->validateInput([
64+
'--database' => 'pgsql',
65+
$parameter => $value,
66+
]);
67+
68+
$this->assertSame($expected, $options[$option]);
69+
}
70+
71+
public function testNoEncryptionOptions(): void {
72+
$options = $this->validateInput(['--database' => 'mysql']);
73+
74+
foreach (['dbsslmode', 'dbsslca', 'dbsslcert', 'dbsslkey', 'dbsslcrl', 'dbsslnoverify'] as $option) {
75+
$this->assertArrayNotHasKey($option, $options);
76+
}
77+
}
78+
79+
/**
80+
* An option that does not apply to the chosen database is not filtered out here, it
81+
* has to be reported by the database setup instead of being silently dropped.
82+
*/
83+
public function testForwardsEncryptionOptionsRegardlessOfDatabase(): void {
84+
$options = $this->validateInput([
85+
'--database' => 'sqlite',
86+
'--database-ssl-ca' => '/ca.pem',
87+
]);
88+
89+
$this->assertSame('/ca.pem', $options['dbsslca']);
90+
}
91+
}

tests/lib/Setup/MySQLTest.php

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,6 @@
1717
use Test\TestCase;
1818

1919
class MySQLTest extends TestCase {
20-
/**
21-
* Numeric literals instead of the PDO::MYSQL_ATTR_* constants: those are deprecated
22-
* since PHP 8.5 and only defined when the MySQL driver is available.
23-
*/
24-
private const ATTR_SSL_KEY = 1006;
25-
private const ATTR_SSL_CERT = 1007;
26-
private const ATTR_SSL_CA = 1008;
27-
private const ATTR_SSL_VERIFY_SERVER_CERT = 1013;
28-
private const ATTR_INIT_COMMAND = 1002;
29-
3020
private SystemConfig&MockObject $config;
3121
private MySQL $database;
3222

@@ -48,6 +38,23 @@ protected function setUp(): void {
4838
);
4939
}
5040

41+
/**
42+
* The numeric value of a PDO MySQL attribute, e.g. `SSL_CA`.
43+
*
44+
* The values are not stable across PHP versions, so they must never be hardcoded.
45+
* Since PHP 8.5 the `PDO::MYSQL_ATTR_*` constants are deprecated in favor of
46+
* `Pdo\Mysql::ATTR_*`, and either only exists with the MySQL driver installed.
47+
*/
48+
private function attribute(string $name): int {
49+
if (!extension_loaded('pdo_mysql')) {
50+
$this->markTestSkipped('The pdo_mysql extension is required to resolve the PDO attribute values');
51+
}
52+
if (PHP_VERSION_ID >= 80500 && class_exists(\Pdo\Mysql::class)) {
53+
return (int)constant('Pdo\Mysql::ATTR_' . $name);
54+
}
55+
return (int)constant('PDO::MYSQL_ATTR_' . $name);
56+
}
57+
5158
/**
5259
* MySQL/MariaDB is configured through PDO driver options, keyed by the numeric PDO
5360
* attributes - which is why the web installer and CLI cannot pass them directly.
@@ -60,10 +67,10 @@ public function testInitializeMapsEncryptionOptions(): void {
6067
'dbhost' => 'db.example.org',
6168
'dbtableprefix' => 'oc_',
6269
'dbdriveroptions' => [
63-
self::ATTR_SSL_CA => '/ca.pem',
64-
self::ATTR_SSL_CERT => '/client.crt',
65-
self::ATTR_SSL_KEY => '/client.key',
66-
self::ATTR_SSL_VERIFY_SERVER_CERT => false,
70+
$this->attribute('SSL_CA') => '/ca.pem',
71+
$this->attribute('SSL_CERT') => '/client.crt',
72+
$this->attribute('SSL_KEY') => '/client.key',
73+
$this->attribute('SSL_VERIFY_SERVER_CERT') => false,
6774
],
6875
]);
6976

@@ -87,13 +94,13 @@ public function testInitializeMergesWithRawDriverOptions(): void {
8794
'dbhost' => 'db.example.org',
8895
'dbtableprefix' => 'oc_',
8996
'dbdriveroptions' => [
90-
self::ATTR_INIT_COMMAND => 'SET wait_timeout = 28800',
91-
self::ATTR_SSL_CA => '/ca.pem',
97+
$this->attribute('INIT_COMMAND') => 'SET wait_timeout = 28800',
98+
$this->attribute('SSL_CA') => '/ca.pem',
9299
],
93100
]);
94101

95102
$this->database->initialize($this->options([
96-
'dbdriveroptions' => [self::ATTR_INIT_COMMAND => 'SET wait_timeout = 28800'],
103+
'dbdriveroptions' => [$this->attribute('INIT_COMMAND') => 'SET wait_timeout = 28800'],
97104
'dbsslca' => '/ca.pem',
98105
]));
99106
}

0 commit comments

Comments
 (0)