From e75be017e4d89a5f63b627e856050808235c1203 Mon Sep 17 00:00:00 2001 From: Sonia Appasamy Date: Wed, 15 Nov 2023 15:23:15 -0500 Subject: [PATCH] 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 --- client/web/src/components/app.tsx | 1 + .../web/src/components/exit-node-selector.tsx | 512 +++++++++++++++--- client/web/src/components/views/home-view.tsx | 5 +- client/web/src/hooks/exit-nodes.ts | 199 +++++++ client/web/src/hooks/node-data.ts | 4 + client/web/src/ui/search-input.tsx | 28 + client/web/web.go | 57 ++ 7 files changed, 741 insertions(+), 65 deletions(-) create mode 100644 client/web/src/hooks/exit-nodes.ts create mode 100644 client/web/src/ui/search-input.tsx diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 03fe4cd95..106b2e847 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -55,6 +55,7 @@ function WebClient({ readonly={!auth.canManageNode} node={data} updateNode={updateNode} + updatePrefs={updatePrefs} /> diff --git a/client/web/src/components/exit-node-selector.tsx b/client/web/src/components/exit-node-selector.tsx index 2bab376f3..cb113d065 100644 --- a/client/web/src/components/exit-node-selector.tsx +++ b/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 | undefined + updatePrefs: (p: PrefsUpdate) => Promise disabled?: boolean }) { const [open, setOpen] = useState(false) - const [selected, setSelected] = useState( - node.AdvertiseExitNode ? runAsExitNode : noExitNode - ) - useEffect(() => { - setSelected(node.AdvertiseExitNode ? runAsExitNode : noExitNode) - }, [node]) + const [selected, setSelected] = useState(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 ( - <> + + } + asChild + >
- {selected === runAsExitNode ? "Running as exit node" : "None"} + {selected.Location && ( + <> + {" "} + + )} + {selected === runAsExitNode + ? "Running as exit node" + : selected.Name}

)}
- {open && ( -
-
- - -
- -
- )} - +
) } -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("") + const { data: exitNodes } = useExitNodes(node.TailnetName, filter) + + const hasNodes = useMemo( + () => exitNodes.find((n) => n.nodes.length > 0), + [exitNodes] + ) + return ( -
- {items.map((v) => ( - - ))} +
+ setFilter(e.target.value)} + /> + {/* TODO(sonia): use loading spinner when loading useExitNodes */} +
+ {hasNodes ? ( + exitNodes.map( + (group) => + group.nodes.length > 0 && ( +
+ {group.name && ( +
+ {group.name} +
+ )} + {group.nodes.map((n) => ( + onSelect(n)} + isSelected={selected.ID == n.ID} + /> + ))} +
+ ) + ) + ) : ( +
+ {filter + ? `No exit nodes matching “${filter}”` + : "No exit nodes available"} +
+ )} +
) } + +function ExitNodeSelectorItem({ + node, + isSelected, + onSelect, +}: { + node: ExitNode + isSelected: boolean + onSelect: () => void +}) { + return ( + + ) +} + +function CountryFlag({ code }: { code: string }) { + return ( + countryFlags[code.toLowerCase()] || ( + + {code.toUpperCase()} + + ) + ) +} + +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: "🇿🇼", +} diff --git a/client/web/src/components/views/home-view.tsx b/client/web/src/components/views/home-view.tsx index a3b8b33b2..fa9adf4db 100644 --- a/client/web/src/components/views/home-view.tsx +++ b/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 | undefined + updatePrefs: (p: PrefsUpdate) => Promise }) { return (
@@ -36,6 +38,7 @@ export default function HomeView({ className="mb-5" node={node} updateNode={updateNode} + updatePrefs={updatePrefs} disabled={readonly} /> ([]) + + 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>() + + 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() + 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 ": 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(m: Map): 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…", +} diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index 20257e1a3..470ef1fbe 100644 --- a/client/web/src/hooks/node-data.ts +++ b/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. diff --git a/client/web/src/ui/search-input.tsx b/client/web/src/ui/search-input.tsx new file mode 100644 index 000000000..8577a503f --- /dev/null +++ b/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 + +/** + * SearchInput is a standard input with a search icon. + */ +const SearchInput = forwardRef((props, ref) => { + const { className, inputClassName, ...rest } = props + return ( +
+ + +
+ ) +}) +SearchInput.displayName = "SearchInput" +export default SearchInput diff --git a/client/web/web.go b/client/web/web.go index 2819f8f11..404d2a945 100644 --- a/client/web/web.go +++ b/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