Skip to content
Open
120 changes: 117 additions & 3 deletions app/components/form/fields/TlsCertsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,37 @@
*
* Copyright Oxide Computer Company
*/
import { SubjectAlternativeNameExtension, X509Certificate } from '@peculiar/x509'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useController, useForm, type Control } from 'react-hook-form'
import type { Merge } from 'type-fest'

import type { CertificateCreate } from '@oxide/api'
import { OpenLink12Icon } from '@oxide/design-system/icons/react'

import type { SiloCreateFormValues } from '~/forms/silo-create'
import { Button } from '~/ui/lib/Button'
import { FieldLabel } from '~/ui/lib/FieldLabel'
import { Message } from '~/ui/lib/Message'
import * as MiniTable from '~/ui/lib/MiniTable'
import { Modal } from '~/ui/lib/Modal'
import { links } from '~/util/links'

import { DescriptionField } from './DescriptionField'
import { FileField } from './FileField'
import { validateName } from './NameField'
import { TextField } from './TextField'

export function TlsCertsField({ control }: { control: Control<SiloCreateFormValues> }) {
// default export is most convenient for dynamic import
// eslint-disable-next-line import/no-default-export
export default function TlsCertsField({
control,
siloName,
}: {
control: Control<SiloCreateFormValues>
siloName: string
}) {
const [showAddCert, setShowAddCert] = useState(false)

const {
Expand Down Expand Up @@ -80,6 +93,7 @@ export function TlsCertsField({ control }: { control: Control<SiloCreateFormValu
setShowAddCert(false)
}}
allNames={items.map((item) => item.name)}
siloName={siloName}
/>
)}
</>
Expand All @@ -103,10 +117,18 @@ type AddCertModalProps = {
onDismiss: () => void
onSubmit: (values: CertFormValues) => void
allNames: string[]
siloName: string
}

const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => {
const { control, handleSubmit } = useForm<CertFormValues>({ defaultValues })
const AddCertModal = ({ onDismiss, onSubmit, allNames, siloName }: AddCertModalProps) => {
const { watch, control, handleSubmit } = useForm<CertFormValues>({ defaultValues })

const file = watch('cert')

const { data: certValidation } = useQuery({
queryKey: ['validateImage', ...(file ? [file.name, file.size, file.lastModified] : [])],
queryFn: file ? () => validateCertificate(file) : skipToken,
})
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@charliepark pointed out we might want to set the stale time directly here. I'm going to experiment with that and also maybe tweaking the query key.


return (
<Modal isOpen onDismiss={onDismiss} title="Add TLS certificate">
Expand Down Expand Up @@ -135,6 +157,13 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => {
required
control={control}
/>
{siloName && (
<CertDomainNotice
{...certValidation}
siloName={siloName}
domain="r2.oxide-preview.com"
/>
)}
<FileField id="key-input" name="key" label="Key" required control={control} />
</Modal.Section>
</form>
Expand All @@ -147,3 +176,88 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => {
</Modal>
)
}

const validateCertificate = async (file: File) => {
return parseCertificate(await file.text())
}

function parseCertificate(certPem: string) {
try {
const cert = new X509Certificate(certPem)
const nameItems = cert.getExtension(SubjectAlternativeNameExtension)?.names.items || []
return {
commonName: cert.subjectName.getField('CN') || [],
subjectAltNames: nameItems.map((item) => item.value) || [],
}
} catch {
return null
}
}

function matchesDomain(pattern: string, domain: string): boolean {
const patternParts = pattern.split('.')
const domainParts = domain.split('.')

if (patternParts.length !== domainParts.length) return false

return patternParts.every(
(part, i) => part === '*' || part.toLowerCase() === domainParts[i].toLowerCase()
Comment thread
benjaminleonard marked this conversation as resolved.
Outdated
)
}

function CertDomainNotice({
commonName = [],
subjectAltNames = [],
siloName,
domain,
}: {
commonName?: string[]
subjectAltNames?: string[]
siloName: string
domain: string
}) {
if (commonName.length === 0 && subjectAltNames.length === 0) {
return null
}

const expectedDomain = `${siloName}.sys.${domain}`
const domains = [...commonName, ...subjectAltNames]

const matches = domains.some(
(d) => matchesDomain(d, expectedDomain) || matchesDomain(d, `*.sys.${domain}`)
)

if (matches) return null

return (
<Message
variant="info"
title="Certificate domain mismatch"
content={
<div className="flex flex-col space-y-2">
Expected to match {expectedDomain} <br />
<div>
Found:
<ul className="ml-4 list-disc">
{domains.map((domain, index) => (
<li key={index}>{domain}</li>
))}
</ul>
</div>
<div>
Learn more about{' '}
<a
target="_blank"
rel="noreferrer"
href={links.systemSiloDocs} // would need updating
className="inline-flex items-center underline"
>
silo certs
<OpenLink12Icon className="ml-1" />
</a>
</div>
</div>
}
/>
)
}
11 changes: 8 additions & 3 deletions app/forms/silo-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*
* Copyright Oxide Computer Company
*/
import { useEffect } from 'react'
import { lazy, Suspense, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useNavigate } from 'react-router-dom'

Expand All @@ -17,7 +17,6 @@ import { NameField } from '~/components/form/fields/NameField'
import { NumberField } from '~/components/form/fields/NumberField'
import { RadioField } from '~/components/form/fields/RadioField'
import { TextField } from '~/components/form/fields/TextField'
import { TlsCertsField } from '~/components/form/fields/TlsCertsField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { HL } from '~/components/HL'
import { addToast } from '~/stores/toast'
Expand All @@ -27,6 +26,9 @@ import { Message } from '~/ui/lib/Message'
import { pb } from '~/util/path-builder'
import { GiB } from '~/util/units'

// Lazy loading `TlsCertsFields` to avoid adding the cert parser to the main bundle
const TlsCertsField = lazy(() => import('~/components/form/fields/TlsCertsField'))

export type SiloCreateFormValues = Omit<SiloCreate, 'mappedFleetRoles'> & {
siloAdminGetsFleetAdmin: boolean
siloViewerGetsFleetViewer: boolean
Expand Down Expand Up @@ -65,6 +67,7 @@ export function CreateSiloSideModalForm() {

const form = useForm({ defaultValues })
const identityMode = form.watch('identityMode')
const siloName = form.watch('name')
// Clear the adminGroupName if the user selects the "local only" identity mode
useEffect(() => {
if (identityMode === 'local_only') {
Expand Down Expand Up @@ -170,7 +173,9 @@ export function CreateSiloSideModalForm() {
</div>
</div>
<FormDivider />
<TlsCertsField control={form.control} />
<Suspense fallback={null}>
<TlsCertsField control={form.control} siloName={siloName} />
</Suspense>
</SideModalForm>
)
}
Expand Down
Loading