mirror of https://github.com/tailscale/tailscale/
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 <sonia@tailscale.com>pull/10287/head
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