From 9b0b9406ff579f55a2ce73c36f44a76834e7128c 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 | 507 +++++++++++++++--- client/web/src/components/views/home-view.tsx | 5 +- client/web/src/hooks/exit-nodes.ts | 184 +++++++ client/web/src/hooks/node-data.ts | 4 + client/web/src/ui/search-input.tsx | 28 + client/web/web.go | 59 +- 7 files changed, 721 insertions(+), 67 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 1f2f9d4f65a13..867ded25c2a63 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -67,6 +67,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 2bab376f3f809..6653550cad864 100644 --- a/client/web/src/components/exit-node-selector.tsx +++ b/client/web/src/components/exit-node-selector.tsx @@ -1,57 +1,80 @@ 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, + useEffect, + 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)) + useEffect(() => setSelected(toSelectedExitNode(node)), [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: + if (old !== noExitNode) { + // stop using exit node + updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" }).catch(reset) + } + // start advertising as exit node + updateNode({ AdvertiseExitNode: true })?.catch(reset) break + default: + if (old === runAsExitNode) { + // stop advertising as exit node + updateNode({ AdvertiseExitNode: false })?.catch(reset) + } + // start using exit node + updatePrefs({ ExitNodeIDSet: true, ExitNodeID: n.ID }).catch(reset) } - 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 +82,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) { + node.Name = `${node.Location.Country}: ${node.Location.City}` + } + 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 10dbbb824a8b7..b8b0319796ac4 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( + Array.from(countryNodes.values()).reduce((p, c) => [...p, ...c]), + (l) => l.Country + ) + ) + } else { + // Otherwise, show the best match on a city-level. + // + // 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}`) + ) + ) + } + + 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 { + return a.Name.localeCompare(b.Name) +} + +// 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 434ba02e44a9f..5113b12ec1f2b 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" export type NodeData = { Profile: UserProfile @@ -26,6 +27,7 @@ export type NodeData = { IsTagged: boolean Tags: string[] RunningSSHServer: boolean + ExitNodeStatus?: ExitNode & { Online: boolean } DebugMode: "" | "login" | "full" // empty when not running in any debug mode } @@ -54,6 +56,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 0000000000000..8577a503f040a --- /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 7e56c365df76d..f2ac0db85d360 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -343,14 +343,12 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo // It should only be called by Server.ServeHTTP, via Server.apiHandler, // which protects the handler using gorilla csrf. func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-CSRF-Token", csrf.Token(r)) if r.URL.Path != "/api/data" { // only endpoint allowed for login client http.Error(w, "invalid endpoint", http.StatusNotFound) return } switch r.Method { case httpm.GET: - // TODO(soniaappasamy): we may want a minimal node data response here s.serveGetNodeData(w, r) return } @@ -505,6 +503,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 @@ -537,6 +538,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 @@ -612,9 +614,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