Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions core/Command/Maintenance/Install.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@
use function get_class;

class Install extends Command {
/**
* SSL/TLS command line options and the installer options they provide. The database
* setup translates those, see \OC\Setup\AbstractDatabase::ENCRYPTION_OPTIONS.
* `--database-ssl-no-verify` is handled separately as it takes no value.
*/
private const array SSL_OPTIONS = [
'database-ssl-mode' => 'dbsslmode',
'database-ssl-ca' => 'dbsslca',
'database-ssl-cert' => 'dbsslcert',
'database-ssl-key' => 'dbsslkey',
'database-ssl-crl' => 'dbsslcrl',
];

public function __construct(
private SystemConfig $config,
private IniGetWrapper $iniGetWrapper,
Expand All @@ -46,6 +59,12 @@ protected function configure(): void {
->addOption('database-user', null, InputOption::VALUE_REQUIRED, 'Login to connect to the database')
->addOption('database-pass', null, InputOption::VALUE_OPTIONAL, 'Password of the database user', null)
->addOption('database-table-space', null, InputOption::VALUE_OPTIONAL, 'Table space of the database (oci only)', null)
->addOption('database-ssl-mode', null, InputOption::VALUE_REQUIRED, 'Encryption mode for the database connection, e.g. "require" or "verify-full" (pgsql only)')
->addOption('database-ssl-ca', null, InputOption::VALUE_REQUIRED, 'Path to the CA certificate the database server is verified against (mysql and pgsql only)')
->addOption('database-ssl-cert', null, InputOption::VALUE_REQUIRED, 'Path to the client certificate used to authenticate against the database (mysql and pgsql only)')
->addOption('database-ssl-key', null, InputOption::VALUE_REQUIRED, 'Path to the private key of the client certificate (mysql and pgsql only)')
->addOption('database-ssl-crl', null, InputOption::VALUE_REQUIRED, 'Path to the certificate revocation list (pgsql only)')
->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)')
->addOption('disable-admin-user', null, InputOption::VALUE_NONE, 'Disable the creation of an admin user')
->addOption('admin-user', null, InputOption::VALUE_REQUIRED, 'Login of the admin account', 'admin')
->addOption('admin-pass', null, InputOption::VALUE_REQUIRED, 'Password of the admin account')
Expand Down Expand Up @@ -184,6 +203,19 @@ protected function validateInput(InputInterface $input, OutputInterface $output,
if ($db === 'oci') {
$options['dbtablespace'] = $input->getParameterOption('--database-table-space', '');
}
// The database setup translates these into the system config values that configure
// an encrypted connection, and rejects the ones it does not support,
// see \OC\Setup\AbstractDatabase::getEncryptionConfig()
foreach (self::SSL_OPTIONS as $option => $installerOption) {
$value = $input->getOption($option);
if ($value !== null) {
$options[$installerOption] = (string)$value;
}
}
if ($input->getOption('database-ssl-no-verify')) {
$options['dbsslnoverify'] = true;
}

return $options;
}

Expand Down
6 changes: 6 additions & 0 deletions core/Controller/SetupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ public function display(array $post): void {
'dbtablespace' => '',
'dbhost' => 'localhost',
'dbtype' => '',
'dbsslmode' => '',
'dbsslca' => '',
'dbsslcert' => '',
'dbsslkey' => '',
'dbsslcrl' => '',
'dbsslnoverify' => false,
'hasAutoconfig' => false,
'serverRoot' => \OC::$SERVERROOT,
'version' => implode('.', $this->serverVersion->getVersion()),
Expand Down
13 changes: 13 additions & 0 deletions core/src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ export type SetupConfig = {
dbhost: string
dbtype: DbType | ''

/** Encryption mode of the connection, pgsql only */
dbsslmode: string
/** Path to the CA certificate the database server is verified against */
dbsslca: string
/** Path to the client certificate used to authenticate against the database */
dbsslcert: string
/** Path to the private key of the client certificate */
dbsslkey: string
/** Path to the certificate revocation list, pgsql only */
dbsslcrl: string
/** Skip verifying that the server certificate matches the host, mysql only */
dbsslnoverify: boolean

databases: Partial<Record<DbType, string>>

hasAutoconfig: boolean
Expand Down
67 changes: 67 additions & 0 deletions core/src/views/Setup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const defaultConfig = Object.freeze({
dbtablespace: '',
dbhost: '',
dbtype: '',
dbsslmode: '',
dbsslca: '',
dbsslcert: '',
dbsslkey: '',
dbsslcrl: '',
dbsslnoverify: false,
databases: {
sqlite: 'SQLite',
mysql: 'MySQL/MariaDB',
Expand Down Expand Up @@ -155,6 +161,67 @@ describe('Default setup page', () => {
})
})

describe('Encrypted database connection', () => {
beforeEach(cleanup)
beforeEach(() => {
removeInitialState()
mockInitialState('core', 'links', links)
})

it.each(['sqlite', 'oci'])('Is not offered for %s', async (dbtype) => {
mockInitialState('core', 'config', {
...defaultConfig,
dbtype,
databases: { sqlite: 'SQLite', mysql: 'MySQL/MariaDB', pgsql: 'PostgreSQL', oci: 'Oracle' },
} as SetupConfig)
const component = render(SetupView)

await expect(component.findByText('Encrypted database connection')).rejects.toThrow()
})

it('Offers the PDO options for mysql', async () => {
mockInitialState('core', 'config', { ...defaultConfig, dbtype: 'mysql' } as SetupConfig)
const component = render(SetupView)

await expect(component.findByText('Encrypted database connection')).resolves.not.toThrow()
await expect(component.findByRole('textbox', { name: /CA certificate path/ })).resolves.not.toThrow()
await expect(component.findByRole('textbox', { name: /Client certificate path/ })).resolves.not.toThrow()
await expect(component.findByRole('textbox', { name: /Client certificate key path/ })).resolves.not.toThrow()
await expect(component.findByRole('checkbox', { name: /Do not verify that the server certificate/ })).resolves.not.toThrow()

// Both are PostgreSQL specific
await expect(component.findByRole('textbox', { name: /Encryption mode/ })).rejects.toThrow()
await expect(component.findByRole('textbox', { name: /Certificate revocation list path/ })).rejects.toThrow()
})

it('Offers the libpq parameters for pgsql', async () => {
mockInitialState('core', 'config', { ...defaultConfig, dbtype: 'pgsql' } as SetupConfig)
const component = render(SetupView)

await expect(component.findByRole('textbox', { name: /Encryption mode/ })).resolves.not.toThrow()
await expect(component.findByRole('textbox', { name: /CA certificate path/ })).resolves.not.toThrow()
await expect(component.findByRole('textbox', { name: /Client certificate path/ })).resolves.not.toThrow()
await expect(component.findByRole('textbox', { name: /Client certificate key path/ })).resolves.not.toThrow()
await expect(component.findByRole('textbox', { name: /Certificate revocation list path/ })).resolves.not.toThrow()

// MySQL specific
await expect(component.findByRole('checkbox', { name: /Do not verify that the server certificate/ })).rejects.toThrow()
})

it('Renders the submitted values on error', async () => {
mockInitialState('core', 'config', {
...defaultConfig,
dbtype: 'pgsql',
dbsslmode: 'verify-full',
dbsslca: '/ca.pem',
} as SetupConfig)
const component = render(SetupView)

expect((await component.findByRole('textbox', { name: /Encryption mode/ }) as HTMLInputElement).value).toBe('verify-full')
expect((await component.findByRole('textbox', { name: /CA certificate path/ }) as HTMLInputElement).value).toBe('/ca.pem')
})
})

describe('Setup page with errors and warning', () => {
beforeEach(cleanup)
beforeEach(() => {
Expand Down
71 changes: 71 additions & 0 deletions core/src/views/Setup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,69 @@
name="dbhost"
spellcheck="false" />
</fieldset>

<!-- Encrypted database connection -->
<details v-if="supportsEncryptedConnection" data-cy-setup-form-database-encryption>
<summary>{{ t('core', 'Encrypted database connection') }}</summary>

<fieldset>
<legend class="hidden-visually">
{{ t('core', 'Encrypted database connection') }}
</legend>

<NcTextField
v-if="config.dbtype === 'pgsql'"
v-model="config.dbsslmode"
:helper-text="t('core', 'Supported modes: disable, allow, prefer, require, verify-ca, verify-full.')"
:label="t('core', 'Encryption mode')"
autocapitalize="none"
autocomplete="off"
name="dbsslmode"
spellcheck="false" />

<NcTextField
v-model="config.dbsslca"
:helper-text="t('core', 'Has to be readable by the web server.')"
:label="t('core', 'CA certificate path')"
autocapitalize="none"
autocomplete="off"
name="dbsslca"
spellcheck="false" />

<NcTextField
v-model="config.dbsslcert"
:label="t('core', 'Client certificate path')"
autocapitalize="none"
autocomplete="off"
name="dbsslcert"
spellcheck="false" />

<NcTextField
v-model="config.dbsslkey"
:label="t('core', 'Client certificate key path')"
autocapitalize="none"
autocomplete="off"
name="dbsslkey"
spellcheck="false" />

<NcTextField
v-if="config.dbtype === 'pgsql'"
v-model="config.dbsslcrl"
:label="t('core', 'Certificate revocation list path')"
autocapitalize="none"
autocomplete="off"
name="dbsslcrl"
spellcheck="false" />

<NcCheckboxRadioSwitch
v-if="config.dbtype === 'mysql'"
v-model="config.dbsslnoverify"
name="dbsslnoverify"
type="checkbox">
{{ t('core', 'Do not verify that the server certificate matches the database host') }}
</NcCheckboxRadioSwitch>
</fieldset>
</details>
</fieldset>
</details>

Expand Down Expand Up @@ -324,6 +387,14 @@ export default defineComponent({
return 'success'
},

/**
* Only MySQL/MariaDB and PostgreSQL can be configured to use an encrypted
* connection through the installer, see OC\Setup\AbstractDatabase.
*/
supportsEncryptedConnection(): boolean {
return this.config?.dbtype === 'mysql' || this.config?.dbtype === 'pgsql'
},

firstAndOnlyDatabase(): string | null {
const dbNames = Object.values(this.config?.databases || {})
if (dbNames.length === 1) {
Expand Down
2 changes: 1 addition & 1 deletion lib/private/DB/ConnectionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ public function createConnectionParams(string $configPrefix = '', array $additio
//additional driver options, eg. for mysql ssl
$driverOptions = $this->config->getValue($configPrefix . 'dbdriveroptions', $this->config->getValue('dbdriveroptions', null));
if ($driverOptions) {
$connectionParams['driverOptions'] = $driverOptions;
$connectionParams['driverOptions'] = array_merge($connectionParams['driverOptions'], $driverOptions);
}

// set default table creation options
Expand Down
54 changes: 52 additions & 2 deletions lib/private/Setup/AbstractDatabase.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@
*/
protected const array CONNECTION_ENCRYPTION_OPTIONS = ['dbdriveroptions'];

/**
* Installer options describing an encrypted database connection independently of the
* database in use, as provided by the web installer and `occ maintenance:install`.
* @var string[]
*/
protected const array ENCRYPTION_OPTIONS = ['dbsslmode', 'dbsslca', 'dbsslcert', 'dbsslkey', 'dbsslcrl', 'dbsslnoverify'];

/**
* The subset of {@see static::ENCRYPTION_OPTIONS} this database supports.
* @var string[]
*/
protected const array SUPPORTED_ENCRYPTION_OPTIONS = [];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public string $dbprettyname = 'Abstract';

silence out some warnings from psalm

protected string $dbUser;
protected string $dbPassword;
protected string $dbName;
Expand Down Expand Up @@ -53,16 +66,46 @@
if (substr_count($config['dbname'], '.') >= 1) {
$errors[] = $this->trans->t('You cannot use dots in the database name %s', [$this->dbprettyname]);
}
return array_merge($errors, $this->validateEncryptionOptions($config));
}

/**
* Validate the installer options configuring an encrypted database connection.
*
* @param array $config The options passed to the installer
* @return string[]
*/
protected function validateEncryptionOptions(array $config): array {
$errors = [];
foreach (static::CONNECTION_ENCRYPTION_OPTIONS as $option) {
if (isset($config[$option]) && !is_array($config[$option])) {
// Fail instead of ignoring the option, otherwise the instance would be
// installed with an unencrypted connection without the admin noticing.
$errors[] = $this->trans->t('The database option "%1$s" for %2$s has to be a list of values', [$option, $this->dbprettyname]);

Check failure on line 82 in lib/private/Setup/AbstractDatabase.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedThisPropertyFetch

lib/private/Setup/AbstractDatabase.php:82:109: UndefinedThisPropertyFetch: Instance property OC\Setup\AbstractDatabase::$dbprettyname is not defined (see https://psalm.dev/041)
}
}
foreach (static::ENCRYPTION_OPTIONS as $option) {
if (!empty($config[$option]) && !in_array($option, static::SUPPORTED_ENCRYPTION_OPTIONS, true)) {
$errors[] = $this->trans->t('The database option "%1$s" is not supported by %2$s', [$option, $this->dbprettyname]);

Check failure on line 87 in lib/private/Setup/AbstractDatabase.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedThisPropertyFetch

lib/private/Setup/AbstractDatabase.php:87:98: UndefinedThisPropertyFetch: Instance property OC\Setup\AbstractDatabase::$dbprettyname is not defined (see https://psalm.dev/041)
}
}
// A client certificate is useless without its private key and vice versa
if (in_array('dbsslcert', static::SUPPORTED_ENCRYPTION_OPTIONS, true)
&& empty($config['dbsslcert']) !== empty($config['dbsslkey'])) {
$errors[] = $this->trans->t('The database options "dbsslcert" and "dbsslkey" have to be provided together');
}
return $errors;
}

/**
* Translate the `ENCRYPTION_OPTIONS` into the system config values that
* configure an encrypted connection for this database.
*
* @param array $config The options passed to the installer
* @return array<string, array> System config values, empty if no option was provided
*/
protected function getEncryptionConfig(array $config): array {
return [];
}

public function initialize(array $config): void {
$dbUser = $config['dbuser'];
$dbPass = $config['dbpass'];
Expand Down Expand Up @@ -95,6 +138,13 @@
$configValues[$option] = $config[$option];
}

// The database independent options end up in the same config values, so they are
// applied on top of any raw value provided, e.g. through an autoconfig file.
// array_replace() instead of array_merge() to keep the numeric PDO attribute keys.
foreach ($this->getEncryptionConfig($config) as $option => $value) {
$configValues[$option] = array_replace($configValues[$option] ?? [], $value);
}

$this->config->setValues($configValues);

$this->dbUser = $dbUser;
Expand Down
Loading
Loading