Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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()
)
}

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="mt-2 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
Loading