Skip to content

Commit f9366d3

Browse files
authored
ICMPv4 + v6 in firewall rules (#3212)
Closes #3207 This uses `ICMPv4` and `ICMPv6`. Generally we try to follow what the API calls things, but the strings in the API are `icmp` and `icmp6`, while all the human-readable stuff like doc comments, PR titles, and docs use the ICMPv6 version. <img width="489" height="256" alt="Screenshot 2026-05-04 at 6 45 34 PM" src="https://github.com/user-attachments/assets/87a332a7-1431-426a-a990-462308ea82b1" /> <img width="482" height="492" alt="Screenshot 2026-05-04 at 6 45 47 PM" src="https://github.com/user-attachments/assets/ec2ac598-8fe0-44db-b758-13a7159343c2" /> <img width="476" height="489" alt="image" src="https://github.com/user-attachments/assets/ae4ecae3-8666-4df9-b191-5f3f81b1e35d" /> <img width="487" height="265" alt="Screenshot 2026-05-04 at 6 48 05 PM" src="https://github.com/user-attachments/assets/d7933e48-6baf-495c-97a0-a57005e6efb6" />
1 parent 621c61b commit f9366d3

6 files changed

Lines changed: 198 additions & 94 deletions

File tree

app/components/ProtocolBadge.tsx

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,36 @@
66
* Copyright Oxide Computer Company
77
*/
88

9+
import { match } from 'ts-pattern'
10+
911
import { Badge } from '@oxide/design-system/ui'
1012

1113
import type { VpcFirewallRuleProtocol } from '~/api'
14+
import { PROTOCOL_LABELS } from '~/util/protocol'
1215

13-
type ProtocolBadgeProps = {
14-
protocol: VpcFirewallRuleProtocol
15-
}
16-
17-
export const ProtocolBadge = ({ protocol }: ProtocolBadgeProps) => {
18-
if (protocol.type === 'tcp' || protocol.type === 'udp') {
19-
return <Badge>{protocol.type.toUpperCase()}</Badge>
20-
}
21-
22-
if (protocol.value === null) {
23-
// All ICMP types
24-
return <Badge>ICMP</Badge>
25-
}
26-
16+
export const ProtocolBadge = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) => {
17+
// only ICMP v4/v6 carry a nullable type/code value; tcp/udp have none
18+
const value = match(protocol)
19+
.with({ type: 'tcp' }, { type: 'udp' }, () => null)
20+
.with({ type: 'icmp' }, { type: 'icmp6' }, (p) => p.value)
21+
.exhaustive()
2722
return (
2823
<div className="space-x-0.5">
29-
<Badge>ICMP</Badge>
30-
<Badge variant="solid">
31-
<span className="flex items-center gap-1.5">
32-
<span>type {protocol.value.icmpType}</span>
33-
{protocol.value.code && (
34-
<>
35-
<span className="border-l-accent-secondary h-[10px] border-l" />
36-
<span>code {protocol.value.code}</span>
37-
</>
38-
)}
39-
</span>
40-
</Badge>
24+
<Badge>{PROTOCOL_LABELS[protocol.type]}</Badge>
25+
{/* null value (or tcp/udp) means all types, so there's no type/code badge */}
26+
{value !== null && (
27+
<Badge variant="solid">
28+
<span className="flex items-center gap-1.5">
29+
<span>type {value.icmpType}</span>
30+
{value.code && (
31+
<>
32+
<span className="border-l-accent-secondary h-2.5 border-l" />
33+
<span>code {value.code}</span>
34+
</>
35+
)}
36+
</span>
37+
</Badge>
38+
)}
4139
</div>
4240
)
4341
}

app/forms/firewall-rules-common.tsx

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ import { KEYS } from '~/ui/util/keys'
5252
import { ALL_ISH } from '~/util/consts'
5353
import { validateIp, validateIpNet } from '~/util/ip'
5454
import { links } from '~/util/links'
55-
import { getProtocolDisplayName, getProtocolKey, ICMP_TYPES } from '~/util/protocol'
55+
import {
56+
getProtocolDisplayName,
57+
getProtocolKey,
58+
ICMP_TYPES,
59+
PROTOCOL_LABELS,
60+
} from '~/util/protocol'
5661
import { capitalize, normalizeDashes } from '~/util/str'
5762

5863
import { type FirewallRuleValues } from './firewall-rules-util'
@@ -289,22 +294,27 @@ const directionItems: Array<{ value: VpcFirewallRuleDirection; label: string }>
289294
{ value: 'outbound', label: 'Outbound' },
290295
]
291296

292-
const protocolTypeItems: Array<{ value: VpcFirewallRuleProtocol['type']; label: string }> =
293-
[
294-
{ value: 'tcp', label: 'TCP' },
295-
{ value: 'udp', label: 'UDP' },
296-
{ value: 'icmp', label: 'ICMP' },
297-
]
297+
const protocolTypeItems = [
298+
{ value: 'tcp', label: 'TCP' },
299+
{ value: 'udp', label: 'UDP' },
300+
{ value: 'icmp', label: 'ICMPv4' },
301+
{ value: 'icmp6', label: 'ICMPv6' },
302+
] satisfies Array<{ value: VpcFirewallRuleProtocol['type']; label: string }>
298303

299-
const icmpTypeItems = [
304+
const buildIcmpTypeItems = (types: Record<number, string>) => [
300305
{ value: '', label: 'All types', selectedLabel: 'All types' },
301-
...Object.entries(ICMP_TYPES).map(([type, name]) => ({
306+
...Object.entries(types).map(([type, name]) => ({
302307
value: type,
303308
label: `${type} - ${name}`,
304309
selectedLabel: type,
305310
})),
306311
]
307312

313+
const icmpTypeItems = {
314+
icmp: buildIcmpTypeItems(ICMP_TYPES.icmp),
315+
icmp6: buildIcmpTypeItems(ICMP_TYPES.icmp6),
316+
}
317+
308318
const targetAndHostTableColumns = [
309319
{
310320
header: 'Type',
@@ -343,13 +353,13 @@ const isDuplicateProtocol = (
343353
return existingProtocols.some((p) => p.type === newProtocol.type)
344354
}
345355

346-
if (newProtocol.type === 'icmp') {
356+
if (newProtocol.type === 'icmp' || newProtocol.type === 'icmp6') {
347357
if (newProtocol.value === null) {
348-
return existingProtocols.some((p) => p.type === 'icmp' && p.value === null)
358+
return existingProtocols.some((p) => p.type === newProtocol.type && p.value === null)
349359
}
350360
return existingProtocols.some(
351361
(p) =>
352-
p.type === 'icmp' &&
362+
p.type === newProtocol.type &&
353363
p.value?.icmpType === newProtocol.value?.icmpType &&
354364
p.value?.code === newProtocol.value?.code
355365
)
@@ -361,9 +371,15 @@ const isDuplicateProtocol = (
361371
type ParseResult<T> = { success: true; data: T } | { success: false; message: string }
362372

363373
const parseIcmpType = (value: string | undefined): ParseResult<number | undefined> => {
364-
if (value === undefined || value === '') return { success: true, data: undefined }
365-
const parsed = parseInt(value, 10)
366-
if (isNaN(parsed) || parsed < 0 || parsed > 255) {
374+
const trimmedValue = value?.trim()
375+
if (trimmedValue === undefined || trimmedValue === '') {
376+
return { success: true, data: undefined }
377+
}
378+
if (!/^\d+$/.test(trimmedValue)) {
379+
return { success: false, message: `ICMP type must be a number between 0 and 255` }
380+
}
381+
const parsed = parseInt(trimmedValue, 10)
382+
if (parsed < 0 || parsed > 255) {
367383
return { success: false, message: `ICMP type must be a number between 0 and 255` }
368384
}
369385
return { success: true, data: parsed }
@@ -423,7 +439,7 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })
423439
const submitProtocol = protocolForm.handleSubmit((values) => {
424440
if (values.protocolType === 'tcp' || values.protocolType === 'udp') {
425441
addProtocolIfUnique({ type: values.protocolType })
426-
} else if (values.protocolType === 'icmp') {
442+
} else if (values.protocolType === 'icmp' || values.protocolType === 'icmp6') {
427443
// this parse should never fail because we've already validated, but doing
428444
// it this way keeps the just-in-case early return logic consistent
429445
const parseResult = parseIcmpType(values.icmpType)
@@ -432,14 +448,15 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })
432448
const icmpType = parseResult.data
433449
if (icmpType === undefined) {
434450
// All ICMP types
435-
addProtocolIfUnique({ type: 'icmp', value: null })
451+
addProtocolIfUnique({ type: values.protocolType, value: null })
436452
} else {
437453
// Specific ICMP type
438454
const icmpValue: VpcFirewallIcmpFilter = { icmpType }
439-
if (values.icmpCode) {
440-
icmpValue.code = values.icmpCode
455+
const icmpCode = values.icmpCode?.trim()
456+
if (icmpCode) {
457+
icmpValue.code = icmpCode
441458
}
442-
addProtocolIfUnique({ type: 'icmp', value: icmpValue })
459+
addProtocolIfUnique({ type: values.protocolType, value: icmpValue })
443460
}
444461
}
445462
protocolForm.reset()
@@ -461,19 +478,28 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })
461478
control={protocolForm.control}
462479
placeholder=""
463480
items={protocolTypeItems}
481+
// ICMPv4 and ICMPv6 type numbers mean different things, so clear the
482+
// selected ICMP type/code when switching protocol. Also clear errors:
483+
// setValue doesn't revalidate, so a stale type error would otherwise
484+
// linger on the now-empty field.
485+
onChange={() => {
486+
protocolForm.setValue('icmpType', '')
487+
protocolForm.setValue('icmpCode', '')
488+
protocolForm.clearErrors(['icmpType', 'icmpCode'])
489+
}}
464490
/>
465491

466-
{selectedProtocolType === 'icmp' && (
492+
{(selectedProtocolType === 'icmp' || selectedProtocolType === 'icmp6') && (
467493
<>
468494
<ComboboxField
469-
label="ICMP type"
495+
label={`${PROTOCOL_LABELS[selectedProtocolType]} type`}
470496
name="icmpType"
471497
control={protocolForm.control}
472498
description="Leave blank to match any type"
473499
placeholder=""
474500
allowArbitraryValues
475501
onInputChange={(value) => protocolForm.setValue('icmpType', value)}
476-
items={icmpTypeItems}
502+
items={icmpTypeItems[selectedProtocolType]}
477503
validate={(value) => {
478504
const result = parseIcmpType(value)
479505
if (!result.success) return result.message
@@ -482,7 +508,7 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })
482508

483509
{selectedIcmpType !== undefined && selectedIcmpType !== '' && (
484510
<TextField
485-
label="ICMP code"
511+
label={`${PROTOCOL_LABELS[selectedProtocolType]} code`}
486512
name="icmpCode"
487513
control={protocolForm.control}
488514
description={

app/table/cells/ProtocolCell.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,23 @@ import { Badge } from '@oxide/design-system/ui'
1010

1111
import type { VpcFirewallRuleProtocol } from '~/api'
1212
import { Tooltip } from '~/ui/lib/Tooltip'
13+
import { PROTOCOL_LABELS } from '~/util/protocol'
1314

1415
import { EmptyCell } from './EmptyCell'
1516

1617
export const ProtocolCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) => (
17-
<Badge>{protocol.type.toUpperCase()}</Badge>
18+
<Badge>{PROTOCOL_LABELS[protocol.type]}</Badge>
1819
)
1920

2021
/** Generate tooltip content for empty protocol cells in the mini table */
2122
const protocolEmptyCellTooltipContent = (protocol: VpcFirewallRuleProtocol): string => {
22-
if (protocol.type === 'tcp') return 'This firewall rule will match all TCP traffic'
23-
if (protocol.type === 'udp') return 'This firewall rule will match all UDP traffic'
24-
// in this case, the user could be looking at the type column or the code column, but both get the same tooltip
25-
if (protocol.value === null) {
26-
return 'This firewall rule will match all ICMP traffic'
23+
const label = PROTOCOL_LABELS[protocol.type]
24+
if (protocol.type === 'tcp' || protocol.type === 'udp' || protocol.value === null) {
25+
return `This firewall rule will match all ${label} traffic`
2726
}
28-
// in this case, there's an icmpType but no code, which means the user is looking at the code column
29-
return `This firewall rule will match all ICMP traffic of type ${protocol.value.icmpType}`
27+
// type column shows nothing only when value is null (handled above), so
28+
// reaching here means we're in the code column and there is a type but no code
29+
return `This firewall rule will match all ${label} traffic of type ${protocol.value.icmpType}`
3030
}
3131

3232
export const ProtocolEmptyCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) => (
@@ -39,15 +39,17 @@ export const ProtocolEmptyCell = ({ protocol }: { protocol: VpcFirewallRuleProto
3939

4040
export const ProtocolTypeCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) =>
4141
// icmpType could be zero, so we check for `not undefined`
42-
protocol.type === 'icmp' && protocol.value?.icmpType !== undefined ? (
42+
(protocol.type === 'icmp' || protocol.type === 'icmp6') &&
43+
protocol.value?.icmpType !== undefined ? (
4344
protocol.value.icmpType
4445
) : (
4546
<ProtocolEmptyCell protocol={protocol} />
4647
)
4748

4849
export const ProtocolCodeCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) =>
4950
// code could be zero, so we check for `not undefined`
50-
protocol.type === 'icmp' && protocol.value?.code !== undefined ? (
51+
(protocol.type === 'icmp' || protocol.type === 'icmp6') &&
52+
protocol.value?.code !== undefined ? (
5153
protocol.value.code
5254
) : (
5355
<ProtocolEmptyCell protocol={protocol} />

app/util/protocol.ts

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88

99
import type { VpcFirewallRuleProtocol } from '~/api'
1010

11-
export const ICMP_TYPES: Record<number, string> = {
11+
// Common suggestions from the IANA ICMP Type Numbers registry. Users may enter
12+
// any valid type number, including types not listed here.
13+
// https://www.iana.org/assignments/icmp-parameters/icmp-parameters.xhtml#icmp-parameters-types
14+
const ICMPV4_TYPES: Record<number, string> = {
1215
0: 'Echo Reply',
1316
3: 'Destination Unreachable',
1417
5: 'Redirect Message',
@@ -21,26 +24,47 @@ export const ICMP_TYPES: Record<number, string> = {
2124
14: 'Timestamp Reply',
2225
}
2326

24-
/**
25-
* Get the human-readable name for an ICMP type
26-
*/
27-
export const getIcmpTypeName = (type: number): string | undefined => ICMP_TYPES[type]
27+
// Common suggestions from the IANA ICMPv6 Type Numbers registry. Users may enter
28+
// any valid type number, including types not listed here.
29+
// https://www.iana.org/assignments/icmpv6-parameters/icmpv6-parameters.xhtml#icmpv6-parameters-2
30+
const ICMPV6_TYPES: Record<number, string> = {
31+
1: 'Destination Unreachable',
32+
2: 'Packet Too Big',
33+
3: 'Time Exceeded',
34+
4: 'Parameter Problem',
35+
128: 'Echo Request',
36+
129: 'Echo Reply',
37+
130: 'Multicast Listener Query',
38+
131: 'Multicast Listener Report',
39+
132: 'Multicast Listener Done',
40+
133: 'Router Solicitation',
41+
134: 'Router Advertisement',
42+
135: 'Neighbor Solicitation',
43+
136: 'Neighbor Advertisement',
44+
137: 'Redirect Message',
45+
}
2846

29-
/**
30-
* Get a display name for a protocol, including ICMP types and codes
31-
*/
47+
export const ICMP_TYPES: Record<'icmp' | 'icmp6', Record<number, string>> = {
48+
icmp: ICMPV4_TYPES,
49+
icmp6: ICMPV6_TYPES,
50+
}
51+
52+
export const PROTOCOL_LABELS = {
53+
tcp: 'TCP',
54+
udp: 'UDP',
55+
icmp: 'ICMPv4',
56+
icmp6: 'ICMPv6',
57+
} as const satisfies Record<VpcFirewallRuleProtocol['type'], string>
58+
59+
/** Get a display name for a protocol, including ICMP types and codes */
3260
export const getProtocolDisplayName = (protocol: VpcFirewallRuleProtocol): string => {
33-
if (protocol.type === 'icmp') {
34-
if (protocol.value === null) {
35-
return 'ICMP (All types)'
36-
} else {
37-
const typeName =
38-
ICMP_TYPES[protocol.value.icmpType] || `Type ${protocol.value.icmpType}`
39-
const codePart = protocol.value.code ? ` | Code ${protocol.value.code}` : ''
40-
return `ICMP ${protocol.value.icmpType} - ${typeName}${codePart}`
41-
}
42-
}
43-
return protocol.type.toUpperCase()
61+
const label = PROTOCOL_LABELS[protocol.type]
62+
if (protocol.type === 'tcp' || protocol.type === 'udp') return label
63+
if (protocol.value === null) return `${label} (All types)`
64+
const typeName =
65+
ICMP_TYPES[protocol.type][protocol.value.icmpType] || `Type ${protocol.value.icmpType}`
66+
const codePart = protocol.value.code ? ` | Code ${protocol.value.code}` : ''
67+
return `${label} ${protocol.value.icmpType} - ${typeName}${codePart}`
4468
}
4569

4670
/**
@@ -52,6 +76,6 @@ export const getProtocolKey = (protocol: VpcFirewallRuleProtocol): string => {
5276
return protocol.type
5377
}
5478
return protocol.value === null
55-
? 'icmp|all'
56-
: `icmp|${protocol.value.icmpType}|${protocol.value.code || 'all'}`
79+
? `${protocol.type}|all`
80+
: `${protocol.type}|${protocol.value.icmpType}|${protocol.value.code || 'all'}`
5781
}

mock-api/vpc.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,11 @@ export const firewallRules: Json<VpcFirewallRule[]> = [
321321
description: 'we just want to test with lots of filters',
322322
filters: {
323323
ports: ['3389', '45-89'],
324-
protocols: [{ type: 'tcp' }, { type: 'icmp', value: { icmp_type: 5, code: '1-3' } }],
324+
protocols: [
325+
{ type: 'tcp' },
326+
{ type: 'icmp', value: { icmp_type: 5, code: '1-3' } },
327+
{ type: 'icmp6', value: { icmp_type: 128 } },
328+
],
325329
hosts: [
326330
{ type: 'instance', value: 'hello-friend' },
327331
{ type: 'subnet', value: 'my-subnet' },

0 commit comments

Comments
 (0)