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>
This commit is contained in:
Sonia Appasamy 2023-11-15 15:23:15 -05:00 committed by Sonia Appasamy
parent 0e27ec2cd9
commit e75be017e4
7 changed files with 741 additions and 65 deletions

View File

@ -55,6 +55,7 @@ function WebClient({
readonly={!auth.canManageNode} readonly={!auth.canManageNode}
node={data} node={data}
updateNode={updateNode} updateNode={updateNode}
updatePrefs={updatePrefs}
/> />
</Route> </Route>
<Route path="/details"> <Route path="/details">

View File

@ -1,57 +1,82 @@
import cx from "classnames" import cx from "classnames"
import React, { useCallback, useEffect, useMemo, useState } from "react" import { default as React, useCallback, useMemo, useState } from "react"
import { NodeData, NodeUpdate } from "src/hooks/node-data" 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 Check } from "src/icons/check.svg"
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg" import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
import { ReactComponent as Search } from "src/icons/search.svg" import Popover from "src/ui/popover"
import SearchInput from "src/ui/search-input"
const noExitNode = "None"
const runAsExitNode = "Run as exit node…"
export default function ExitNodeSelector({ export default function ExitNodeSelector({
className, className,
node, node,
updateNode, updateNode,
updatePrefs,
disabled, disabled,
}: { }: {
className?: string className?: string
node: NodeData node: NodeData
updateNode: (update: NodeUpdate) => Promise<void> | undefined updateNode: (update: NodeUpdate) => Promise<void> | undefined
updatePrefs: (p: PrefsUpdate) => Promise<void>
disabled?: boolean disabled?: boolean
}) { }) {
const [open, setOpen] = useState<boolean>(false) const [open, setOpen] = useState<boolean>(false)
const [selected, setSelected] = useState( const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
node.AdvertiseExitNode ? runAsExitNode : noExitNode
)
useEffect(() => {
setSelected(node.AdvertiseExitNode ? runAsExitNode : noExitNode)
}, [node])
const handleSelect = useCallback( const handleSelect = useCallback(
(item: string) => { (n: ExitNode) => {
setOpen(false) setOpen(false)
if (item === selected) { if (n.ID === selected.ID) {
return // no update return // no update
} }
const old = selected const old = selected
setSelected(item) setSelected(n) // optimistic UI update
var update: NodeUpdate = {} const reset = () => setSelected(old)
switch (item) {
case noExitNode: switch (n.ID) {
// turn off exit node case noExitNode.ID: {
update = { AdvertiseExitNode: false } if (old === runAsExitNode) {
break // stop advertising as exit node
case runAsExitNode: updateNode({ AdvertiseExitNode: false })?.catch(reset)
// turn on exit node } else {
update = { AdvertiseExitNode: true } // stop using exit node
updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" }).catch(reset)
}
break break
} }
updateNode(update)?.catch(() => setSelected(old)) 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()
}
}
}
}, },
[setOpen, selected, setSelected] [setOpen, selected, setSelected]
) )
// TODO: close on click outside
// TODO(sonia): allow choosing to use another exit node
const [ const [
none, // not using exit nodes none, // not using exit nodes
@ -59,15 +84,30 @@ export default function ExitNodeSelector({
using, // using another exit node using, // using another exit node
] = useMemo( ] = useMemo(
() => [ () => [
selected === noExitNode, selected.ID === noExitNode.ID,
selected === runAsExitNode, selected.ID === runAsExitNode.ID,
selected !== noExitNode && selected !== runAsExitNode, selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
], ],
[selected] [selected]
) )
return ( return (
<> <Popover
open={disabled ? false : open}
onOpenChange={setOpen}
side="bottom"
sideOffset={5}
align="start"
alignOffset={8}
content={
<ExitNodeSelectorInner
node={node}
selected={selected}
onSelect={handleSelect}
/>
}
asChild
>
<div <div
className={cx( className={cx(
"p-1.5 rounded-md border flex items-stretch gap-1.5", "p-1.5 rounded-md border flex items-stretch gap-1.5",
@ -103,7 +143,14 @@ export default function ExitNodeSelector({
"text-white": advertising || using, "text-white": advertising || using,
})} })}
> >
{selected === runAsExitNode ? "Running as exit node" : "None"} {selected.Location && (
<>
<CountryFlag code={selected.Location.CountryCode} />{" "}
</>
)}
{selected === runAsExitNode
? "Running as exit node"
: selected.Name}
</p> </p>
<ChevronDown <ChevronDown
className={cx("ml-1", { className={cx("ml-1", {
@ -131,47 +178,384 @@ export default function ExitNodeSelector({
</button> </button>
)} )}
</div> </div>
{open && ( </Popover>
<div className="absolute ml-1.5 -mt-3 w-full max-w-md py-1 bg-white rounded-lg shadow">
<div className="w-full px-4 py-2 flex items-center gap-2.5">
<Search />
<input
className="flex-1 leading-snug"
placeholder="Search exit nodes…"
/>
</div>
<DropdownSection
items={[noExitNode, runAsExitNode]}
selected={selected}
onSelect={handleSelect}
/>
</div>
)}
</>
) )
} }
function DropdownSection({ function toSelectedExitNode(data: NodeData): ExitNode {
items, 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, selected,
onSelect, onSelect,
}: { }: {
items: string[] node: NodeData
selected?: string selected: ExitNode
onSelect: (item: string) => void onSelect: (node: ExitNode) => void
}) { }) {
const [filter, setFilter] = useState<string>("")
const { data: exitNodes } = useExitNodes(node.TailnetName, filter)
const hasNodes = useMemo(
() => exitNodes.find((n) => n.nodes.length > 0),
[exitNodes]
)
return ( return (
<div className="w-full mt-1 pt-1 border-t border-gray-200"> <div className="w-[calc(var(--radix-popover-trigger-width)-16px)] py-1 rounded-lg shadow">
{items.map((v) => ( <SearchInput
<button name="exit-node-search"
key={v} inputClassName="w-full px-4 py-2"
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100" autoCorrect="off"
onClick={() => onSelect(v)} autoComplete="off"
autoCapitalize="off"
placeholder="Search exit nodes…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{/* TODO(sonia): use loading spinner when loading useExitNodes */}
<div className="pt-1 border-t border-gray-200 max-h-64 overflow-y-scroll">
{hasNodes ? (
exitNodes.map(
(group) =>
group.nodes.length > 0 && (
<div
key={group.id}
className="pb-1 mb-1 border-b last:border-b-0 last:mb-0"
> >
<div className="leading-snug">{v}</div> {group.name && (
{selected == v && <Check />} <div className="px-4 py-2 text-neutral-500 text-xs font-medium uppercase tracking-wide">
</button> {group.name}
</div>
)}
{group.nodes.map((n) => (
<ExitNodeSelectorItem
key={`${n.ID}-${n.Name}`}
node={n}
onSelect={() => onSelect(n)}
isSelected={selected.ID == n.ID}
/>
))} ))}
</div> </div>
) )
)
) : (
<div className="text-center truncate text-gray-500 p-5">
{filter
? `No exit nodes matching “${filter}`
: "No exit nodes available"}
</div>
)}
</div>
</div>
)
}
function ExitNodeSelectorItem({
node,
isSelected,
onSelect,
}: {
node: ExitNode
isSelected: boolean
onSelect: () => void
}) {
return (
<button
key={node.ID}
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
onClick={onSelect}
>
<div>
{node.Location && (
<>
<CountryFlag code={node.Location.CountryCode} />{" "}
</>
)}
<span className="leading-snug">{node.Name}</span>
</div>
{isSelected && <Check />}
</button>
)
}
function CountryFlag({ code }: { code: string }) {
return (
countryFlags[code.toLowerCase()] || (
<span className="font-medium text-gray-500 text-xs">
{code.toUpperCase()}
</span>
)
)
}
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: "🇿🇼",
} }

View File

@ -1,7 +1,7 @@
import cx from "classnames" import cx from "classnames"
import React from "react" import React from "react"
import ExitNodeSelector from "src/components/exit-node-selector" 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 ArrowRight } from "src/icons/arrow-right.svg"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg" import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { Link } from "wouter" import { Link } from "wouter"
@ -10,10 +10,12 @@ export default function HomeView({
readonly, readonly,
node, node,
updateNode, updateNode,
updatePrefs,
}: { }: {
readonly: boolean readonly: boolean
node: NodeData node: NodeData
updateNode: (update: NodeUpdate) => Promise<void> | undefined updateNode: (update: NodeUpdate) => Promise<void> | undefined
updatePrefs: (p: PrefsUpdate) => Promise<void>
}) { }) {
return ( return (
<div className="mb-12 w-full"> <div className="mb-12 w-full">
@ -36,6 +38,7 @@ export default function HomeView({
className="mb-5" className="mb-5"
node={node} node={node}
updateNode={updateNode} updateNode={updateNode}
updatePrefs={updatePrefs}
disabled={readonly} disabled={readonly}
/> />
<Link <Link

View File

@ -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…",
}

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { apiFetch, setUnraidCsrfToken } from "src/api" import { apiFetch, setUnraidCsrfToken } from "src/api"
import { ExitNode } from "src/hooks/exit-nodes"
import { VersionInfo } from "src/hooks/self-update" import { VersionInfo } from "src/hooks/self-update"
export type NodeData = { export type NodeData = {
@ -28,6 +29,7 @@ export type NodeData = {
IsTagged: boolean IsTagged: boolean
Tags: string[] Tags: string[]
RunningSSHServer: boolean RunningSSHServer: boolean
ExitNodeStatus?: ExitNode & { Online: boolean }
} }
type NodeState = type NodeState =
@ -52,6 +54,8 @@ export type NodeUpdate = {
export type PrefsUpdate = { export type PrefsUpdate = {
RunSSHSet?: boolean RunSSHSet?: boolean
RunSSH?: boolean RunSSH?: boolean
ExitNodeIDSet?: boolean
ExitNodeID?: string
} }
// useNodeData returns basic data about the current node. // useNodeData returns basic data about the current node.

View File

@ -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

View File

@ -528,6 +528,9 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
} }
return return
case path == "/exit-nodes" && r.Method == httpm.GET:
s.serveGetExitNodes(w, r)
return
case strings.HasPrefix(path, "/local/"): case strings.HasPrefix(path, "/local/"):
s.proxyRequestToLocalAPI(w, r) s.proxyRequestToLocalAPI(w, r)
return return
@ -560,6 +563,7 @@ type nodeData struct {
UnraidToken string UnraidToken string
URLPrefix string // if set, the URL prefix the client is served behind URLPrefix string // if set, the URL prefix the client is served behind
ExitNodeStatus *exitNodeWithStatus
AdvertiseExitNode bool AdvertiseExitNode bool
AdvertiseRoutes string AdvertiseRoutes string
RunningSSHServer bool RunningSSHServer bool
@ -634,9 +638,62 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
data.AdvertiseRoutes += r.String() 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) 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 { type nodeUpdate struct {
AdvertiseRoutes string AdvertiseRoutes string
AdvertiseExitNode bool AdvertiseExitNode bool