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(null) const isExternal = service.transport === Transport.EXTERNAL const isHttp = service.proto === L4.HTTP const form = useForm({ 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
{isHttp ? <> What clients speak here {edgeHint(form.values.edge)} {" "}The filters see the same HTTP/1.1 whichever it is, so the chain does not change. : 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 ? <> name} proto={service.proto} transport={service.transport} /> : null} {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."} {error ? {error} : null}
} /** * 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
(null) const [removing, setRemoving] = useState
(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 <> Protected addresses {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."} {addresses.map(address => { const kind = addressKind(address.ip_int) const detail = rowDetail(address.ip_int) return {/* 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. */} {kind.label} {/* 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. */} {bareAddress(address.ip_int)}:{address.port} {decrypts(service) ? }> {edgeLabel(service, address)} : 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 => setEditing(address)}> {tag.label} )} {/* 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 ? {detail} : null} setEditing(address)}> setRemoving(address)}> })} setAddOpen(false)} service={service} /> setEditing(null)} service={service} edit={editing ?? undefined} /> 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 Every decrypted service's traffic is written to firegex0, 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. 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. {quic ? <> 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 port is invented: 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. : null} }