diff --git a/app/forms/firewall-rules-common.tsx b/app/forms/firewall-rules-common.tsx new file mode 100644 index 000000000..bc7c9aa33 --- /dev/null +++ b/app/forms/firewall-rules-common.tsx @@ -0,0 +1,507 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useController, type Control } from 'react-hook-form' + +import type { ApiError, VpcFirewallRuleHostFilter, VpcFirewallRuleTarget } from '~/api' +import { parsePortRange } from '~/api/util' +import { CheckboxField } from '~/components/form/fields/CheckboxField' +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { ListboxField } from '~/components/form/fields/ListboxField' +import { NameField } from '~/components/form/fields/NameField' +import { NumberField } from '~/components/form/fields/NumberField' +import { RadioField } from '~/components/form/fields/RadioField' +import { TextField, TextFieldInner } from '~/components/form/fields/TextField' +import { useForm } from '~/hooks/use-form' +import { Badge } from '~/ui/lib/Badge' +import { Button } from '~/ui/lib/Button' +import { FormDivider } from '~/ui/lib/Divider' +import { Message } from '~/ui/lib/Message' +import * as MiniTable from '~/ui/lib/MiniTable' +import { TextInputHint } from '~/ui/lib/TextInput' +import { KEYS } from '~/ui/util/keys' +import { links } from '~/util/links' + +import { type FirewallRuleValues } from './firewall-rules-util' + +type PortRangeFormValues = { + portRange: string +} + +const portRangeDefaultValues: PortRangeFormValues = { + portRange: '', +} + +type HostFormValues = { + type: VpcFirewallRuleHostFilter['type'] + value: string +} + +const hostDefaultValues: HostFormValues = { + type: 'vpc', + value: '', +} + +type TargetFormValues = { + type: VpcFirewallRuleTarget['type'] + value: string +} + +const targetDefaultValues: TargetFormValues = { + type: 'vpc', + value: '', +} + +type CommonFieldsProps = { + error: ApiError | null + control: Control + nameTaken: (name: string) => boolean +} + +function getFilterValueProps(hostType: VpcFirewallRuleHostFilter['type']) { + switch (hostType) { + case 'vpc': + return { label: 'VPC name' } + case 'subnet': + return { label: 'Subnet name' } + case 'instance': + return { label: 'Instance name' } + case 'ip': + return { label: 'IP address', helpText: 'An IPv4 or IPv6 address' } + case 'ip_net': + return { + label: 'IP network', + helpText: 'Looks like 192.168.0.0/16 or fd00:1122:3344:0001::1/64', + } + } +} + +const DocsLinkMessage = () => ( + + Read the{' '} + + guest networking guide + {' '} + and{' '} + + API docs + {' '} + to learn more about firewall rules. + + } + /> +) + +export const CommonFields = ({ error, control, nameTaken }: CommonFieldsProps) => { + const portRangeForm = useForm({ defaultValues: portRangeDefaultValues }) + const ports = useController({ name: 'ports', control }).field + const submitPortRange = portRangeForm.handleSubmit(({ portRange }) => { + const portRangeValue = portRange.trim() + // at this point we've already validated in validate() that it parses and + // that it is not already in the list + ports.onChange([...ports.value, portRangeValue]) + portRangeForm.reset() + }) + + const hostForm = useForm({ defaultValues: hostDefaultValues }) + const hosts = useController({ name: 'hosts', control }).field + const submitHost = hostForm.handleSubmit(({ type, value }) => { + // ignore click if empty or a duplicate + // TODO: show error instead of ignoring click + if (!type || !value) return + if (hosts.value.some((t) => t.value === value && t.type === type)) return + + hosts.onChange([...hosts.value, { type, value }]) + hostForm.reset() + }) + + const targetForm = useForm({ defaultValues: targetDefaultValues }) + const targets = useController({ name: 'targets', control }).field + const submitTarget = targetForm.handleSubmit(({ type, value }) => { + // TODO: do this with a normal validation + // ignore click if empty or a duplicate + // TODO: show error instead of ignoring click + if (!type || !value) return + if (targets.value.some((t) => t.value === value && t.type === type)) return + + targets.onChange([...targets.value, { type, value }]) + targetForm.reset() + }) + + return ( + <> + + {/* omitting value prop makes it a boolean value. beautiful */} + {/* TODO: better text or heading or tip or something on this checkbox */} + + Enabled + + { + if (nameTaken(name)) { + // TODO: might be worth mentioning that the names are unique per VPC as opposed to globally + return 'Name taken. To update an existing rule, edit it directly.' + } + }} + /> + + + + + An inbound rule applies to traffic to the targets, while an outbound + rule applies to traffic from the targets. + + } + items={[ + { value: 'inbound', label: 'Inbound' }, + { value: 'outbound', label: 'Outbound' }, + ]} + /> + + + + + {/* Really this should be its own
, but you can't have a form inside a form, + so we just stick the submit handler in a button onClick */} +

Targets

+ + Targets determine the instances to which this rule applies. You can target + instances directly by name, or specify a VPC, VPC subnet, IP, or IP subnet, + which will apply the rule to traffic going to all matching instances. Targets + are additive: the rule applies to instances matching{' '} + any target. + + } + /> + {/* TODO: make ListboxField smarter with the values like RadioField is */} + + +
+ { + if (e.key === KEYS.enter) { + e.preventDefault() // prevent full form submission + submitTarget(e) + } + }} + // TODO: validate here, but it's complicated because it's conditional + // on which type is selected + /> + +
+ + +
+
+ + {!!targets.value.length && ( + + + Type + Value + {/* For remove button */} + + + + {targets.value.map((t, index) => ( + + + {t.type} + + {t.value} + + targets.onChange( + targets.value.filter( + (i) => !(i.value === t.value && i.type === t.type) + ) + ) + } + label={`remove target ${t.value}`} + /> + + ))} + + + )} + + + +

Filters

+ + + Filters reduce the scope of this rule. Without filters, the rule applies to all + traffic to the targets (or from the targets, if it’s an outbound rule). + With multiple filters, the rule applies to traffic matching{' '} + all filters. + + } + /> + +
+ {/* We have to blow this up instead of using TextField to get better + text styling on the label */} +
+ + + A single destination port (1234) or a range (1234–2345) + + { + if (e.key === KEYS.enter) { + e.preventDefault() // prevent full form submission + submitPortRange(e) + } + }} + validate={(value) => { + if (!parsePortRange(value)) return 'Not a valid port range' + if (ports.value.includes(value.trim())) return 'Port range already added' + }} + /> +
+
+ + +
+
+ + {!!ports.value.length && ( + + + Port ranges + {/* For remove button */} + + + + {ports.value.map((p) => ( + + {p} + ports.onChange(ports.value.filter((p1) => p1 !== p))} + label={`remove port ${p}`} + /> + + ))} + + + )} + +
+ Protocol filters +
+ + TCP + +
+
+ + UDP + +
+
+ + ICMP + +
+
+ +
+

Host filters

+ + Host filters match the “other end” of traffic from the + target’s perspective: for an inbound rule, they match the source of + traffic. For an outbound rule, they match the destination. + + } + /> + + + {/* For everything but IP this is a name, but for IP it's an IP. + So we should probably have the label on this field change when the + host type changes. Also need to confirm that it's just an IP and + not a block. */} + { + if (e.key === KEYS.enter) { + e.preventDefault() // prevent full form submission + submitHost(e) + } + }} + // TODO: validate here, but it's complicated because it's conditional + // on which type is selected + /> + +
+ + +
+ + {!!hosts.value.length && ( + + + Type + Value + {/* For remove button */} + + + + {hosts.value.map((h, index) => ( + + + {h.type} + + {h.value} + + hosts.onChange( + hosts.value.filter( + (i) => !(i.value === h.value && i.type === h.type) + ) + ) + } + label={`remove host ${h.value}`} + /> + + ))} + + + )} +
+ + {error && ( + <> + +
{error.message}
+ + )} + + ) +} diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index 39dc0a444..5b79075b6 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -6,54 +6,26 @@ * Copyright Oxide Computer Company */ import { useMemo } from 'react' -import { useController, type Control } from 'react-hook-form' import { useNavigate, useParams, type LoaderFunctionArgs } from 'react-router-dom' import * as R from 'remeda' import { apiQueryClient, firewallRuleGetToPut, - parsePortRange, useApiMutation, useApiQueryClient, usePrefetchedApiQuery, - type ApiError, type VpcFirewallRule, - type VpcFirewallRuleHostFilter, - type VpcFirewallRuleTarget, } from '@oxide/api' -import { CheckboxField } from '~/components/form/fields/CheckboxField' -import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { ListboxField } from '~/components/form/fields/ListboxField' -import { NameField } from '~/components/form/fields/NameField' -import { NumberField } from '~/components/form/fields/NumberField' -import { RadioField } from '~/components/form/fields/RadioField' -import { TextField, TextFieldInner } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' import { getVpcSelector, useForm, useVpcSelector } from '~/hooks' import { addToast } from '~/stores/toast' -import { Badge } from '~/ui/lib/Badge' -import { Button } from '~/ui/lib/Button' -import { FormDivider } from '~/ui/lib/Divider' -import { Message } from '~/ui/lib/Message' -import * as MiniTable from '~/ui/lib/MiniTable' -import { TextInputHint } from '~/ui/lib/TextInput' -import { KEYS } from '~/ui/util/keys' -import { links } from '~/util/links' import { pb } from '~/util/path-builder' +import { CommonFields } from './firewall-rules-common' import { valuesToRuleUpdate, type FirewallRuleValues } from './firewall-rules-util' -/** convert in the opposite direction for when we're creating from existing rule */ -const ruleToValues = (rule: VpcFirewallRule): FirewallRuleValues => ({ - ...rule, - enabled: rule.status === 'enabled', - protocols: rule.filters.protocols || [], - ports: rule.filters.ports || [], - hosts: rule.filters.hosts || [], -}) - /** Empty form for when we're not creating from an existing rule */ const defaultValuesEmpty: FirewallRuleValues = { enabled: true, @@ -73,482 +45,14 @@ const defaultValuesEmpty: FirewallRuleValues = { targets: [], } -type PortRangeFormValues = { - portRange: string -} - -const portRangeDefaultValues: PortRangeFormValues = { - portRange: '', -} - -type HostFormValues = { - type: VpcFirewallRuleHostFilter['type'] - value: string -} - -const hostDefaultValues: HostFormValues = { - type: 'vpc', - value: '', -} - -type TargetFormValues = { - type: VpcFirewallRuleTarget['type'] - value: string -} - -const targetDefaultValues: TargetFormValues = { - type: 'vpc', - value: '', -} - -type CommonFieldsProps = { - error: ApiError | null - control: Control - nameTaken: (name: string) => boolean -} - -function getFilterValueProps(hostType: VpcFirewallRuleHostFilter['type']) { - switch (hostType) { - case 'vpc': - return { label: 'VPC name' } - case 'subnet': - return { label: 'Subnet name' } - case 'instance': - return { label: 'Instance name' } - case 'ip': - return { label: 'IP address', helpText: 'An IPv4 or IPv6 address' } - case 'ip_net': - return { - label: 'IP network', - helpText: 'Looks like 192.168.0.0/16 or fd00:1122:3344:0001::1/64', - } - } -} - -const DocsLinkMessage = () => ( - - Read the{' '} - - guest networking guide - {' '} - and{' '} - - API docs - {' '} - to learn more about firewall rules. - - } - /> -) - -export const CommonFields = ({ error, control, nameTaken }: CommonFieldsProps) => { - const portRangeForm = useForm({ defaultValues: portRangeDefaultValues }) - const ports = useController({ name: 'ports', control }).field - const submitPortRange = portRangeForm.handleSubmit(({ portRange }) => { - const portRangeValue = portRange.trim() - // at this point we've already validated in validate() that it parses and - // that it is not already in the list - ports.onChange([...ports.value, portRangeValue]) - portRangeForm.reset() - }) - - const hostForm = useForm({ defaultValues: hostDefaultValues }) - const hosts = useController({ name: 'hosts', control }).field - const submitHost = hostForm.handleSubmit(({ type, value }) => { - // ignore click if empty or a duplicate - // TODO: show error instead of ignoring click - if (!type || !value) return - if (hosts.value.some((t) => t.value === value && t.type === type)) return - - hosts.onChange([...hosts.value, { type, value }]) - hostForm.reset() - }) - - const targetForm = useForm({ defaultValues: targetDefaultValues }) - const targets = useController({ name: 'targets', control }).field - const submitTarget = targetForm.handleSubmit(({ type, value }) => { - // TODO: do this with a normal validation - // ignore click if empty or a duplicate - // TODO: show error instead of ignoring click - if (!type || !value) return - if (targets.value.some((t) => t.value === value && t.type === type)) return - - targets.onChange([...targets.value, { type, value }]) - targetForm.reset() - }) - - return ( - <> - - {/* omitting value prop makes it a boolean value. beautiful */} - {/* TODO: better text or heading or tip or something on this checkbox */} - - Enabled - - { - if (nameTaken(name)) { - // TODO: might be worth mentioning that the names are unique per VPC as opposed to globally - return 'Name taken. To update an existing rule, edit it directly.' - } - }} - /> - - - - - An inbound rule applies to traffic to the targets, while an outbound - rule applies to traffic from the targets. - - } - items={[ - { value: 'inbound', label: 'Inbound' }, - { value: 'outbound', label: 'Outbound' }, - ]} - /> - - - - - {/* Really this should be its own , but you can't have a form inside a form, - so we just stick the submit handler in a button onClick */} -

Targets

- - Targets determine the instances to which this rule applies. You can target - instances directly by name, or specify a VPC, VPC subnet, IP, or IP subnet, - which will apply the rule to traffic going to all matching instances. Targets - are additive: the rule applies to instances matching{' '} - any target. - - } - /> - {/* TODO: make ListboxField smarter with the values like RadioField is */} - - -
- { - if (e.key === KEYS.enter) { - e.preventDefault() // prevent full form submission - submitTarget(e) - } - }} - // TODO: validate here, but it's complicated because it's conditional - // on which type is selected - /> - -
- - -
-
- - {!!targets.value.length && ( - - - Type - Value - {/* For remove button */} - - - - {targets.value.map((t, index) => ( - - - {t.type} - - {t.value} - - targets.onChange( - targets.value.filter( - (i) => !(i.value === t.value && i.type === t.type) - ) - ) - } - label={`remove target ${t.value}`} - /> - - ))} - - - )} - - - -

Filters

- - - Filters reduce the scope of this rule. Without filters, the rule applies to all - traffic to the targets (or from the targets, if it’s an outbound rule). - With multiple filters, the rule applies to traffic matching{' '} - all filters. - - } - /> - -
- {/* We have to blow this up instead of using TextField to get better - text styling on the label */} -
- - - A single destination port (1234) or a range (1234–2345) - - { - if (e.key === KEYS.enter) { - e.preventDefault() // prevent full form submission - submitPortRange(e) - } - }} - validate={(value) => { - if (!parsePortRange(value)) return 'Not a valid port range' - if (ports.value.includes(value.trim())) return 'Port range already added' - }} - /> -
-
- - -
-
- - {!!ports.value.length && ( - - - Port ranges - {/* For remove button */} - - - - {ports.value.map((p) => ( - - {p} - ports.onChange(ports.value.filter((p1) => p1 !== p))} - label={`remove port ${p}`} - /> - - ))} - - - )} - -
- Protocol filters -
- - TCP - -
-
- - UDP - -
-
- - ICMP - -
-
- -
-

Host filters

- - Host filters match the “other end” of traffic from the - target’s perspective: for an inbound rule, they match the source of - traffic. For an outbound rule, they match the destination. - - } - /> - - - {/* For everything but IP this is a name, but for IP it's an IP. - So we should probably have the label on this field change when the - host type changes. Also need to confirm that it's just an IP and - not a block. */} - { - if (e.key === KEYS.enter) { - e.preventDefault() // prevent full form submission - submitHost(e) - } - }} - // TODO: validate here, but it's complicated because it's conditional - // on which type is selected - /> - -
- - -
- - {!!hosts.value.length && ( - - - Type - Value - {/* For remove button */} - - - - {hosts.value.map((h, index) => ( - - - {h.type} - - {h.value} - - hosts.onChange( - hosts.value.filter( - (i) => !(i.value === h.value && i.type === h.type) - ) - ) - } - label={`remove host ${h.value}`} - /> - - ))} - - - )} -
- - {error && ( - <> - -
{error.message}
- - )} - - ) -} +/** convert in the opposite direction for when we're creating from existing rule */ +const ruleToValues = (rule: VpcFirewallRule): FirewallRuleValues => ({ + ...rule, + enabled: rule.status === 'enabled', + protocols: rule.filters.protocols || [], + ports: rule.filters.ports || [], + hosts: rule.filters.hosts || [], +}) CreateFirewallRuleForm.loader = async ({ params }: LoaderFunctionArgs) => { await apiQueryClient.prefetchQuery('vpcFirewallRulesView', { diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index e03fadcb5..b71617f5c 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -26,7 +26,7 @@ import { import { invariant } from '~/util/invariant' import { pb } from '~/util/path-builder' -import { CommonFields } from './firewall-rules-create' +import { CommonFields } from './firewall-rules-common' import { valuesToRuleUpdate, type FirewallRuleValues } from './firewall-rules-util' EditFirewallRuleForm.loader = async ({ params }: LoaderFunctionArgs) => { diff --git a/app/forms/firewall-rules-util.ts b/app/forms/firewall-rules-util.ts index ed2cbc60c..3b0a59c75 100644 --- a/app/forms/firewall-rules-util.ts +++ b/app/forms/firewall-rules-util.ts @@ -7,6 +7,9 @@ */ import type { VpcFirewallRule, VpcFirewallRuleTarget, VpcFirewallRuleUpdate } from '~/api' +// this file is separate from firewall-rules-common because of rules around fast refresh: +// you can only export components from a file that exports components + export type FirewallRuleValues = { enabled: boolean priority: number