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