client/web: add subnet routes view
Add UI view for mutating the node's advertised subnet routes. Updates #10261 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:

committed by
Sonia Appasamy

parent
7aa981ba49
commit
ecd1ccb917
11
client/web/src/assets/icons/clock.svg
Normal file
11
client/web/src/assets/icons/clock.svg
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_14876_118476)">
|
||||||
|
<path d="M8.00065 14.6667C11.6825 14.6667 14.6673 11.6819 14.6673 8.00004C14.6673 4.31814 11.6825 1.33337 8.00065 1.33337C4.31875 1.33337 1.33398 4.31814 1.33398 8.00004C1.33398 11.6819 4.31875 14.6667 8.00065 14.6667Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 4V8L10.6667 9.33333" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_14876_118476">
|
||||||
|
<rect width="16" height="16" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 678 B |
4
client/web/src/assets/icons/plus.svg
Normal file
4
client/web/src/assets/icons/plus.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 4.16663V15.8333" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.16602 10H15.8327" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 329 B |
@ -8,6 +8,7 @@ import DeviceDetailsView from "src/components/views/device-details-view"
|
|||||||
import HomeView from "src/components/views/home-view"
|
import HomeView from "src/components/views/home-view"
|
||||||
import LoginView from "src/components/views/login-view"
|
import LoginView from "src/components/views/login-view"
|
||||||
import SSHView from "src/components/views/ssh-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 { UpdatingView } from "src/components/views/updating-view"
|
||||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||||
import useNodeData, { NodeData } from "src/hooks/node-data"
|
import useNodeData, { NodeData } from "src/hooks/node-data"
|
||||||
@ -34,7 +35,7 @@ function WebClient({
|
|||||||
auth: AuthResponse
|
auth: AuthResponse
|
||||||
newSession: () => Promise<void>
|
newSession: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const { data, refreshData, updateNode, updatePrefs } = useNodeData()
|
const { data, refreshData, nodeUpdaters } = useNodeData()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshData()
|
refreshData()
|
||||||
}, [auth, refreshData])
|
}, [auth, refreshData])
|
||||||
@ -56,19 +57,24 @@ function WebClient({
|
|||||||
<HomeView
|
<HomeView
|
||||||
readonly={!auth.canManageNode}
|
readonly={!auth.canManageNode}
|
||||||
node={data}
|
node={data}
|
||||||
updateNode={updateNode}
|
nodeUpdaters={nodeUpdaters}
|
||||||
updatePrefs={updatePrefs}
|
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/details">
|
<Route path="/details">
|
||||||
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
|
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/subnets">{/* TODO */}Subnet router</Route>
|
<Route path="/subnets">
|
||||||
|
<SubnetRouterView
|
||||||
|
readonly={!auth.canManageNode}
|
||||||
|
node={data}
|
||||||
|
nodeUpdaters={nodeUpdaters}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
<Route path="/ssh">
|
<Route path="/ssh">
|
||||||
<SSHView
|
<SSHView
|
||||||
readonly={!auth.canManageNode}
|
readonly={!auth.canManageNode}
|
||||||
runningSSH={data.RunningSSHServer}
|
runningSSH={data.RunningSSHServer}
|
||||||
updatePrefs={updatePrefs}
|
nodeUpdaters={nodeUpdaters}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/serve">{/* TODO */}Share local content</Route>
|
<Route path="/serve">{/* TODO */}Share local content</Route>
|
||||||
|
@ -11,21 +11,19 @@ import useExitNodes, {
|
|||||||
runAsExitNode,
|
runAsExitNode,
|
||||||
trimDNSSuffix,
|
trimDNSSuffix,
|
||||||
} from "src/hooks/exit-nodes"
|
} 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 Popover from "src/ui/popover"
|
||||||
import SearchInput from "src/ui/search-input"
|
import SearchInput from "src/ui/search-input"
|
||||||
|
|
||||||
export default function ExitNodeSelector({
|
export default function ExitNodeSelector({
|
||||||
className,
|
className,
|
||||||
node,
|
node,
|
||||||
updateNode,
|
nodeUpdaters,
|
||||||
updatePrefs,
|
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
node: NodeData
|
node: NodeData
|
||||||
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
nodeUpdaters: NodeUpdaters
|
||||||
updatePrefs: (p: PrefsUpdate) => Promise<void>
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState<boolean>(false)
|
const [open, setOpen] = useState<boolean>(false)
|
||||||
@ -37,48 +35,11 @@ export default function ExitNodeSelector({
|
|||||||
if (n.ID === selected.ID) {
|
if (n.ID === selected.ID) {
|
||||||
return // no update
|
return // no update
|
||||||
}
|
}
|
||||||
|
|
||||||
const old = selected
|
const old = selected
|
||||||
setSelected(n) // optimistic UI update
|
setSelected(n) // optimistic UI update
|
||||||
const reset = () => setSelected(old)
|
nodeUpdaters.postExitNode(n).catch(() => 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[selected, updateNode, updatePrefs]
|
[nodeUpdaters, selected]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@ -186,12 +147,12 @@ export default function ExitNodeSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toSelectedExitNode(data: NodeData): ExitNode {
|
function toSelectedExitNode(data: NodeData): ExitNode {
|
||||||
if (data.AdvertiseExitNode) {
|
if (data.AdvertisingExitNode) {
|
||||||
return runAsExitNode
|
return runAsExitNode
|
||||||
}
|
}
|
||||||
if (data.ExitNodeStatus) {
|
if (data.UsingExitNode) {
|
||||||
// TODO(sonia): also use online status
|
// TODO(sonia): also use online status
|
||||||
const node = { ...data.ExitNodeStatus }
|
const node = { ...data.UsingExitNode }
|
||||||
if (node.Location) {
|
if (node.Location) {
|
||||||
// For mullvad nodes, use location as name.
|
// For mullvad nodes, use location as name.
|
||||||
node.Name = `${node.Location.Country}: ${node.Location.City}`
|
node.Name = `${node.Location.Country}: ${node.Location.City}`
|
||||||
|
@ -6,19 +6,17 @@ import React from "react"
|
|||||||
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
|
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
|
||||||
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
|
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
|
||||||
import ExitNodeSelector from "src/components/exit-node-selector"
|
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"
|
import { Link } from "wouter"
|
||||||
|
|
||||||
export default function HomeView({
|
export default function HomeView({
|
||||||
readonly,
|
readonly,
|
||||||
node,
|
node,
|
||||||
updateNode,
|
nodeUpdaters,
|
||||||
updatePrefs,
|
|
||||||
}: {
|
}: {
|
||||||
readonly: boolean
|
readonly: boolean
|
||||||
node: NodeData
|
node: NodeData
|
||||||
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
nodeUpdaters: NodeUpdaters
|
||||||
updatePrefs: (p: PrefsUpdate) => Promise<void>
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-12 w-full">
|
<div className="mb-12 w-full">
|
||||||
@ -40,8 +38,7 @@ export default function HomeView({
|
|||||||
<ExitNodeSelector
|
<ExitNodeSelector
|
||||||
className="mb-5"
|
className="mb-5"
|
||||||
node={node}
|
node={node}
|
||||||
updateNode={updateNode}
|
nodeUpdaters={nodeUpdaters}
|
||||||
updatePrefs={updatePrefs}
|
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
@ -52,13 +49,12 @@ export default function HomeView({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mb-3">Settings</h2>
|
<h2 className="mb-3">Settings</h2>
|
||||||
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
|
<SettingsCard
|
||||||
{/* <SettingsCard
|
|
||||||
link="/subnets"
|
link="/subnets"
|
||||||
className="mb-3"
|
className="mb-3"
|
||||||
title="Subnet router"
|
title="Subnet router"
|
||||||
body="Add devices to your tailnet without installing Tailscale on them."
|
body="Add devices to your tailnet without installing Tailscale on them."
|
||||||
/> */}
|
/>
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
link="/ssh"
|
link="/ssh"
|
||||||
className="mb-3"
|
className="mb-3"
|
||||||
@ -73,6 +69,7 @@ export default function HomeView({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
|
||||||
{/* <SettingsCard
|
{/* <SettingsCard
|
||||||
link="/serve"
|
link="/serve"
|
||||||
title="Share local content"
|
title="Share local content"
|
||||||
|
@ -2,17 +2,17 @@
|
|||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { PrefsUpdate } from "src/hooks/node-data"
|
import { NodeUpdaters } from "src/hooks/node-data"
|
||||||
import Toggle from "src/ui/toggle"
|
import Toggle from "src/ui/toggle"
|
||||||
|
|
||||||
export default function SSHView({
|
export default function SSHView({
|
||||||
readonly,
|
readonly,
|
||||||
runningSSH,
|
runningSSH,
|
||||||
updatePrefs,
|
nodeUpdaters,
|
||||||
}: {
|
}: {
|
||||||
readonly: boolean
|
readonly: boolean
|
||||||
runningSSH: boolean
|
runningSSH: boolean
|
||||||
updatePrefs: (p: PrefsUpdate) => Promise<void>
|
nodeUpdaters: NodeUpdaters
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -32,7 +32,9 @@ export default function SSHView({
|
|||||||
<div className="-mx-5 px-4 py-3 bg-white rounded-lg border border-gray-200 flex gap-2.5 mb-3">
|
<div className="-mx-5 px-4 py-3 bg-white rounded-lg border border-gray-200 flex gap-2.5 mb-3">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={runningSSH}
|
checked={runningSSH}
|
||||||
onChange={() => updatePrefs({ RunSSHSet: true, RunSSH: !runningSSH })}
|
onChange={() =>
|
||||||
|
nodeUpdaters.patchPrefs({ RunSSHSet: true, RunSSH: !runningSSH })
|
||||||
|
}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
/>
|
/>
|
||||||
<div className="text-black text-sm font-medium leading-tight">
|
<div className="text-black text-sm font-medium leading-tight">
|
||||||
|
146
client/web/src/components/views/subnet-router-view.tsx
Normal file
146
client/web/src/components/views/subnet-router-view.tsx
Normal file
@ -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<boolean>(
|
||||||
|
advertisedRoutes.length === 0 && !readonly
|
||||||
|
)
|
||||||
|
const [inputText, setInputText] = useState<string>("")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="mb-1">Subnet router</h1>
|
||||||
|
<p className="description mb-5">
|
||||||
|
Add devices to your tailnet without installing Tailscale.{" "}
|
||||||
|
<a
|
||||||
|
href="https://tailscale.com/kb/1019/subnets/"
|
||||||
|
className="text-indigo-700"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Learn more →
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{inputOpen ? (
|
||||||
|
<div className="-mx-5 card shadow">
|
||||||
|
<p className="font-medium leading-snug mb-3">Advertise new routes</p>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
className="text-sm"
|
||||||
|
placeholder="192.168.0.0/24"
|
||||||
|
value={inputText}
|
||||||
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="my-2 h-6 text-neutral-500 text-sm leading-tight">
|
||||||
|
Add multiple routes by providing a comma-separated list.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
nodeUpdaters
|
||||||
|
.postSubnetRoutes([
|
||||||
|
...advertisedRoutes.map((r) => r.Route),
|
||||||
|
...inputText.split(","),
|
||||||
|
])
|
||||||
|
.then(() => {
|
||||||
|
setInputText("")
|
||||||
|
setInputOpen(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={readonly || !inputText}
|
||||||
|
>
|
||||||
|
Advertise routes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setInputOpen(true)} disabled={readonly}>
|
||||||
|
<Plus />
|
||||||
|
Advertise new route
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="-mx-5 mt-10">
|
||||||
|
{advertisedRoutes.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="px-5 py-3 bg-white rounded-lg border border-gray-200">
|
||||||
|
{advertisedRoutes.map((r) => (
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center pb-2.5 mb-2.5 border-b border-b-gray-200 last:pb-0 last:mb-0 last:border-b-0"
|
||||||
|
key={r.Route}
|
||||||
|
>
|
||||||
|
<div className="text-neutral-800 leading-snug">{r.Route}</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{r.Approved ? (
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{r.Approved ? (
|
||||||
|
<div className="text-emerald-800 text-sm leading-tight">
|
||||||
|
Approved
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-neutral-500 text-sm leading-tight">
|
||||||
|
Pending approval
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
intent="secondary"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
onClick={() =>
|
||||||
|
nodeUpdaters.postSubnetRoutes(
|
||||||
|
advertisedRoutes
|
||||||
|
.map((it) => it.Route)
|
||||||
|
.filter((it) => it !== r.Route)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={readonly}
|
||||||
|
>
|
||||||
|
Stop advertising…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 w-full text-center text-neutral-500 text-sm leading-tight">
|
||||||
|
To approve routes, in the admin console go to{" "}
|
||||||
|
<a
|
||||||
|
href={`https://login.tailscale.com/admin/machines/${node.IP}`}
|
||||||
|
className="text-indigo-700"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
the machine’s route settings
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="px-5 py-4 bg-stone-50 rounded-lg border border-gray-200 text-center text-neutral-500">
|
||||||
|
Not advertising any routes
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -8,6 +8,7 @@ export type ExitNode = {
|
|||||||
ID: string
|
ID: string
|
||||||
Name: string
|
Name: string
|
||||||
Location?: ExitNodeLocation
|
Location?: ExitNodeLocation
|
||||||
|
Online?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExitNodeLocation = {
|
type ExitNodeLocation = {
|
||||||
@ -87,9 +88,8 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
|
|||||||
return // not possible, doing this for type safety
|
return // not possible, doing this for type safety
|
||||||
}
|
}
|
||||||
nodes.push({
|
nodes.push({
|
||||||
ID: bestNode.ID,
|
...bestNode,
|
||||||
Name: name(bestNode.Location),
|
Name: name(bestNode.Location),
|
||||||
Location: bestNode.Location,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// 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 { 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"
|
import { VersionInfo } from "src/hooks/self-update"
|
||||||
|
|
||||||
export type NodeData = {
|
export type NodeData = {
|
||||||
@ -16,8 +16,9 @@ export type NodeData = {
|
|||||||
ID: string
|
ID: string
|
||||||
KeyExpiry: string
|
KeyExpiry: string
|
||||||
KeyExpired: boolean
|
KeyExpired: boolean
|
||||||
AdvertiseExitNode: boolean
|
UsingExitNode?: ExitNode
|
||||||
AdvertiseRoutes: string
|
AdvertisingExitNode: boolean
|
||||||
|
AdvertisedRoutes?: SubnetRoute[]
|
||||||
LicensesURL: string
|
LicensesURL: string
|
||||||
TUNMode: boolean
|
TUNMode: boolean
|
||||||
IsSynology: boolean
|
IsSynology: boolean
|
||||||
@ -32,7 +33,6 @@ export type NodeData = {
|
|||||||
IsTagged: boolean
|
IsTagged: boolean
|
||||||
Tags: string[]
|
Tags: string[]
|
||||||
RunningSSHServer: boolean
|
RunningSSHServer: boolean
|
||||||
ExitNodeStatus?: ExitNode & { Online: boolean }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeState =
|
type NodeState =
|
||||||
@ -49,16 +49,45 @@ export type UserProfile = {
|
|||||||
ProfilePicURL: string
|
ProfilePicURL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NodeUpdate = {
|
export type SubnetRoute = {
|
||||||
AdvertiseRoutes?: string
|
Route: string
|
||||||
AdvertiseExitNode?: boolean
|
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<void>
|
||||||
|
/**
|
||||||
|
* postExitNode updates the node's status as either using or
|
||||||
|
* running as an exit node.
|
||||||
|
*/
|
||||||
|
postExitNode: (d: ExitNode) => Promise<void>
|
||||||
|
/**
|
||||||
|
* postSubnetRoutes updates the node's advertised subnet routes.
|
||||||
|
*/
|
||||||
|
postSubnetRoutes: (d: string[]) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrefsPATCHData = {
|
||||||
RunSSHSet?: boolean
|
RunSSHSet?: boolean
|
||||||
RunSSH?: boolean
|
RunSSH?: boolean
|
||||||
ExitNodeIDSet?: boolean
|
}
|
||||||
ExitNodeID?: string
|
|
||||||
|
type RoutesPOSTData = {
|
||||||
|
UseExitNode?: string
|
||||||
|
AdvertiseExitNode?: boolean
|
||||||
|
AdvertiseRoutes?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// useNodeData returns basic data about the current node.
|
// useNodeData returns basic data about the current node.
|
||||||
@ -78,58 +107,13 @@ export default function useNodeData() {
|
|||||||
[setData]
|
[setData]
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateNode = useCallback(
|
const prefsPATCH = useCallback(
|
||||||
(update: NodeUpdate) => {
|
(d: PrefsPATCHData) => {
|
||||||
// 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) => {
|
|
||||||
setIsPosting(true)
|
setIsPosting(true)
|
||||||
if (data) {
|
if (data) {
|
||||||
const optimisticUpdates = data
|
const optimisticUpdates = data
|
||||||
if (p.RunSSHSet) {
|
if (d.RunSSHSet) {
|
||||||
optimisticUpdates.RunningSSHServer = Boolean(p.RunSSH)
|
optimisticUpdates.RunningSSHServer = Boolean(d.RunSSH)
|
||||||
}
|
}
|
||||||
// Reflect the pref change immediatley on the frontend,
|
// Reflect the pref change immediatley on the frontend,
|
||||||
// then make the prefs PATCH. If the request fails,
|
// then make the prefs PATCH. If the request fails,
|
||||||
@ -143,16 +127,36 @@ export default function useNodeData() {
|
|||||||
refreshData() // refresh data after PATCH finishes
|
refreshData() // refresh data after PATCH finishes
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiFetch("/local/v0/prefs", "PATCH", p)
|
return apiFetch("/local/v0/prefs", "PATCH", d)
|
||||||
.then(onComplete)
|
.then(onComplete)
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
onComplete()
|
onComplete()
|
||||||
alert("Failed to update prefs")
|
alert("Failed to update prefs")
|
||||||
|
throw err
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[setIsPosting, refreshData, setData, data]
|
[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(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
// Initial data load.
|
// Initial data load.
|
||||||
@ -172,5 +176,33 @@ export default function useNodeData() {
|
|||||||
[refreshData]
|
[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 }
|
||||||
}
|
}
|
||||||
|
33
client/web/src/ui/button.tsx
Normal file
33
client/web/src/ui/button.tsx
Normal file
@ -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<HTMLButtonElement>
|
||||||
|
|
||||||
|
export default function Button(props: Props) {
|
||||||
|
const { intent = "primary", className, disabled, children, ...rest } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cx(
|
||||||
|
"px-3 py-2 rounded shadow justify-center items-center gap-2.5 inline-flex font-medium",
|
||||||
|
{
|
||||||
|
"bg-indigo-500 text-white": intent === "primary" && !disabled,
|
||||||
|
"bg-indigo-400 text-indigo-200": intent === "primary" && disabled,
|
||||||
|
"bg-stone-50 shadow border border-stone-200 text-neutral-800":
|
||||||
|
intent === "secondary",
|
||||||
|
"cursor-not-allowed": disabled,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
@ -513,19 +513,15 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||||
switch {
|
switch {
|
||||||
case path == "/data":
|
case path == "/data" && r.Method == httpm.GET:
|
||||||
switch r.Method {
|
s.serveGetNodeData(w, r)
|
||||||
case httpm.GET:
|
|
||||||
s.serveGetNodeData(w, r)
|
|
||||||
case httpm.POST:
|
|
||||||
s.servePostNodeUpdate(w, r)
|
|
||||||
default:
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
case path == "/exit-nodes" && r.Method == httpm.GET:
|
case path == "/exit-nodes" && r.Method == httpm.GET:
|
||||||
s.serveGetExitNodes(w, r)
|
s.serveGetExitNodes(w, r)
|
||||||
return
|
return
|
||||||
|
case path == "/routes" && r.Method == httpm.POST:
|
||||||
|
s.servePostRoutes(w, r)
|
||||||
|
return
|
||||||
case strings.HasPrefix(path, "/local/"):
|
case strings.HasPrefix(path, "/local/"):
|
||||||
s.proxyRequestToLocalAPI(w, r)
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
return
|
return
|
||||||
@ -558,16 +554,21 @@ 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
|
UsingExitNode *exitNode
|
||||||
AdvertiseExitNode bool
|
AdvertisingExitNode bool
|
||||||
AdvertiseRoutes string
|
AdvertisedRoutes []subnetRoute // excludes exit node routes
|
||||||
RunningSSHServer bool
|
RunningSSHServer bool
|
||||||
|
|
||||||
ClientVersion *tailcfg.ClientVersion
|
ClientVersion *tailcfg.ClientVersion
|
||||||
|
|
||||||
LicensesURL string
|
LicensesURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type subnetRoute struct {
|
||||||
|
Route string
|
||||||
|
Approved bool // approved by control server
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||||
st, err := s.lc.Status(r.Context())
|
st, err := s.lc.Status(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -623,35 +624,44 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
|||||||
if st.Self.KeyExpiry != nil {
|
if st.Self.KeyExpiry != nil {
|
||||||
data.KeyExpiry = st.Self.KeyExpiry.Format(time.RFC3339)
|
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 {
|
for _, r := range prefs.AdvertiseRoutes {
|
||||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||||
data.AdvertiseExitNode = true
|
data.AdvertisingExitNode = true
|
||||||
} else {
|
} else {
|
||||||
if data.AdvertiseRoutes != "" {
|
data.AdvertisedRoutes = append(data.AdvertisedRoutes, subnetRoute{
|
||||||
data.AdvertiseRoutes += ","
|
Route: r.String(),
|
||||||
}
|
Approved: routeApproved(r),
|
||||||
data.AdvertiseRoutes += r.String()
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if e := st.ExitNodeStatus; e != nil {
|
if e := st.ExitNodeStatus; e != nil {
|
||||||
data.ExitNodeStatus = &exitNodeWithStatus{
|
data.UsingExitNode = &exitNode{
|
||||||
exitNode: exitNode{ID: e.ID},
|
ID: e.ID,
|
||||||
Online: e.Online,
|
Online: e.Online,
|
||||||
}
|
}
|
||||||
for _, ps := range st.Peer {
|
for _, ps := range st.Peer {
|
||||||
if ps.ID == e.ID {
|
if ps.ID == e.ID {
|
||||||
data.ExitNodeStatus.Name = ps.DNSName
|
data.UsingExitNode.Name = ps.DNSName
|
||||||
data.ExitNodeStatus.Location = ps.Location
|
data.UsingExitNode.Location = ps.Location
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if data.ExitNodeStatus.Name == "" {
|
if data.UsingExitNode.Name == "" {
|
||||||
// Falling back to TailscaleIP/StableNodeID when the peer
|
// Falling back to TailscaleIP/StableNodeID when the peer
|
||||||
// is no longer included in status.
|
// is no longer included in status.
|
||||||
if len(e.TailscaleIPs) > 0 {
|
if len(e.TailscaleIPs) > 0 {
|
||||||
data.ExitNodeStatus.Name = e.TailscaleIPs[0].Addr().String()
|
data.UsingExitNode.Name = e.TailscaleIPs[0].Addr().String()
|
||||||
} else {
|
} else {
|
||||||
data.ExitNodeStatus.Name = string(e.ID)
|
data.UsingExitNode.Name = string(e.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -662,11 +672,7 @@ type exitNode struct {
|
|||||||
ID tailcfg.StableNodeID
|
ID tailcfg.StableNodeID
|
||||||
Name string
|
Name string
|
||||||
Location *tailcfg.Location
|
Location *tailcfg.Location
|
||||||
}
|
Online bool
|
||||||
|
|
||||||
type exitNodeWithStatus struct {
|
|
||||||
exitNode
|
|
||||||
Online bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
|
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)
|
writeJSON(w, exitNodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
type nodeUpdate struct {
|
type postRoutesRequest struct {
|
||||||
AdvertiseRoutes string
|
UseExitNode tailcfg.StableNodeID
|
||||||
|
AdvertiseRoutes []string
|
||||||
AdvertiseExitNode bool
|
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()
|
defer r.Body.Close()
|
||||||
|
|
||||||
var postData nodeUpdate
|
var data postRoutesRequest
|
||||||
type mi map[string]any
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||||
if err := json.NewDecoder(r.Body).Decode(&postData); err != nil {
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
w.WriteHeader(400)
|
|
||||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs, err := s.lc.GetPrefs(r.Context())
|
oldPrefs, err := s.lc.GetPrefs(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
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 {
|
hasExitNodeRoute := func(all []netip.Prefix) bool {
|
||||||
if postData.AdvertiseExitNode {
|
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)
|
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_enable", 1)
|
||||||
} else {
|
} else {
|
||||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_disable", 1)
|
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_disable", 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode)
|
w.WriteHeader(http.StatusOK)
|
||||||
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, "{}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// tailscaleUp starts the daemon with the provided options.
|
// tailscaleUp starts the daemon with the provided options.
|
||||||
|
Reference in New Issue
Block a user