mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-24 11:29:24 +00:00
428 lines
24 KiB
TypeScript
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>
|
|
}
|