import { CodeHighlight } from '@mantine/code-highlight'; import { ActionIcon, Badge, Box, Button, Card, Code, Collapse, Group, Modal, 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 { errorNotify, okNotify, regex_ipv4_no_cidr, regex_ipv6_no_cidr } from '../../js/utils'; import PortAndInterface from '../PortAndInterface'; import YesNoModal from '../YesNoModal'; import { Address, decrypts, Service, serviceQueryKey, services, Transport } from './utils'; type Values = { ip_int: string, port: number, proxy_ip: string, proxy_port: number } export const isInterface = (v: string) => !v.match(regex_ipv6_no_cidr) && !v.match(regex_ipv4_no_cidr) && v.trim().length > 0 && v.trim().length <= 15 && !!v.trim().match(/^[a-zA-Z0-9_.:-]+$/) export const validAddress = (v: string) => !!(v.match(regex_ipv6_no_cidr) || v.match(regex_ipv4_no_cidr) || isInterface(v)) 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 form = useForm({ initialValues: { ip_int: "127.0.0.1", port: 80, proxy_ip: "127.0.0.1", proxy_port: 8080 }, validate: { ip_int: v => validAddress(v) ? (isExternal && isInterface(v) ? "External transport requires an IP, not an interface" : null) : "Invalid IP address or interface name", port: v => (v > 0 && v < 65536) ? null : "Invalid port", proxy_ip: v => (!isExternal || (v.match(regex_ipv6_no_cidr) || v.match(regex_ipv4_no_cidr))) ? null : "Invalid proxy IP", 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({ ip_int: edit.ip_int.split("/")[0], port: edit.port, proxy_ip: edit.proxy_ip ?? "127.0.0.1", proxy_port: edit.proxy_port ?? 8080, }) else form.reset() }, [opened, edit?.address_id]) const submit = async (values: Values) => { setBusy(true) const payload = { ip_int: values.ip_int, port: values.port, ...(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
{isExternal ? <> Its own port, not one another address already uses: the return rule puts the original port back by recognising your proxy's, so two addresses behind the same endpoint could not be told apart on the way out. : 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 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 The same chain runs on every one of them. Adding one costs no connections. {addresses.map(address => {/* No protocol badge: it is the same on every address, because it is a property of the service, and the header already says it once. Repeating it on each row is noise that grows with the list. */} {address.ip_int}:{address.port} {isInterface(address.ip_int) ? ( IFACE ) : null} {decrypts(service) ? }> TLS : null} {address.proxy_port ? → {address.proxy_ip ?? "127.0.0.1"}:{address.proxy_port} : 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 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. * * 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 return Every TLS service's plaintext 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. }