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 <sonia@tailscale.com>main
parent
0e27ec2cd9
commit
e75be017e4
@ -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…", |
||||
} |
||||
@ -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 |
||||
Loading…
Reference in new issue