Skip to content
Merged
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
20 changes: 18 additions & 2 deletions core/src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import Vue from 'vue'
import Setup from './views/Setup.vue'
import WebInstaller from './views/WebInstaller.vue'

type Error = {
error: string
Expand All @@ -24,6 +24,22 @@ 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.
* A string when reflected back from a submitted form, as checkboxes are submitted by value.
*/
dbsslnoverify: boolean | string

databases: Partial<Record<DbType, string>>

hasAutoconfig: boolean
Expand All @@ -39,5 +55,5 @@ export type SetupLinks = {
adminDBConfiguration: string
}

const SetupVue = Vue.extend(Setup)
const SetupVue = Vue.extend(WebInstaller)
new SetupVue().$mount('#content')
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { SetupConfig, SetupLinks } from '../install.ts'

import { cleanup, findByRole, fireEvent, getAllByRole, getByRole, render } from '@testing-library/vue'
import { beforeEach, describe, expect, it } from 'vitest'
import SetupView from './Setup.vue'
import SetupView from './WebInstaller.vue'

import '../../css/guest.css'

Expand All @@ -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,93 @@ 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', { selector: 'summary' })).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', { selector: 'summary' })).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('Submits the no-verify checkbox by value', async () => {
mockInitialState('core', 'config', { ...defaultConfig, dbtype: 'mysql' } as SetupConfig)
const component = render(SetupView)

// The form is submitted natively, so the checkbox needs a name and a value
const checkbox = await component.findByRole('checkbox', { name: /Do not verify that the server certificate/ }) as HTMLInputElement
expect(checkbox.name).toBe('dbsslnoverify')
expect(checkbox.value).toBe('1')
expect(checkbox.checked).toBe(false)

await fireEvent.click(checkbox)
expect((component.getByRole('checkbox', { name: /Do not verify that the server certificate/ }) as HTMLInputElement).checked).toBe(true)
})

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')
})

it('Renders the submitted checkbox value on error', async () => {
mockInitialState('core', 'config', {
...defaultConfig,
dbtype: 'mysql',
// Checkboxes are submitted by value, so the reflected value is a string
dbsslnoverify: '1',
} as SetupConfig)
const component = render(SetupView)

expect((await component.findByRole('checkbox', { name: /Do not verify that the server certificate/ }) as HTMLInputElement).checked).toBe(true)
})
})

describe('Setup page with errors and warning', () => {
beforeEach(cleanup)
beforeEach(() => {
Expand Down
90 changes: 89 additions & 1 deletion core/src/views/Setup.vue → core/src/views/WebInstaller.vue
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,70 @@
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="dbsslnoverify"
name="dbsslnoverify"
type="checkbox"
value="1">
{{ t('core', 'Do not verify that the server certificate matches the database host') }}
</NcCheckboxRadioSwitch>
</fieldset>
</details>
</fieldset>
</details>

Expand Down Expand Up @@ -262,7 +326,7 @@ function checkPasswordEntropy(password: string = ''): PasswordStrength {
}

export default defineComponent({
name: 'Setup',
name: 'WebInstaller',

components: {
IconArrowRight,
Expand Down Expand Up @@ -324,6 +388,30 @@ 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'
},

/**
* The form is submitted natively, so the checkbox needs a `name` to be part of
* the request - which NcCheckboxRadioSwitch only supports for groups of
* checkboxes, meaning the model has to be the list of the checked values.
* The value is submitted as a string and reflected back on validation errors.
*/
dbsslnoverify: {
get(): string[] {
return this.config?.dbsslnoverify ? ['1'] : []
},

set(checked: string[]) {
this.config.dbsslnoverify = checked.includes('1')
},
},

firstAndOnlyDatabase(): string | null {
const dbNames = Object.values(this.config?.databases || {})
if (dbNames.length === 1) {
Expand Down
Loading
Loading