Просмотр исходного кода

client/web: add exit node selector

Add exit node selector (in full management client only) that allows
for advertising as an exit node, or selecting another exit node on
the Tailnet for use.

Updates #10261

Signed-off-by: Sonia Appasamy <[email protected]>
Sonia Appasamy 2 лет назад
Родитель
Сommit
e75be017e4

+ 1 - 0
client/web/src/components/app.tsx

@@ -55,6 +55,7 @@ function WebClient({
               readonly={!auth.canManageNode}
               node={data}
               updateNode={updateNode}
+              updatePrefs={updatePrefs}
             />
           </Route>
           <Route path="/details">

+ 448 - 64
client/web/src/components/exit-node-selector.tsx

@@ -1,57 +1,82 @@
 import cx from "classnames"
-import React, { useCallback, useEffect, useMemo, useState } from "react"
-import { NodeData, NodeUpdate } from "src/hooks/node-data"
+import { default as React, useCallback, useMemo, useState } from "react"
+import useExitNodes, {
+  ExitNode,
+  noExitNode,
+  runAsExitNode,
+  trimDNSSuffix,
+} from "src/hooks/exit-nodes"
+import { NodeData, NodeUpdate, PrefsUpdate } from "src/hooks/node-data"
 import { ReactComponent as Check } from "src/icons/check.svg"
 import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
-import { ReactComponent as Search } from "src/icons/search.svg"
-
-const noExitNode = "None"
-const runAsExitNode = "Run as exit node…"
+import Popover from "src/ui/popover"
+import SearchInput from "src/ui/search-input"
 
 export default function ExitNodeSelector({
   className,
   node,
   updateNode,
+  updatePrefs,
   disabled,
 }: {
   className?: string
   node: NodeData
   updateNode: (update: NodeUpdate) => Promise<void> | undefined
+  updatePrefs: (p: PrefsUpdate) => Promise<void>
   disabled?: boolean
 }) {
   const [open, setOpen] = useState<boolean>(false)
-  const [selected, setSelected] = useState(
-    node.AdvertiseExitNode ? runAsExitNode : noExitNode
-  )
-  useEffect(() => {
-    setSelected(node.AdvertiseExitNode ? runAsExitNode : noExitNode)
-  }, [node])
+  const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
 
   const handleSelect = useCallback(
-    (item: string) => {
+    (n: ExitNode) => {
       setOpen(false)
-      if (item === selected) {
+      if (n.ID === selected.ID) {
         return // no update
       }
+
       const old = selected
-      setSelected(item)
-      var update: NodeUpdate = {}
-      switch (item) {
-        case noExitNode:
-          // turn off exit node
-          update = { AdvertiseExitNode: false }
+      setSelected(n) // optimistic UI update
+      const reset = () => setSelected(old)
+
+      switch (n.ID) {
+        case noExitNode.ID: {
+          if (old === runAsExitNode) {
+            // stop advertising as exit node
+            updateNode({ AdvertiseExitNode: false })?.catch(reset)
+          } else {
+            // stop using exit node
+            updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" }).catch(reset)
+          }
           break
-        case runAsExitNode:
-          // turn on exit node
-          update = { AdvertiseExitNode: true }
+        }
+        case runAsExitNode.ID: {
+          const update = () =>
+            updateNode({ AdvertiseExitNode: true })?.catch(reset)
+          if (old !== noExitNode) {
+            // stop using exit node first
+            updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" })
+              .catch(reset)
+              .then(update)
+          } else {
+            update()
+          }
           break
+        }
+        default: {
+          const update = () =>
+            updatePrefs({ ExitNodeIDSet: true, ExitNodeID: n.ID }).catch(reset)
+          if (old === runAsExitNode) {
+            // stop advertising as exit node first
+            updateNode({ AdvertiseExitNode: false })?.catch(reset).then(update)
+          } else {
+            update()
+          }
+        }
       }
-      updateNode(update)?.catch(() => setSelected(old))
     },
     [setOpen, selected, setSelected]
   )
-  // TODO: close on click outside
-  // TODO(sonia): allow choosing to use another exit node
 
   const [
     none, // not using exit nodes
@@ -59,15 +84,30 @@ export default function ExitNodeSelector({
     using, // using another exit node
   ] = useMemo(
     () => [
-      selected === noExitNode,
-      selected === runAsExitNode,
-      selected !== noExitNode && selected !== runAsExitNode,
+      selected.ID === noExitNode.ID,
+      selected.ID === runAsExitNode.ID,
+      selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
     ],
     [selected]
   )
 
   return (
-    <>
+    <Popover
+      open={disabled ? false : open}
+      onOpenChange={setOpen}
+      side="bottom"
+      sideOffset={5}
+      align="start"
+      alignOffset={8}
+      content={
+        <ExitNodeSelectorInner
+          node={node}
+          selected={selected}
+          onSelect={handleSelect}
+        />
+      }
+      asChild
+    >
       <div
         className={cx(
           "p-1.5 rounded-md border flex items-stretch gap-1.5",
@@ -103,7 +143,14 @@ export default function ExitNodeSelector({
                 "text-white": advertising || using,
               })}
             >
-              {selected === runAsExitNode ? "Running as exit node" : "None"}
+              {selected.Location && (
+                <>
+                  <CountryFlag code={selected.Location.CountryCode} />{" "}
+                </>
+              )}
+              {selected === runAsExitNode
+                ? "Running as exit node"
+                : selected.Name}
             </p>
             <ChevronDown
               className={cx("ml-1", {
@@ -131,47 +178,384 @@ export default function ExitNodeSelector({
           </button>
         )}
       </div>
-      {open && (
-        <div className="absolute ml-1.5 -mt-3 w-full max-w-md py-1 bg-white rounded-lg shadow">
-          <div className="w-full px-4 py-2 flex items-center gap-2.5">
-            <Search />
-            <input
-              className="flex-1 leading-snug"
-              placeholder="Search exit nodes…"
-            />
-          </div>
-          <DropdownSection
-            items={[noExitNode, runAsExitNode]}
-            selected={selected}
-            onSelect={handleSelect}
-          />
-        </div>
-      )}
-    </>
+    </Popover>
   )
 }
 
-function DropdownSection({
-  items,
+function toSelectedExitNode(data: NodeData): ExitNode {
+  if (data.AdvertiseExitNode) {
+    return runAsExitNode
+  }
+  if (data.ExitNodeStatus) {
+    // TODO(sonia): also use online status
+    const node = { ...data.ExitNodeStatus }
+    if (node.Location) {
+      // For mullvad nodes, use location as name.
+      node.Name = `${node.Location.Country}: ${node.Location.City}`
+    } else {
+      // Otherwise use node name w/o DNS suffix.
+      node.Name = trimDNSSuffix(node.Name, data.TailnetName)
+    }
+    return node
+  }
+  return noExitNode
+}
+
+function ExitNodeSelectorInner({
+  node,
   selected,
   onSelect,
 }: {
-  items: string[]
-  selected?: string
-  onSelect: (item: string) => void
+  node: NodeData
+  selected: ExitNode
+  onSelect: (node: ExitNode) => void
 }) {
+  const [filter, setFilter] = useState<string>("")
+  const { data: exitNodes } = useExitNodes(node.TailnetName, filter)
+
+  const hasNodes = useMemo(
+    () => exitNodes.find((n) => n.nodes.length > 0),
+    [exitNodes]
+  )
+
   return (
-    <div className="w-full mt-1 pt-1 border-t border-gray-200">
-      {items.map((v) => (
-        <button
-          key={v}
-          className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
-          onClick={() => onSelect(v)}
-        >
-          <div className="leading-snug">{v}</div>
-          {selected == v && <Check />}
-        </button>
-      ))}
+    <div className="w-[calc(var(--radix-popover-trigger-width)-16px)] py-1 rounded-lg shadow">
+      <SearchInput
+        name="exit-node-search"
+        inputClassName="w-full px-4 py-2"
+        autoCorrect="off"
+        autoComplete="off"
+        autoCapitalize="off"
+        placeholder="Search exit nodes…"
+        value={filter}
+        onChange={(e) => setFilter(e.target.value)}
+      />
+      {/* TODO(sonia): use loading spinner when loading useExitNodes */}
+      <div className="pt-1 border-t border-gray-200 max-h-64 overflow-y-scroll">
+        {hasNodes ? (
+          exitNodes.map(
+            (group) =>
+              group.nodes.length > 0 && (
+                <div
+                  key={group.id}
+                  className="pb-1 mb-1 border-b last:border-b-0 last:mb-0"
+                >
+                  {group.name && (
+                    <div className="px-4 py-2 text-neutral-500 text-xs font-medium uppercase tracking-wide">
+                      {group.name}
+                    </div>
+                  )}
+                  {group.nodes.map((n) => (
+                    <ExitNodeSelectorItem
+                      key={`${n.ID}-${n.Name}`}
+                      node={n}
+                      onSelect={() => onSelect(n)}
+                      isSelected={selected.ID == n.ID}
+                    />
+                  ))}
+                </div>
+              )
+          )
+        ) : (
+          <div className="text-center truncate text-gray-500 p-5">
+            {filter
+              ? `No exit nodes matching “${filter}”`
+              : "No exit nodes available"}
+          </div>
+        )}
+      </div>
     </div>
   )
 }
+
+function ExitNodeSelectorItem({
+  node,
+  isSelected,
+  onSelect,
+}: {
+  node: ExitNode
+  isSelected: boolean
+  onSelect: () => void
+}) {
+  return (
+    <button
+      key={node.ID}
+      className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
+      onClick={onSelect}
+    >
+      <div>
+        {node.Location && (
+          <>
+            <CountryFlag code={node.Location.CountryCode} />{" "}
+          </>
+        )}
+        <span className="leading-snug">{node.Name}</span>
+      </div>
+      {isSelected && <Check />}
+    </button>
+  )
+}
+
+function CountryFlag({ code }: { code: string }) {
+  return (
+    countryFlags[code.toLowerCase()] || (
+      <span className="font-medium text-gray-500 text-xs">
+        {code.toUpperCase()}
+      </span>
+    )
+  )
+}
+
+const countryFlags: { [countryCode: string]: string } = {
+  ad: "🇦🇩",
+  ae: "🇦🇪",
+  af: "🇦🇫",
+  ag: "🇦🇬",
+  ai: "🇦🇮",
+  al: "🇦🇱",
+  am: "🇦🇲",
+  ao: "🇦🇴",
+  aq: "🇦🇶",
+  ar: "🇦🇷",
+  as: "🇦🇸",
+  at: "🇦🇹",
+  au: "🇦🇺",
+  aw: "🇦🇼",
+  ax: "🇦🇽",
+  az: "🇦🇿",
+  ba: "🇧🇦",
+  bb: "🇧🇧",
+  bd: "🇧🇩",
+  be: "🇧🇪",
+  bf: "🇧🇫",
+  bg: "🇧🇬",
+  bh: "🇧🇭",
+  bi: "🇧🇮",
+  bj: "🇧🇯",
+  bl: "🇧🇱",
+  bm: "🇧🇲",
+  bn: "🇧🇳",
+  bo: "🇧🇴",
+  bq: "🇧🇶",
+  br: "🇧🇷",
+  bs: "🇧🇸",
+  bt: "🇧🇹",
+  bv: "🇧🇻",
+  bw: "🇧🇼",
+  by: "🇧🇾",
+  bz: "🇧🇿",
+  ca: "🇨🇦",
+  cc: "🇨🇨",
+  cd: "🇨🇩",
+  cf: "🇨🇫",
+  cg: "🇨🇬",
+  ch: "🇨🇭",
+  ci: "🇨🇮",
+  ck: "🇨🇰",
+  cl: "🇨🇱",
+  cm: "🇨🇲",
+  cn: "🇨🇳",
+  co: "🇨🇴",
+  cr: "🇨🇷",
+  cu: "🇨🇺",
+  cv: "🇨🇻",
+  cw: "🇨🇼",
+  cx: "🇨🇽",
+  cy: "🇨🇾",
+  cz: "🇨🇿",
+  de: "🇩🇪",
+  dj: "🇩🇯",
+  dk: "🇩🇰",
+  dm: "🇩🇲",
+  do: "🇩🇴",
+  dz: "🇩🇿",
+  ec: "🇪🇨",
+  ee: "🇪🇪",
+  eg: "🇪🇬",
+  eh: "🇪🇭",
+  er: "🇪🇷",
+  es: "🇪🇸",
+  et: "🇪🇹",
+  eu: "🇪🇺",
+  fi: "🇫🇮",
+  fj: "🇫🇯",
+  fk: "🇫🇰",
+  fm: "🇫🇲",
+  fo: "🇫🇴",
+  fr: "🇫🇷",
+  ga: "🇬🇦",
+  gb: "🇬🇧",
+  gd: "🇬🇩",
+  ge: "🇬🇪",
+  gf: "🇬🇫",
+  gg: "🇬🇬",
+  gh: "🇬🇭",
+  gi: "🇬🇮",
+  gl: "🇬🇱",
+  gm: "🇬🇲",
+  gn: "🇬🇳",
+  gp: "🇬🇵",
+  gq: "🇬🇶",
+  gr: "🇬🇷",
+  gs: "🇬🇸",
+  gt: "🇬🇹",
+  gu: "🇬🇺",
+  gw: "🇬🇼",
+  gy: "🇬🇾",
+  hk: "🇭🇰",
+  hm: "🇭🇲",
+  hn: "🇭🇳",
+  hr: "🇭🇷",
+  ht: "🇭🇹",
+  hu: "🇭🇺",
+  id: "🇮🇩",
+  ie: "🇮🇪",
+  il: "🇮🇱",
+  im: "🇮🇲",
+  in: "🇮🇳",
+  io: "🇮🇴",
+  iq: "🇮🇶",
+  ir: "🇮🇷",
+  is: "🇮🇸",
+  it: "🇮🇹",
+  je: "🇯🇪",
+  jm: "🇯🇲",
+  jo: "🇯🇴",
+  jp: "🇯🇵",
+  ke: "🇰🇪",
+  kg: "🇰🇬",
+  kh: "🇰🇭",
+  ki: "🇰🇮",
+  km: "🇰🇲",
+  kn: "🇰🇳",
+  kp: "🇰🇵",
+  kr: "🇰🇷",
+  kw: "🇰🇼",
+  ky: "🇰🇾",
+  kz: "🇰🇿",
+  la: "🇱🇦",
+  lb: "🇱🇧",
+  lc: "🇱🇨",
+  li: "🇱🇮",
+  lk: "🇱🇰",
+  lr: "🇱🇷",
+  ls: "🇱🇸",
+  lt: "🇱🇹",
+  lu: "🇱🇺",
+  lv: "🇱🇻",
+  ly: "🇱🇾",
+  ma: "🇲🇦",
+  mc: "🇲🇨",
+  md: "🇲🇩",
+  me: "🇲🇪",
+  mf: "🇲🇫",
+  mg: "🇲🇬",
+  mh: "🇲🇭",
+  mk: "🇲🇰",
+  ml: "🇲🇱",
+  mm: "🇲🇲",
+  mn: "🇲🇳",
+  mo: "🇲🇴",
+  mp: "🇲🇵",
+  mq: "🇲🇶",
+  mr: "🇲🇷",
+  ms: "🇲🇸",
+  mt: "🇲🇹",
+  mu: "🇲🇺",
+  mv: "🇲🇻",
+  mw: "🇲🇼",
+  mx: "🇲🇽",
+  my: "🇲🇾",
+  mz: "🇲🇿",
+  na: "🇳🇦",
+  nc: "🇳🇨",
+  ne: "🇳🇪",
+  nf: "🇳🇫",
+  ng: "🇳🇬",
+  ni: "🇳🇮",
+  nl: "🇳🇱",
+  no: "🇳🇴",
+  np: "🇳🇵",
+  nr: "🇳🇷",
+  nu: "🇳🇺",
+  nz: "🇳🇿",
+  om: "🇴🇲",
+  pa: "🇵🇦",
+  pe: "🇵🇪",
+  pf: "🇵🇫",
+  pg: "🇵🇬",
+  ph: "🇵🇭",
+  pk: "🇵🇰",
+  pl: "🇵🇱",
+  pm: "🇵🇲",
+  pn: "🇵🇳",
+  pr: "🇵🇷",
+  ps: "🇵🇸",
+  pt: "🇵🇹",
+  pw: "🇵🇼",
+  py: "🇵🇾",
+  qa: "🇶🇦",
+  re: "🇷🇪",
+  ro: "🇷🇴",
+  rs: "🇷🇸",
+  ru: "🇷🇺",
+  rw: "🇷🇼",
+  sa: "🇸🇦",
+  sb: "🇸🇧",
+  sc: "🇸🇨",
+  sd: "🇸🇩",
+  se: "🇸🇪",
+  sg: "🇸🇬",
+  sh: "🇸🇭",
+  si: "🇸🇮",
+  sj: "🇸🇯",
+  sk: "🇸🇰",
+  sl: "🇸🇱",
+  sm: "🇸🇲",
+  sn: "🇸🇳",
+  so: "🇸🇴",
+  sr: "🇸🇷",
+  ss: "🇸🇸",
+  st: "🇸🇹",
+  sv: "🇸🇻",
+  sx: "🇸🇽",
+  sy: "🇸🇾",
+  sz: "🇸🇿",
+  tc: "🇹🇨",
+  td: "🇹🇩",
+  tf: "🇹🇫",
+  tg: "🇹🇬",
+  th: "🇹🇭",
+  tj: "🇹🇯",
+  tk: "🇹🇰",
+  tl: "🇹🇱",
+  tm: "🇹🇲",
+  tn: "🇹🇳",
+  to: "🇹🇴",
+  tr: "🇹🇷",
+  tt: "🇹🇹",
+  tv: "🇹🇻",
+  tw: "🇹🇼",
+  tz: "🇹🇿",
+  ua: "🇺🇦",
+  ug: "🇺🇬",
+  um: "🇺🇲",
+  us: "🇺🇸",
+  uy: "🇺🇾",
+  uz: "🇺🇿",
+  va: "🇻🇦",
+  vc: "🇻🇨",
+  ve: "🇻🇪",
+  vg: "🇻🇬",
+  vi: "🇻🇮",
+  vn: "🇻🇳",
+  vu: "🇻🇺",
+  wf: "🇼🇫",
+  ws: "🇼🇸",
+  xk: "🇽🇰",
+  ye: "🇾🇪",
+  yt: "🇾🇹",
+  za: "🇿🇦",
+  zm: "🇿🇲",
+  zw: "🇿🇼",
+}

+ 4 - 1
client/web/src/components/views/home-view.tsx

@@ -1,7 +1,7 @@
 import cx from "classnames"
 import React from "react"
 import ExitNodeSelector from "src/components/exit-node-selector"
-import { NodeData, NodeUpdate } from "src/hooks/node-data"
+import { NodeData, NodeUpdate, PrefsUpdate } from "src/hooks/node-data"
 import { ReactComponent as ArrowRight } from "src/icons/arrow-right.svg"
 import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
 import { Link } from "wouter"
@@ -10,10 +10,12 @@ export default function HomeView({
   readonly,
   node,
   updateNode,
+  updatePrefs,
 }: {
   readonly: boolean
   node: NodeData
   updateNode: (update: NodeUpdate) => Promise<void> | undefined
+  updatePrefs: (p: PrefsUpdate) => Promise<void>
 }) {
   return (
     <div className="mb-12 w-full">
@@ -36,6 +38,7 @@ export default function HomeView({
           className="mb-5"
           node={node}
           updateNode={updateNode}
+          updatePrefs={updatePrefs}
           disabled={readonly}
         />
         <Link

+ 199 - 0
client/web/src/hooks/exit-nodes.ts

@@ -0,0 +1,199 @@
+import { useEffect, useMemo, useState } from "react"
+import { apiFetch } from "src/api"
+
+export type ExitNode = {
+  ID: string
+  Name: string
+  Location?: ExitNodeLocation
+}
+
+type ExitNodeLocation = {
+  Country: string
+  CountryCode: CountryCode
+  City: string
+  CityCode: CityCode
+  Priority: number
+}
+
+type CountryCode = string
+type CityCode = string
+
+export type ExitNodeGroup = {
+  id: string
+  name?: string
+  nodes: ExitNode[]
+}
+
+export default function useExitNodes(tailnetName: string, filter?: string) {
+  const [data, setData] = useState<ExitNode[]>([])
+
+  useEffect(() => {
+    apiFetch("/exit-nodes", "GET")
+      .then((r) => r.json())
+      .then((r) => setData(r))
+      .catch((err) => {
+        alert("Failed operation: " + err.message)
+      })
+  }, [])
+
+  const { tailnetNodesSorted, locationNodesMap } = useMemo(() => {
+    // First going through exit nodes and splitting them into two groups:
+    // 1. tailnetNodes: exit nodes advertised by tailnet's own nodes
+    // 2. locationNodes: exit nodes advertised by non-tailnet Mullvad nodes
+    let tailnetNodes: ExitNode[] = []
+    const locationNodes = new Map<CountryCode, Map<CityCode, ExitNode[]>>()
+
+    data?.forEach((n) => {
+      const loc = n.Location
+      if (!loc) {
+        // 2023-11-15: Currently, if the node doesn't have
+        // location information, it is owned by the tailnet.
+        // Only Mullvad exit nodes have locations filled.
+        tailnetNodes.push({
+          ...n,
+          Name: trimDNSSuffix(n.Name, tailnetName),
+        })
+        return
+      }
+      const countryNodes =
+        locationNodes.get(loc.CountryCode) || new Map<CityCode, ExitNode[]>()
+      const cityNodes = countryNodes.get(loc.CityCode) || []
+      countryNodes.set(loc.CityCode, [...cityNodes, n])
+      locationNodes.set(loc.CountryCode, countryNodes)
+    })
+
+    return {
+      tailnetNodesSorted: tailnetNodes.sort(compareByName),
+      locationNodesMap: locationNodes,
+    }
+  }, [data, tailnetName])
+
+  const mullvadNodesSorted = useMemo(() => {
+    const nodes: ExitNode[] = []
+
+    // addBestMatchNode adds the node with the "higest priority"
+    // match from a list of exit node `options` to `nodes`.
+    const addBestMatchNode = (
+      options: ExitNode[],
+      name: (l: ExitNodeLocation) => string
+    ) => {
+      const bestNode = highestPriorityNode(options)
+      if (!bestNode || !bestNode.Location) {
+        return // not possible, doing this for type safety
+      }
+      nodes.push({
+        ID: bestNode.ID,
+        Name: name(bestNode.Location),
+        Location: bestNode.Location,
+      })
+    }
+
+    if (!Boolean(filter)) {
+      // When nothing is searched, only show a single best-matching
+      // exit node per-country.
+      //
+      // There's too many location-based nodes to display all of them.
+      locationNodesMap.forEach(
+        // add one node per country
+        (countryNodes) =>
+          addBestMatchNode(flattenMap(countryNodes), (l) => l.Country)
+      )
+    } else {
+      // Otherwise, show the best match on a city-level,
+      // with a "Country: Best Match" node at top.
+      //
+      // i.e. We allow for discovering cities through searching.
+      locationNodesMap.forEach((countryNodes) => {
+        countryNodes.forEach(
+          // add one node per city
+          (cityNodes) =>
+            addBestMatchNode(cityNodes, (l) => `${l.Country}: ${l.City}`)
+        )
+        // add the "Country: Best Match" node
+        addBestMatchNode(
+          flattenMap(countryNodes),
+          (l) => `${l.Country}: Best Match`
+        )
+      })
+    }
+
+    return nodes.sort(compareByName)
+  }, [locationNodesMap, Boolean(filter)])
+
+  // Ordered and filtered grouping of exit nodes.
+  const exitNodeGroups = useMemo(() => {
+    const filterLower = !filter ? undefined : filter.toLowerCase()
+
+    return [
+      { id: "self", nodes: filter ? [] : [noExitNode, runAsExitNode] },
+      {
+        id: "tailnet",
+        nodes: filterLower
+          ? tailnetNodesSorted.filter((n) =>
+              n.Name.toLowerCase().includes(filterLower)
+            )
+          : tailnetNodesSorted,
+      },
+      {
+        id: "mullvad",
+        name: "Mullvad VPN",
+        nodes: filterLower
+          ? mullvadNodesSorted.filter((n) =>
+              n.Name.toLowerCase().includes(filterLower)
+            )
+          : mullvadNodesSorted,
+      },
+    ]
+  }, [tailnetNodesSorted, mullvadNodesSorted, filter])
+
+  return { data: exitNodeGroups }
+}
+
+// highestPriorityNode finds the highest priority node for use
+// (the "best match" node) from a list of exit nodes.
+// Nodes with equal priorities are picked between arbitrarily.
+function highestPriorityNode(nodes: ExitNode[]): ExitNode | undefined {
+  return nodes.length === 0
+    ? undefined
+    : nodes.sort(
+        (a, b) => (b.Location?.Priority || 0) - (a.Location?.Priority || 0)
+      )[0]
+}
+
+// compareName compares two exit nodes alphabetically by name.
+function compareByName(a: ExitNode, b: ExitNode): number {
+  if (a.Location && b.Location && a.Location.Country == b.Location.Country) {
+    // Always put "<Country>: Best Match" node at top of country list.
+    if (a.Name.includes(": Best Match")) {
+      return -1
+    } else if (b.Name.includes(": Best Match")) {
+      return 1
+    }
+  }
+  return a.Name.localeCompare(b.Name)
+}
+
+function flattenMap<T, V>(m: Map<T, V[]>): V[] {
+  return Array.from(m.values()).reduce((prev, curr) => [...prev, ...curr])
+}
+
+// trimDNSSuffix trims the tailnet dns name from s, leaving no
+// trailing dots.
+//
+// trimDNSSuffix("hello.ts.net", "ts.net") = "hello"
+// trimDNSSuffix("hello", "ts.net") = "hello"
+export function trimDNSSuffix(s: string, tailnetDNSName: string): string {
+  if (s.endsWith(".")) {
+    s = s.slice(0, -1)
+  }
+  if (s.endsWith("." + tailnetDNSName)) {
+    s = s.replace("." + tailnetDNSName, "")
+  }
+  return s
+}
+
+export const noExitNode: ExitNode = { ID: "NONE", Name: "None" }
+export const runAsExitNode: ExitNode = {
+  ID: "RUNNING",
+  Name: "Run as exit node…",
+}

+ 4 - 0
client/web/src/hooks/node-data.ts

@@ -1,5 +1,6 @@
 import { useCallback, useEffect, useState } from "react"
 import { apiFetch, setUnraidCsrfToken } from "src/api"
+import { ExitNode } from "src/hooks/exit-nodes"
 import { VersionInfo } from "src/hooks/self-update"
 
 export type NodeData = {
@@ -28,6 +29,7 @@ export type NodeData = {
   IsTagged: boolean
   Tags: string[]
   RunningSSHServer: boolean
+  ExitNodeStatus?: ExitNode & { Online: boolean }
 }
 
 type NodeState =
@@ -52,6 +54,8 @@ export type NodeUpdate = {
 export type PrefsUpdate = {
   RunSSHSet?: boolean
   RunSSH?: boolean
+  ExitNodeIDSet?: boolean
+  ExitNodeID?: string
 }
 
 // useNodeData returns basic data about the current node.

+ 28 - 0
client/web/src/ui/search-input.tsx

@@ -0,0 +1,28 @@
+import cx from "classnames"
+import React, { forwardRef, InputHTMLAttributes } from "react"
+import { ReactComponent as Search } from "src/icons/search.svg"
+
+type Props = {
+  className?: string
+  inputClassName?: string
+} & InputHTMLAttributes<HTMLInputElement>
+
+/**
+ * SearchInput is a standard input with a search icon.
+ */
+const SearchInput = forwardRef<HTMLInputElement, Props>((props, ref) => {
+  const { className, inputClassName, ...rest } = props
+  return (
+    <div className={cx("relative", className)}>
+      <Search className="absolute w-[1.25em] h-full ml-2" />
+      <input
+        type="text"
+        className={cx("input px-8", inputClassName)}
+        ref={ref}
+        {...rest}
+      />
+    </div>
+  )
+})
+SearchInput.displayName = "SearchInput"
+export default SearchInput

+ 57 - 0
client/web/web.go

@@ -528,6 +528,9 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
 			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 		}
 		return
+	case path == "/exit-nodes" && r.Method == httpm.GET:
+		s.serveGetExitNodes(w, r)
+		return
 	case strings.HasPrefix(path, "/local/"):
 		s.proxyRequestToLocalAPI(w, r)
 		return
@@ -560,6 +563,7 @@ type nodeData struct {
 	UnraidToken string
 	URLPrefix   string // if set, the URL prefix the client is served behind
 
+	ExitNodeStatus    *exitNodeWithStatus
 	AdvertiseExitNode bool
 	AdvertiseRoutes   string
 	RunningSSHServer  bool
@@ -634,9 +638,62 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
 			data.AdvertiseRoutes += r.String()
 		}
 	}
+	if e := st.ExitNodeStatus; e != nil {
+		data.ExitNodeStatus = &exitNodeWithStatus{
+			exitNode: exitNode{ID: e.ID},
+			Online:   e.Online,
+		}
+		for _, ps := range st.Peer {
+			if ps.ID == e.ID {
+				data.ExitNodeStatus.Name = ps.DNSName
+				data.ExitNodeStatus.Location = ps.Location
+				break
+			}
+		}
+		if data.ExitNodeStatus.Name == "" {
+			// Falling back to TailscaleIP/StableNodeID when the peer
+			// is no longer included in status.
+			if len(e.TailscaleIPs) > 0 {
+				data.ExitNodeStatus.Name = e.TailscaleIPs[0].Addr().String()
+			} else {
+				data.ExitNodeStatus.Name = string(e.ID)
+			}
+		}
+	}
 	writeJSON(w, *data)
 }
 
+type exitNode struct {
+	ID       tailcfg.StableNodeID
+	Name     string
+	Location *tailcfg.Location
+}
+
+type exitNodeWithStatus struct {
+	exitNode
+	Online bool
+}
+
+func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
+	st, err := s.lc.Status(r.Context())
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	var exitNodes []*exitNode
+	for _, ps := range st.Peer {
+		if !ps.ExitNodeOption {
+			continue
+		}
+		exitNodes = append(exitNodes, &exitNode{
+			ID:       ps.ID,
+			Name:     ps.DNSName,
+			Location: ps.Location,
+		})
+	}
+	writeJSON(w, exitNodes)
+}
+
 type nodeUpdate struct {
 	AdvertiseRoutes   string
 	AdvertiseExitNode bool