From 01b77ea63c40bc8d489afa2a9c826e10e55951da Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 15 Oct 2026 11:23:58 -0500 Subject: [PATCH 1/7] Bump API for /v1/system/certificates --- OMICRON_VERSION | 2 +- app/api/__generated__/API_VERSION | 2 +- app/api/__generated__/Api.ts | 103 +++++++++++++++++++++++-- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 47 +++++++++++ app/api/__generated__/nexus-console.ts | 2 + app/api/__generated__/validate.ts | 53 ++++++++++++- mock-api/msw/handlers.ts | 4 + 8 files changed, 206 insertions(+), 9 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index c7ee013e62..c9ffe00fd0 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -6d241a7d21d11cb7979122bde9a906d8d4837d09 +5cda99a72c6bccdc42d668791fa7b0b70b8733d2 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..1779663e4b 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 +5cda99a72c6bccdc42d668791fa7b0b70b8733d2 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/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 69a3889a93..ab2873c72d 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -3036,6 +3036,10 @@ export const handlers = makeHandlers({ certificateDelete: NotImplemented, certificateList: NotImplemented, certificateView: NotImplemented, + siloCertificateCreate: NotImplemented, + siloCertificateDelete: NotImplemented, + siloCertificateList: NotImplemented, + siloCertificateView: NotImplemented, instanceMulticastGroupJoin: NotImplemented, instanceMulticastGroupLeave: NotImplemented, instanceMulticastGroupList: NotImplemented, From a1a30185bc4894de69805faae2d0ee7b6a74c4dc Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 15 Oct 2026 11:31:33 -0500 Subject: [PATCH 2/7] AGENTS.md: check Nexus behavior when writing mocks --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) 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. From 4503c69f8b79c63b9b35537b50583cac8e7e6011 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 15 Oct 2026 11:24:46 -0500 Subject: [PATCH 3/7] Prototype silo certificates tab --- app/components/form/fields/TlsCertsField.tsx | 21 +- .../system/silos/SiloCertificatesTab.tsx | 199 ++++++++++++++++++ app/pages/system/silos/SiloPage.tsx | 1 + app/routes.ts | 1 + .../__snapshots__/path-builder.spec.ts.snap | 14 ++ app/util/path-builder.spec.ts | 1 + app/util/path-builder.ts | 1 + mock-api/msw/db.ts | 21 ++ mock-api/msw/handlers.ts | 36 +++- mock-api/silo.ts | 42 ++++ test/e2e/silos.e2e.ts | 37 ++++ 11 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 app/pages/system/silos/SiloCertificatesTab.tsx diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index 7f7417d934..20543fe7a0 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -11,7 +11,7 @@ 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 type { ApiError, CertificateCreate } from '@oxide/api' import { OpenLink12Icon } from '@oxide/design-system/icons/react' import { getDelegatedDomain } from '~/forms/idp/util' @@ -117,9 +117,18 @@ type AddCertModalProps = { onSubmit: (values: CertFormValues) => void allNames: string[] siloName: string + loading?: boolean + submitError?: ApiError | null } -const AddCertModal = ({ onDismiss, onSubmit, allNames, siloName }: AddCertModalProps) => { +export const AddCertModal = ({ + onDismiss, + onSubmit, + allNames, + siloName, + loading, + submitError, +}: AddCertModalProps) => { const { watch, control, handleSubmit } = useForm({ defaultValues }) const file = watch('cert') @@ -157,6 +166,13 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames, siloName }: AddCertModalP domain={getDelegatedDomain(window.location)} /> + {submitError && ( + + )} @@ -164,6 +180,7 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames, siloName }: AddCertModalP onDismiss={onDismiss} onAction={handleSubmit(onSubmit)} actionText="Add Certificate" + actionLoading={loading} /> ) diff --git a/app/pages/system/silos/SiloCertificatesTab.tsx b/app/pages/system/silos/SiloCertificatesTab.tsx new file mode 100644 index 0000000000..6d919bbb24 --- /dev/null +++ b/app/pages/system/silos/SiloCertificatesTab.tsx @@ -0,0 +1,199 @@ +/* + * 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, useState } from 'react' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type Certificate, +} from '@oxide/api' +import { Key24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { AddCertModal, parseCertificate } from '~/components/form/fields/TlsCertsField' +import { HL } from '~/components/HL' +import { makeCrumb } from '~/hooks/use-crumbs' +import { useSiloSelector } from '~/hooks/use-params' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { CreateButton } from '~/ui/lib/CreateButton' +import { DateTime } from '~/ui/lib/DateTime' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { ALL_ISH } from '~/util/consts' + +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') + +// 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 staticCols = [ + colHelper.accessor('name', {}), + 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 siloSelector = useSiloSelector() + const { silo } = siloSelector + // prefetched by the parent SiloPage loader + const { data: siloData } = usePrefetchedQuery(q(api.siloView, { path: siloSelector })) + const [showCreate, setShowCreate] = useState(false) + 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 createCert = useApiMutation(api.siloCertificateCreate, { + onSuccess(cert) { + queryClient.invalidateEndpoint('siloCertificateList') + addToast( + <> + Certificate {cert.name} added + + ) + setShowCreate(false) + }, + }) + + 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 + extraContent: isLastCert + ? 'This is the only certificate for this silo. Without it, the silo will not be reachable over HTTPS.' + : undefined, + }), + }, + ], + [deleteCert, isLastCert] + ) + + 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" + onClick={() => setShowCreate(true)} + /> + ), + }) + + const allNames = allCerts.items.map((c) => c.name) + + return ( + <> +
+ setShowCreate(true)}>Add certificate +
+ {table} + {showCreate && ( + { + createCert.reset() + setShowCreate(false) + }} + onSubmit={async ({ cert, key, ...rest }) => { + // 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() }, + }) + }} + allNames={allNames} + siloName={siloData.name} + loading={createCert.isPending} + submitError={createCert.error} + /> + )} + + ) +} 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..964d980bda 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -67,6 +67,7 @@ export default [ route('quotas', 'pages/system/silos/SiloQuotasTab.tsx'), route('fleet-roles', 'pages/system/silos/SiloFleetRolesTab.tsx'), route('scim', 'pages/system/silos/SiloScimTab.tsx'), + route('certificates', 'pages/system/silos/SiloCertificatesTab.tsx'), ]), ]), empty('issues'), diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 721e74ad56..073724d463 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -687,6 +687,20 @@ exports[`breadcrumbs 2`] = ` "path": "/access", }, ], + "siloCertificates (/system/silos/s/certificates)": [ + { + "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/path-builder.spec.ts b/app/util/path-builder.spec.ts index e1feb23286..2e41827e0e 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -114,6 +114,7 @@ test('path builder', () => { "serialConsole": "/projects/p/instances/i/serial-console", "silo": "/system/silos/s/idps", "siloAccess": "/access", + "siloCertificates": "/system/silos/s/certificates", "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..d7fe0bfcca 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -171,6 +171,7 @@ 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), samlIdp: (params: PP.IdentityProvider) => href('/system/silos/:silo/idps/saml/:provider', params), 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 ab2873c72d..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) @@ -3036,10 +3068,6 @@ export const handlers = makeHandlers({ certificateDelete: NotImplemented, certificateList: NotImplemented, certificateView: NotImplemented, - siloCertificateCreate: NotImplemented, - siloCertificateDelete: NotImplemented, - siloCertificateList: NotImplemented, - siloCertificateView: NotImplemented, instanceMulticastGroupJoin: NotImplemented, instanceMulticastGroupLeave: NotImplemented, instanceMulticastGroupList: NotImplemented, diff --git a/mock-api/silo.ts b/mock-api/silo.ts index 6734ce1dec..361f973a24 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,44 @@ 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----- +` + +// 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: mazeWarCertPem, + 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..c51fd21b71 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -586,3 +586,40 @@ 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('button', { name: 'Add certificate' }).click() + const dialog = page.getByRole('dialog', { name: 'Add TLS certificate' }) + await dialog.getByRole('textbox', { name: 'Name' }).fill('new-cert') + await chooseFile(page.getByLabel('Cert', { exact: true }), 'small') + await chooseFile(page.getByLabel('Key'), 'small') + await page.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() +}) From ac75567cc71c6ae29189a464c1254b70f1f2ea7a Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 15 Oct 2026 11:49:51 -0500 Subject: [PATCH 4/7] Make last-cert delete warning prominent --- app/pages/system/silos/SiloCertificatesTab.tsx | 15 ++++++++++++--- app/stores/confirm-delete.tsx | 3 ++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/pages/system/silos/SiloCertificatesTab.tsx b/app/pages/system/silos/SiloCertificatesTab.tsx index 6d919bbb24..35005f6187 100644 --- a/app/pages/system/silos/SiloCertificatesTab.tsx +++ b/app/pages/system/silos/SiloCertificatesTab.tsx @@ -33,6 +33,7 @@ import { useQueryTable } from '~/table/QueryTable' import { CreateButton } 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 type { Route } from './+types/SiloCertificatesTab' @@ -142,9 +143,17 @@ export default function SiloCertificatesTab() { // 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 - extraContent: isLastCert - ? 'This is the only certificate for this silo. Without it, the silo will not be reachable over HTTPS.' - : undefined, + // 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, }), }, ], 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}`, From 4c43227b07e79b9cbe0c92adeec7b8186c6f5d5f Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 15 Oct 2026 11:50:44 -0500 Subject: [PATCH 5/7] Use side modal form for adding silo cert --- app/components/form/fields/TlsCertsField.tsx | 98 +++++++++---------- app/forms/silo-certificate-create.tsx | 81 +++++++++++++++ .../system/silos/SiloCertificatesTab.tsx | 60 +++--------- app/routes.ts | 5 +- .../__snapshots__/path-builder.spec.ts.snap | 14 +++ app/util/path-builder.spec.ts | 1 + app/util/path-builder.ts | 2 + test/e2e/silos.e2e.ts | 9 +- 8 files changed, 167 insertions(+), 103 deletions(-) create mode 100644 app/forms/silo-certificate-create.tsx diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index 20543fe7a0..d13db0dbff 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -8,10 +8,10 @@ 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 { ApiError, CertificateCreate } from '@oxide/api' +import type { CertificateCreate } from '@oxide/api' import { OpenLink12Icon } from '@oxide/design-system/icons/react' import { getDelegatedDomain } from '~/forms/idp/util' @@ -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', @@ -117,25 +117,11 @@ type AddCertModalProps = { onSubmit: (values: CertFormValues) => void allNames: string[] siloName: string - loading?: boolean - submitError?: ApiError | null } -export const AddCertModal = ({ - onDismiss, - onSubmit, - allNames, - siloName, - loading, - submitError, -}: 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 AddCertModal = ({ onDismiss, onSubmit, allNames, siloName }: AddCertModalProps) => { + const { control, handleSubmit } = useForm({ + defaultValues: certDefaultValues, }) return ( @@ -143,36 +129,7 @@ export const AddCertModal = ({
- - allNames.includes(name) - ? 'A certificate with this name already exists' - : undefined - } - /> - - - - - {submitError && ( - - )} +
@@ -180,12 +137,51 @@ export const AddCertModal = ({ onDismiss={onDismiss} onAction={handleSubmit(onSubmit)} actionText="Add Certificate" - actionLoading={loading} /> ) } +/** 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/pages/system/silos/SiloCertificatesTab.tsx b/app/pages/system/silos/SiloCertificatesTab.tsx index 35005f6187..691633848b 100644 --- a/app/pages/system/silos/SiloCertificatesTab.tsx +++ b/app/pages/system/silos/SiloCertificatesTab.tsx @@ -7,12 +7,12 @@ */ import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useState } from 'react' +import { useCallback } from 'react' +import { Outlet } from 'react-router' import { api, getListQFn, - q, queryClient, useApiMutation, usePrefetchedQuery, @@ -21,20 +21,21 @@ import { import { Key24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { AddCertModal, parseCertificate } from '~/components/form/fields/TlsCertsField' +import { parseCertificate } from '~/components/form/fields/TlsCertsField' import { HL } from '~/components/HL' import { makeCrumb } from '~/hooks/use-crumbs' -import { useSiloSelector } from '~/hooks/use-params' +import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' -import { CreateButton } from '~/ui/lib/CreateButton' +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' @@ -55,7 +56,9 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { return null } -export const handle = makeCrumb('Certificates') +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 }) { @@ -100,11 +103,7 @@ const staticCols = [ ] export default function SiloCertificatesTab() { - const siloSelector = useSiloSelector() - const { silo } = siloSelector - // prefetched by the parent SiloPage loader - const { data: siloData } = usePrefetchedQuery(q(api.siloView, { path: siloSelector })) - const [showCreate, setShowCreate] = useState(false) + const { silo } = useSiloSelector() const { data: allCerts } = usePrefetchedQuery(allSiloCertsQuery(silo).optionsFn()) const isLastCert = allCerts.items.length === 1 @@ -119,18 +118,6 @@ export default function SiloCertificatesTab() { }, }) - const createCert = useApiMutation(api.siloCertificateCreate, { - onSuccess(cert) { - queryClient.invalidateEndpoint('siloCertificateList') - addToast( - <> - Certificate {cert.name} added - - ) - setShowCreate(false) - }, - }) - const makeActions = useCallback( (cert: Certificate): MenuAction[] => [ { @@ -170,39 +157,18 @@ export default function SiloCertificatesTab() { title="No certificates" body="Add a TLS certificate to see it here" buttonText="Add certificate" - onClick={() => setShowCreate(true)} + buttonTo={pb.siloCertificatesNew({ silo })} /> ), }) - const allNames = allCerts.items.map((c) => c.name) - return ( <>
- setShowCreate(true)}>Add certificate + Add certificate
{table} - {showCreate && ( - { - createCert.reset() - setShowCreate(false) - }} - onSubmit={async ({ cert, key, ...rest }) => { - // 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() }, - }) - }} - allNames={allNames} - siloName={siloData.name} - loading={createCert.isPending} - submitError={createCert.error} - /> - )} + ) } diff --git a/app/routes.ts b/app/routes.ts index 964d980bda..0ed4b0e99c 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -67,7 +67,10 @@ export default [ route('quotas', 'pages/system/silos/SiloQuotasTab.tsx'), route('fleet-roles', 'pages/system/silos/SiloFleetRolesTab.tsx'), route('scim', 'pages/system/silos/SiloScimTab.tsx'), - route('certificates', 'pages/system/silos/SiloCertificatesTab.tsx'), + layout('pages/system/silos/SiloCertificatesTab.tsx', [ + empty('certificates'), + route('certificates-new', 'forms/silo-certificate-create.tsx'), + ]), ]), ]), empty('issues'), diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 073724d463..d96180a316 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -701,6 +701,20 @@ exports[`breadcrumbs 2`] = ` "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/path-builder.spec.ts b/app/util/path-builder.spec.ts index 2e41827e0e..da24a2b670 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -115,6 +115,7 @@ test('path builder', () => { "silo": "/system/silos/s/idps", "siloAccess": "/access", "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 d7fe0bfcca..963968579f 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -172,6 +172,8 @@ export const pb = { 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), samlIdp: (params: PP.IdentityProvider) => href('/system/silos/:silo/idps/saml/:provider', params), diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index c51fd21b71..46d503db7e 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -596,12 +596,13 @@ test('Silo certificates tab', async ({ page }) => { }) // add - await page.getByRole('button', { name: 'Add certificate' }).click() + 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(page.getByLabel('Cert', { exact: true }), 'small') - await chooseFile(page.getByLabel('Key'), 'small') - await page.getByRole('button', { name: 'Add Certificate' }).click() + 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' }) From c63650d5512ffddaaf3287ae922ac9f978bce6e6 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 15 Oct 2026 12:01:21 -0500 Subject: [PATCH 6/7] Add silo cert detail side modal --- app/forms/silo-certificate-view.tsx | 157 ++++++++++++++++++ .../system/silos/SiloCertificatesTab.tsx | 10 +- app/routes.ts | 1 + .../__snapshots__/path-builder.spec.ts.snap | 14 ++ app/util/certificate.ts | 65 ++++++++ app/util/path-builder.spec.ts | 2 + app/util/path-builder.ts | 2 + app/util/path-params.ts | 1 + mock-api/silo.ts | 27 ++- test/e2e/silos.e2e.ts | 18 ++ 10 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 app/forms/silo-certificate-view.tsx create mode 100644 app/util/certificate.ts 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 index 691633848b..f8faf16459 100644 --- a/app/pages/system/silos/SiloCertificatesTab.tsx +++ b/app/pages/system/silos/SiloCertificatesTab.tsx @@ -7,7 +7,7 @@ */ import { createColumnHelper } from '@tanstack/react-table' -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { Outlet } from 'react-router' import { @@ -27,6 +27,7 @@ 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' @@ -86,8 +87,10 @@ function DomainsCell({ cert }: { cert: string }) { } const colHelper = createColumnHelper() -const staticCols = [ - colHelper.accessor('name', {}), +const makeStaticCols = (silo: string) => [ + colHelper.accessor('name', { + cell: makeLinkCell((certificate) => pb.siloCertificate({ silo, certificate })), + }), colHelper.accessor('description', Columns.description), colHelper.accessor('cert', { id: 'domains', @@ -147,6 +150,7 @@ export default function SiloCertificatesTab() { [deleteCert, isLastCert] ) + const staticCols = useMemo(() => makeStaticCols(silo), [silo]) const columns = useColsWithActions(staticCols, makeActions) const { table } = useQueryTable({ query: siloCertsQuery(silo), diff --git a/app/routes.ts b/app/routes.ts index 0ed4b0e99c..2a79c13227 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -70,6 +70,7 @@ export default [ layout('pages/system/silos/SiloCertificatesTab.tsx', [ empty('certificates'), route('certificates-new', 'forms/silo-certificate-create.tsx'), + route('certificates/:certificate', 'forms/silo-certificate-view.tsx'), ]), ]), ]), diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index d96180a316..948dd79e62 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -687,6 +687,20 @@ 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", 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 da24a2b670..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,7 @@ 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", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 963968579f..f4cda62dae 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -174,6 +174,8 @@ export const pb = { 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/silo.ts b/mock-api/silo.ts index 361f973a24..49920f28ef 100644 --- a/mock-api/silo.ts +++ b/mock-api/silo.ts @@ -249,6 +249,31 @@ AgNJADBGAiEAqeST0LMSYOw662qNZq6/Kx3JZtQ0W4nMRnrvjNCmvRwCIQDRoJVx -----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 } @@ -267,7 +292,7 @@ export const certificates: DbCertificate[] = [ id: 'b80d698f-4c8c-4394-892b-476541667f3a', name: 'maze-war-cert-old', description: '', - cert: mazeWarCertPem, + cert: mazeWarChainPem, service: 'external_api', time_created: new Date(2024, 2, 1).toISOString(), time_modified: new Date(2024, 2, 1).toISOString(), diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 46d503db7e..d3960362ae 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -624,3 +624,21 @@ test('Silo certificates tab', async ({ page }) => { 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') +}) From d2d163652fabffc4935bbb83c9678a4a68f2c20c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 15 Oct 2026 15:43:20 -0500 Subject: [PATCH 7/7] back to pinning omicron main --- OMICRON_VERSION | 2 +- app/api/__generated__/OMICRON_VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index c9ffe00fd0..da785ffb52 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -5cda99a72c6bccdc42d668791fa7b0b70b8733d2 +8e361f6be9176cd49da1f8c3dcf7ad6fba74966c diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 1779663e4b..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 -5cda99a72c6bccdc42d668791fa7b0b70b8733d2 +8e361f6be9176cd49da1f8c3dcf7ad6fba74966c