diff --git a/AGENTS.md b/AGENTS.md index 92ca0e08b8..d7bec9da5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,7 @@ # Mock API work - Only implement what is necessary to exercise the UI; keep the db seeded via `mock-api/msw/db.ts`. +- Before writing a mock handler, read the Nexus code behind the endpoint (the handler in `nexus/src/external_api/http_entrypoints.rs` and the app-layer function it calls) at the pinned `OMICRON_VERSION` rather than guessing from the OpenAPI types. Optional query params often aren't defaulted the way they look: e.g., a parent selector may be required when the child is given by name and rejected when it's given by ID. Link the Nexus source in a comment where the mock mirrors a non-obvious rule. - Store API response objects in the mock tables when possible so state persists across calls. - Enforce role checks with `requireFleetViewer`/`requireFleetCollab`/`requireFleetAdmin`, and return realistic errors (e.g. downgrade guard in `systemUpdateStatus`). - All UUIDs in `mock-api/` must be valid RFC 4122 (a safety test enforces this). Use `uuidgen` to generate them—do not hand-write UUIDs. diff --git a/OMICRON_VERSION b/OMICRON_VERSION index c7ee013e62..da785ffb52 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -6d241a7d21d11cb7979122bde9a906d8d4837d09 +8e361f6be9176cd49da1f8c3dcf7ad6fba74966c diff --git a/app/api/__generated__/API_VERSION b/app/api/__generated__/API_VERSION index d5f572486a..71420bab8f 100644 --- a/app/api/__generated__/API_VERSION +++ b/app/api/__generated__/API_VERSION @@ -1 +1 @@ -2026091500.0.0 +2026101500.0.0 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index a021b1bf49..801e6d6771 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -6857,6 +6857,33 @@ export interface AuditLogListQueryParams { startTime?: Date } +export interface SiloCertificateListQueryParams { + limit?: number | null + pageToken?: string | null + silo?: NameOrId + sortBy?: NameOrIdSortMode +} + +export interface SiloCertificateCreateQueryParams { + silo: NameOrId +} + +export interface SiloCertificateViewPathParams { + certificate: NameOrId +} + +export interface SiloCertificateViewQueryParams { + silo?: NameOrId +} + +export interface SiloCertificateDeletePathParams { + certificate: NameOrId +} + +export interface SiloCertificateDeleteQueryParams { + silo?: NameOrId +} + export interface PhysicalDiskDisableAdoptionPathParams { physicalDiskAdoptionReqId: string } @@ -7738,7 +7765,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '2026091500.0.0' + apiVersion = '2026101500.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host @@ -8393,7 +8420,7 @@ export class Api { }) }, /** - * List certificates for external endpoints + * List TLS certificates in current silo */ certificateList: ( { query = {} }: { query?: CertificateListQueryParams }, @@ -8407,7 +8434,7 @@ export class Api { }) }, /** - * Create system-wide x.509 certificate + * Create TLS certificate in current silo */ certificateCreate: ( { body }: { body: CertificateCreate }, @@ -8421,7 +8448,7 @@ export class Api { }) }, /** - * Fetch certificate + * Fetch TLS certificate in current silo */ certificateView: ( { path }: { path: CertificateViewPathParams }, @@ -8434,7 +8461,7 @@ export class Api { }) }, /** - * Delete certificate + * Delete TLS certificate in current silo */ certificateDelete: ( { path }: { path: CertificateDeletePathParams }, @@ -10097,6 +10124,72 @@ export class Api { ...params, }) }, + /** + * List TLS certificates in silo + */ + siloCertificateList: ( + { query = {} }: { query?: SiloCertificateListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/certificates`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Create TLS certificate in silo + */ + siloCertificateCreate: ( + { query, body }: { query: SiloCertificateCreateQueryParams; body: CertificateCreate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/certificates`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Fetch TLS certificate in silo + */ + siloCertificateView: ( + { + path, + query = {}, + }: { path: SiloCertificateViewPathParams; query?: SiloCertificateViewQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/certificates/${path.certificate}`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Delete TLS certificate in silo + */ + siloCertificateDelete: ( + { + path, + query = {}, + }: { + path: SiloCertificateDeletePathParams + query?: SiloCertificateDeleteQueryParams + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/certificates/${path.certificate}`, + method: 'DELETE', + query, + ...params, + }) + }, /** * Enable adoption of a physical disk for general use */ diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index fc3991af55..10434beed4 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -6d241a7d21d11cb7979122bde9a906d8d4837d09 +8e361f6be9176cd49da1f8c3dcf7ad6fba74966c diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 4e62687608..947379b5ff 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -1004,6 +1004,33 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/system/certificates` */ + siloCertificateList: (params: { + query: Api.SiloCertificateListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/certificates` */ + siloCertificateCreate: (params: { + query: Api.SiloCertificateCreateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/system/certificates/:certificate` */ + siloCertificateView: (params: { + path: Api.SiloCertificateViewPathParams + query: Api.SiloCertificateViewQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/system/certificates/:certificate` */ + siloCertificateDelete: (params: { + path: Api.SiloCertificateDeletePathParams + query: Api.SiloCertificateDeleteQueryParams + req: Request + cookies: Record + }) => Promisable /** `PUT /v1/system/hardware/disk-adoption-request` */ physicalDiskEnableAdoption: (params: { body: Json @@ -2979,6 +3006,26 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/audit-log', handler(handlers['auditLogList'], schema.AuditLogListParams, null) ), + http.get( + '/v1/system/certificates', + handler(handlers['siloCertificateList'], schema.SiloCertificateListParams, null) + ), + http.post( + '/v1/system/certificates', + handler( + handlers['siloCertificateCreate'], + schema.SiloCertificateCreateParams, + schema.CertificateCreate + ) + ), + http.get( + '/v1/system/certificates/:certificate', + handler(handlers['siloCertificateView'], schema.SiloCertificateViewParams, null) + ), + http.delete( + '/v1/system/certificates/:certificate', + handler(handlers['siloCertificateDelete'], schema.SiloCertificateDeleteParams, null) + ), http.put( '/v1/system/hardware/disk-adoption-request', handler( diff --git a/app/api/__generated__/nexus-console.ts b/app/api/__generated__/nexus-console.ts index e5a59a336b..baf48a9865 100644 --- a/app/api/__generated__/nexus-console.ts +++ b/app/api/__generated__/nexus-console.ts @@ -23,6 +23,7 @@ export const nexusConsoleRoutes = [ '/access', '/device/success', '/device/verify', + '/groups/{path:.*}', '/images/{path:.*}', '/login/{silo_name}/local', '/login/{silo_name}/saml/{provider_name}', @@ -31,5 +32,6 @@ export const nexusConsoleRoutes = [ '/projects/{path:.*}', '/settings/{path:.*}', '/system/{path:.*}', + '/users/{path:.*}', '/utilization', ] diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index c7056c6c35..38a32e0e62 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -4775,7 +4775,11 @@ export const TufRepo = z.preprocess( processResponseBody, z.object({ fileName: z.string(), - hash: z.string(), + hash: z + .string() + .min(64) + .max(64) + .regex(/^[0-9a-fA-F]{64}$/), systemVersion: z .string() .regex( @@ -7060,6 +7064,53 @@ export const AuditLogListParams = z.preprocess( }) ) +export const SiloCertificateListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + limit: z.int().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + silo: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + }), + }) +) + +export const SiloCertificateCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + silo: NameOrId, + }), + }) +) + +export const SiloCertificateViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + certificate: NameOrId, + }), + query: z.object({ + silo: NameOrId.optional(), + }), + }) +) + +export const SiloCertificateDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + certificate: NameOrId, + }), + query: z.object({ + silo: NameOrId.optional(), + }), + }) +) + export const PhysicalDiskEnableAdoptionParams = z.preprocess( processResponseBody, z.object({ diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index 7f7417d934..d13db0dbff 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -8,7 +8,7 @@ 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 { useController, useForm, useWatch, type Control } from 'react-hook-form' import type { Merge } from 'type-fest' import type { CertificateCreate } from '@oxide/api' @@ -104,7 +104,7 @@ export type CertFormValues = Merge< { key: File | null; cert: File | null } // swap strings for Files > -const defaultValues: CertFormValues = { +export const certDefaultValues: CertFormValues = { description: '', name: '', service: 'external_api', @@ -120,13 +120,8 @@ type AddCertModalProps = { } const AddCertModal = ({ onDismiss, onSubmit, allNames, siloName }: AddCertModalProps) => { - const { watch, control, handleSubmit } = useForm({ defaultValues }) - - const file = watch('cert') - - const { data: certValidation } = useQuery({ - queryKey: ['validateCert', ...(file ? [file.name, file.size, file.lastModified] : [])], - queryFn: file ? () => file.text().then(parseCertificate) : skipToken, + const { control, handleSubmit } = useForm({ + defaultValues: certDefaultValues, }) return ( @@ -134,29 +129,7 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames, siloName }: AddCertModalP
- - allNames.includes(name) - ? 'A certificate with this name already exists' - : undefined - } - /> - - - - +
@@ -169,6 +142,46 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames, siloName }: AddCertModalP ) } +/** Shared by the silo create form's cert modal and the silo cert create form */ +export function CertFields({ + control, + allNames, + siloName, +}: { + control: Control + allNames: string[] + siloName: string +}) { + const file = useWatch({ control, name: 'cert' }) + + const { data: certValidation } = useQuery({ + queryKey: ['validateCert', ...(file ? [file.name, file.size, file.lastModified] : [])], + queryFn: file ? () => file.text().then(parseCertificate) : skipToken, + }) + + return ( + <> + + allNames.includes(name) + ? 'A certificate with this name already exists' + : undefined + } + /> + + + + + + ) +} + const PEM_BLOCK_RE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/ export function parseCertificate(certPem: string) { diff --git a/app/forms/silo-certificate-create.tsx b/app/forms/silo-certificate-create.tsx new file mode 100644 index 0000000000..86c4ab073c --- /dev/null +++ b/app/forms/silo-certificate-create.tsx @@ -0,0 +1,81 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useForm } from 'react-hook-form' +import { useNavigate } from 'react-router' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, +} from '@oxide/api' + +import { certDefaultValues, CertFields } from '~/components/form/fields/TlsCertsField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useSiloSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { ALL_ISH } from '~/util/consts' +import { pb } from '~/util/path-builder' + +export const handle = titleCrumb('Add Certificate') + +export default function SiloCertificateCreateForm() { + const navigate = useNavigate() + const siloSelector = useSiloSelector() + const { silo } = siloSelector + + // both prefetched by parent route loaders (SiloPage, SiloCertificatesTab) + const { data: siloData } = usePrefetchedQuery(q(api.siloView, { path: siloSelector })) + const { data: allCerts } = usePrefetchedQuery( + getListQFn(api.siloCertificateList, { query: { silo, limit: ALL_ISH } }).optionsFn() + ) + + const onDismiss = () => navigate(pb.siloCertificates({ silo })) + + const createCert = useApiMutation(api.siloCertificateCreate, { + onSuccess(cert) { + queryClient.invalidateEndpoint('siloCertificateList') + // prettier-ignore + addToast(<>Certificate {cert.name} added) + onDismiss() + }, + }) + + const form = useForm({ defaultValues: certDefaultValues }) + + return ( + { + // required fields, always present if form validation passed + if (!cert || !key) return + createCert.mutate({ + query: { silo }, + body: { ...rest, cert: await cert.text(), key: await key.text() }, + }) + }} + loading={createCert.isPending || createCert.isSuccess} + submitError={createCert.error} + > + c.name)} + siloName={siloData.name} + /> + + ) +} diff --git a/app/forms/silo-certificate-view.tsx b/app/forms/silo-certificate-view.tsx new file mode 100644 index 0000000000..bce5f88aa7 --- /dev/null +++ b/app/forms/silo-certificate-view.tsx @@ -0,0 +1,157 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router' + +import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' +import { Key16Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { titleCrumb } from '~/hooks/use-crumbs' +import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' +import { DateTime } from '~/ui/lib/DateTime' +import { FieldLabel } from '~/ui/lib/FieldLabel' +import { Message } from '~/ui/lib/Message' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { Truncate } from '~/ui/lib/Truncate' +import { parseCertChain, type ParsedCert } from '~/util/certificate' +import { pb } from '~/util/path-builder' + +import type { Route } from './+types/silo-certificate-view' + +const certView = (silo: string, certificate: string) => + q(api.siloCertificateView, { path: { certificate }, query: { silo } }) + +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { silo, certificate } = params + await queryClient.prefetchQuery(certView(silo, certificate)) + return null +} + +export const handle = titleCrumb('Certificate') + +export default function SiloCertificateView({ params }: Route.ComponentProps) { + const { silo, certificate } = params + const navigate = useNavigate() + const { data: cert } = usePrefetchedQuery(certView(silo, certificate)) + + // fingerprinting uses WebCrypto, which is async + const { data: chain, isPending } = useQuery({ + queryKey: ['parseCertChain', cert.id, cert.timeModified], + queryFn: () => parseCertChain(cert.cert), + }) + const leaf = chain?.[0] + + return ( + navigate(pb.siloCertificates({ silo }))} + subtitle={ + + {cert.name} + + } + > + + + + + + {/* chain is null when parsing failed, undefined while pending */} + {!isPending && !leaf && ( + + )} + {leaf && } + {chain && chain.length > 1 && } + +
+
+ PEM + +
+
+          {cert.cert.trim()}
+        
+
+
+ ) +} + +function LeafProperties({ leaf }: { leaf: ParsedCert }) { + const expired = leaf.notAfter < new Date() + return ( + + +
    + {leaf.domains.map((d) => ( +
  • {d}
  • + ))} +
+
+ + + + {leaf.selfSigned && Self-signed} + + + + + + + + + {expired && Expired} + + + {leaf.keyAlgorithm} + + + + + + +
+ ) +} + +function chainLabel(i: number, length: number, selfSigned: boolean) { + if (i === 0) return 'Leaf' + // only call the last cert a root if it's actually self-signed; chains often + // stop at an intermediate and leave the root to the client's trust store + if (i === length - 1 && selfSigned) return 'Root' + return `Intermediate ${i}` +} + +function ChainList({ chain }: { chain: ParsedCert[] }) { + return ( +
+ + Chain ({chain.length} certificates) + +
    + {chain.map((c, i) => ( +
  1. +
    + {chainLabel(i, chain.length, c.selfSigned)}: {c.subject} +
    +
    Issued by {c.issuer}
    +
  2. + ))} +
+
+ ) +} diff --git a/app/pages/system/silos/SiloCertificatesTab.tsx b/app/pages/system/silos/SiloCertificatesTab.tsx new file mode 100644 index 0000000000..f8faf16459 --- /dev/null +++ b/app/pages/system/silos/SiloCertificatesTab.tsx @@ -0,0 +1,178 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { createColumnHelper } from '@tanstack/react-table' +import { useCallback, useMemo } from 'react' +import { Outlet } from 'react-router' + +import { + api, + getListQFn, + queryClient, + useApiMutation, + usePrefetchedQuery, + type Certificate, +} from '@oxide/api' +import { Key24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { parseCertificate } from '~/components/form/fields/TlsCertsField' +import { HL } from '~/components/HL' +import { makeCrumb } from '~/hooks/use-crumbs' +import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { makeLinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { CreateLink } from '~/ui/lib/CreateButton' +import { DateTime } from '~/ui/lib/DateTime' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Message } from '~/ui/lib/Message' +import { ALL_ISH } from '~/util/consts' +import { pb } from '~/util/path-builder' + +import type { Route } from './+types/SiloCertificatesTab' + +const siloCertsQuery = (silo: string) => + getListQFn(api.siloCertificateList, { query: { silo } }) + +// a silo has a handful of certs at most, so fetch all of them to know the +// count and names regardless of the table's current page +const allSiloCertsQuery = (silo: string) => + getListQFn(api.siloCertificateList, { query: { silo, limit: ALL_ISH } }) + +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { silo } = params + await Promise.all([ + queryClient.prefetchQuery(siloCertsQuery(silo).optionsFn()), + queryClient.prefetchQuery(allSiloCertsQuery(silo).optionsFn()), + ]) + return null +} + +export const handle = makeCrumb('Certificates', (params) => + pb.siloCertificates(getSiloSelector(params)) +) + +// The API only returns the PEM, so pull domains and expiry out client-side +function ExpiresCell({ cert }: { cert: string }) { + const { notAfter, isValid } = parseCertificate(cert) + if (!isValid || !notAfter) return Unknown + return ( +
+ + {notAfter < new Date() && Expired} +
+ ) +} + +function DomainsCell({ cert }: { cert: string }) { + const { subjectAltNames, commonNames } = parseCertificate(cert) + const domains = subjectAltNames.length > 0 ? subjectAltNames : commonNames + if (domains.length === 0) return — + return ( +
    + {domains.map((d) => ( +
  • {d}
  • + ))} +
+ ) +} + +const colHelper = createColumnHelper() +const makeStaticCols = (silo: string) => [ + colHelper.accessor('name', { + cell: makeLinkCell((certificate) => pb.siloCertificate({ silo, certificate })), + }), + colHelper.accessor('description', Columns.description), + colHelper.accessor('cert', { + id: 'domains', + header: 'Domains', + cell: (info) => , + }), + colHelper.accessor('cert', { + id: 'expires', + header: 'Expires', + cell: (info) => , + }), + colHelper.accessor('timeCreated', Columns.timeCreated), +] + +export default function SiloCertificatesTab() { + const { silo } = useSiloSelector() + const { data: allCerts } = usePrefetchedQuery(allSiloCertsQuery(silo).optionsFn()) + const isLastCert = allCerts.items.length === 1 + + const { mutateAsync: deleteCert } = useApiMutation(api.siloCertificateDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('siloCertificateList') + addToast( + <> + Certificate {variables.path.certificate} deleted + + ) + }, + }) + + const makeActions = useCallback( + (cert: Certificate): MenuAction[] => [ + { + label: 'Delete', + onActivate: confirmDelete({ + // silo must be omitted when the cert is specified by ID + doDelete: () => deleteCert({ path: { certificate: cert.id } }), + label: cert.name, + resourceKind: 'certificate', + // Nexus allows deleting a silo's last cert, which breaks TLS for the + // silo. Drop this warning once the API prevents it. + // https://github.com/oxidecomputer/omicron/issues/11272 + // TODO: like the user and group delete discussed in the users and + // groups PR, deleting the last cert probably wants a typed + // confirmation (enter the cert or silo name) rather than a plain + // confirm button. + extraContent: isLastCert ? ( + + ) : undefined, + }), + }, + ], + [deleteCert, isLastCert] + ) + + const staticCols = useMemo(() => makeStaticCols(silo), [silo]) + const columns = useColsWithActions(staticCols, makeActions) + const { table } = useQueryTable({ + query: siloCertsQuery(silo), + columns, + emptyState: ( + } + title="No certificates" + body="Add a TLS certificate to see it here" + buttonText="Add certificate" + buttonTo={pb.siloCertificatesNew({ silo })} + /> + ), + }) + + return ( + <> +
+ Add certificate +
+ {table} + + + ) +} diff --git a/app/pages/system/silos/SiloPage.tsx b/app/pages/system/silos/SiloPage.tsx index 280a6fba9c..cdd933659e 100644 --- a/app/pages/system/silos/SiloPage.tsx +++ b/app/pages/system/silos/SiloPage.tsx @@ -62,6 +62,7 @@ export default function SiloPage() { Identity Providers + Certificates IP Pools Subnet Pools Quotas diff --git a/app/routes.ts b/app/routes.ts index f6168f9841..2a79c13227 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -67,6 +67,11 @@ export default [ route('quotas', 'pages/system/silos/SiloQuotasTab.tsx'), route('fleet-roles', 'pages/system/silos/SiloFleetRolesTab.tsx'), route('scim', 'pages/system/silos/SiloScimTab.tsx'), + layout('pages/system/silos/SiloCertificatesTab.tsx', [ + empty('certificates'), + route('certificates-new', 'forms/silo-certificate-create.tsx'), + route('certificates/:certificate', 'forms/silo-certificate-view.tsx'), + ]), ]), ]), empty('issues'), diff --git a/app/stores/confirm-delete.tsx b/app/stores/confirm-delete.tsx index 420ffbd4e3..a21ec83b94 100644 --- a/app/stores/confirm-delete.tsx +++ b/app/stores/confirm-delete.tsx @@ -39,7 +39,8 @@ export const confirmDelete = modalContent: (

Are you sure you want to delete {displayLabel}?

- {extraContent ?

{extraContent}

: null} + {/* strings get a paragraph, elements like a Message render as-is */} + {typeof extraContent === 'string' ?

{extraContent}

: extraContent}
), errorTitle: `Could not delete ${resourceKind}`, diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 721e74ad56..948dd79e62 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -687,6 +687,48 @@ exports[`breadcrumbs 2`] = ` "path": "/access", }, ], + "siloCertificate (/system/silos/s/certificates/c)": [ + { + "label": "Silos", + "path": "/system/silos", + }, + { + "label": "s", + "path": "/system/silos/s/idps", + }, + { + "label": "Certificates", + "path": "/system/silos/s/certificates", + }, + ], + "siloCertificates (/system/silos/s/certificates)": [ + { + "label": "Silos", + "path": "/system/silos", + }, + { + "label": "s", + "path": "/system/silos/s/idps", + }, + { + "label": "Certificates", + "path": "/system/silos/s/certificates", + }, + ], + "siloCertificatesNew (/system/silos/s/certificates-new)": [ + { + "label": "Silos", + "path": "/system/silos", + }, + { + "label": "s", + "path": "/system/silos/s/idps", + }, + { + "label": "Certificates", + "path": "/system/silos/s/certificates", + }, + ], "siloFleetRoles (/system/silos/s/fleet-roles)": [ { "label": "Silos", diff --git a/app/util/certificate.ts b/app/util/certificate.ts new file mode 100644 index 0000000000..2c524fdd5c --- /dev/null +++ b/app/util/certificate.ts @@ -0,0 +1,65 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { SubjectAlternativeNameExtension, X509Certificate } from '@peculiar/x509' + +const PEM_BLOCKS_RE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g + +export type ParsedCert = { + subject: string + issuer: string + /** DNS names from the SAN extension, falling back to CN when there are none */ + domains: string[] + notBefore: Date + notAfter: Date + serialNumber: string + /** colon-separated uppercase hex, the format browsers and openssl show */ + sha256Fingerprint: string + keyAlgorithm: string + selfSigned: boolean +} + +const toHex = (buf: ArrayBuffer) => + Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0').toUpperCase()) + +function describeKey(alg: Algorithm & { namedCurve?: string; modulusLength?: number }) { + if (alg.namedCurve) return `${alg.name} ${alg.namedCurve}` + if (alg.modulusLength) return `${alg.name} ${alg.modulusLength}-bit` + return alg.name +} + +async function parseOne(pem: string): Promise { + const cert = new X509Certificate(pem) + const sans = cert.getExtension(SubjectAlternativeNameExtension)?.names.items ?? [] + const domains = + sans.length > 0 ? sans.map((n) => n.value) : cert.subjectName.getField('CN') + return { + subject: cert.subject, + issuer: cert.issuer, + domains, + notBefore: cert.notBefore, + notAfter: cert.notAfter, + serialNumber: cert.serialNumber, + sha256Fingerprint: toHex(await cert.getThumbprint('SHA-256')).join(':'), + keyAlgorithm: describeKey(cert.publicKey.algorithm), + selfSigned: cert.subject === cert.issuer, + } +} + +/** + * Parse every cert in a PEM chain, leaf first by convention. Returns null if + * there are no PEM blocks or any block fails to parse. + */ +export async function parseCertChain(pem: string): Promise { + const blocks = pem.match(PEM_BLOCKS_RE) + if (!blocks) return null + try { + return await Promise.all(blocks.map(parseOne)) + } catch { + return null + } +} diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index e1feb23286..79105529d1 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -42,6 +42,7 @@ const params = { instance: 'i', vpc: 'v', silo: 's', + certificate: 'c', version: 'vs', provider: 'pr', sledId: '5c56b522-c9b8-49e4-9f9a-8d52a89ec3e0', @@ -114,6 +115,9 @@ test('path builder', () => { "serialConsole": "/projects/p/instances/i/serial-console", "silo": "/system/silos/s/idps", "siloAccess": "/access", + "siloCertificate": "/system/silos/s/certificates/c", + "siloCertificates": "/system/silos/s/certificates", + "siloCertificatesNew": "/system/silos/s/certificates-new", "siloFleetRoles": "/system/silos/s/fleet-roles", "siloIdps": "/system/silos/s/idps", "siloIdpsNew": "/system/silos/s/idps-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index abe51e79ec..f4cda62dae 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -171,6 +171,11 @@ export const pb = { siloQuotas: (params: PP.Silo) => href('/system/silos/:silo/quotas', params), siloFleetRoles: (params: PP.Silo) => href('/system/silos/:silo/fleet-roles', params), siloScim: (params: PP.Silo) => href('/system/silos/:silo/scim', params), + siloCertificates: (params: PP.Silo) => href('/system/silos/:silo/certificates', params), + siloCertificatesNew: (params: PP.Silo) => + href('/system/silos/:silo/certificates-new', params), + siloCertificate: (params: PP.SiloCertificate) => + href('/system/silos/:silo/certificates/:certificate', params), samlIdp: (params: PP.IdentityProvider) => href('/system/silos/:silo/idps/saml/:provider', params), diff --git a/app/util/path-params.ts b/app/util/path-params.ts index 7e16ba201f..3955c1e5ea 100644 --- a/app/util/path-params.ts +++ b/app/util/path-params.ts @@ -14,6 +14,7 @@ export type Instance = Required export type Vpc = Required export type Silo = Required export type IdentityProvider = Required +export type SiloCertificate = Silo & { certificate: string } export type Sled = Required export type Image = Required export type Snapshot = Required diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 7529cb93ec..8123587400 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -445,6 +445,26 @@ export const lookup = { const { is_default: _, ...systemPool } = pool return systemPool }, + // https://github.com/oxidecomputer/omicron/blob/5cda99a/nexus/src/app/certificate.rs#L67-L90 + siloCertificate({ + certificate: id, + ...siloSelector + }: Sel.Silo & { certificate: string }): (typeof db.certificates)[number] { + if (isUuid(id)) { + ensureNoParentSelectors('certificate', siloSelector) + return lookupById(db.certificates, id) + } + + if (!siloSelector.silo) { + throw invalidRequest( + 'certificate should either be a UUID or silo should be specified' + ) + } + const silo = lookup.silo(siloSelector) + const cert = db.certificates.find((c) => c.siloId === silo.id && c.name === id) + if (!cert) throw notFoundErr(`certificate '${id}'`) + return cert + }, siloSubnetPool(path: Sel.SubnetPool & Sel.Silo): Json { const silo = lookup.silo(path) const pool = lookup.subnetPool(path) @@ -661,6 +681,7 @@ const initDb = { racks: [...mock.racks], roleAssignments: [...mock.roleAssignments], scimTokens: [...mock.scimTokens], + certificates: [...mock.certificates], silos: [...mock.silos], siloQuotas: [...mock.siloQuotas], siloProvisioned: [...mock.siloProvisioned], diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 69a3889a93..979bcf2a2e 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -2502,6 +2502,38 @@ export const handlers = makeHandlers({ }, // SCIM token endpoints + siloCertificateList({ query, cookies }) { + requireFleetViewer(cookies) + const silo = lookup.silo({ silo: query.silo }) + const certs = db.certificates + .filter((c) => c.siloId === silo.id) + .map(({ siloId: _siloId, ...cert }) => cert) + return paginated(query, certs) + }, + siloCertificateCreate({ query, body, cookies }) { + requireFleetAdmin(cookies) + const silo = lookup.silo({ silo: query.silo }) + errIfExists( + db.certificates.filter((c) => c.siloId === silo.id), + { name: body.name }, + 'certificate' + ) + const { key: _key, ...rest } = body + const newCert: Json = { id: uuid(), ...getTimestamps(), ...rest } + db.certificates.push({ ...newCert, siloId: silo.id }) + return json(newCert, { status: 201 }) + }, + siloCertificateView({ path, query, cookies }) { + requireFleetViewer(cookies) + const { siloId: _siloId, ...cert } = lookup.siloCertificate({ ...path, ...query }) + return cert + }, + siloCertificateDelete({ path, query, cookies }) { + requireFleetAdmin(cookies) + const cert = lookup.siloCertificate({ ...path, ...query }) + db.certificates = db.certificates.filter((c) => c.id !== cert.id) + return 204 + }, scimTokenList({ query, cookies }) { const silo = lookup.silo({ silo: query.silo }) requireFleetAdminOrSiloAdmin(cookies, silo.id) diff --git a/mock-api/silo.ts b/mock-api/silo.ts index 6734ce1dec..49920f28ef 100644 --- a/mock-api/silo.ts +++ b/mock-api/silo.ts @@ -8,6 +8,7 @@ import * as R from 'remeda' import type { + Certificate, IdentityProvider, SamlIdentityProvider, ScimClientBearerToken, @@ -232,3 +233,69 @@ export const scimTokens: DbScimToken[] = [ siloId: defaultSilo.id, }, ] + +// Self-signed, valid until 2036, SANs maze-war.sys.oxide-dev.test and wildcard +const mazeWarCertPem = `-----BEGIN CERTIFICATE----- +MIIByDCCAW2gAwIBAgIUKuBoCIKd3mtVnA3Cxk3gfP9hrSAwCgYIKoZIzj0EAwIw +JjEkMCIGA1UEAwwbbWF6ZS13YXIuc3lzLm94aWRlLWRldi50ZXN0MB4XDTI2MTAx +NTE2MjQzNFoXDTM2MTAxMjE2MjQzNFowJjEkMCIGA1UEAwwbbWF6ZS13YXIuc3lz +Lm94aWRlLWRldi50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElkR9qjM7 +EsZrI8vafuzmk/j7u/5lAaRekCnwHCOPMBUhPUmycy+3O8T75mu0SEFeFUxglRK1 +JIFsYRaGKp5OkKN5MHcwHQYDVR0OBBYEFGKTDcfMv8KXyb5+lTf17rMuYPdsMA8G +A1UdEwEB/wQFMAMBAf8wRQYDVR0RBD4wPIIbbWF6ZS13YXIuc3lzLm94aWRlLWRl +di50ZXN0gh0qLm1hemUtd2FyLnN5cy5veGlkZS1kZXYudGVzdDAKBggqhkjOPQQD +AgNJADBGAiEAqeST0LMSYOw662qNZq6/Kx3JZtQ0W4nMRnrvjNCmvRwCIQDRoJVx +2OpJKp/Tjo+Y2TudNIefL7OPPGgtdlA6nYBraA== +-----END CERTIFICATE----- +` + +// Leaf for maze-war signed by a dev intermediate CA, plus that CA: a 2-cert chain +const mazeWarChainPem = `-----BEGIN CERTIFICATE----- +MIIBizCCATCgAwIBAgIUBLNKr97uHQSxU4DmN0OAmdobgjowCgYIKoZIzj0EAwIw +ODEiMCAGA1UEAwwZT3hpZGUgRGV2IEludGVybWVkaWF0ZSBDQTESMBAGA1UECgwJ +T3hpZGUgRGV2MB4XDTI2MTAxNTE3MDIyMloXDTI3MTAxNTE3MDIyMlowJjEkMCIG +A1UEAwwbbWF6ZS13YXIuc3lzLm94aWRlLWRldi50ZXN0MFkwEwYHKoZIzj0CAQYI +KoZIzj0DAQcDQgAEwleCvLkvbawUFLkfs4gDnZjkGFjkiqEfh5D6txDRabHTo1Dh +iReD1QyHrxCifWGeJVpax6QNgy5kvXa6EDtTyqMqMCgwJgYDVR0RBB8wHYIbbWF6 +ZS13YXIuc3lzLm94aWRlLWRldi50ZXN0MAoGCCqGSM49BAMCA0kAMEYCIQCxt0LO ++aInVDyqhu/ouzR7UTIH79C/ZDrjgqdrITJ8yAIhAJye0aVuucWVccgT12NwlMO+ +pMcun4jbGzsIPY2dIN1d +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIBpTCCAUqgAwIBAgIUeEbRcrHC07isMhWvXyO9wBaAfBYwCgYIKoZIzj0EAwIw +ODEiMCAGA1UEAwwZT3hpZGUgRGV2IEludGVybWVkaWF0ZSBDQTESMBAGA1UECgwJ +T3hpZGUgRGV2MB4XDTI2MTAxNTE3MDIyMloXDTM2MTAxMjE3MDIyMlowODEiMCAG +A1UEAwwZT3hpZGUgRGV2IEludGVybWVkaWF0ZSBDQTESMBAGA1UECgwJT3hpZGUg +RGV2MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEf/H1fKMcaNvv3yb7dIE6X2kN +Y9IjlWLSltDwPBpVonqgcPrzEYAJZKfFYCFSzlohOwv0s1UOUOSX9Yw9Pu+ndaMy +MDAwHQYDVR0OBBYEFCpQ9OXUG3w8Gr7wuRMH/AHSofBaMA8GA1UdEwEB/wQFMAMB +Af8wCgYIKoZIzj0EAwIDSQAwRgIhAMvumMM4u7MjY5cpmzB6DnxYCTxbDxdXdd7b +jt9sXNefAiEAmZLjluRWCzQK3lJ+9fZR2FE8k9MAU1Hj3EVQ1nhGBFk= +-----END CERTIFICATE----- +` + +// Certificates are stored with siloId for filtering, like SCIM tokens +type DbCertificate = Json & { siloId: string } + +export const certificates: DbCertificate[] = [ + { + id: '7d36cbfc-9dba-4600-9816-323a22b4c719', + name: 'maze-war-cert', + description: 'Wildcard cert for the maze-war silo', + cert: mazeWarCertPem, + service: 'external_api', + time_created: new Date(2025, 8, 15).toISOString(), + time_modified: new Date(2025, 8, 15).toISOString(), + siloId: defaultSilo.id, + }, + { + id: 'b80d698f-4c8c-4394-892b-476541667f3a', + name: 'maze-war-cert-old', + description: '', + cert: mazeWarChainPem, + service: 'external_api', + time_created: new Date(2024, 2, 1).toISOString(), + time_modified: new Date(2024, 2, 1).toISOString(), + siloId: defaultSilo.id, + }, +] diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index ae46947f99..d3960362ae 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -586,3 +586,59 @@ test('Quotas tab', async ({ page }) => { await expectRowVisible(table, { Resource: 'Memory', Quota: '50 GiB' }) await expectRowVisible(table, { Resource: 'Storage', Quota: '7168 GiB' }) }) + +test('Silo certificates tab', async ({ page }) => { + await page.goto('/system/silos/maze-war/certificates') + const table = page.getByRole('table') + await expectRowVisible(table, { + name: 'maze-war-cert', + Domains: 'maze-war.sys.oxide-dev.test*.maze-war.sys.oxide-dev.test', + }) + + // add + await page.getByRole('link', { name: 'Add certificate' }).click() + await expect(page).toHaveURL('/system/silos/maze-war/certificates-new') + const dialog = page.getByRole('dialog', { name: 'Add TLS certificate' }) + await dialog.getByRole('textbox', { name: 'Name' }).fill('new-cert') + await chooseFile(dialog.getByLabel('Cert', { exact: true }), 'small') + await chooseFile(dialog.getByLabel('Key'), 'small') + await dialog.getByRole('button', { name: 'Add certificate' }).click() + await expect(dialog).toBeHidden() + await closeToast(page) + await expectRowVisible(table, { name: 'new-cert' }) + + // no last-cert warning while other certs remain + const confirm = page.getByRole('dialog', { name: 'Delete certificate' }) + const lastCertWarning = confirm.getByText('This is the only certificate') + for (const name of ['maze-war-cert-old', 'new-cert']) { + await clickRowAction(page, name, 'Delete') + await expect(lastCertWarning).toBeHidden() + await confirm.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByRole('cell', { name, exact: true })).toBeHidden() + await closeToast(page) + } + + // warning on the last one + await clickRowAction(page, 'maze-war-cert', 'Delete') + await expect(lastCertWarning).toBeVisible() + await confirm.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByText('No certificates')).toBeVisible() +}) + +test('Silo certificate detail', async ({ page }) => { + await page.goto('/system/silos/maze-war/certificates') + await page.getByRole('link', { name: 'maze-war-cert-old' }).click() + await expect(page).toHaveURL('/system/silos/maze-war/certificates/maze-war-cert-old') + + const modal = page.getByRole('dialog', { name: 'Certificate details' }) + await expect( + modal.getByText('CN=Oxide Dev Intermediate CA, O=Oxide Dev').first() + ).toBeVisible() + await expect(modal.getByText('Chain (2 certificates)')).toBeVisible() + await expect(modal.getByText('Root: CN=Oxide Dev Intermediate CA')).toBeVisible() + await expect(modal.getByText('ECDSA P-256')).toBeVisible() + + await modal.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expect(modal).toBeHidden() + await expect(page).toHaveURL('/system/silos/maze-war/certificates') +})