Skip to content

Commit 950767c

Browse files
authored
Validate IPv6 prefix field in VPC create form (#3343)
Closes #3339 <img width="481" height="120" alt="image" src="https://github.com/user-attachments/assets/b9706bff-bff9-4a16-82f6-8d53335a13a8" />
1 parent 7cd18ad commit 950767c

4 files changed

Lines changed: 140 additions & 4 deletions

File tree

app/forms/vpc-create.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
import { useForm } from 'react-hook-form'
99
import { useNavigate } from 'react-router'
10+
import type { SetNonNullable } from 'type-fest'
1011

1112
import { api, q, queryClient, useApiMutation, type VpcCreate } from '@oxide/api'
1213

@@ -19,13 +20,15 @@ import { titleCrumb } from '~/hooks/use-crumbs'
1920
import { useProjectSelector } from '~/hooks/use-params'
2021
import { addToast } from '~/stores/toast'
2122
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
23+
import { validateVpcIpv6Prefix } from '~/util/ip'
2224
import { docLinks } from '~/util/links'
2325
import { pb } from '~/util/path-builder'
2426

25-
const defaultValues: VpcCreate = {
27+
const defaultValues: SetNonNullable<Required<VpcCreate>> = {
2628
name: '',
2729
description: '',
2830
dnsName: '',
31+
ipv6Prefix: '',
2932
}
3033

3134
export const handle = titleCrumb('New VPC')
@@ -56,15 +59,30 @@ export default function CreateVpcSideModalForm() {
5659
form={form}
5760
formType="create"
5861
resourceName="VPC"
59-
onSubmit={(values) => createVpc.mutate({ query: projectSelector, body: values })}
62+
onSubmit={({ ipv6Prefix, ...rest }) =>
63+
createVpc.mutate({
64+
query: projectSelector,
65+
body: { ...rest, ipv6Prefix: ipv6Prefix.trim() || undefined },
66+
})
67+
}
6068
onDismiss={() => navigate(pb.vpcs(projectSelector))}
6169
loading={createVpc.isPending}
6270
submitError={createVpc.error}
6371
>
6472
<NameField name="name" control={form.control} />
6573
<DescriptionField name="description" control={form.control} />
6674
<NameField name="dnsName" label="DNS name" control={form.control} />
67-
<TextField name="ipv6Prefix" label="IPV6 prefix" control={form.control} />
75+
<TextField
76+
name="ipv6Prefix"
77+
label="IPv6 prefix"
78+
control={form.control}
79+
validate={(value) => {
80+
const prefix = value.trim()
81+
// field is optional — API generates a prefix if none is given
82+
if (!prefix) return
83+
return validateVpcIpv6Prefix(prefix)
84+
}}
85+
/>
6886
<SideModalFormDocs docs={[docLinks.vpcs]} />
6987
</SideModalForm>
7088
)

app/util/ip.spec.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import { describe, expect, test } from 'vitest'
1010

1111
import type { ExternalIp, IpVersion, UnicastIpPool } from '~/api'
1212

13-
import { getEphemeralIpSlots, toUrlCheckableIpv6, parseIp, parseIpNet } from './ip'
13+
import {
14+
getEphemeralIpSlots,
15+
toUrlCheckableIpv6,
16+
parseIp,
17+
parseIpNet,
18+
validateVpcIpv6Prefix,
19+
} from './ip'
1420

1521
const makePool = (ipVersion: IpVersion, name = `pool-${ipVersion}`): UnicastIpPool => ({
1622
id: `id-${name}`,
@@ -336,3 +342,34 @@ test.each([
336342
])('parseIpNet message: %s', (input, message) => {
337343
expect(parseIpNet(input)).toEqual({ type: 'error', message })
338344
})
345+
346+
describe('validateVpcIpv6Prefix', () => {
347+
test.each([
348+
'fd00::/48',
349+
'fd2d:4569:88b2::/48',
350+
'fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/48',
351+
'fd00::1/48', // host bits are fine, matching oxnet
352+
'fc00::/48', // std's is_unique_local covers fc00::/7
353+
])('valid: %s', (s) => {
354+
expect(validateVpcIpv6Prefix(s)).toBeUndefined()
355+
})
356+
357+
const notV6 = 'Must be an IPv6 prefix'
358+
const notUla = 'Must be a unique local address (fc00::/7)'
359+
const badPrefixWidth = 'Width must be 48'
360+
361+
test.each([
362+
['nonsense', nonsense],
363+
['fd00::', nonsense],
364+
['10.0.0.0/8', notV6],
365+
['::/48', notUla],
366+
['fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/48', notUla],
367+
['2001:db8::/48', notUla],
368+
['fe00::/48', notUla],
369+
['fd00::/64', badPrefixWidth],
370+
['fd00::/40', badPrefixWidth],
371+
['fd00::/129', ipv6Width],
372+
])('invalid: %s', (input, message) => {
373+
expect(validateVpcIpv6Prefix(input)).toEqual(message)
374+
})
375+
})

app/util/ip.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,32 @@ export function validateIpNet(ipNet: string): string | undefined {
137137
if (result.type === 'error') return result.message
138138
}
139139

140+
// The API requires a VPC IPv6 prefix to be a unique local address (fc00::/7)
141+
// with a width of exactly 48. Anything else is rejected on create.
142+
// https://github.com/oxidecomputer/omicron/blob/6db4c7e/common/src/api/external/mod.rs#L1287-L1288
143+
// https://github.com/oxidecomputer/omicron/blob/6db4c7e/nexus/db-model/src/vpc.rs#L86-L98
144+
export const VPC_IPV6_PREFIX_WIDTH = 48
145+
146+
/** First hextet of a valid IPv6 address, e.g. 0xfd00 for `fd00::1` or `fd00::` */
147+
function firstHextet(address: string): number {
148+
// a leading `::` means the first hextet is zero
149+
if (address.startsWith(':')) return 0
150+
return parseInt(address.split(':', 1)[0], 16)
151+
}
152+
153+
export function validateVpcIpv6Prefix(value: string): string | undefined {
154+
const result = parseIpNet(value)
155+
if (result.type === 'error') return result.message
156+
if (result.type !== 'v6') return 'Must be an IPv6 prefix'
157+
// Rust's `Ipv6Addr::is_unique_local` checks fc00::/7
158+
if ((firstHextet(result.address) & 0xfe00) !== 0xfc00) {
159+
return 'Must be a unique local address (fc00::/7)'
160+
}
161+
if (result.width !== VPC_IPV6_PREFIX_WIDTH) {
162+
return `Width must be ${VPC_IPV6_PREFIX_WIDTH}`
163+
}
164+
}
165+
140166
/**
141167
* Get compatible IP versions from an instance's NICs. External IPs route
142168
* through the primary interface, so only its IP stack matters.

test/e2e/vpcs.e2e.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,61 @@ test('can edit VPC', async ({ page }) => {
7474
})
7575
})
7676

77+
test('IPv6 prefix is validated on VPC create', async ({ page }) => {
78+
await page.goto('/projects/mock-project/vpcs')
79+
await page.getByRole('link', { name: 'New VPC' }).click()
80+
81+
const dialog = page.getByRole('dialog', { name: 'Create VPC' })
82+
await expect(dialog).toBeVisible()
83+
84+
await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill('vpc-v6')
85+
await dialog.getByRole('textbox', { name: 'DNS name' }).fill('vpc-v6')
86+
87+
const prefixField = dialog.getByRole('textbox', { name: 'IPv6 prefix' })
88+
const submitButton = dialog.getByRole('button', { name: 'Create VPC' })
89+
90+
await prefixField.fill('not a prefix 🎉')
91+
await submitButton.click()
92+
await expect(
93+
dialog.getByText('Must contain an IP address and a width, separated by a /')
94+
).toBeVisible()
95+
96+
// field revalidates on change after the first submit attempt
97+
await prefixField.fill('10.0.0.0/8')
98+
await expect(dialog.getByText('Must be an IPv6 prefix')).toBeVisible()
99+
100+
await prefixField.fill('2001:db8::/48')
101+
await expect(dialog.getByText('Must be a unique local address (fc00::/7)')).toBeVisible()
102+
103+
await prefixField.fill('fd00::/64')
104+
await expect(dialog.getByText('Width must be 48')).toBeVisible()
105+
106+
// empty is fine — the field is optional
107+
await prefixField.clear()
108+
await expect(dialog.getByText('Width must be 48')).toBeHidden()
109+
110+
await prefixField.fill(' fd2d:4569:88b2::/48 ')
111+
await submitButton.click()
112+
113+
await expect(dialog).toBeHidden()
114+
await expect(page.getByRole('heading', { name: 'vpc-v6' })).toBeVisible()
115+
await expect(page.getByText('fd2d:4569:88b2::/48')).toBeVisible()
116+
})
117+
118+
test('whitespace-only IPv6 prefix is omitted on VPC create', async ({ page }) => {
119+
await page.goto('/projects/mock-project/vpcs')
120+
await page.getByRole('link', { name: 'New VPC' }).click()
121+
122+
const dialog = page.getByRole('dialog', { name: 'Create VPC' })
123+
await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill('vpc-generated-v6')
124+
await dialog.getByRole('textbox', { name: 'DNS name' }).fill('vpc-generated-v6')
125+
await dialog.getByRole('textbox', { name: 'IPv6 prefix' }).fill(' ')
126+
await dialog.getByRole('button', { name: 'Create VPC' }).click()
127+
128+
await expect(dialog).toBeHidden()
129+
await expect(page.getByRole('heading', { name: 'vpc-generated-v6' })).toBeVisible()
130+
})
131+
77132
test('can create and delete subnet', async ({ page }) => {
78133
await page.goto('/projects/mock-project/vpcs/default')
79134
await page.getByRole('tab', { name: 'VPC Subnets' }).click()

0 commit comments

Comments
 (0)