firegex/frontend/src/components/Services/AddressList.tsx
2026-09-23 11:21:51 +02:00

428 lines
24 KiB
TypeScript

import { CodeHighlight } from '@mantine/code-highlight';
import { ActionIcon, Badge, Box, Button, Card, Code, Collapse, Group, Modal, SegmentedControl, Space, Stack, Text, Tooltip } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { BsPlusLg, BsTrashFill } from 'react-icons/bs';
import { MdEdit } from 'react-icons/md';
import { TbEye, TbShieldLock } from 'react-icons/tb';
import { bareAddress, errorNotify, ipInterfacesQuery, isAddressOrInterface, isInterfaceName, isIpAddress, okNotify } from '../../js/utils';
import { addressKind, BADGE_WIDTH } from '../InterfaceInput';
import PortAndInterface from '../PortAndInterface';
import YesNoModal from '../YesNoModal';
import AddressOptions, { addressCapabilities, addressTags, edgeHint, EDGE_OPTIONS } from './AddressOptions';
import { Address, decrypts, L4, protoLabel, Service, serviceQueryKey, services, Transport, Upstream } from './utils';
type Values = {
ip_int: string, port: number, proxy_ip: string, proxy_port: number,
/** `http` only: what is spoken at this address. */
edge: string,
/** Where the service is, when it is not on this port. Empty means "it is". */
target_port: number | string,
/** What the service behind this address speaks. */
upstream: string,
}
/**
* What one address of a service is reached over, in the words the operator chose it in.
*
* Every protocol but `http` has one answer for the whole service, and the header already
* says it. `http` is the one that does not: a TCP address there carries HTTP/1.1 and
* HTTP/2, and the UDP one beside it carries HTTP/3, so a row that said only "HTTP" would
* be describing two different things with one word.
*/
const edgeLabel = (service: Service, address: { edge?: string, proto: string }) =>
service.proto === L4.HTTP
? ({ [L4.TCP]: "HTTP", [L4.TLS]: "HTTPS", [L4.QUIC]: "HTTP/3" }[
address.edge ?? address.proto] ?? "HTTP")
: protoLabel(service.proto)
function AddressModal({ opened, onClose, service, edit }: {
opened: boolean, onClose: () => void, service: Service, edit?: Address,
}) {
const queryClient = useQueryClient()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const isExternal = service.transport === Transport.EXTERNAL
const isHttp = service.proto === L4.HTTP
const form = useForm<Values>({
initialValues: {
ip_int: "127.0.0.1", port: 80, proxy_ip: "127.0.0.1", proxy_port: 8080,
edge: L4.TCP, target_port: "", upstream: Upstream.SAME,
},
validate: {
ip_int: v => !isAddressOrInterface(v, { cidr: !isExternal })
? "Not an IP address, and not an interface name either"
: (isExternal && isInterfaceName(v)
? "Your own proxy is handed one address: the return rule has to put the original port back, and an interface is not one address"
: null),
port: v => (v > 0 && v < 65536) ? null : "Invalid port",
proxy_ip: v => (!isExternal || isIpAddress(v)) ? null : "Not an IP address: your proxy is reached at one, never at an interface",
proxy_port: v => (!isExternal || (v > 0 && v < 65536)) ? null : "Your proxy's port is required",
},
})
useEffect(() => {
if (!opened) return
setError(null)
if (edit) form.setValues({
//: Only the prefix that means "this one address" comes off. Cutting at the
// first slash took a real range down to its first host and saved it as one,
// which is a service quietly protecting a fraction of what it did before.
ip_int: bareAddress(edit.ip_int), port: edit.port,
proxy_ip: edit.proxy_ip ?? "127.0.0.1", proxy_port: edit.proxy_port ?? 8080,
edge: edit.edge ?? edit.proto, target_port: edit.target_port ?? "",
upstream: edit.upstream ?? Upstream.SAME,
})
else form.reset()
}, [opened, edit?.address_id])
// What this address can say depends on what it is reached over, which on an HTTPS
// service is being chosen in this very form.
const caps = addressCapabilities(service.proto, service.transport, form.values.edge)
const anyOption = caps.canPublish || caps.canChooseUpstream || caps.isExternal
const submit = async (values: Values) => {
setBusy(true)
// Sent wherever it means something, and sent even when it is empty: this is a
// PUT over the whole row, and `0` is how an operator says the traffic goes back
// to arriving where it is sent. An absent field keeps whatever the row had.
const payload = {
ip_int: values.ip_int, port: values.port,
...(isHttp ? { edge: values.edge } : {}),
...(caps.canPublish ? {
target_port: Number(values.target_port) !== values.port
? Number(values.target_port) || 0 : 0,
} : {}),
// Where the address has no decrypted leg the choice is taken back rather than
// left out: left out, the row keeps what it had, and an address moved to the
// clear would keep an answer that no longer applies to anything.
...(isHttp || caps.canChooseUpstream
? { upstream: caps.canChooseUpstream ? values.upstream : Upstream.SAME } : {}),
...(isExternal ? { proxy_ip: values.proxy_ip, proxy_port: values.proxy_port } : {}),
}
try {
const err = edit
? await services.editAddress(service.service_id, edit.address_id, payload)
: await services.addAddress(service.service_id, payload)
if (err) setError(err)
else {
okNotify(edit ? "Address updated" : "Address added",
`${values.ip_int}:${values.port} is now part of ${service.name}`)
queryClient.invalidateQueries({ queryKey: serviceQueryKey })
onClose()
}
} catch (err) {
setError(`${err}`)
}
setBusy(false)
}
return <Modal opened={opened} onClose={onClose} centered
title={edit ? "Change this address" : "Protect another address"}>
<form onSubmit={form.onSubmit(submit)}>
<PortAndInterface form={form} int_name="ip_int" port_name="port"
label="Where clients reach it"
description={isExternal
? "One address and its port. This layer rewrites the destination and puts the original port back on the way out, which takes an address it can recognise."
: "An IP address, or the name of an interface — an interface protects whatever address that link is carrying, an address protects that one alone. Normally the service listens here too; when it does not, say so below."}
includeInterfaceNames={!isExternal} />
{isHttp ? <>
<Space h="md" />
<Text size="sm" fw={500} mb={6}>What clients speak here</Text>
<SegmentedControl fullWidth data={EDGE_OPTIONS}
{...form.getInputProps('edge')} />
<Text size="xs" c="dimmed" mt={6}>
{edgeHint(form.values.edge)}
{" "}The filters see the same HTTP/1.1 whichever it is, so the chain
does not change.
</Text>
</> : null}
{/* The same options the ⚙ on a row of the creation form offers, in the same
words, and offered on an **edit** as well as on an add.
They were add-only while this endpoint rewrote the address and nothing else,
which left an operator who had set one of them while creating the service with
no way back to it short of deleting the address. The edit already takes the
rules back and reinstalls them, so rewriting the rest of the row costs
nothing more than what it already spends. */}
{anyOption ? <>
<Space h="md" />
<AddressOptions form={form} values={form.values}
field={name => name} proto={service.proto}
transport={service.transport} />
</> : null}
<Text size="xs" c="dimmed" mt="md">
{edit
? "Only this address stops being protected while the rules are replaced. The rest of the service keeps its connections."
: "The chain is already running, so this only points one more address at it. Nothing is dropped."}
</Text>
<Group justify="flex-end" mt="xl">
<Button loading={busy} type="submit">{edit ? "Save" : "Add address"}</Button>
</Group>
</form>
{error ? <Text c="red" size="sm" mt="md">{error}</Text> : null}
</Modal>
}
/**
* Everywhere one service is protected.
*
* A list rather than a field, because a service routinely answers on more than one
* address and one chain should cover all of them — two services with hand-copied
* chains is how one of them silently stops being protected.
*/
export default function AddressList({ service }: { service: Service }) {
const queryClient = useQueryClient()
const [addOpen, setAddOpen] = useState(false)
const [editing, setEditing] = useState<Address | null>(null)
const [removing, setRemoving] = useState<Address | null>(null)
const addresses = service.addresses ?? []
const only = addresses.length === 1
//: What each interface is carrying at the moment, so a row named `wg0` can say
// where that actually is. An interface with nothing on it is worth seeing too:
// on the proxy layer a UDP relay has to bind one of these.
const interfaces = ipInterfacesQuery()
const carriedBy = (name: string) => (interfaces.data ?? [])
.filter(i => i.name === name && !i.addr.toLowerCase().startsWith("fe80:"))
.map(i => i.addr)
//: …and the other direction, which is the same question asked of an address row.
const arrivesOn = (addr: string) => (interfaces.data ?? [])
.filter(i => i.addr === addr).map(i => i.name)
/**
* What a row says beside the address, in the words the picker used to choose it.
*
* The same pairing as the list it was chosen from — an interface carries addresses,
* an address sits on an interface — because this is where an operator comes back to
* check what they picked, and finding it described differently is a reason to wonder
* whether it is the same thing.
*/
const rowDetail = (ip_int: string) => {
if (isInterfaceName(ip_int)) {
if (!interfaces.data) return ""
const ips = carriedBy(ip_int)
return ips.length > 0 ? `now ${ips.join("\u00a0· ")}` : "no address on it right now"
}
const names = arrivesOn(bareAddress(ip_int))
return names.length > 0 ? `on ${names.join(", ")}` : ""
}
const remove = async (address: Address) => {
try {
const err = await services.deleteAddress(service.service_id, address.address_id)
if (err) errorNotify("Could not remove the address", err)
else {
okNotify("Address removed", `${address.ip_int}:${address.port} is no longer protected`)
queryClient.invalidateQueries({ queryKey: serviceQueryKey })
}
} catch (err) {
errorNotify("Could not remove the address", `${err}`)
}
}
return <>
<Group justify="space-between" align="center" wrap="nowrap">
<Box style={{ minWidth: 0 }}>
<Text fw={600} size="sm">Protected addresses</Text>
<Text size="xs" c="dimmed">
{service.transport === Transport.EXTERNAL
? "Each one handed to your own proxy. Adding one costs no connections."
: "Addresses, or the interfaces they arrive on. The same chain runs on every one of them, and adding one costs no connections."}
</Text>
</Box>
<Button size="xs" variant="light" leftSection={<BsPlusLg size={12} />}
style={{ flexShrink: 0 }}
onClick={() => setAddOpen(true)}>Add an address</Button>
</Group>
<Space h="sm" />
<Stack gap="xs">
{addresses.map(address => {
const kind = addressKind(address.ip_int)
const detail = rowDetail(address.ip_int)
return <Card key={address.address_id} withBorder radius="md" p="xs"
bg="transparent" style={{ borderColor: 'var(--mantine-color-dark-4)' }}>
<Group justify="space-between" wrap="nowrap">
{/* The row a service is read from is laid out like the list it was
chosen in: the same badge, at the same width, then the value, then
what is on it. No protocol badge — it is a property of the service,
the header already says it once, and repeating it on every row is
noise that grows with the list. */}
{/* Grows to everything the buttons do not take, rather than to its
own contents. Sized to content, each row was as wide as its own
longest line — so whether the note beside the badges fitted was
decided by how long that note is, and the row carrying a full
IPv6 kept it inline while the one saying `now 127.0.0.1` pushed
it onto a second line. Two rows of one list, wrapping opposite
ways, for a reason nothing on screen explains. */}
<Group gap="xs" wrap="nowrap" align="flex-start"
style={{ flex: 1, minWidth: 0 }}>
<Tooltip position="bottom" disabled={!isInterfaceName(address.ip_int)}
label="An interface, not an address: the rules match on the name, so this follows whatever address the link is carrying.">
<Badge size="xs" variant="light" color={kind.color}
style={{ flexShrink: 0, width: BADGE_WIDTH, marginTop: 4 }}>
{kind.label}
</Badge>
</Tooltip>
{/* The badge is a column of its own, exactly as in the list this was
chosen from: everything that has to wrap wraps under the value
rather than under the badge. */}
<Group gap="xs" wrap="wrap" align="center"
style={{ flex: '1 1 auto', minWidth: 0, rowGap: 2 }}>
<Code>{bareAddress(address.ip_int)}:{address.port}</Code>
{decrypts(service) ? <Tooltip position="bottom"
label="The engine decrypts here: clients dial this address as they always did, and the filters see the plaintext inside the process.">
<Badge size="xs" variant="light" color="grape"
leftSection={<TbShieldLock size={10} />}>
{edgeLabel(service, address)}
</Badge>
</Tooltip> : null}
{/* Said on the row, and in the same words the panel that sets
them uses: this is where an operator comes back to check what
they chose, and a setting named differently here reads as a
different setting. Clicking one opens that panel. */}
{addressTags(address,
addressCapabilities(service.proto, service.transport, address.edge)).map(tag =>
<Tooltip key={tag.label} position="bottom" multiline w={300}
label={tag.hint}>
<Badge size="xs" variant="light" color="teal"
style={{ cursor: 'pointer' }}
onClick={() => setEditing(address)}>
{tag.label}
</Badge>
</Tooltip>)}
{/* The note takes a line of its own rather than the corner of
this one. Its basis is a full-length IPv6 with its prefix — when
that much room is not left beside the badges, the whole note wraps
down instead of being squeezed into the gap, which is what put
`fd07:…:fe4` and `0:be17` on two lines with the digits broken
between them. It shrank that far because the basis was 80px and
`anywhere` let it: a tag or two added to the row is all it took to
use the space up. The basis is the whole of the rule — a floor
beside it was tried and is what overflowed the card on a phone,
where the column is narrower than any floor worth setting, and it
bought nothing: an item only shares a line when its basis fitted
there, so on a line of its own it is already as wide as it can be.
`anywhere` stays as the last resort, so a phone wraps an address
rather than pushing it past the edge. */}
{detail ? <Text size="xs" c="dimmed" ff="monospace"
style={{ flex: '1 1 320px', minWidth: 0, overflowWrap: 'anywhere' }}>
{detail}
</Text> : null}
</Group>
</Group>
<Group gap={4} wrap="nowrap">
<Tooltip label="Move this address" position="bottom">
<ActionIcon size="sm" variant="subtle" onClick={() => setEditing(address)}>
<MdEdit size={14} />
</ActionIcon>
</Tooltip>
<Tooltip position="bottom"
label={only ? "A service has to be reachable somewhere" : "Stop protecting this address"}>
<Box>
<ActionIcon size="sm" variant="subtle" color="red" disabled={only}
onClick={() => setRemoving(address)}>
<BsTrashFill size={12} />
</ActionIcon>
</Box>
</Tooltip>
</Group>
</Group>
</Card>
})}
</Stack>
<PlaintextCapture service={service} />
<AddressModal opened={addOpen} onClose={() => setAddOpen(false)} service={service} />
<AddressModal opened={!!editing} onClose={() => setEditing(null)} service={service}
edit={editing ?? undefined} />
<YesNoModal
title="Stop protecting this address?"
description={removing
? `${removing.ip_int}:${removing.port} keeps answering, without any filter in front of it. The rest of ${service.name} is untouched.`
: ""}
opened={!!removing}
onClose={() => setRemoving(null)}
action={async () => { if (removing) await remove(removing) }}
/>
</>
}
/**
* Where the decrypted traffic of every TLS and QUIC service can be watched.
*
* The engine decrypts inside the process that filters, so the plaintext never becomes
* packets on any interface — which is exactly what removed the two loopback ports a TLS
* service used to occupy, and would also have removed any way to watch it. So the engine
* writes the decrypted stream out itself, onto `firegex0`: one interface carrying every
* TLS service's plaintext and nothing else, which is what a capture tool wants to be
* pointed at.
*
* What arrives there is a **reconstruction** — the engine frames the bytes it decrypted
* as the TCP stream they were, because the stream that actually crossed the wire was
* encrypted. Wireshark follows it normally; it is not the wire, and the interface says so.
*
* A QUIC service is carried there too, one TCP stream per QUIC stream, which costs one
* more piece of invention: the streams of one connection share its four-tuple, so each is
* given a synthetic client port to be told apart by. An operator reading a port out of
* that capture has to know it names a stream and not a socket, which is why the panel
* says so rather than leaving it to be discovered.
*
* Collapsed by default: it is a thing you go looking for while debugging a filter that is
* not matching, not something that should sit in the way the rest of the time.
*/
function PlaintextCapture({ service }: { service: Service }) {
const [open, setOpen] = useState(false)
if (!decrypts(service)) return null
// An HTTPS service carries QUIC too, on whichever of its addresses say HTTP/3, and the
// invented port needs saying there exactly as much.
const quic = service.proto === L4.QUIC
|| (service.proto === L4.HTTP && (service.addresses ?? []).some(a => a.edge === L4.QUIC))
return <Box mt="md">
<Group gap="xs">
<Button size="compact-xs" variant="subtle" leftSection={<TbEye size={13} />}
onClick={() => setOpen(o => !o)}>
{open ? "Hide" : "Watch the decrypted traffic"}
</Button>
</Group>
<Collapse expanded={open}>
<Space h="xs" />
<Text size="xs" c="dimmed">
Every decrypted service's traffic is written to <Code>firegex0</Code>, and
nothing else is. Point Wireshark or tcpdump at it on the host running
firegex — the container shares its network namespace, so the interface is
there — and you get the decrypted traffic of the whole instance, both
directions, with no filter to write.
</Text>
<Space h="xs" />
<CodeHighlight language="bash" withCopyButton
copyLabel="Copy the capture command"
code={`sudo tcpdump -i firegex0 -w decrypted.pcap`} />
<Space h="xs" />
<Text size="xs" c="dimmed">
These are reconstructed packets, not the ones that crossed the wire — what
crossed the wire was encrypted, and the engine decrypts inside the process
rather than putting the plaintext back on a socket. That is what lets a TLS
service occupy no extra port at all. What is written is decrypted traffic:
as sensitive as the private key that would have produced it, so treat the
file the same way.
</Text>
{quic ? <>
<Space h="xs" />
<Text size="xs" c="dimmed">
On QUIC each stream arrives as a TCP stream of its own, and over
HTTP/3 that is one per request — what you see is the same HTTP/1.1
the filters were shown, because what crossed the wire was a
compressed header block inside an encrypted packet. The client
<b> port is invented</b>: the streams of one connection share the
client's real port, so each is given one of its own to be told apart
by. It names a stream, not a socket — the address beside it is real.
</Text>
</> : null}
</Collapse>
</Box>
}