From 763b9daa8408798d18e2d8133a0e02de8e5b57d2 Mon Sep 17 00:00:00 2001
From: Mario Minardi
Date: Mon, 11 Dec 2023 13:40:29 -0700
Subject: [PATCH] client/web: add visual indication for exit node pending
approval (#10532)
Add visual indication when running as an exit node prior to receiving
admin approval.
Updates https://github.com/tailscale/tailscale/issues/10261
Signed-off-by: Mario Minardi
Co-authored-by: Sonia Appasamy
---
client/web/src/api.ts | 10 +++++--
.../web/src/components/exit-node-selector.tsx | 28 +++++++++++++++++--
client/web/src/types.ts | 1 +
client/web/web.go | 11 +++++---
4 files changed, 42 insertions(+), 8 deletions(-)
diff --git a/client/web/src/api.ts b/client/web/src/api.ts
index 74bdf9176..9414e2d5d 100644
--- a/client/web/src/api.ts
+++ b/client/web/src/api.ts
@@ -92,7 +92,8 @@ export function useAPI() {
(
key: string,
fetch: Promise,
- optimisticData: (current: MutateDataType) => MutateDataType
+ optimisticData: (current: MutateDataType) => MutateDataType,
+ revalidate?: boolean // optionally specify whether to run final revalidation (step 3)
): Promise => {
const options: MutatorOptions = {
/**
@@ -105,6 +106,7 @@ export function useAPI() {
*/
populateCache: false,
optimisticData,
+ revalidate: revalidate,
}
return mutate(key, fetch, options)
},
@@ -226,8 +228,12 @@ export function useAPI() {
...old,
UsingExitNode: Boolean(body.UseExitNode) ? t.data : undefined,
AdvertisingExitNode: Boolean(body.AdvertiseExitNode),
+ AdvertisingExitNodeApproved: Boolean(body.AdvertiseExitNode)
+ ? true // gets updated in revalidation
+ : old.AdvertisingExitNodeApproved,
}
- }
+ },
+ false // skip final revalidation
)
.then(() => metrics.forEach((m) => incrementMetric(m)))
.catch(handlePostError("Failed to update exit node"))
diff --git a/client/web/src/components/exit-node-selector.tsx b/client/web/src/components/exit-node-selector.tsx
index aa8eef018..3edfc5d08 100644
--- a/client/web/src/components/exit-node-selector.tsx
+++ b/client/web/src/components/exit-node-selector.tsx
@@ -14,6 +14,7 @@ import useExitNodes, {
import { ExitNode, NodeData } from "src/types"
import Popover from "src/ui/popover"
import SearchInput from "src/ui/search-input"
+import { useSWRConfig } from "swr"
export default function ExitNodeSelector({
className,
@@ -27,7 +28,14 @@ export default function ExitNodeSelector({
const api = useAPI()
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState(toSelectedExitNode(node))
+ const [pending, setPending] = useState(false)
+ const { mutate } = useSWRConfig() // allows for global mutation
useEffect(() => setSelected(toSelectedExitNode(node)), [node])
+ useEffect(() => {
+ setPending(
+ node.AdvertisingExitNode && node.AdvertisingExitNodeApproved === false
+ )
+ }, [node])
const handleSelect = useCallback(
(n: ExitNode) => {
@@ -35,9 +43,18 @@ export default function ExitNodeSelector({
if (n.ID === selected.ID) {
return // no update
}
+ // Eager clear of pending state to avoid UI oddities
+ if (n.ID !== runAsExitNode.ID) {
+ setPending(false)
+ }
api({ action: "update-exit-node", data: n })
+
+ // refresh data after short timeout to pick up any pending approval updates
+ setTimeout(() => {
+ mutate("/data")
+ }, 1000)
},
- [api, selected]
+ [api, mutate, selected.ID]
)
const [
@@ -52,7 +69,7 @@ export default function ExitNodeSelector({
selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
!selected.Online,
],
- [selected]
+ [selected.ID, selected.Online]
)
return (
@@ -61,6 +78,7 @@ export default function ExitNodeSelector({
"rounded-md",
{
"bg-red-600": offline,
+ "bg-yellow-400": pending,
},
className
)}
@@ -160,6 +178,12 @@ export default function ExitNodeSelector({
blocked until you disable the exit node or select a different one.
)}
+ {pending && (
+
+ Pending approval to run as exit node. This device won't be usable as
+ an exit node until then.
+
+ )}
)
}
diff --git a/client/web/src/types.ts b/client/web/src/types.ts
index bd72935ee..62fa4c59f 100644
--- a/client/web/src/types.ts
+++ b/client/web/src/types.ts
@@ -15,6 +15,7 @@ export type NodeData = {
KeyExpired: boolean
UsingExitNode?: ExitNode
AdvertisingExitNode: boolean
+ AdvertisingExitNodeApproved: boolean
AdvertisedRoutes?: SubnetRoute[]
TUNMode: boolean
IsSynology: boolean
diff --git a/client/web/web.go b/client/web/web.go
index ebb1f1f39..956d3defa 100644
--- a/client/web/web.go
+++ b/client/web/web.go
@@ -589,10 +589,11 @@ type nodeData struct {
UnraidToken string
URLPrefix string // if set, the URL prefix the client is served behind
- UsingExitNode *exitNode
- AdvertisingExitNode bool
- AdvertisedRoutes []subnetRoute // excludes exit node routes
- RunningSSHServer bool
+ UsingExitNode *exitNode
+ AdvertisingExitNode bool
+ AdvertisingExitNodeApproved bool // whether running this node as an exit node has been approved by an admin
+ AdvertisedRoutes []subnetRoute // excludes exit node routes
+ RunningSSHServer bool
ClientVersion *tailcfg.ClientVersion
@@ -693,6 +694,8 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
return p == route
})
}
+ data.AdvertisingExitNodeApproved = routeApproved(exitNodeRouteV4) || routeApproved(exitNodeRouteV6)
+
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
data.AdvertisingExitNode = true