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:

committed by
Sonia Appasamy

parent
0e27ec2cd9
commit
e75be017e4
@ -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<void> | undefined
|
||||
updatePrefs: (p: PrefsUpdate) => Promise<void>
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
const [selected, setSelected] = useState(
|
||||
node.AdvertiseExitNode ? runAsExitNode : noExitNode
|
||||
)
|
||||
useEffect(() => {
|
||||
setSelected(node.AdvertiseExitNode ? runAsExitNode : noExitNode)
|
||||
}, [node])
|
||||
const [selected, setSelected] = useState<ExitNode>(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 (
|
||||
<>
|
||||
<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
|
||||
className={cx(
|
||||
"p-1.5 rounded-md border flex items-stretch gap-1.5",
|
||||
@ -103,7 +143,14 @@ export default function ExitNodeSelector({
|
||||
"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>
|
||||
<ChevronDown
|
||||
className={cx("ml-1", {
|
||||
@ -131,47 +178,384 @@ export default function ExitNodeSelector({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
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<string>("")
|
||||
const { data: exitNodes } = useExitNodes(node.TailnetName, filter)
|
||||
|
||||
const hasNodes = useMemo(
|
||||
() => exitNodes.find((n) => n.nodes.length > 0),
|
||||
[exitNodes]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-full mt-1 pt-1 border-t border-gray-200">
|
||||
{items.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
|
||||
onClick={() => onSelect(v)}
|
||||
>
|
||||
<div className="leading-snug">{v}</div>
|
||||
{selected == v && <Check />}
|
||||
</button>
|
||||
))}
|
||||
<div className="w-[calc(var(--radix-popover-trigger-width)-16px)] py-1 rounded-lg shadow">
|
||||
<SearchInput
|
||||
name="exit-node-search"
|
||||
inputClassName="w-full px-4 py-2"
|
||||
autoCorrect="off"
|
||||
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"
|
||||
>
|
||||
{group.name && (
|
||||
<div className="px-4 py-2 text-neutral-500 text-xs font-medium uppercase tracking-wide">
|
||||
{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 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: "🇿🇼",
|
||||
}
|
||||
|
Reference in New Issue
Block a user