Store interactive setup certificates in the data folder. (#115981)

This commit is contained in:
Aleh Zasypkin 2021-10-25 20:28:47 +02:00 committed by GitHub
parent 1732927fb1
commit 5947cee096
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 203 additions and 209 deletions

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { getConfigPath } from '@kbn/utils';
import { getConfigPath, getDataPath } from '@kbn/utils';
import inquirer from 'inquirer';
import { duration } from 'moment';
import { merge } from 'lodash';
@ -30,7 +30,7 @@ const logger: Logger = {
get: () => logger,
};
export const kibanaConfigWriter = new KibanaConfigWriter(getConfigPath(), logger);
export const kibanaConfigWriter = new KibanaConfigWriter(getConfigPath(), getDataPath(), logger);
export const elasticsearch = new ElasticsearchService(logger).setup({
connectionCheckInterval: duration(Infinity),
elasticsearch: {

View file

@ -30,6 +30,7 @@ describe('KibanaConfigWriter', () => {
kibanaConfigWriter = new KibanaConfigWriter(
'/some/path/kibana.yml',
'/data',
loggingSystemMock.createLogger()
);
});
@ -37,15 +38,15 @@ describe('KibanaConfigWriter', () => {
afterEach(() => jest.resetAllMocks());
describe('#isConfigWritable()', () => {
it('returns `false` if config directory is not writable even if kibana yml is writable', async () => {
it('returns `false` if data directory is not writable even if kibana yml is writable', async () => {
mockFsAccess.mockImplementation((path, modifier) =>
path === '/some/path' && modifier === constants.W_OK ? Promise.reject() : Promise.resolve()
path === '/data' && modifier === constants.W_OK ? Promise.reject() : Promise.resolve()
);
await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(false);
});
it('returns `false` if kibana yml is NOT writable if even config directory is writable', async () => {
it('returns `false` if kibana yml is NOT writable if even data directory is writable', async () => {
mockFsAccess.mockImplementation((path, modifier) =>
path === '/some/path/kibana.yml' && modifier === constants.W_OK
? Promise.reject()
@ -55,219 +56,208 @@ describe('KibanaConfigWriter', () => {
await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(false);
});
it('returns `true` if both kibana yml and config directory are writable', async () => {
it('returns `true` if both kibana yml and data directory are writable', async () => {
mockFsAccess.mockResolvedValue(undefined);
await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(true);
});
it('returns `true` even if kibana yml does not exist when config directory is writable', async () => {
it('returns `true` even if kibana yml does not exist even if data directory is writable', async () => {
mockFsAccess.mockImplementation((path) =>
path === '/some/path/kibana.yml' ? Promise.reject() : Promise.resolve()
);
await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(true);
await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(false);
});
});
describe('#writeConfig()', () => {
describe('without existing config', () => {
beforeEach(() => {
mockReadFile.mockResolvedValue('');
});
it('throws if cannot write CA file', async () => {
mockWriteFile.mockRejectedValue(new Error('Oh no!'));
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: '',
serviceAccountToken: { name: '', value: '' },
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
expect(mockWriteFile).toHaveBeenCalledTimes(1);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
});
it('throws if cannot write config to yaml file', async () => {
mockWriteFile.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('Oh no!'));
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
expect(mockWriteFile).toHaveBeenCalledTimes(2);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
expect(mockWriteFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
`
);
});
it('throws if cannot read existing config', async () => {
mockReadFile.mockRejectedValue(new Error('Oh no!'));
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('throws if cannot parse existing config', async () => {
mockReadFile.mockResolvedValue('foo: bar\nfoo: baz');
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).rejects.toMatchInlineSnapshot(`
[YAMLException: duplicated mapping key at line 2, column 1:
foo: baz
^]
`);
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('can successfully write CA certificate and elasticsearch config with service token', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).resolves.toBeUndefined();
expect(mockWriteFile).toHaveBeenCalledTimes(2);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
expect(mockWriteFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
`
);
});
it('can successfully write CA certificate and elasticsearch config with credentials', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
username: 'username',
password: 'password',
})
).resolves.toBeUndefined();
expect(mockWriteFile).toHaveBeenCalledTimes(2);
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
expect(mockWriteFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.password: password
elasticsearch.username: username
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
`
);
});
it('can successfully write elasticsearch config without CA certificate', async () => {
await expect(
kibanaConfigWriter.writeConfig({
host: 'some-host',
username: 'username',
password: 'password',
})
).resolves.toBeUndefined();
expect(mockWriteFile).toHaveBeenCalledTimes(1);
expect(mockWriteFile).toHaveBeenCalledWith(
'/some/path/kibana.yml',
`
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.password: password
elasticsearch.username: username
`
);
});
beforeEach(() => {
mockReadFile.mockResolvedValue(
'# Default Kibana configuration for docker target\nserver.host: "0.0.0.0"\nserver.shutdownTimeout: "5s"'
);
});
describe('with existing config (no conflicts)', () => {
beforeEach(() => {
mockReadFile.mockResolvedValue(
'# Default Kibana configuration for docker target\nserver.host: "0.0.0.0"\nserver.shutdownTimeout: "5s"'
);
});
it('throws if cannot write CA file', async () => {
mockWriteFile.mockRejectedValue(new Error('Oh no!'));
it('can successfully write CA certificate and elasticsearch config', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).resolves.toBeUndefined();
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: '',
serviceAccountToken: { name: '', value: '' },
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
expect(mockReadFile).toHaveBeenCalledTimes(1);
expect(mockReadFile).toHaveBeenCalledWith('/some/path/kibana.yml', 'utf-8');
expect(mockWriteFile).toHaveBeenCalledTimes(1);
expect(mockWriteFile).toHaveBeenCalledWith('/data/ca_1234.crt', 'ca-content');
});
expect(mockWriteFile).toHaveBeenCalledTimes(2);
expect(mockWriteFile.mock.calls).toMatchInlineSnapshot(`
it('throws if cannot write config to yaml file', async () => {
mockWriteFile.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('Oh no!'));
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
expect(mockWriteFile.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Array [
"/some/path/ca_1234.crt",
"ca-content",
],
Array [
"/some/path/kibana.yml",
"# Default Kibana configuration for docker target
server.host: \\"0.0.0.0\\"
server.shutdownTimeout: \\"5s\\"
"/data/ca_1234.crt",
"ca-content",
],
Array [
"/some/path/kibana.yml",
"# Default Kibana configuration for docker target
server.host: \\"0.0.0.0\\"
server.shutdownTimeout: \\"5s\\"
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/data/ca_1234.crt]
",
],
]
`);
});
",
],
]
`);
});
describe('with existing config (with conflicts)', () => {
it('throws if cannot read existing config', async () => {
mockReadFile.mockRejectedValue(new Error('Oh no!'));
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('throws if cannot parse existing config', async () => {
mockReadFile.mockResolvedValue('foo: bar\nfoo: baz');
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).rejects.toMatchInlineSnapshot(`
[YAMLException: duplicated mapping key at line 2, column 1:
foo: baz
^]
`);
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('can successfully write CA certificate and elasticsearch config with credentials', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
username: 'username',
password: 'password',
})
).resolves.toBeUndefined();
expect(mockWriteFile.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/data/ca_1234.crt",
"ca-content",
],
Array [
"/some/path/kibana.yml",
"# Default Kibana configuration for docker target
server.host: \\"0.0.0.0\\"
server.shutdownTimeout: \\"5s\\"
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.password: password
elasticsearch.username: username
elasticsearch.ssl.certificateAuthorities: [/data/ca_1234.crt]
",
],
]
`);
});
it('can successfully write elasticsearch config without CA certificate', async () => {
await expect(
kibanaConfigWriter.writeConfig({
host: 'some-host',
username: 'username',
password: 'password',
})
).resolves.toBeUndefined();
expect(mockWriteFile.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/some/path/kibana.yml",
"# Default Kibana configuration for docker target
server.host: \\"0.0.0.0\\"
server.shutdownTimeout: \\"5s\\"
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.password: password
elasticsearch.username: username
",
],
]
`);
});
it('can successfully write CA certificate and elasticsearch config with service token', async () => {
await expect(
kibanaConfigWriter.writeConfig({
caCert: 'ca-content',
host: 'some-host',
serviceAccountToken: { name: 'some-token', value: 'some-value' },
})
).resolves.toBeUndefined();
expect(mockReadFile).toHaveBeenCalledTimes(1);
expect(mockReadFile).toHaveBeenCalledWith('/some/path/kibana.yml', 'utf-8');
expect(mockWriteFile).toHaveBeenCalledTimes(2);
expect(mockWriteFile.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/data/ca_1234.crt",
"ca-content",
],
Array [
"/some/path/kibana.yml",
"# Default Kibana configuration for docker target
server.host: \\"0.0.0.0\\"
server.shutdownTimeout: \\"5s\\"
# This section was automatically generated during setup.
elasticsearch.hosts: [some-host]
elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/data/ca_1234.crt]
",
],
]
`);
});
describe('with conflicts', () => {
beforeEach(() => {
jest.spyOn(Date.prototype, 'toISOString').mockReturnValue('some date');
mockReadFile.mockResolvedValue(
@ -291,7 +281,7 @@ elasticsearch.username: username
expect(mockWriteFile.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/some/path/ca_1234.crt",
"/data/ca_1234.crt",
"ca-content",
],
Array [
@ -312,7 +302,7 @@ elasticsearch.username: username
elasticsearch.hosts: [some-host]
monitoring.ui.container.elasticsearch.enabled: true
elasticsearch.serviceAccountToken: some-value
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
elasticsearch.ssl.certificateAuthorities: [/data/ca_1234.crt]
",
],

View file

@ -31,24 +31,23 @@ export type WriteConfigParameters = {
);
export class KibanaConfigWriter {
constructor(private readonly configPath: string, private readonly logger: Logger) {}
constructor(
private readonly configPath: string,
private readonly dataDirectoryPath: string,
private readonly logger: Logger
) {}
/**
* Checks if we can write to the Kibana configuration file and configuration directory.
* Checks if we can write to the Kibana configuration file and data directory.
*/
public async isConfigWritable() {
try {
// We perform two separate checks here:
// 1. If we can write to config directory to add a new CA certificate file and potentially Kibana configuration
// file if it doesn't exist for some reason.
// 1. If we can write to data directory to add a new CA certificate file.
// 2. If we can write to the Kibana configuration file if it exists.
const canWriteToConfigDirectory = fs.access(path.dirname(this.configPath), constants.W_OK);
await Promise.all([
canWriteToConfigDirectory,
fs.access(this.configPath, constants.F_OK).then(
() => fs.access(this.configPath, constants.W_OK),
() => canWriteToConfigDirectory
),
fs.access(this.dataDirectoryPath, constants.W_OK),
fs.access(this.configPath, constants.W_OK),
]);
return true;
} catch {
@ -61,7 +60,7 @@ export class KibanaConfigWriter {
* @param params
*/
public async writeConfig(params: WriteConfigParameters) {
const caPath = path.join(path.dirname(this.configPath), `ca_${Date.now()}.crt`);
const caPath = path.join(this.dataDirectoryPath, `ca_${Date.now()}.crt`);
const config: Record<string, string | string[]> = { 'elasticsearch.hosts': [params.host] };
if ('serviceAccountToken' in params) {
config['elasticsearch.serviceAccountToken'] = params.serviceAccountToken.value;

View file

@ -10,6 +10,7 @@ import chalk from 'chalk';
import type { Subscription } from 'rxjs';
import type { TypeOf } from '@kbn/config-schema';
import { getDataPath } from '@kbn/utils';
import type { CorePreboot, Logger, PluginInitializerContext, PrebootPlugin } from 'src/core/server';
import { ElasticsearchConnectionStatus } from '../common';
@ -146,7 +147,11 @@ Go to ${chalk.cyanBright.underline(url)} to get started.
basePath: core.http.basePath,
logger: this.#logger.get('routes'),
preboot: { ...core.preboot, completeSetup },
kibanaConfigWriter: new KibanaConfigWriter(configPath, this.#logger.get('kibana-config')),
kibanaConfigWriter: new KibanaConfigWriter(
configPath,
getDataPath(),
this.#logger.get('kibana-config')
),
elasticsearch,
verificationCode,
getConfig: this.#getConfig.bind(this),