Skip to content

Commit 8585b4e

Browse files
authored
Merge pull request #62959 from nextcloud/feat/allow-ssl-db-installation
feat(setup): allow to configure ssl db connection during installation
2 parents 40911b9 + 2a9fdf1 commit 8585b4e

17 files changed

Lines changed: 742 additions & 12 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

core/Controller/SetupController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ public function display(array $post): void {
8181
'dbtablespace' => '',
8282
'dbhost' => 'localhost',
8383
'dbtype' => '',
84+
'dbsslmode' => '',
85+
'dbsslca' => '',
86+
'dbsslcert' => '',
87+
'dbsslkey' => '',
88+
'dbsslcrl' => '',
89+
'dbsslnoverify' => false,
8490
'hasAutoconfig' => false,
8591
'serverRoot' => \OC::$SERVERROOT,
8692
'version' => implode('.', $this->serverVersion->getVersion()),

core/src/install.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import Vue from 'vue'
7-
import Setup from './views/Setup.vue'
7+
import WebInstaller from './views/WebInstaller.vue'
88

99
type Error = {
1010
error: string
@@ -24,6 +24,22 @@ export type SetupConfig = {
2424
dbhost: string
2525
dbtype: DbType | ''
2626

27+
/** Encryption mode of the connection, pgsql only */
28+
dbsslmode: string
29+
/** Path to the CA certificate the database server is verified against */
30+
dbsslca: string
31+
/** Path to the client certificate used to authenticate against the database */
32+
dbsslcert: string
33+
/** Path to the private key of the client certificate */
34+
dbsslkey: string
35+
/** Path to the certificate revocation list, pgsql only */
36+
dbsslcrl: string
37+
/**
38+
* Skip verifying that the server certificate matches the host, mysql only.
39+
* A string when reflected back from a submitted form, as checkboxes are submitted by value.
40+
*/
41+
dbsslnoverify: boolean | string
42+
2743
databases: Partial<Record<DbType, string>>
2844

2945
hasAutoconfig: boolean
@@ -39,5 +55,5 @@ export type SetupLinks = {
3955
adminDBConfiguration: string
4056
}
4157

42-
const SetupVue = Vue.extend(Setup)
58+
const SetupVue = Vue.extend(WebInstaller)
4359
new SetupVue().$mount('#content')
Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { SetupConfig, SetupLinks } from '../install.ts'
77

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

1212
import '../../css/guest.css'
1313

@@ -20,6 +20,12 @@ const defaultConfig = Object.freeze({
2020
dbtablespace: '',
2121
dbhost: '',
2222
dbtype: '',
23+
dbsslmode: '',
24+
dbsslca: '',
25+
dbsslcert: '',
26+
dbsslkey: '',
27+
dbsslcrl: '',
28+
dbsslnoverify: false,
2329
databases: {
2430
sqlite: 'SQLite',
2531
mysql: 'MySQL/MariaDB',
@@ -155,6 +161,93 @@ describe('Default setup page', () => {
155161
})
156162
})
157163

164+
describe('Encrypted database connection', () => {
165+
beforeEach(cleanup)
166+
beforeEach(() => {
167+
removeInitialState()
168+
mockInitialState('core', 'links', links)
169+
})
170+
171+
it.each(['sqlite', 'oci'])('Is not offered for %s', async (dbtype) => {
172+
mockInitialState('core', 'config', {
173+
...defaultConfig,
174+
dbtype,
175+
databases: { sqlite: 'SQLite', mysql: 'MySQL/MariaDB', pgsql: 'PostgreSQL', oci: 'Oracle' },
176+
} as SetupConfig)
177+
const component = render(SetupView)
178+
179+
await expect(component.findByText('Encrypted database connection', { selector: 'summary' })).rejects.toThrow()
180+
})
181+
182+
it('Offers the PDO options for mysql', async () => {
183+
mockInitialState('core', 'config', { ...defaultConfig, dbtype: 'mysql' } as SetupConfig)
184+
const component = render(SetupView)
185+
186+
await expect(component.findByText('Encrypted database connection', { selector: 'summary' })).resolves.not.toThrow()
187+
await expect(component.findByRole('textbox', { name: /CA certificate path/ })).resolves.not.toThrow()
188+
await expect(component.findByRole('textbox', { name: /Client certificate path/ })).resolves.not.toThrow()
189+
await expect(component.findByRole('textbox', { name: /Client certificate key path/ })).resolves.not.toThrow()
190+
await expect(component.findByRole('checkbox', { name: /Do not verify that the server certificate/ })).resolves.not.toThrow()
191+
192+
// Both are PostgreSQL specific
193+
await expect(component.findByRole('textbox', { name: /Encryption mode/ })).rejects.toThrow()
194+
await expect(component.findByRole('textbox', { name: /Certificate revocation list path/ })).rejects.toThrow()
195+
})
196+
197+
it('Submits the no-verify checkbox by value', async () => {
198+
mockInitialState('core', 'config', { ...defaultConfig, dbtype: 'mysql' } as SetupConfig)
199+
const component = render(SetupView)
200+
201+
// The form is submitted natively, so the checkbox needs a name and a value
202+
const checkbox = await component.findByRole('checkbox', { name: /Do not verify that the server certificate/ }) as HTMLInputElement
203+
expect(checkbox.name).toBe('dbsslnoverify')
204+
expect(checkbox.value).toBe('1')
205+
expect(checkbox.checked).toBe(false)
206+
207+
await fireEvent.click(checkbox)
208+
expect((component.getByRole('checkbox', { name: /Do not verify that the server certificate/ }) as HTMLInputElement).checked).toBe(true)
209+
})
210+
211+
it('Offers the libpq parameters for pgsql', async () => {
212+
mockInitialState('core', 'config', { ...defaultConfig, dbtype: 'pgsql' } as SetupConfig)
213+
const component = render(SetupView)
214+
215+
await expect(component.findByRole('textbox', { name: /Encryption mode/ })).resolves.not.toThrow()
216+
await expect(component.findByRole('textbox', { name: /CA certificate path/ })).resolves.not.toThrow()
217+
await expect(component.findByRole('textbox', { name: /Client certificate path/ })).resolves.not.toThrow()
218+
await expect(component.findByRole('textbox', { name: /Client certificate key path/ })).resolves.not.toThrow()
219+
await expect(component.findByRole('textbox', { name: /Certificate revocation list path/ })).resolves.not.toThrow()
220+
221+
// MySQL specific
222+
await expect(component.findByRole('checkbox', { name: /Do not verify that the server certificate/ })).rejects.toThrow()
223+
})
224+
225+
it('Renders the submitted values on error', async () => {
226+
mockInitialState('core', 'config', {
227+
...defaultConfig,
228+
dbtype: 'pgsql',
229+
dbsslmode: 'verify-full',
230+
dbsslca: '/ca.pem',
231+
} as SetupConfig)
232+
const component = render(SetupView)
233+
234+
expect((await component.findByRole('textbox', { name: /Encryption mode/ }) as HTMLInputElement).value).toBe('verify-full')
235+
expect((await component.findByRole('textbox', { name: /CA certificate path/ }) as HTMLInputElement).value).toBe('/ca.pem')
236+
})
237+
238+
it('Renders the submitted checkbox value on error', async () => {
239+
mockInitialState('core', 'config', {
240+
...defaultConfig,
241+
dbtype: 'mysql',
242+
// Checkboxes are submitted by value, so the reflected value is a string
243+
dbsslnoverify: '1',
244+
} as SetupConfig)
245+
const component = render(SetupView)
246+
247+
expect((await component.findByRole('checkbox', { name: /Do not verify that the server certificate/ }) as HTMLInputElement).checked).toBe(true)
248+
})
249+
})
250+
158251
describe('Setup page with errors and warning', () => {
159252
beforeEach(cleanup)
160253
beforeEach(() => {
Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,70 @@
186186
name="dbhost"
187187
spellcheck="false" />
188188
</fieldset>
189+
190+
<!-- Encrypted database connection -->
191+
<details v-if="supportsEncryptedConnection" data-cy-setup-form-database-encryption>
192+
<summary>{{ t('core', 'Encrypted database connection') }}</summary>
193+
194+
<fieldset>
195+
<legend class="hidden-visually">
196+
{{ t('core', 'Encrypted database connection') }}
197+
</legend>
198+
199+
<NcTextField
200+
v-if="config.dbtype === 'pgsql'"
201+
v-model="config.dbsslmode"
202+
:helper-text="t('core', 'Supported modes: disable, allow, prefer, require, verify-ca, verify-full.')"
203+
:label="t('core', 'Encryption mode')"
204+
autocapitalize="none"
205+
autocomplete="off"
206+
name="dbsslmode"
207+
spellcheck="false" />
208+
209+
<NcTextField
210+
v-model="config.dbsslca"
211+
:helper-text="t('core', 'Has to be readable by the web server.')"
212+
:label="t('core', 'CA certificate path')"
213+
autocapitalize="none"
214+
autocomplete="off"
215+
name="dbsslca"
216+
spellcheck="false" />
217+
218+
<NcTextField
219+
v-model="config.dbsslcert"
220+
:label="t('core', 'Client certificate path')"
221+
autocapitalize="none"
222+
autocomplete="off"
223+
name="dbsslcert"
224+
spellcheck="false" />
225+
226+
<NcTextField
227+
v-model="config.dbsslkey"
228+
:label="t('core', 'Client certificate key path')"
229+
autocapitalize="none"
230+
autocomplete="off"
231+
name="dbsslkey"
232+
spellcheck="false" />
233+
234+
<NcTextField
235+
v-if="config.dbtype === 'pgsql'"
236+
v-model="config.dbsslcrl"
237+
:label="t('core', 'Certificate revocation list path')"
238+
autocapitalize="none"
239+
autocomplete="off"
240+
name="dbsslcrl"
241+
spellcheck="false" />
242+
243+
<NcCheckboxRadioSwitch
244+
v-if="config.dbtype === 'mysql'"
245+
v-model="dbsslnoverify"
246+
name="dbsslnoverify"
247+
type="checkbox"
248+
value="1">
249+
{{ t('core', 'Do not verify that the server certificate matches the database host') }}
250+
</NcCheckboxRadioSwitch>
251+
</fieldset>
252+
</details>
189253
</fieldset>
190254
</details>
191255

@@ -262,7 +326,7 @@ function checkPasswordEntropy(password: string = ''): PasswordStrength {
262326
}
263327
264328
export default defineComponent({
265-
name: 'Setup',
329+
name: 'WebInstaller',
266330
267331
components: {
268332
IconArrowRight,
@@ -324,6 +388,30 @@ export default defineComponent({
324388
return 'success'
325389
},
326390
391+
/**
392+
* Only MySQL/MariaDB and PostgreSQL can be configured to use an encrypted
393+
* connection through the installer, see OC\Setup\AbstractDatabase.
394+
*/
395+
supportsEncryptedConnection(): boolean {
396+
return this.config?.dbtype === 'mysql' || this.config?.dbtype === 'pgsql'
397+
},
398+
399+
/**
400+
* The form is submitted natively, so the checkbox needs a `name` to be part of
401+
* the request - which NcCheckboxRadioSwitch only supports for groups of
402+
* checkboxes, meaning the model has to be the list of the checked values.
403+
* The value is submitted as a string and reflected back on validation errors.
404+
*/
405+
dbsslnoverify: {
406+
get(): string[] {
407+
return this.config?.dbsslnoverify ? ['1'] : []
408+
},
409+
410+
set(checked: string[]) {
411+
this.config.dbsslnoverify = checked.includes('1')
412+
},
413+
},
414+
327415
firstAndOnlyDatabase(): string | null {
328416
const dbNames = Object.values(this.config?.databases || {})
329417
if (dbNames.length === 1) {

0 commit comments

Comments
 (0)