Optional ssl for fleet logstash output (#216216)

## Summary

Resolves: https://github.com/elastic/kibana/issues/145266

* Allows SSL configuration to be disabled for Fleet agent logstash
output
  * Adds an SSL toggle in the logstash output form.
  * On is the default state of the form
  * When off:
    * Authentication form section is removed
    * Logstash input config has SSL related fields removed
* Submitting update removes SSL fields and related SSL secrets in output
config
    * Shows a call out to proceed with caution
This commit is contained in:
Michel Losier 2025-04-02 08:36:32 -07:00 committed by GitHub
parent 8a7860cf61
commit cc09a96efe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 167 additions and 33 deletions

View file

@ -144,6 +144,10 @@ describe('EditOutputFlyout', () => {
id: 'output123',
is_default: false,
is_default_monitoring: false,
ssl: {
certificate: 'ssl-cert-value',
key: 'ssl-key-value',
},
});
expect(utils.queryByTestId('advancedSSLOptionsButton')).not.toBeNull();
fireEvent.click(utils.getByTestId('advancedSSLOptionsButton'));

View file

@ -6,7 +6,7 @@
*/
import React, { useEffect } from 'react';
import { EuiSpacer, EuiLink } from '@elastic/eui';
import { EuiSpacer, EuiLink, EuiSwitch, EuiCallOut } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
@ -76,7 +76,37 @@ export const OutputFormLogstashSection: React.FunctionComponent<Props> = (props)
</>
)}
<EuiSpacer size="m" />
<LogstashInstructions />
<EuiSwitch
label={i18n.translate('xpack.fleet.settings.editOutputFlyout.logstashSSLSwitchLabel', {
defaultMessage: 'Enable SSL',
})}
{...inputs.logstashEnableSSLInput.props}
/>
{!inputs.logstashEnableSSLInput.value && (
<>
<EuiSpacer size="m" />
<EuiCallOut
title={i18n.translate(
'xpack.fleet.settings.editOutputFlyout.logstashSSLSwitchCalloutTitle',
{ defaultMessage: 'Proceed with caution!' }
)}
color="warning"
iconType="warning"
>
<p>
{i18n.translate(
'xpack.fleet.settings.editOutputFlyout.logstashSSLSwitchCalloutMessage',
{
defaultMessage:
'Using SSL/TLS ensures that your Elastic Agents send encrypted data to trusted Logstash servers, and that your Logstash servers receive data from trusted Elastic Agent clients.',
}
)}
</p>
</EuiCallOut>
</>
)}
<EuiSpacer size="m" />
<LogstashInstructions isSSLEnabled={inputs.logstashEnableSSLInput.value} />
<EuiSpacer size="m" />
<MultiRowInput
placeholder={i18n.translate(
@ -108,13 +138,15 @@ export const OutputFormLogstashSection: React.FunctionComponent<Props> = (props)
{...inputs.logstashHostsInput.props}
/>
<EuiSpacer size="m" />
<SSLFormSection
inputs={inputs}
useSecretsStorage={useSecretsStorage}
isConvertedToSecret={isConvertedToSecret.sslKey}
onToggleSecretAndClearValue={onToggleSecretAndClearValue}
type={inputs.typeInput.value as FormType}
/>
{inputs.logstashEnableSSLInput.value && (
<SSLFormSection
inputs={inputs}
useSecretsStorage={useSecretsStorage}
isConvertedToSecret={isConvertedToSecret.sslKey}
onToggleSecretAndClearValue={onToggleSecretAndClearValue}
type={inputs.typeInput.value as FormType}
/>
)}
</>
);
};

View file

@ -92,6 +92,7 @@ export interface OutputFormInputsType {
diskQueueEncryptionEnabled: ReturnType<typeof useSwitchInput>;
diskQueueCompressionEnabled: ReturnType<typeof useSwitchInput>;
compressionLevelInput: ReturnType<typeof useSelectInput>;
logstashEnableSSLInput: ReturnType<typeof useSwitchInput>;
logstashHostsInput: ReturnType<typeof useComboInput>;
presetInput: ReturnType<typeof useInput>;
additionalYamlConfigInput: ReturnType<typeof useInput>;
@ -362,6 +363,11 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOupu
const isSSLEditable = isDisabled('ssl');
// Logstash inputs
const logstashEnableSSLInput = useSwitchInput(
output?.type === 'logstash' ? Boolean(output?.ssl) : true,
isSSLEditable
);
const logstashHostsInput = useComboInput(
'logstashHostsComboxBox',
output?.hosts ?? [],
@ -376,18 +382,20 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOupu
);
const sslCertificateInput = useInput(
output?.ssl?.certificate ?? '',
output?.type === 'logstash' ? validateSSLCertificate : undefined,
output?.type === 'logstash' && logstashEnableSSLInput.value
? validateSSLCertificate
: undefined,
isSSLEditable
);
const sslKeyInput = useInput(
output?.ssl?.key ?? '',
output?.type === 'logstash' ? validateSSLKey : undefined,
output?.type === 'logstash' && logstashEnableSSLInput.value ? validateSSLKey : undefined,
isSSLEditable
);
const sslKeySecretInput = useSecretInput(
(output as NewLogstashOutput)?.secrets?.ssl?.key,
output?.type === 'logstash' ? validateSSLKeySecret : undefined,
output?.type === 'logstash' && logstashEnableSSLInput.value ? validateSSLKeySecret : undefined,
isSSLEditable
);
@ -582,6 +590,7 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOupu
diskQueueMaxSizeInput,
diskQueueCompressionEnabled,
compressionLevelInput,
logstashEnableSSLInput,
logstashHostsInput,
presetInput,
additionalYamlConfigInput,
@ -680,7 +689,7 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOupu
additionalYamlConfigValid &&
nameInputValid &&
sslCertificateValid &&
((sslKeyInput.value && sslKeyValid) || (sslKeySecretInput.value && sslKeySecretValid))
(sslKeyValid || sslKeySecretValid)
);
}
if (isKafka) {
@ -943,19 +952,23 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOupu
is_default: defaultOutputInput.value,
is_default_monitoring: defaultMonitoringOutputInput.value,
config_yaml: additionalYamlConfigInput.value,
ssl: {
certificate: sslCertificateInput.value,
key: sslKeyInput.value || undefined,
certificate_authorities: sslCertificateAuthoritiesInput.value.filter(
(val) => val !== ''
),
},
ssl: logstashEnableSSLInput.value
? {
certificate: sslCertificateInput.value,
key: sslKeyInput.value || undefined,
certificate_authorities: sslCertificateAuthoritiesInput.value.filter(
(val) => val !== ''
),
}
: null,
...(!sslKeyInput.value &&
sslKeySecretInput.value && {
secrets: {
ssl: {
key: sslKeySecretInput.value,
},
ssl: logstashEnableSSLInput.value
? {
key: sslKeySecretInput.value,
}
: undefined,
},
}),
proxy_id: proxyIdValue,
@ -1116,6 +1129,7 @@ export function useOutputForm(onSucess: () => void, output?: Output, defaultOupu
kafkaBrokerTimeoutInput.value,
kafkaBrokerReachabilityTimeoutInput.value,
kafkaBrokerAckReliabilityInput.value,
logstashEnableSSLInput.value,
logstashHostsInput.value,
sslCertificateInput.value,
sslKeyInput.value,

View file

@ -9,15 +9,17 @@ export const LOGSTASH_CONFIG_PIPELINES = `- pipeline.id: elastic-agent-pipeline
path.config: "/etc/path/to/elastic-agent-pipeline.conf"
`;
export function getLogstashPipeline(apiKey?: string) {
return `input {
elastic_agent {
port => 5044
const inputSSLConfig = `
ssl_enabled => true
ssl_certificate_authorities => ["<ca_path>"]
ssl_certificate => "<server_cert_path>"
ssl_key => "<server_cert_key_in_pkcs8>"
ssl_client_authentication => "required"
ssl_client_authentication => "required"`;
export function getLogstashPipeline(isSSLEnabled: boolean, apiKey?: string) {
return `input {
elastic_agent {
port => 5044 ${isSSLEnabled ? inputSSLConfig : ''}
}
}

View file

@ -27,7 +27,11 @@ import { MissingPrivilegesToolTip } from '../../../../../../components/missing_p
import { getLogstashPipeline, LOGSTASH_CONFIG_PIPELINES } from './helpers';
import { useLogstashApiKey } from './hooks';
export const LogstashInstructions = () => {
interface LogstashInstructionsProps {
isSSLEnabled: boolean;
}
export const LogstashInstructions = ({ isSSLEnabled }: LogstashInstructionsProps) => {
const { docLinks } = useStartServices();
return (
@ -57,7 +61,7 @@ export const LogstashInstructions = () => {
}}
/>
<EuiSpacer size="m" />
<LogstashInstructionSteps />
<LogstashInstructionSteps isSSLEnabled={isSSLEnabled} />
</>
</CollapsibleCallout>
);
@ -100,7 +104,10 @@ const CollapsibleCallout: React.FunctionComponent<EuiCallOutProps> = ({ children
);
};
const LogstashInstructionSteps = () => {
interface LogstashInstructionStepsProps {
isSSLEnabled: boolean;
}
const LogstashInstructionSteps = ({ isSSLEnabled }: LogstashInstructionStepsProps) => {
const { docLinks } = useStartServices();
const logstashApiKey = useLogstashApiKey();
const authz = useAuthz();
@ -170,7 +177,7 @@ const LogstashInstructionSteps = () => {
/>
<EuiSpacer size="m" />
<EuiCodeBlock paddingSize="m" language="yaml" isCopyable>
{getLogstashPipeline(logstashApiKey.apiKey)}
{getLogstashPipeline(isSSLEnabled, logstashApiKey.apiKey)}
</EuiCodeBlock>
</>
),
@ -225,7 +232,7 @@ const LogstashInstructionSteps = () => {
),
},
],
[logstashApiKey, docLinks, hasAllSettings]
[logstashApiKey, docLinks, hasAllSettings, isSSLEnabled]
);
return (

View file

@ -136,6 +136,24 @@ function getMockedSoClient(
});
}
case outputIdToUuid('existing-logstash-output-with-ssl'): {
return mockOutputSO('existing-logstash-output-with-ssl', {
type: 'logstash',
is_default: false,
ssl: {
certificate: 'cert-value',
certificate_authorities: ['/path/to/CAs'],
},
secrets: {
ssl: {
key: {
id: 'wnES3pUBqsj3cVixODPG',
},
},
},
});
}
case outputIdToUuid('existing-kafka-output'): {
return mockOutputSO('existing-kafka-output', {
type: 'kafka',
@ -2317,6 +2335,30 @@ describe('Output Service', () => {
'Remote_elasticsearch output cannot be used with agentless integration in agentless policy. Please create a new Elasticsearch output.'
);
});
it('Should delete SSL fields if SSL field is null', async () => {
const soClient = getMockedSoClient({});
mockedAgentPolicyService.list.mockResolvedValue({
items: [{}],
} as unknown as ReturnType<typeof mockedAgentPolicyService.list>);
mockedAgentPolicyService.hasAPMIntegration.mockReturnValue(false);
mockedAgentPolicyService.hasFleetServerIntegration.mockReturnValue(false);
mockedAgentPolicyService.list.mockResolvedValue({
items: [],
} as any);
await outputService.update(soClient, esClientMock, 'existing-logstash-output-with-ssl', {
type: 'logstash',
hosts: ['0.0.0.0'],
ssl: null,
});
expect(soClient.update).toBeCalledWith(expect.anything(), expect.anything(), {
type: 'logstash',
hosts: ['0.0.0.0'],
ssl: null,
});
});
});
describe('delete', () => {

View file

@ -1689,6 +1689,39 @@ describe('secrets', () => {
expect(result.secretsToDelete).toEqual([{ id: 'token' }]);
});
it('should delete secret if secret is undefined in update', async () => {
const result = await extractAndUpdateOutputSecrets({
oldOutput: {
id: 'logstash-id',
name: 'logstash',
type: 'logstash',
is_default: false,
is_default_monitoring: false,
secrets: {
ssl: {
key: {
id: 'ssl-key-token',
},
},
},
},
outputUpdate: {
id: 'logstash-id',
name: 'logstash',
type: 'logstash',
secrets: {
ssl: undefined,
},
is_default: false,
is_default_monitoring: false,
proxy_id: null,
},
esClient: esClientMock,
});
expect(result.secretsToDelete).toEqual([{ id: 'ssl-key-token' }]);
});
});
describe('deleteOutputSecrets', () => {
it('should delete existing secrets', async () => {