Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions app/forms/vpc-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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<Required<VpcCreate>> = {
name: '',
description: '',
dnsName: '',
ipv6Prefix: '',
}

export const handle = titleCrumb('New VPC')
Expand Down Expand Up @@ -56,15 +59,30 @@ 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}
>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<NameField name="dnsName" label="DNS name" control={form.control} />
<TextField name="ipv6Prefix" label="IPV6 prefix" control={form.control} />
<TextField
name="ipv6Prefix"
label="IPv6 prefix"
control={form.control}
validate={(value) => {
const prefix = value.trim()
// field is optional — API generates a prefix if none is given
if (!prefix) return
return validateVpcIpv6Prefix(prefix)
}}
/>
<SideModalFormDocs docs={[docLinks.vpcs]} />
</SideModalForm>
)
Expand Down
39 changes: 38 additions & 1 deletion app/util/ip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down Expand Up @@ -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)
})
})

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked against the Rust logic with the following cargo script:

#!/usr/bin/env -S cargo +nightly -Zscript
---
[dependencies]
oxnet = "=0.1.6"
---

use oxnet::Ipv6Net;
use std::str::FromStr;

fn backend_accepts(value: &str) -> bool {
    Ipv6Net::from_str(value)
        .is_ok_and(|prefix| prefix.is_unique_local() && prefix.width() == 48)
}

fn main() {
    let cases = [
        ("fd00::/48", true),
        ("fd2d:4569:88b2::/48", true),
        ("fd00::1/48", true),
        ("fc00::/48", true),
        ("fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/48", true),
        ("nonsense", false),
        ("fd00::", false),
        ("10.0.0.0/8", false),
        ("::/48", false),
        ("fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/48", false),
        ("2001:db8::/48", false),
        ("fe00::/48", false),
        ("fd00::/64", false),
        ("fd00::/40", false),
        ("fd00::/129", false),
    ];

    let mismatches: Vec<_> = cases
        .into_iter()
        .filter(|(value, expected)| backend_accepts(value) != *expected)
        .collect();

    if mismatches.is_empty() {
        println!("all {} cases match oxnet 0.1.6", cases.len());
    } else {
        for (value, expected) in mismatches {
            eprintln!("{value}: expected {expected}, got {}", backend_accepts(value));
        }
        std::process::exit(1);
    }
}

26 changes: 26 additions & 0 deletions app/util/ip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vpc in the name is deliberate — there are VPC-specific rules here.

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.
Expand Down
55 changes: 55 additions & 0 deletions test/e2e/vpcs.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading