diff --git a/client/web/src/assets/icons/clock.svg b/client/web/src/assets/icons/clock.svg new file mode 100644 index 000000000..e88dc4165 --- /dev/null +++ b/client/web/src/assets/icons/clock.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/web/src/assets/icons/plus.svg b/client/web/src/assets/icons/plus.svg new file mode 100644 index 000000000..1257dcfa6 --- /dev/null +++ b/client/web/src/assets/icons/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 8a4a8f3e4..2b885f14d 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -8,6 +8,7 @@ import DeviceDetailsView from "src/components/views/device-details-view" import HomeView from "src/components/views/home-view" import LoginView from "src/components/views/login-view" import SSHView from "src/components/views/ssh-view" +import SubnetRouterView from "src/components/views/subnet-router-view" import { UpdatingView } from "src/components/views/updating-view" import useAuth, { AuthResponse } from "src/hooks/auth" import useNodeData, { NodeData } from "src/hooks/node-data" @@ -34,7 +35,7 @@ function WebClient({ auth: AuthResponse newSession: () => Promise }) { - const { data, refreshData, updateNode, updatePrefs } = useNodeData() + const { data, refreshData, nodeUpdaters } = useNodeData() useEffect(() => { refreshData() }, [auth, refreshData]) @@ -56,19 +57,24 @@ function WebClient({ - {/* TODO */}Subnet router + + + {/* TODO */}Share local content diff --git a/client/web/src/components/exit-node-selector.tsx b/client/web/src/components/exit-node-selector.tsx index 5e3a0dece..973e624fe 100644 --- a/client/web/src/components/exit-node-selector.tsx +++ b/client/web/src/components/exit-node-selector.tsx @@ -11,21 +11,19 @@ import useExitNodes, { runAsExitNode, trimDNSSuffix, } from "src/hooks/exit-nodes" -import { NodeData, NodeUpdate, PrefsUpdate } from "src/hooks/node-data" +import { NodeData, NodeUpdaters } from "src/hooks/node-data" import Popover from "src/ui/popover" import SearchInput from "src/ui/search-input" export default function ExitNodeSelector({ className, node, - updateNode, - updatePrefs, + nodeUpdaters, disabled, }: { className?: string node: NodeData - updateNode: (update: NodeUpdate) => Promise | undefined - updatePrefs: (p: PrefsUpdate) => Promise + nodeUpdaters: NodeUpdaters disabled?: boolean }) { const [open, setOpen] = useState(false) @@ -37,48 +35,11 @@ export default function ExitNodeSelector({ if (n.ID === selected.ID) { return // no update } - const old = selected 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.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() - } - } - } + nodeUpdaters.postExitNode(n).catch(() => setSelected(old)) }, - [selected, updateNode, updatePrefs] + [nodeUpdaters, selected] ) const [ @@ -186,12 +147,12 @@ export default function ExitNodeSelector({ } function toSelectedExitNode(data: NodeData): ExitNode { - if (data.AdvertiseExitNode) { + if (data.AdvertisingExitNode) { return runAsExitNode } - if (data.ExitNodeStatus) { + if (data.UsingExitNode) { // TODO(sonia): also use online status - const node = { ...data.ExitNodeStatus } + const node = { ...data.UsingExitNode } if (node.Location) { // For mullvad nodes, use location as name. node.Name = `${node.Location.Country}: ${node.Location.City}` diff --git a/client/web/src/components/views/home-view.tsx b/client/web/src/components/views/home-view.tsx index 3b349ce92..dbe79a8bf 100644 --- a/client/web/src/components/views/home-view.tsx +++ b/client/web/src/components/views/home-view.tsx @@ -6,19 +6,17 @@ import React from "react" import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg" import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg" import ExitNodeSelector from "src/components/exit-node-selector" -import { NodeData, NodeUpdate, PrefsUpdate } from "src/hooks/node-data" +import { NodeData, NodeUpdaters } from "src/hooks/node-data" import { Link } from "wouter" export default function HomeView({ readonly, node, - updateNode, - updatePrefs, + nodeUpdaters, }: { readonly: boolean node: NodeData - updateNode: (update: NodeUpdate) => Promise | undefined - updatePrefs: (p: PrefsUpdate) => Promise + nodeUpdaters: NodeUpdaters }) { return (
@@ -40,8 +38,7 @@ export default function HomeView({

Settings

- {/* TODO(sonia,will): hiding unimplemented settings pages until implemented */} - {/* */} + /> + {/* TODO(sonia,will): hiding unimplemented settings pages until implemented */} {/* Promise + nodeUpdaters: NodeUpdaters }) { return ( <> @@ -32,7 +32,9 @@ export default function SSHView({
updatePrefs({ RunSSHSet: true, RunSSH: !runningSSH })} + onChange={() => + nodeUpdaters.patchPrefs({ RunSSHSet: true, RunSSH: !runningSSH }) + } disabled={readonly} />
diff --git a/client/web/src/components/views/subnet-router-view.tsx b/client/web/src/components/views/subnet-router-view.tsx new file mode 100644 index 000000000..d800a13c0 --- /dev/null +++ b/client/web/src/components/views/subnet-router-view.tsx @@ -0,0 +1,146 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import React, { useMemo, useState } from "react" +import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg" +import { ReactComponent as Clock } from "src/assets/icons/clock.svg" +import { ReactComponent as Plus } from "src/assets/icons/plus.svg" +import { NodeData, NodeUpdaters } from "src/hooks/node-data" +import Button from "src/ui/button" +import Input from "src/ui/input" + +export default function SubnetRouterView({ + readonly, + node, + nodeUpdaters, +}: { + readonly: boolean + node: NodeData + nodeUpdaters: NodeUpdaters +}) { + const advertisedRoutes = useMemo( + () => node.AdvertisedRoutes || [], + [node.AdvertisedRoutes] + ) + const [inputOpen, setInputOpen] = useState( + advertisedRoutes.length === 0 && !readonly + ) + const [inputText, setInputText] = useState("") + + return ( + <> +

Subnet router

+

+ Add devices to your tailnet without installing Tailscale.{" "} + + Learn more → + +

+ {inputOpen ? ( +
+

Advertise new routes

+ setInputText(e.target.value)} + /> +

+ Add multiple routes by providing a comma-separated list. +

+ +
+ ) : ( + + )} +
+ {advertisedRoutes.length > 0 ? ( + <> +
+ {advertisedRoutes.map((r) => ( +
+
{r.Route}
+
+
+ {r.Approved ? ( + + ) : ( + + )} + {r.Approved ? ( +
+ Approved +
+ ) : ( +
+ Pending approval +
+ )} +
+ +
+
+ ))} +
+
+ To approve routes, in the admin console go to{" "} + + the machine’s route settings + + . +
+ + ) : ( +
+ Not advertising any routes +
+ )} +
+ + ) +} diff --git a/client/web/src/hooks/exit-nodes.ts b/client/web/src/hooks/exit-nodes.ts index ae43778ca..9f08e0be8 100644 --- a/client/web/src/hooks/exit-nodes.ts +++ b/client/web/src/hooks/exit-nodes.ts @@ -8,6 +8,7 @@ export type ExitNode = { ID: string Name: string Location?: ExitNodeLocation + Online?: boolean } type ExitNodeLocation = { @@ -87,9 +88,8 @@ export default function useExitNodes(tailnetName: string, filter?: string) { return // not possible, doing this for type safety } nodes.push({ - ID: bestNode.ID, + ...bestNode, Name: name(bestNode.Location), - Location: bestNode.Location, }) } diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index f42ff3a1d..1a115985e 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -1,9 +1,9 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { apiFetch, setUnraidCsrfToken } from "src/api" -import { ExitNode } from "src/hooks/exit-nodes" +import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes" import { VersionInfo } from "src/hooks/self-update" export type NodeData = { @@ -16,8 +16,9 @@ export type NodeData = { ID: string KeyExpiry: string KeyExpired: boolean - AdvertiseExitNode: boolean - AdvertiseRoutes: string + UsingExitNode?: ExitNode + AdvertisingExitNode: boolean + AdvertisedRoutes?: SubnetRoute[] LicensesURL: string TUNMode: boolean IsSynology: boolean @@ -32,7 +33,6 @@ export type NodeData = { IsTagged: boolean Tags: string[] RunningSSHServer: boolean - ExitNodeStatus?: ExitNode & { Online: boolean } } type NodeState = @@ -49,16 +49,45 @@ export type UserProfile = { ProfilePicURL: string } -export type NodeUpdate = { - AdvertiseRoutes?: string - AdvertiseExitNode?: boolean +export type SubnetRoute = { + Route: string + Approved: boolean } -export type PrefsUpdate = { +/** + * NodeUpdaters provides a set of mutation functions for a node. + * + * These functions handle both making the requested change, as well as + * refreshing the app's node data state upon completion to reflect any + * relevant changes in the UI. + */ +export type NodeUpdaters = { + /** + * patchPrefs updates node preferences. + * Only provided preferences will be updated. + * Similar to running the tailscale set command in the CLI. + */ + patchPrefs: (d: PrefsPATCHData) => Promise + /** + * postExitNode updates the node's status as either using or + * running as an exit node. + */ + postExitNode: (d: ExitNode) => Promise + /** + * postSubnetRoutes updates the node's advertised subnet routes. + */ + postSubnetRoutes: (d: string[]) => Promise +} + +type PrefsPATCHData = { RunSSHSet?: boolean RunSSH?: boolean - ExitNodeIDSet?: boolean - ExitNodeID?: string +} + +type RoutesPOSTData = { + UseExitNode?: string + AdvertiseExitNode?: boolean + AdvertiseRoutes?: string[] } // useNodeData returns basic data about the current node. @@ -78,58 +107,13 @@ export default function useNodeData() { [setData] ) - const updateNode = useCallback( - (update: NodeUpdate) => { - // The contents of this function are mostly copied over - // from the legacy client's web.html file. - // It makes all data updates through one API endpoint. - // As we build out the web client in React, - // this endpoint will eventually be deprecated. - - if (isPosting || !data) { - return - } - setIsPosting(true) - - update = { - ...update, - // Default to current data value for any unset fields. - AdvertiseRoutes: - update.AdvertiseRoutes !== undefined - ? update.AdvertiseRoutes - : data.AdvertiseRoutes, - AdvertiseExitNode: - update.AdvertiseExitNode !== undefined - ? update.AdvertiseExitNode - : data.AdvertiseExitNode, - } - - return apiFetch("/data", "POST", update, { up: "true" }) - .then((r) => r.json()) - .then((r) => { - setIsPosting(false) - const err = r["error"] - if (err) { - throw new Error(err) - } - refreshData() - }) - .catch((err) => { - setIsPosting(false) - alert("Failed operation: " + err.message) - throw err - }) - }, - [data, isPosting, refreshData] - ) - - const updatePrefs = useCallback( - (p: PrefsUpdate) => { + const prefsPATCH = useCallback( + (d: PrefsPATCHData) => { setIsPosting(true) if (data) { const optimisticUpdates = data - if (p.RunSSHSet) { - optimisticUpdates.RunningSSHServer = Boolean(p.RunSSH) + if (d.RunSSHSet) { + optimisticUpdates.RunningSSHServer = Boolean(d.RunSSH) } // Reflect the pref change immediatley on the frontend, // then make the prefs PATCH. If the request fails, @@ -143,16 +127,36 @@ export default function useNodeData() { refreshData() // refresh data after PATCH finishes } - return apiFetch("/local/v0/prefs", "PATCH", p) + return apiFetch("/local/v0/prefs", "PATCH", d) .then(onComplete) - .catch(() => { + .catch((err) => { onComplete() alert("Failed to update prefs") + throw err }) }, [setIsPosting, refreshData, setData, data] ) + const routesPOST = useCallback( + (d: RoutesPOSTData) => { + setIsPosting(true) + const onComplete = () => { + setIsPosting(false) + refreshData() // refresh data after POST finishes + } + + return apiFetch("/routes", "POST", d) + .then(onComplete) + .catch((err) => { + onComplete() + alert("Failed to update routes") + throw err + }) + }, + [setIsPosting, refreshData] + ) + useEffect( () => { // Initial data load. @@ -172,5 +176,33 @@ export default function useNodeData() { [refreshData] ) - return { data, refreshData, updateNode, updatePrefs, isPosting } + const nodeUpdaters: NodeUpdaters = useMemo( + () => ({ + patchPrefs: prefsPATCH, + postExitNode: (node) => + routesPOST({ + AdvertiseExitNode: node.ID === runAsExitNode.ID, + UseExitNode: + node.ID === noExitNode.ID || node.ID === runAsExitNode.ID + ? undefined + : node.ID, + AdvertiseRoutes: data?.AdvertisedRoutes?.map((r) => r.Route), // unchanged + }), + postSubnetRoutes: (routes) => + routesPOST({ + AdvertiseRoutes: routes, + AdvertiseExitNode: data?.AdvertisingExitNode, // unchanged + UseExitNode: data?.UsingExitNode?.ID, // unchanged + }), + }), + [ + data?.AdvertisingExitNode, + data?.AdvertisedRoutes, + data?.UsingExitNode?.ID, + prefsPATCH, + routesPOST, + ] + ) + + return { data, refreshData, nodeUpdaters, isPosting } } diff --git a/client/web/src/ui/button.tsx b/client/web/src/ui/button.tsx new file mode 100644 index 000000000..844ece720 --- /dev/null +++ b/client/web/src/ui/button.tsx @@ -0,0 +1,33 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import cx from "classnames" +import React, { ButtonHTMLAttributes } from "react" + +type Props = { + intent?: "primary" | "secondary" +} & ButtonHTMLAttributes + +export default function Button(props: Props) { + const { intent = "primary", className, disabled, children, ...rest } = props + + return ( + + ) +} diff --git a/client/web/web.go b/client/web/web.go index f009c0f6b..46b4c7508 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -513,19 +513,15 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-CSRF-Token", csrf.Token(r)) path := strings.TrimPrefix(r.URL.Path, "/api") switch { - case path == "/data": - switch r.Method { - case httpm.GET: - s.serveGetNodeData(w, r) - case httpm.POST: - s.servePostNodeUpdate(w, r) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } + case path == "/data" && r.Method == httpm.GET: + s.serveGetNodeData(w, r) return case path == "/exit-nodes" && r.Method == httpm.GET: s.serveGetExitNodes(w, r) return + case path == "/routes" && r.Method == httpm.POST: + s.servePostRoutes(w, r) + return case strings.HasPrefix(path, "/local/"): s.proxyRequestToLocalAPI(w, r) return @@ -558,16 +554,21 @@ type nodeData struct { UnraidToken string URLPrefix string // if set, the URL prefix the client is served behind - ExitNodeStatus *exitNodeWithStatus - AdvertiseExitNode bool - AdvertiseRoutes string - RunningSSHServer bool + UsingExitNode *exitNode + AdvertisingExitNode bool + AdvertisedRoutes []subnetRoute // excludes exit node routes + RunningSSHServer bool ClientVersion *tailcfg.ClientVersion LicensesURL string } +type subnetRoute struct { + Route string + Approved bool // approved by control server +} + func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { st, err := s.lc.Status(r.Context()) if err != nil { @@ -623,35 +624,44 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { if st.Self.KeyExpiry != nil { data.KeyExpiry = st.Self.KeyExpiry.Format(time.RFC3339) } + + routeApproved := func(route netip.Prefix) bool { + if st.Self == nil || st.Self.AllowedIPs == nil { + return false + } + return st.Self.AllowedIPs.ContainsFunc(func(p netip.Prefix) bool { + return p == route + }) + } for _, r := range prefs.AdvertiseRoutes { if r == exitNodeRouteV4 || r == exitNodeRouteV6 { - data.AdvertiseExitNode = true + data.AdvertisingExitNode = true } else { - if data.AdvertiseRoutes != "" { - data.AdvertiseRoutes += "," - } - data.AdvertiseRoutes += r.String() + data.AdvertisedRoutes = append(data.AdvertisedRoutes, subnetRoute{ + Route: r.String(), + Approved: routeApproved(r), + }) } } if e := st.ExitNodeStatus; e != nil { - data.ExitNodeStatus = &exitNodeWithStatus{ - exitNode: exitNode{ID: e.ID}, - Online: e.Online, + data.UsingExitNode = &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 + data.UsingExitNode.Name = ps.DNSName + data.UsingExitNode.Location = ps.Location break } } - if data.ExitNodeStatus.Name == "" { + if data.UsingExitNode.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() + data.UsingExitNode.Name = e.TailscaleIPs[0].Addr().String() } else { - data.ExitNodeStatus.Name = string(e.ID) + data.UsingExitNode.Name = string(e.ID) } } } @@ -662,11 +672,7 @@ type exitNode struct { ID tailcfg.StableNodeID Name string Location *tailcfg.Location -} - -type exitNodeWithStatus struct { - exitNode - Online bool + Online bool } func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) { @@ -689,60 +695,69 @@ func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) { writeJSON(w, exitNodes) } -type nodeUpdate struct { - AdvertiseRoutes string +type postRoutesRequest struct { + UseExitNode tailcfg.StableNodeID + AdvertiseRoutes []string AdvertiseExitNode bool } -func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) { +func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - var postData nodeUpdate - type mi map[string]any - if err := json.NewDecoder(r.Body).Decode(&postData); err != nil { - w.WriteHeader(400) - json.NewEncoder(w).Encode(mi{"error": err.Error()}) + var data postRoutesRequest + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) return } - prefs, err := s.lc.GetPrefs(r.Context()) + oldPrefs, err := s.lc.GetPrefs(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - isCurrentlyExitNode := slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV4) || slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV6) + // Calculate routes. + routesStr := strings.Join(data.AdvertiseRoutes, ",") + routes, err := netutil.CalcAdvertiseRoutes(routesStr, data.AdvertiseExitNode) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - if postData.AdvertiseExitNode != isCurrentlyExitNode { - if postData.AdvertiseExitNode { + hasExitNodeRoute := func(all []netip.Prefix) bool { + return slices.Contains(all, exitNodeRouteV4) || + slices.Contains(all, exitNodeRouteV6) + } + + if !data.UseExitNode.IsZero() && hasExitNodeRoute(routes) { + http.Error(w, "cannot use and advertise exit node at same time", http.StatusBadRequest) + return + } + + // Make prefs update. + p := &ipn.MaskedPrefs{ + AdvertiseRoutesSet: true, + ExitNodeIDSet: true, + Prefs: ipn.Prefs{ + ExitNodeID: data.UseExitNode, + AdvertiseRoutes: routes, + }, + } + if _, err := s.lc.EditPrefs(r.Context(), p); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Report metrics. + if data.AdvertiseExitNode != hasExitNodeRoute(oldPrefs.AdvertiseRoutes) { + if data.AdvertiseExitNode { s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_enable", 1) } else { s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_disable", 1) } } - routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(mi{"error": err.Error()}) - return - } - mp := &ipn.MaskedPrefs{ - AdvertiseRoutesSet: true, - WantRunningSet: true, - } - mp.Prefs.WantRunning = true - mp.Prefs.AdvertiseRoutes = routes - s.logf("Doing edit: %v", mp.Pretty()) - - if _, err := s.lc.EditPrefs(r.Context(), mp); err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(mi{"error": err.Error()}) - return - } - - w.Header().Set("Content-Type", "application/json") - io.WriteString(w, "{}") + w.WriteHeader(http.StatusOK) } // tailscaleUp starts the daemon with the provided options.