diff --git a/package-lock.json b/package-lock.json index 3e9107859..07820ac18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7961,6 +7961,11 @@ "integrity": "sha512-v7qlPA0VpKUlEdhghbDqRoKMxFB3h3Ch688TApBJ6v+XLDdvWCGLJIYiPKGZnS6MAOie+IorCfNYVHOPIHSWwQ==", "dev": true }, + "node_modules/@types/jsrsasign": { + "version": "10.5.12", + "resolved": "https://registry.npmjs.org/@types/jsrsasign/-/jsrsasign-10.5.12.tgz", + "integrity": "sha512-sOA+eVnHU+FziThpMhuqs/tjFKe5gHVJKIS7g1BzhXP+e2FS8OvtzM0K3IzFxVksDOr98Gz5FJiZVxZ9uFoHhw==" + }, "node_modules/@types/lodash": { "version": "4.14.195", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz", @@ -19481,10 +19486,9 @@ } }, "node_modules/jsrsasign": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.0.0.tgz", - "integrity": "sha512-BtRwVKS+5dsgPpAtzJcpo5OoWjSs1/zllSBG0+8o8/aV0Ki76m6iZwHnwnsqoTdhfFZDN1XIdcaZr5ZkP+H2gg==", - "peer": true, + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.1.0.tgz", + "integrity": "sha512-Ov74K9GihaK9/9WncTe1mPmvrO7Py665TUfUKvraXBpu+xcTWitrtuOwcjf4KMU9maPaYn0OuaWy0HOzy/GBXg==", "funding": { "url": "https://github.com/kjur/jsrsasign#donations" } @@ -31017,8 +31021,10 @@ "@kubev2v/common": "*", "@kubev2v/legacy": "*", "@migtools/lib-ui": "8.4.1", + "@types/jsrsasign": "10.5.12", "immer": "^10.0.3", "jsonpath": "^1.1.1", + "jsrsasign": "11.1.0", "luxon": "^3.3.0", "msw": "^1.2.3", "streamsaver": "^2.0.6", diff --git a/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json b/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json index d6ef0a28b..80be85a59 100644 --- a/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json +++ b/packages/forklift-console-plugin/locales/en/plugin__forklift-console-plugin.json @@ -51,7 +51,9 @@ "Cannot delete migration plan": "Cannot delete migration plan", "Cannot delete network mapping": "Cannot delete network mapping", "Cannot delete storage mapping": "Cannot delete storage mapping", + "Cannot retrieve certificate": "Cannot retrieve certificate", "Category": "Category", + "Certificate change detected": "Certificate change detected", "Clear all filters": "Clear all filters", "Click the update credentials button to save your changes, button is disabled until a change is detected.": "Click the update credentials button to save your changes, button is disabled until a change is detected.", "Close": "Close", @@ -60,6 +62,7 @@ "Concern": "Concern", "Concerns": "Concerns", "Conditions": "Conditions", + "Confirm": "Confirm", "Connection Failed": "Connection Failed", "Controller CPU limit": "Controller CPU limit", "Controller Memory limit": "Controller Memory limit", @@ -100,6 +103,7 @@ "Determines the frequency with which the system checks the status of snapshot creation or removal during oVirt warm migration. The default value is 10 seconds.": "Determines the frequency with which the system checks the status of snapshot creation or removal during oVirt warm migration. The default value is 10 seconds.", "Domain": "Domain", "Domain name": "Domain name", + "Drag and drop a file or upload one": "Drag and drop a file or upload one", "Duplicate": "Duplicate", "Edit": "Edit", "Edit Controller CPU limit": "Edit Controller CPU limit", @@ -130,9 +134,11 @@ "Error: The format of the provided CA certificate is invalid. Ensure the CA certificate format is valid.": "Error: The format of the provided CA certificate is invalid. Ensure the CA certificate format is valid.", "Error: this field must be set to a boolean value.": "Error: this field must be set to a boolean value.", "ESXi": "ESXi", + "Expiration date": "Expiration date", "Failed": "Failed", "False": "False", "Features": "Features", + "Fetch certificate from URL": "Fetch certificate from URL", "Filter by cluster": "Filter by cluster", "Filter by endpoint": "Filter by endpoint", "Filter by features": "Filter by features", @@ -159,6 +165,7 @@ "Host cluster": "Host cluster", "Hosts": "Hosts", "How to create a migration plan": "How to create a migration plan", + "I trust the authenticity of this certificate": "I trust the authenticity of this certificate", "If true, the provider's CA certificate won't be validated.": "If true, the provider's CA certificate won't be validated.", "If true, the provider's TLS certificate won't be validated.": "If true, the provider's TLS certificate won't be validated.", "Image": "Image", @@ -168,8 +175,10 @@ "Invalid username": "Invalid username", "Inventory": "Inventory", "Inventory server is not reachable. To troubleshoot, check the Forklift controller pod logs.": "Inventory server is not reachable. To troubleshoot, check the Forklift controller pod logs.", + "Issuer": "Issuer", "List of objects depended by this object. If ALL objects in the list have been deleted,\n this object will be garbage collected. If this object is managed by a controller,\n then an entry in this list will point to this controller, with the controller field set to true.\n There cannot be more than one managing controller.": "List of objects depended by this object. If ALL objects in the list have been deleted,\n this object will be garbage collected. If this object is managed by a controller,\n then an entry in this list will point to this controller, with the controller field set to true.\n There cannot be more than one managing controller.", "Loading": "Loading", + "Loading...": "Loading...", "Manage Columns": "Manage Columns", "managed": "managed", "Managed": "Managed", @@ -336,6 +345,7 @@ "Sets the maximum number of VMs that can be migrated simultaneously. The default value is 20 virtual machines.": "Sets the maximum number of VMs that can be migrated simultaneously. The default value is 20 virtual machines.", "Sets the memory limits allocated to the main container in the controller pod. The default value is 800Mi.": "Sets the memory limits allocated to the main container in the controller pod. The default value is 800Mi.", "Settings": "Settings", + "SHA-1 fingerprint": "SHA-1 fingerprint", "Show archived": "Show archived", "Show managed": "Show managed", "Show the welcome card": "Show the welcome card", @@ -366,7 +376,9 @@ "Target provider": "Target provider", "Template": "Template", "Tenant": "Tenant", + "The certificate is not a valid PEM-encoded X.509 certificate": "The certificate is not a valid PEM-encoded X.509 certificate", "The chosen provider is no longer available.": "The chosen provider is no longer available.", + "The current certificate does not match the certificate fetched from URL. Manually validate the fingerprint before proceeding.": "The current certificate does not match the certificate fetched from URL. Manually validate the fingerprint before proceeding.", "The interval in minutes for precopy. Default value is 60.": "The interval in minutes for precopy. Default value is 60.", "The interval in seconds for snapshot pooling. Default value is 10.": "The interval in seconds for snapshot pooling. Default value is 10.", "The limit for CPU usage by the controller, specified in milliCPU. Default value is 500m.": "The limit for CPU usage by the controller, specified in milliCPU. Default value is 500m.", @@ -405,6 +417,7 @@ "Unsupported provider type": "Unsupported provider type", "Update credentials": "Update credentials", "Updated": "Updated", + "Upload": "Upload", "URL": "URL", "URL of the API endpoint of the Red Hat Virtualization Manager (RHVM) on which the source VM is mounted. Ensure that the URL includes the path leading to the RHVM API server, usually /ovirt-engine/api. For example, https://rhv-host-example.com/ovirt-engine/api.": "URL of the API endpoint of the Red Hat Virtualization Manager (RHVM) on which the source VM is mounted. Ensure that the URL includes the path leading to the RHVM API server, usually /ovirt-engine/api. For example, https://rhv-host-example.com/ovirt-engine/api.", "URL of the NFS file share that serves the OVA., for example, 10.10.0.10:/ova": "URL of the NFS file share that serves the OVA., for example, 10.10.0.10:/ova", @@ -416,6 +429,7 @@ "Validation Failed": "Validation Failed", "vCenter": "vCenter", "VDDK init image": "VDDK init image", + "Verify certificate": "Verify certificate", "View details": "View details", "View provider details": "View provider details", "Virtual Machine Migrations": "Virtual Machine Migrations", diff --git a/packages/forklift-console-plugin/package.json b/packages/forklift-console-plugin/package.json index 516a98287..d0c73cea9 100644 --- a/packages/forklift-console-plugin/package.json +++ b/packages/forklift-console-plugin/package.json @@ -26,8 +26,10 @@ "@kubev2v/common": "*", "@kubev2v/legacy": "*", "@migtools/lib-ui": "8.4.1", + "@types/jsrsasign": "10.5.12", "immer": "^10.0.3", "jsonpath": "^1.1.1", + "jsrsasign": "11.1.0", "luxon": "^3.3.0", "msw": "^1.2.3", "streamsaver": "^2.0.6", diff --git a/packages/forklift-console-plugin/src/modules/Providers/hooks/__tests__/useTlsCertifcate.test.ts b/packages/forklift-console-plugin/src/modules/Providers/hooks/__tests__/useTlsCertifcate.test.ts new file mode 100644 index 000000000..dfede0ec1 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Providers/hooks/__tests__/useTlsCertifcate.test.ts @@ -0,0 +1,19 @@ +import { toColonSeparatedHex } from '../useTlsCertificate'; + +describe('valid hex to colon separated', () => { + it('works for empty string', () => { + expect(toColonSeparatedHex('')).toBe(''); + }); + it('works for single letter', () => { + expect(toColonSeparatedHex('a')).toBe('A'); + }); + it('works for n=2', () => { + expect(toColonSeparatedHex('ab')).toBe('AB'); + }); + it('works for n=3', () => { + expect(toColonSeparatedHex('abc')).toBe('AB:C'); + }); + it('works for n=4', () => { + expect(toColonSeparatedHex('abcd')).toBe('AB:CD'); + }); +}); diff --git a/packages/forklift-console-plugin/src/modules/Providers/hooks/index.ts b/packages/forklift-console-plugin/src/modules/Providers/hooks/index.ts index d1c9a24fc..3529e9773 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/hooks/index.ts +++ b/packages/forklift-console-plugin/src/modules/Providers/hooks/index.ts @@ -5,6 +5,7 @@ export * from './useK8sWatchSecretData'; export * from './useProviderInventory'; export * from './useProvidersInventoryList'; export * from './useProviderType'; +export * from './useTlsCertificate'; export * from './useToggle'; export * from './utils'; // @endindex diff --git a/packages/forklift-console-plugin/src/modules/Providers/hooks/useTlsCertificate.ts b/packages/forklift-console-plugin/src/modules/Providers/hooks/useTlsCertificate.ts new file mode 100644 index 000000000..db7bba587 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Providers/hooks/useTlsCertificate.ts @@ -0,0 +1,85 @@ +import { useEffect, useState } from 'react'; +import { KJUR, pemtohex, X509, zulutodate } from 'jsrsasign'; + +import { consoleFetch } from '@openshift-console/dynamic-plugin-sdk'; + +import { getServicesApiUrl } from '../utils'; + +/** + * @param value PEM encoded certificate + * @returns parsed certificate or undefined if parsing failed + */ +const parseToX509 = (value: string) => { + if (!value) return undefined; + try { + const cert = new X509(); + cert.readCertPEM(value); + return cert; + } catch (e) { + return undefined; + } +}; + +export const toColonSeparatedHex = (hexString: string) => + Array.from(hexString.toUpperCase()) + // [a,b,c,d] => [[c,d][a,b]] + .reduce( + ([last = [], ...rest]: string[][], char: string) => + last.length != 2 ? [[...last, char], ...rest] : [[char], last, ...rest], + [], + ) + // [[c,d][a,b]] => [[a,b], [c,d]] + .reverse() + .map((tuples) => tuples.join('')) + .join(':'); + +/** + * @param pemEncodedCert valid PEM encoded certificate + * @returns SHA1 thumbprint + */ +export const calculateThumbprint = (pemEncodedCert: string) => + toColonSeparatedHex(KJUR.crypto.Util.hashHex(pemtohex(pemEncodedCert), 'sha1')); + +/** + * @param url URL param for the tls-certificate endpoint + * @returns certificate and props calculated/parsed based on the certificate + */ +export const useTlsCertificate = (url: string) => { + const [certificate, setCertificate] = useState(''); + const [fetchError, setFetchError] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + consoleFetch(getServicesApiUrl(`tls-certificate?URL=${url}`), { + method: 'GET', + }) + .then((response: Response) => response.text()) + .then((certificate) => setCertificate(certificate)) + .catch((e) => setFetchError(e)) + .then(() => setLoading(false)); + }, [url]); + + const x509Cert: X509 = parseToX509(certificate); + const certError = !x509Cert && !loading && !fetchError; + const { + thumbprint = '', + issuer = '', + validTo = undefined, + } = x509Cert + ? { + thumbprint: calculateThumbprint(certificate), + issuer: KJUR.asn1.x509.X500Name.onelineToLDAP(x509Cert.getIssuerString()), + validTo: zulutodate(x509Cert.getNotAfter()), + } + : {}; + + return { + loading, + fetchError, + certError, + thumbprint, + issuer, + validTo, + certificate, + }; +}; diff --git a/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/CertificateUpload.style.css b/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/CertificateUpload.style.css new file mode 100644 index 000000000..3d8496df6 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/CertificateUpload.style.css @@ -0,0 +1,3 @@ +.forklift-certificate-upload-margin { + margin-top: 0.5rem; +} \ No newline at end of file diff --git a/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/CertificateUpload.tsx b/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/CertificateUpload.tsx new file mode 100644 index 000000000..ee8639bfa --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/CertificateUpload.tsx @@ -0,0 +1,76 @@ +import React, { FC } from 'react'; +import { useModal } from 'src/modules/Providers/modals'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Button, FileUpload, FileUploadProps, Flex, FlexItem } from '@patternfly/react-core'; + +import { FetchCertificateModal } from './FetchCertificateModal'; + +import './CertificateUpload.style.css'; + +export interface CertificateUploadProps extends FileUploadProps { + url?: string; +} + +/** + * Provide the certificate using following paths: + * 1. manual copy-paste (provided by FileUpload widget) + * 2. uploading a file (provided by FileUpload widget) + * 3. fetch from the specified URL (via tls-certificate endpoint) end verify + */ +export const CertificateUpload: FC = ({ + id, + url, + value, + filenamePlaceholder, + browseButtonText, + validated, + onDataChange, + onTextChange, + onClearClick, + isDisabled, + type, +}) => { + const { showModal } = useModal(); + const { t } = useForkliftTranslation(); + const isText = !type || type === 'text'; + return ( + <> + + {url && isText && ( + + + + + + )} + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/FetchCertificateModal.tsx b/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/FetchCertificateModal.tsx new file mode 100644 index 000000000..eb88f7787 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/FetchCertificateModal.tsx @@ -0,0 +1,70 @@ +import React, { FC, useState } from 'react'; +import { calculateThumbprint, useTlsCertificate } from 'src/modules/Providers/hooks'; +import { useModal } from 'src/modules/Providers/modals'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { Loading } from '@kubev2v/common'; +import { Alert, Button, Modal, ModalVariant } from '@patternfly/react-core'; + +import { VerifyCertificate } from './VerifyCertificate'; + +export const FetchCertificateModal: FC<{ + url: string; + existingCert: string; + handleSave: (cert: string) => void; +}> = ({ existingCert, url, handleSave }) => { + const { toggleModal } = useModal(); + const { t } = useForkliftTranslation(); + const [isTrusted, setIsTrusted] = useState(false); + const { loading, fetchError, certError, thumbprint, issuer, validTo, certificate } = + useTlsCertificate(url); + const success = !loading && !fetchError && !certError; + const hasThumbprintChanged = + existingCert && success && thumbprint !== calculateThumbprint(existingCert); + return ( + { + handleSave(certificate); + toggleModal(); + }} + isDisabled={!success || !isTrusted} + > + {t('Confirm')} + , + , + ]} + > + {loading && } + + {fetchError && ( + + {t('Cannot retrieve certificate')} + + )} + + {certError && ( + + {t('The certificate is not a valid PEM-encoded X.509 certificate')} + + )} + + {success && ( + + )} + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/VerifyCertificate.tsx b/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/VerifyCertificate.tsx new file mode 100644 index 000000000..d6a9e3453 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/VerifyCertificate.tsx @@ -0,0 +1,69 @@ +import React, { FC } from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import { + Alert, + Checkbox, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, +} from '@patternfly/react-core'; + +/** + * Verify the certificate. Logic and UI based on the component used in the standalone MTV UI. + * @link https://github.com/kubev2v/forklift-ui/blob/c347020d3162b891636c3109e426343911b6c498/pkg/web/src/app/Providers/components/AddEditProviderModal/AddEditProviderModal.tsx#L399 + */ +export const VerifyCertificate: FC<{ + thumbprint: string; + issuer: string; + validTo: Date; + hasThumbprintChanged: boolean; + isTrusted: boolean; + setIsTrusted: (flag: boolean) => void; +}> = ({ thumbprint, issuer, validTo, isTrusted, setIsTrusted, hasThumbprintChanged }) => { + const { t } = useForkliftTranslation(); + + return ( + <> + {hasThumbprintChanged && ( + + {t( + 'The current certificate does not match the certificate fetched from URL. Manually validate the fingerprint before proceeding.', + )} + + )} + + + + + {t('Issuer')} + {issuer} + + + {t('SHA-1 fingerprint')} + {thumbprint} + + + {t('Expiration date')} + + {validTo?.toUTCString() ?? ''} + + + + + + + + + + ); +}; diff --git a/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/index.ts b/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/index.ts new file mode 100644 index 000000000..dd04d7198 --- /dev/null +++ b/packages/forklift-console-plugin/src/modules/Providers/utils/components/CertificateUpload/index.ts @@ -0,0 +1,4 @@ +// @index(['./*', /style/g], f => `export * from '${f.path}';`) +export * from './CertificateUpload'; +export * from './FetchCertificateModal'; +// @endindex diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/create/components/EditProvider.tsx b/packages/forklift-console-plugin/src/modules/Providers/views/create/components/EditProvider.tsx index 09192df99..96f3bd33c 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/create/components/EditProvider.tsx +++ b/packages/forklift-console-plugin/src/modules/Providers/views/create/components/EditProvider.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { ModalHOC } from 'src/modules/Providers/modals'; import { useForkliftTranslation } from 'src/utils/i18n'; import { @@ -54,12 +55,12 @@ export const EditProvider: React.FC = ({ ); case 'vsphere': return ( - <> + - + ); case 'ova': return ( diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/details/components/CredentialsSection/VSphereCredentialsSection.tsx b/packages/forklift-console-plugin/src/modules/Providers/views/details/components/CredentialsSection/VSphereCredentialsSection.tsx index 52055f390..7afa64d5e 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/details/components/CredentialsSection/VSphereCredentialsSection.tsx +++ b/packages/forklift-console-plugin/src/modules/Providers/views/details/components/CredentialsSection/VSphereCredentialsSection.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { ModalHOC } from 'src/modules/Providers/modals'; import { vsphereSecretValidator } from 'src/modules/Providers/utils'; import { @@ -14,10 +15,12 @@ export type VSphereCredentialsSectionProps = Omit< >; export const VSphereCredentialsSection: React.FC = (props) => ( - + + + ); diff --git a/packages/forklift-console-plugin/src/modules/Providers/views/details/components/CredentialsSection/components/edit/VSphereCredentialsEdit.tsx b/packages/forklift-console-plugin/src/modules/Providers/views/details/components/CredentialsSection/components/edit/VSphereCredentialsEdit.tsx index f3f39ba70..db0445042 100644 --- a/packages/forklift-console-plugin/src/modules/Providers/views/details/components/CredentialsSection/components/edit/VSphereCredentialsEdit.tsx +++ b/packages/forklift-console-plugin/src/modules/Providers/views/details/components/CredentialsSection/components/edit/VSphereCredentialsEdit.tsx @@ -1,12 +1,12 @@ import React, { useCallback, useReducer } from 'react'; import { Base64 } from 'js-base64'; import { safeBase64Decode, vsphereSecretFieldValidator } from 'src/modules/Providers/utils'; +import { CertificateUpload } from 'src/modules/Providers/utils/components/CertificateUpload'; import { ForkliftTrans, useForkliftTranslation } from 'src/utils/i18n'; import { Button, Divider, - FileUpload, Form, FormGroup, Popover, @@ -23,6 +23,7 @@ export const VSphereCredentialsEdit: React.FC = ({ secret, o const { t } = useForkliftTranslation(); const user = safeBase64Decode(secret?.data?.user || ''); + const url = safeBase64Decode(secret?.data?.url || ''); const password = safeBase64Decode(secret?.data?.password || ''); const cacert = safeBase64Decode(secret?.data?.cacert || ''); const insecureSkipVerify = safeBase64Decode(secret?.data?.insecureSkipVerify || '') === 'true'; @@ -210,16 +211,14 @@ export const VSphereCredentialsEdit: React.FC = ({ secret, o helperTextInvalid={state.validation.cacert.msg} validated={state.validation.cacert.type} > - handleChange('cacert', value)} onTextChange={(value) => handleChange('cacert', value)} onClearClick={() => handleChange('cacert', '')} - browseButtonText="Upload" isDisabled={insecureSkipVerify} />