diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index 8a22867ba..da5ec721d 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -7,6 +7,7 @@ */ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' +import type { SetNonNullable } from 'type-fest' import { api, q, queryClient, useApiMutation, type VpcCreate } from '@oxide/api' @@ -19,13 +20,15 @@ import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { validateVpcIpv6Prefix } from '~/util/ip' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -const defaultValues: VpcCreate = { +const defaultValues: SetNonNullable> = { name: '', description: '', dnsName: '', + ipv6Prefix: '', } export const handle = titleCrumb('New VPC') @@ -56,7 +59,12 @@ export default function CreateVpcSideModalForm() { form={form} formType="create" resourceName="VPC" - onSubmit={(values) => createVpc.mutate({ query: projectSelector, body: values })} + onSubmit={({ ipv6Prefix, ...rest }) => + createVpc.mutate({ + query: projectSelector, + body: { ...rest, ipv6Prefix: ipv6Prefix.trim() || undefined }, + }) + } onDismiss={() => navigate(pb.vpcs(projectSelector))} loading={createVpc.isPending} submitError={createVpc.error} @@ -64,7 +72,17 @@ export default function CreateVpcSideModalForm() { - + { + const prefix = value.trim() + // field is optional — API generates a prefix if none is given + if (!prefix) return + return validateVpcIpv6Prefix(prefix) + }} + /> ) diff --git a/app/util/ip.spec.ts b/app/util/ip.spec.ts index 6965d8d38..5e21f2df5 100644 --- a/app/util/ip.spec.ts +++ b/app/util/ip.spec.ts @@ -10,7 +10,13 @@ import { describe, expect, test } from 'vitest' import type { ExternalIp, IpVersion, UnicastIpPool } from '~/api' -import { getEphemeralIpSlots, toUrlCheckableIpv6, parseIp, parseIpNet } from './ip' +import { + getEphemeralIpSlots, + toUrlCheckableIpv6, + parseIp, + parseIpNet, + validateVpcIpv6Prefix, +} from './ip' const makePool = (ipVersion: IpVersion, name = `pool-${ipVersion}`): UnicastIpPool => ({ id: `id-${name}`, @@ -336,3 +342,34 @@ test.each([ ])('parseIpNet message: %s', (input, message) => { expect(parseIpNet(input)).toEqual({ type: 'error', message }) }) + +describe('validateVpcIpv6Prefix', () => { + test.each([ + 'fd00::/48', + 'fd2d:4569:88b2::/48', + 'fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/48', + 'fd00::1/48', // host bits are fine, matching oxnet + 'fc00::/48', // std's is_unique_local covers fc00::/7 + ])('valid: %s', (s) => { + expect(validateVpcIpv6Prefix(s)).toBeUndefined() + }) + + const notV6 = 'Must be an IPv6 prefix' + const notUla = 'Must be a unique local address (fc00::/7)' + const badPrefixWidth = 'Width must be 48' + + test.each([ + ['nonsense', nonsense], + ['fd00::', nonsense], + ['10.0.0.0/8', notV6], + ['::/48', notUla], + ['fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/48', notUla], + ['2001:db8::/48', notUla], + ['fe00::/48', notUla], + ['fd00::/64', badPrefixWidth], + ['fd00::/40', badPrefixWidth], + ['fd00::/129', ipv6Width], + ])('invalid: %s', (input, message) => { + expect(validateVpcIpv6Prefix(input)).toEqual(message) + }) +}) diff --git a/app/util/ip.ts b/app/util/ip.ts index 760d703b6..6f2df6bb7 100644 --- a/app/util/ip.ts +++ b/app/util/ip.ts @@ -137,6 +137,32 @@ export function validateIpNet(ipNet: string): string | undefined { if (result.type === 'error') return result.message } +// The API requires a VPC IPv6 prefix to be a unique local address (fc00::/7) +// with a width of exactly 48. Anything else is rejected on create. +// https://github.com/oxidecomputer/omicron/blob/6db4c7e/common/src/api/external/mod.rs#L1287-L1288 +// https://github.com/oxidecomputer/omicron/blob/6db4c7e/nexus/db-model/src/vpc.rs#L86-L98 +export const VPC_IPV6_PREFIX_WIDTH = 48 + +/** First hextet of a valid IPv6 address, e.g. 0xfd00 for `fd00::1` or `fd00::` */ +function firstHextet(address: string): number { + // a leading `::` means the first hextet is zero + if (address.startsWith(':')) return 0 + return parseInt(address.split(':', 1)[0], 16) +} + +export function validateVpcIpv6Prefix(value: string): string | undefined { + const result = parseIpNet(value) + if (result.type === 'error') return result.message + if (result.type !== 'v6') return 'Must be an IPv6 prefix' + // Rust's `Ipv6Addr::is_unique_local` checks fc00::/7 + if ((firstHextet(result.address) & 0xfe00) !== 0xfc00) { + return 'Must be a unique local address (fc00::/7)' + } + if (result.width !== VPC_IPV6_PREFIX_WIDTH) { + return `Width must be ${VPC_IPV6_PREFIX_WIDTH}` + } +} + /** * Get compatible IP versions from an instance's NICs. External IPs route * through the primary interface, so only its IP stack matters. diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 1c5bf7590..36d42894e 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -74,6 +74,61 @@ test('can edit VPC', async ({ page }) => { }) }) +test('IPv6 prefix is validated on VPC create', async ({ page }) => { + await page.goto('/projects/mock-project/vpcs') + await page.getByRole('link', { name: 'New VPC' }).click() + + const dialog = page.getByRole('dialog', { name: 'Create VPC' }) + await expect(dialog).toBeVisible() + + await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill('vpc-v6') + await dialog.getByRole('textbox', { name: 'DNS name' }).fill('vpc-v6') + + const prefixField = dialog.getByRole('textbox', { name: 'IPv6 prefix' }) + const submitButton = dialog.getByRole('button', { name: 'Create VPC' }) + + await prefixField.fill('not a prefix 🎉') + await submitButton.click() + await expect( + dialog.getByText('Must contain an IP address and a width, separated by a /') + ).toBeVisible() + + // field revalidates on change after the first submit attempt + await prefixField.fill('10.0.0.0/8') + await expect(dialog.getByText('Must be an IPv6 prefix')).toBeVisible() + + await prefixField.fill('2001:db8::/48') + await expect(dialog.getByText('Must be a unique local address (fc00::/7)')).toBeVisible() + + await prefixField.fill('fd00::/64') + await expect(dialog.getByText('Width must be 48')).toBeVisible() + + // empty is fine — the field is optional + await prefixField.clear() + await expect(dialog.getByText('Width must be 48')).toBeHidden() + + await prefixField.fill(' fd2d:4569:88b2::/48 ') + await submitButton.click() + + await expect(dialog).toBeHidden() + await expect(page.getByRole('heading', { name: 'vpc-v6' })).toBeVisible() + await expect(page.getByText('fd2d:4569:88b2::/48')).toBeVisible() +}) + +test('whitespace-only IPv6 prefix is omitted on VPC create', async ({ page }) => { + await page.goto('/projects/mock-project/vpcs') + await page.getByRole('link', { name: 'New VPC' }).click() + + const dialog = page.getByRole('dialog', { name: 'Create VPC' }) + await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill('vpc-generated-v6') + await dialog.getByRole('textbox', { name: 'DNS name' }).fill('vpc-generated-v6') + await dialog.getByRole('textbox', { name: 'IPv6 prefix' }).fill(' ') + await dialog.getByRole('button', { name: 'Create VPC' }).click() + + await expect(dialog).toBeHidden() + await expect(page.getByRole('heading', { name: 'vpc-generated-v6' })).toBeVisible() +}) + test('can create and delete subnet', async ({ page }) => { await page.goto('/projects/mock-project/vpcs/default') await page.getByRole('tab', { name: 'VPC Subnets' }).click()