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
+
+ )}
+
+
+
+
+ ))}
+
+
+ >
+ ) : (
+
+ 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.