Skip to content
Draft
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
8 changes: 5 additions & 3 deletions app/components/ProtocolBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { Badge } from '@oxide/design-system/ui'

import type { VpcFirewallRuleProtocol } from '~/api'
import { getIcmpLabel } from '~/util/protocol'

type ProtocolBadgeProps = {
protocol: VpcFirewallRuleProtocol
Expand All @@ -19,14 +20,15 @@ export const ProtocolBadge = ({ protocol }: ProtocolBadgeProps) => {
return <Badge>{protocol.type.toUpperCase()}</Badge>
}

const label = getIcmpLabel(protocol.type)

if (protocol.value === null) {
// All ICMP types
return <Badge>ICMP</Badge>
return <Badge>{label}</Badge>
}

return (
<div className="space-x-0.5">
<Badge>ICMP</Badge>
<Badge>{label}</Badge>
<Badge variant="solid">
<span className="flex items-center gap-1.5">
<span>type {protocol.value.icmpType}</span>
Expand Down
48 changes: 34 additions & 14 deletions app/forms/firewall-rules-common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { useEffect, type ReactNode } from 'react'
import { useController, useForm, type Control } from 'react-hook-form'
import { match } from 'ts-pattern'

import { Badge } from '@oxide/design-system/ui'

Expand Down Expand Up @@ -52,7 +53,13 @@ import { KEYS } from '~/ui/util/keys'
import { ALL_ISH } from '~/util/consts'
import { validateIp, validateIpNet } from '~/util/ip'
import { links } from '~/util/links'
import { getProtocolDisplayName, getProtocolKey, ICMP_TYPES } from '~/util/protocol'
import {
getIcmpLabel,
getProtocolDisplayName,
getProtocolKey,
ICMPV4_TYPES,
ICMPV6_TYPES,
} from '~/util/protocol'
import { capitalize, normalizeDashes } from '~/util/str'

import { type FirewallRuleValues } from './firewall-rules-util'
Expand Down Expand Up @@ -293,18 +300,22 @@ const protocolTypeItems: Array<{ value: VpcFirewallRuleProtocol['type']; label:
[
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
{ value: 'icmp', label: 'ICMP' },
{ value: 'icmp', label: 'ICMPv4' },
{ value: 'icmp6', label: 'ICMPv6' },
]

const icmpTypeItems = [
const buildIcmpTypeItems = (types: Record<number, string>) => [
{ value: '', label: 'All types', selectedLabel: 'All types' },
...Object.entries(ICMP_TYPES).map(([type, name]) => ({
...Object.entries(types).map(([type, name]) => ({
value: type,
label: `${type} - ${name}`,
selectedLabel: type,
})),
]

const icmpV4TypeItems = buildIcmpTypeItems(ICMPV4_TYPES)
const icmpV6TypeItems = buildIcmpTypeItems(ICMPV6_TYPES)

const targetAndHostTableColumns = [
{
header: 'Type',
Expand Down Expand Up @@ -343,13 +354,13 @@ const isDuplicateProtocol = (
return existingProtocols.some((p) => p.type === newProtocol.type)
}

if (newProtocol.type === 'icmp') {
if (newProtocol.type === 'icmp' || newProtocol.type === 'icmp6') {
if (newProtocol.value === null) {
return existingProtocols.some((p) => p.type === 'icmp' && p.value === null)
return existingProtocols.some((p) => p.type === newProtocol.type && p.value === null)
}
return existingProtocols.some(
(p) =>
p.type === 'icmp' &&
p.type === newProtocol.type &&
p.value?.icmpType === newProtocol.value?.icmpType &&
p.value?.code === newProtocol.value?.code
)
Expand Down Expand Up @@ -423,7 +434,7 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })
const submitProtocol = protocolForm.handleSubmit((values) => {
if (values.protocolType === 'tcp' || values.protocolType === 'udp') {
addProtocolIfUnique({ type: values.protocolType })
} else if (values.protocolType === 'icmp') {
} else if (values.protocolType === 'icmp' || values.protocolType === 'icmp6') {
// this parse should never fail because we've already validated, but doing
// it this way keeps the just-in-case early return logic consistent
const parseResult = parseIcmpType(values.icmpType)
Expand All @@ -432,14 +443,14 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })
const icmpType = parseResult.data
if (icmpType === undefined) {
// All ICMP types
addProtocolIfUnique({ type: 'icmp', value: null })
addProtocolIfUnique({ type: values.protocolType, value: null })
} else {
// Specific ICMP type
const icmpValue: VpcFirewallIcmpFilter = { icmpType }
if (values.icmpCode) {
icmpValue.code = values.icmpCode
}
addProtocolIfUnique({ type: 'icmp', value: icmpValue })
addProtocolIfUnique({ type: values.protocolType, value: icmpValue })
}
}
protocolForm.reset()
Expand All @@ -461,19 +472,28 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })
control={protocolForm.control}
placeholder=""
items={protocolTypeItems}
// ICMPv4 and ICMPv6 type numbers mean different things, so clear the
// selected ICMP type/code when switching protocol
onChange={() => {
protocolForm.setValue('icmpType', '')
protocolForm.setValue('icmpCode', '')
}}
/>

{selectedProtocolType === 'icmp' && (
{(selectedProtocolType === 'icmp' || selectedProtocolType === 'icmp6') && (
<>
<ComboboxField
label="ICMP type"
label={`${getIcmpLabel(selectedProtocolType)} type`}
name="icmpType"
control={protocolForm.control}
description="Leave blank to match any type"
placeholder=""
allowArbitraryValues
onInputChange={(value) => protocolForm.setValue('icmpType', value)}
items={icmpTypeItems}
items={match(selectedProtocolType)
.with('icmp', () => icmpV4TypeItems)
.with('icmp6', () => icmpV6TypeItems)
.exhaustive()}
validate={(value) => {
const result = parseIcmpType(value)
if (!result.success) return result.message
Expand All @@ -482,7 +502,7 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })

{selectedIcmpType !== undefined && selectedIcmpType !== '' && (
<TextField
label="ICMP code"
label={`${getIcmpLabel(selectedProtocolType)} code`}
name="icmpCode"
control={protocolForm.control}
description={
Expand Down
26 changes: 21 additions & 5 deletions app/table/cells/ProtocolCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,43 @@
* Copyright Oxide Computer Company
*/

import { match } from 'ts-pattern'

import { Badge } from '@oxide/design-system/ui'

import type { VpcFirewallRuleProtocol } from '~/api'
import { Tooltip } from '~/ui/lib/Tooltip'

import { EmptyCell } from './EmptyCell'

const protocolLabel = (protocol: VpcFirewallRuleProtocol) =>
match(protocol.type)
.with('tcp', () => 'TCP')
.with('udp', () => 'UDP')
.with('icmp', () => 'ICMPv4')
.with('icmp6', () => 'ICMPv6')
.exhaustive()

const isIcmp = (
protocol: VpcFirewallRuleProtocol
): protocol is Extract<VpcFirewallRuleProtocol, { type: 'icmp' | 'icmp6' }> =>
protocol.type === 'icmp' || protocol.type === 'icmp6'

export const ProtocolCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) => (
<Badge>{protocol.type.toUpperCase()}</Badge>
<Badge>{protocolLabel(protocol)}</Badge>
)

/** Generate tooltip content for empty protocol cells in the mini table */
const protocolEmptyCellTooltipContent = (protocol: VpcFirewallRuleProtocol): string => {
if (protocol.type === 'tcp') return 'This firewall rule will match all TCP traffic'
if (protocol.type === 'udp') return 'This firewall rule will match all UDP traffic'
const label = protocolLabel(protocol)
// in this case, the user could be looking at the type column or the code column, but both get the same tooltip
if (protocol.value === null) {
return 'This firewall rule will match all ICMP traffic'
return `This firewall rule will match all ${label} traffic`
}
// in this case, there's an icmpType but no code, which means the user is looking at the code column
return `This firewall rule will match all ICMP traffic of type ${protocol.value.icmpType}`
return `This firewall rule will match all ${label} traffic of type ${protocol.value.icmpType}`
}

export const ProtocolEmptyCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) => (
Expand All @@ -39,15 +55,15 @@ export const ProtocolEmptyCell = ({ protocol }: { protocol: VpcFirewallRuleProto

export const ProtocolTypeCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) =>
// icmpType could be zero, so we check for `not undefined`
protocol.type === 'icmp' && protocol.value?.icmpType !== undefined ? (
isIcmp(protocol) && protocol.value?.icmpType !== undefined ? (
protocol.value.icmpType
) : (
<ProtocolEmptyCell protocol={protocol} />
)

export const ProtocolCodeCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) =>
// code could be zero, so we check for `not undefined`
protocol.type === 'icmp' && protocol.value?.code !== undefined ? (
isIcmp(protocol) && protocol.value?.code !== undefined ? (
protocol.value.code
) : (
<ProtocolEmptyCell protocol={protocol} />
Expand Down
66 changes: 52 additions & 14 deletions app/util/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
* Copyright Oxide Computer Company
*/

import { match } from 'ts-pattern'

import type { VpcFirewallRuleProtocol } from '~/api'

export const ICMP_TYPES: Record<number, string> = {
// IANA ICMP Type Numbers registry:
// https://www.iana.org/assignments/icmp-parameters/icmp-parameters.xhtml#icmp-parameters-types
export const ICMPV4_TYPES: Record<number, string> = {
0: 'Echo Reply',
3: 'Destination Unreachable',
5: 'Redirect Message',
Expand All @@ -21,26 +25,60 @@ export const ICMP_TYPES: Record<number, string> = {
14: 'Timestamp Reply',
}

// IANA ICMPv6 Type Numbers registry:
// https://www.iana.org/assignments/icmpv6-parameters/icmpv6-parameters.xhtml#icmpv6-parameters-2
export const ICMPV6_TYPES: Record<number, string> = {
1: 'Destination Unreachable',
2: 'Packet Too Big',
3: 'Time Exceeded',
4: 'Parameter Problem',
128: 'Echo Request',
129: 'Echo Reply',
130: 'Multicast Listener Query',
131: 'Multicast Listener Report',
132: 'Multicast Listener Done',
133: 'Router Solicitation',
134: 'Router Advertisement',
135: 'Neighbor Solicitation',
136: 'Neighbor Advertisement',
137: 'Redirect Message',
}

export type IcmpVariant = 'icmp' | 'icmp6'

const typesFor = (variant: IcmpVariant) =>
match(variant)
.with('icmp', () => ICMPV4_TYPES)
.with('icmp6', () => ICMPV6_TYPES)
.exhaustive()

export const getIcmpLabel = (variant: IcmpVariant) =>
match(variant)
.with('icmp', () => 'ICMPv4' as const)
.with('icmp6', () => 'ICMPv6' as const)
.exhaustive()

/**
* Get the human-readable name for an ICMP type
*/
export const getIcmpTypeName = (type: number): string | undefined => ICMP_TYPES[type]
export const getIcmpTypeName = (variant: IcmpVariant, type: number): string | undefined =>
typesFor(variant)[type]

/**
* Get a display name for a protocol, including ICMP types and codes
*/
export const getProtocolDisplayName = (protocol: VpcFirewallRuleProtocol): string => {
if (protocol.type === 'icmp') {
if (protocol.value === null) {
return 'ICMP (All types)'
} else {
const typeName =
ICMP_TYPES[protocol.value.icmpType] || `Type ${protocol.value.icmpType}`
const codePart = protocol.value.code ? ` | Code ${protocol.value.code}` : ''
return `ICMP ${protocol.value.icmpType} - ${typeName}${codePart}`
}
if (protocol.type === 'tcp' || protocol.type === 'udp') {
return protocol.type.toUpperCase()
}
const label = getIcmpLabel(protocol.type)
if (protocol.value === null) {
return `${label} (All types)`
}
return protocol.type.toUpperCase()
const typeName =
typesFor(protocol.type)[protocol.value.icmpType] || `Type ${protocol.value.icmpType}`
const codePart = protocol.value.code ? ` | Code ${protocol.value.code}` : ''
return `${label} ${protocol.value.icmpType} - ${typeName}${codePart}`
}

/**
Expand All @@ -52,6 +90,6 @@ export const getProtocolKey = (protocol: VpcFirewallRuleProtocol): string => {
return protocol.type
}
return protocol.value === null
? 'icmp|all'
: `icmp|${protocol.value.icmpType}|${protocol.value.code || 'all'}`
? `${protocol.type}|all`
: `${protocol.type}|${protocol.value.icmpType}|${protocol.value.code || 'all'}`
}
6 changes: 5 additions & 1 deletion mock-api/vpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,11 @@ export const firewallRules: Json<VpcFirewallRule[]> = [
description: 'we just want to test with lots of filters',
filters: {
ports: ['3389', '45-89'],
protocols: [{ type: 'tcp' }, { type: 'icmp', value: { icmp_type: 5, code: '1-3' } }],
protocols: [
{ type: 'tcp' },
{ type: 'icmp', value: { icmp_type: 5, code: '1-3' } },
{ type: 'icmp6', value: { icmp_type: 128 } },
],
hosts: [
{ type: 'instance', value: 'hello-friend' },
{ type: 'subnet', value: 'my-subnet' },
Expand Down
Loading
Loading