client/web: add serve/funnel view

Adds a new view to the web client for managing serve/funnel.
The view is permissioned by the "serve" and "funnel" grants,
and allows for http/https/tcp proxy and plain text serving.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy 2024-03-05 11:06:02 -05:00
parent 7429e8912a
commit a483a7fb25
No known key found for this signature in database
22 changed files with 2156 additions and 103 deletions

View File

@ -281,6 +281,8 @@ const (
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management
capFeatureExitNodes capFeature = "exitnodes" // grants peer ability to advertise-as and use exit nodes
capFeatureServe capFeature = "serve" // grants peer ability to share resources over Tailscale Serve
capFeatureFunnel capFeature = "funnel" // grants peer ability to share resources over Tailscale Funnel
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
)
@ -292,6 +294,8 @@ var validCaps []capFeature = []capFeature{
capFeatureSSH,
capFeatureSubnets,
capFeatureExitNodes,
capFeatureServe,
capFeatureFunnel,
capFeatureAccount,
}

View File

@ -11,7 +11,9 @@
"dependencies": {
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"classnames": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@ -3,7 +3,7 @@
import { useCallback } from "react"
import useToaster from "src/hooks/toaster"
import { ExitNode, NodeData, SubnetRoute } from "src/types"
import { ExitNode, NodeData, ServeData, SubnetRoute } from "src/types"
import { assertNever } from "src/utils/util"
import { MutatorOptions, SWRConfiguration, useSWRConfig } from "swr"
import { noExitNode, runAsExitNode } from "./hooks/exit-nodes"
@ -20,6 +20,8 @@ type APIType =
| { action: "update-prefs"; data: LocalPrefsData }
| { action: "update-routes"; data: SubnetRoute[] }
| { action: "update-exit-node"; data: ExitNode }
| { action: "patch-serve-item"; data: ServeData } // add or update
| { action: "delete-serve-item"; data: ServeData }
/**
* POST /api/up data
@ -239,6 +241,28 @@ export function useAPI() {
.catch(handlePostError("Failed to update exit node"))
}
/**
* "patch-serve-item" handles adding or updating an item in the
* node's serve config.
*/
case "patch-serve-item": {
// todo: report metric?
return apiFetch("/serve/items", "PATCH", t.data).catch(
handlePostError("Failed to update item")
)
}
/**
* "delete-serve-item" handles deleting an item in the node's
* serve config.
*/
case "delete-serve-item": {
// todo: report metric?
return apiFetch("/serve/items", "DELETE", t.data).catch(
handlePostError("Failed to delete item")
)
}
default:
assertNever(t)
}
@ -263,7 +287,7 @@ let unraidCsrfToken: string | undefined // required for unraid POST requests (#8
*/
export function apiFetch<T>(
endpoint: string,
method: "GET" | "POST" | "PATCH",
method: "GET" | "POST" | "PATCH" | "DELETE",
body?: any
): Promise<T> {
const urlParams = new URLSearchParams(window.location.search)

View File

@ -1,4 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 9H11C9.89543 9 9 9.89543 9 11V20C9 21.1046 9.89543 22 11 22H20C21.1046 22 22 21.1046 22 20V11C22 9.89543 21.1046 9 20 9Z" stroke="#292828" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5" stroke="#292828" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 9H11C9.89543 9 9 9.89543 9 11V20C9 21.1046 9.89543 22 11 22H20C21.1046 22 22 21.1046 22 20V11C22 9.89543 21.1046 9 20 9Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 649 B

After

Width:  |  Height:  |  Size: 615 B

View File

@ -0,0 +1,12 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_14876_118713)">
<path d="M9 16.5C13.1421 16.5 16.5 13.1421 16.5 9C16.5 4.85786 13.1421 1.5 9 1.5C4.85786 1.5 1.5 4.85786 1.5 9C1.5 13.1421 4.85786 16.5 9 16.5Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.5 9H16.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 1.5C10.876 3.55376 11.9421 6.21903 12 9C11.9421 11.781 10.876 14.4462 9 16.5C7.12404 14.4462 6.05794 11.781 6 9C6.05794 6.21903 7.12404 3.55376 9 1.5V1.5Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_14876_118713">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 794 B

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.25 6.75L9 1.5L15.75 6.75V15C15.75 15.3978 15.592 15.7794 15.3107 16.0607C15.0294 16.342 14.6478 16.5 14.25 16.5H3.75C3.35218 16.5 2.97064 16.342 2.68934 16.0607C2.40804 15.7794 2.25 15.3978 2.25 15V6.75Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.75 16.5V9H11.25V16.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 490 B

View File

@ -125,7 +125,7 @@ function AddressRow({
"text-gray-900 group-hover:text-gray-600"
)}
>
<Copy className="w-4 h-4" />
<Copy className="w-4 h-4" stroke="#292828" />
</span>
</button>
</li>

View File

@ -8,11 +8,12 @@ import DeviceDetailsView from "src/components/views/device-details-view"
import DisconnectedView from "src/components/views/disconnected-view"
import HomeView from "src/components/views/home-view"
import LoginView from "src/components/views/login-view"
import ServeView from "src/components/views/serve-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, canEdit } from "src/hooks/auth"
import { Feature, NodeData, featureDescription } from "src/types"
import { Feature, NodeData, featureLongName } from "src/types"
import Card from "src/ui/card"
import EmptyState from "src/ui/empty-state"
import LoadingDots from "src/ui/loading-dots"
@ -70,7 +71,9 @@ function WebClient({
<FeatureRoute path="/ssh" feature="ssh" node={node}>
<SSHView readonly={!canEdit("ssh", auth)} node={node} />
</FeatureRoute>
{/* <Route path="/serve">Share local content</Route> */}
<FeatureRoute path="/serve" feature="serve" node={node}>
<ServeView node={node} auth={auth} />
</FeatureRoute>
<FeatureRoute path="/update" feature="auto-update" node={node}>
<UpdatingView
versionInfo={node.ClientVersion}
@ -113,7 +116,7 @@ function FeatureRoute({
{!node.Features[feature] ? (
<Card className="mt-8">
<EmptyState
description={`${featureDescription(
description={`${featureLongName(
feature
)} not available on this device.`}
/>

View File

@ -5,13 +5,15 @@ import cx from "classnames"
import React, { useMemo } from "react"
import { apiFetch } from "src/api"
import ArrowRight from "src/assets/icons/arrow-right.svg?react"
import Globe from "src/assets/icons/globe.svg?react"
import Machine from "src/assets/icons/machine.svg?react"
import AddressCard from "src/components/address-copy-card"
import ExitNodeSelector from "src/components/exit-node-selector"
import { AuthResponse, canEdit } from "src/hooks/auth"
import { NodeData } from "src/types"
import { NodeData, ServeData } from "src/types"
import Card from "src/ui/card"
import { pluralize } from "src/utils/util"
import useSWR from "swr"
import { Link, useLocation } from "wouter"
export default function HomeView({
@ -21,10 +23,18 @@ export default function HomeView({
node: NodeData
auth: AuthResponse
}) {
const { data: serveData } = useSWR<ServeData[]>("/serve/items")
const [allServeAndFunnel, onlyFunnel] = useMemo(
() => [
serveData?.length || 0,
serveData?.filter((d) => d.shareType === "funnel").length || 0,
],
[serveData]
)
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
() => [
node.AdvertisedRoutes?.length,
node.AdvertisedRoutes?.filter((r) => !r.Approved).length,
node.AdvertisedRoutes?.length || 0,
node.AdvertisedRoutes?.filter((r) => !r.Approved).length || 0,
],
[node.AdvertisedRoutes]
)
@ -98,11 +108,13 @@ export default function HomeView({
}
footer={
pendingSubnetRoutes
? `${pendingSubnetRoutes} ${pluralize(
"route",
"routes",
pendingSubnetRoutes
)} pending approval`
? {
text: `${pendingSubnetRoutes} ${pluralize(
"route",
"routes",
pendingSubnetRoutes
)} pending approval`,
}
: undefined
}
/>
@ -122,12 +134,26 @@ export default function HomeView({
}
/>
)}
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
{/* <SettingsCard
link="/serve"
title="Share local content"
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
/> */}
{node.Features["serve"] && (
<SettingsCard
link="/serve"
title="Share local content"
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
badge={
allServeAndFunnel > 0
? { text: `${allServeAndFunnel} shared` }
: undefined
}
footer={
onlyFunnel
? {
text: `${onlyFunnel} shared on the internet`,
icon: <Globe className="w-4 h-4 stroke-gray-500" />,
}
: undefined
}
/>
)}
</div>
</div>
)
@ -148,7 +174,10 @@ function SettingsCard({
text: string
icon?: JSX.Element
}
footer?: string
footer?: {
text: string
icon?: JSX.Element
}
className?: string
}) {
const [, setLocation] = useLocation()
@ -180,7 +209,10 @@ function SettingsCard({
{footer && (
<>
<hr className="my-3" />
<div className="text-gray-500 text-sm leading-tight">{footer}</div>
<div className="flex items-center gap-[6px] text-gray-500 text-sm leading-tight">
{footer.text}
{footer.icon}
</div>
</>
)}
</Card>

View File

@ -0,0 +1,685 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { useAPI } from "src/api"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import Copy from "src/assets/icons/copy.svg?react"
import Globe from "src/assets/icons/globe.svg?react"
import Home from "src/assets/icons/home.svg?react"
import Plus from "src/assets/icons/plus.svg?react"
import { AuthResponse, canEdit } from "src/hooks/auth"
import useToaster from "src/hooks/toaster"
import {
Destination,
DestinationPort,
DestinationProtocol,
NodeData,
ServeData,
ShareType,
Target,
TargetType,
} from "src/types"
import Badge from "src/ui/badge"
import Button from "src/ui/button"
import Card from "src/ui/card"
import Collapsible from "src/ui/collapsible"
import DropdownMenu from "src/ui/dropdown-menu"
import EmptyState from "src/ui/empty-state"
import Input from "src/ui/input"
import QuickCopy from "src/ui/quick-copy"
import Tooltip from "src/ui/tooltip"
import { copyText } from "src/utils/clipboard"
import { assertNever, capitalize } from "src/utils/util"
import useSWR from "swr"
export default function ServeView({
node,
auth,
}: {
node: NodeData
auth: AuthResponse
}) {
const api = useAPI()
const { data, mutate } = useSWR<ServeData[]>("/serve/items")
const hasItems = (data?.length || 0) > 0
const [canEditServe, canEditFunnel] = useMemo(
() => [
canEdit("serve", auth) && node.Features.serve,
canEdit("funnel", auth) && node.Features.funnel,
],
[auth, node.Features]
)
const readonly = !canEditServe && !canEditFunnel // whole page is readonly
const [editorOpen, setEditorOpen] = useState<boolean>(!hasItems)
const [editingItem, setEditingItem] = useState<ServeData | undefined>()
useEffect(() => setEditorOpen(!hasItems), [hasItems])
return (
<>
<h1 className="mb-1">Share local content</h1>
<p className="description mb-5">
Share local ports, services, and content to your Tailscale network or to
the broader internet.{" "}
<a
href="https://tailscale.com/kb/1312/serve"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
<div className="mt-5">
{!readonly &&
(editorOpen && !editingItem ? (
<ServeEditorCard
className="-mx-5"
node={node}
canEditServe={canEditServe}
canEditFunnel={canEditFunnel}
showCancelButton={hasItems}
onClose={() => {
mutate() // refresh from any edits
setEditorOpen(false)
}}
/>
) : (
<Button
intent="primary"
prefixIcon={<Plus />}
onClick={() => {
setEditorOpen(true)
setEditingItem(undefined)
}}
>
Share more local content
</Button>
))}
{!data || data.length === 0 ? (
<Card empty className="-mx-5 mt-10">
<EmptyState description="Not sharing any content" />
</Card>
) : (
<div className="-mx-5 mt-10 flex flex-col gap-4">
{data.map((d) => {
const url = serveItemURL(d.destination, node)
const isEditing =
editingItem &&
url === serveItemURL(editingItem.destination, node)
return isEditing ? (
<ServeEditorCard
key={url}
node={node}
canEditServe={canEditServe}
canEditFunnel={canEditFunnel}
initialState={editingItem}
showCancelButton
onClose={() => {
mutate() // refresh from any edits
setEditorOpen(false)
setEditingItem(undefined)
}}
/>
) : (
<ServeItemCard
key={url}
url={url}
canEditServe={canEditServe}
canEditFunnel={canEditFunnel}
disabled={
readonly ||
(d.shareType === "serve" && !canEditServe) ||
(d.shareType === "funnel" && !canEditFunnel) ||
Boolean(d.isForeground)
}
data={d}
onEditSelect={() => {
setEditingItem(d)
setEditorOpen(true)
}}
onEditShareType={(t: ShareType) =>
api({
action: "patch-serve-item",
data: { ...d, shareType: t, isEdit: true },
}).then(() => mutate())
}
/>
)
})}
</div>
)}
</div>
</>
)
}
function serveItemURL(destination: Destination, node: NodeData): string {
let portPart = `:${destination.port}`
if (destination.protocol === "https" && portPart === ":443") {
portPart = ""
} else if (destination.protocol === "http" && portPart === ":80") {
portPart = ""
}
return `${
destination.protocol === "tls-terminated-tcp" ? "tcp" : destination.protocol
}://${node.DeviceName}.${node.TailnetName}${portPart}${destination.path}`
}
function ServeItemCard({
data,
url,
canEditServe,
canEditFunnel,
disabled,
onEditSelect,
onEditShareType,
}: {
data: ServeData
url: string
canEditServe: boolean
canEditFunnel: boolean
disabled: boolean
onEditSelect: () => void
onEditShareType: (t: ShareType) => void
}) {
return (
<Card noPadding className="p-4 w-full">
<p className="text-gray-800 text-lg font-medium leading-[25.20px]">
{data.target.type === "plainText"
? `Plain text “${data.target.value}`
: data.target.type === "localHttpPort"
? data.target.value
: assertNever(data.target.type)}
</p>
{data.destination.protocol === "tls-terminated-tcp" && (
<Badge className="mt-2 text-sm" variant="tag" color="green">
TLS terminated
</Badge>
)}
<p className="mt-2 text-gray-500 leading-snug">Shared at</p>
<QuickCopy
className="text-blue-700 font-medium"
primaryActionValue={url}
primaryActionSubject="url"
hideAffordance
>
{url}
<Copy className="inline ml-2 w-[18px] h-[18px] stroke-blue-700" />
</QuickCopy>
{/**
* Dropdown to toggle share type is disabled if user is not allowed
* to edit both serve and funnel.
*/}
{!disabled && canEditServe && canEditFunnel && (
<div className="mt-4 flex justify-between">
<DropdownMenu
asChild
trigger={
<Button
className={cx({
"stroke-gray-400": disabled,
"stroke-gray-800": !disabled,
})}
sizeVariant="small"
prefixIcon={
data.shareType === "serve" ? (
<Home className="w-[18px] h-[18px]" />
) : data.shareType === "funnel" ? (
<Globe className="w-[18px] h-[18px]" />
) : (
assertNever(data.shareType)
)
}
suffixIcon={<ChevronDown />}
disabled={disabled}
>
{data.shareType === "serve"
? "Shared within your tailnet"
: data.shareType === "funnel"
? "Shared on the internet"
: assertNever(data.shareType)}
</Button>
}
side="bottom"
align="start"
>
<DropdownMenu.RadioGroup
value={data.shareType}
onValueChange={(t) => onEditShareType(t as ShareType)}
>
<DropdownMenu.RadioItem value="serve">
Shared within your tailnet
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="funnel">
Shared on the internet
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu>
<Button sizeVariant="small" onClick={onEditSelect}>
Edit
</Button>
</div>
)}
{data.isForeground && (
<div className="mt-4 flex justify-end">
<Tooltip
content="This content cannot be edited because its shared in a
foreground session started on the machines command line."
>
<Badge className="mt-2 text-sm" variant="tag">
{/* TODO(ale): replace with different icon, this is placeholder */}
<Globe className="stroke-gray-800 mr-[6px] h-3 w-3" />
Foreground session
</Badge>
</Tooltip>
</div>
)}
</Card>
)
}
function ServeEditorCard({
node,
canEditServe,
canEditFunnel,
initialState,
showCancelButton,
onClose,
className,
}: {
node: NodeData
canEditServe: boolean
canEditFunnel: boolean
initialState?: ServeData // editing existing config
showCancelButton: boolean
onClose: () => void
className?: string
}) {
const api = useAPI()
const toaster = useToaster()
const [error, setError] = useState<string | undefined>()
const [data, setData] = useState<ServeData>(
initialState || {
target: { type: "localHttpPort", value: "" },
destination: { protocol: "https", port: 443, path: "" },
shareType: "serve",
}
)
const onSubmit = useCallback(
() =>
api({
action: "patch-serve-item",
data: {
...data,
isEdit: initialState !== undefined,
},
})
.then(() => {
copyText(serveItemURL(data.destination, node))
.then(() => toaster.show({ message: "Copied url to clipboard" }))
.catch(() =>
toaster.show({
message: "Failed to copy url",
variant: "danger",
})
)
onClose()
})
.catch((err) => setError(err?.message)),
[api, data, initialState, node, onClose, toaster]
)
const onDelete = useCallback(
(toDelete: ServeData) =>
api({
action: "delete-serve-item",
data: toDelete,
}).then(() => {
toaster.show({ message: "Deleted item" })
onClose()
}),
[api, onClose, toaster]
)
return (
<Card noPadding className={cx("p-5 !border-0 shadow-popover", className)}>
<TargetSection
target={data.target}
setTarget={(target) =>
setData((o) => ({
...o,
target,
destination: {
...o.destination,
protocol:
/**
* "plainText" cannot be served over "tcp".
* So we reset the protocol to "https" when switching from
* "localHttpPort" to "plainText" incase "tcp" was selected.
*/
target.type === "plainText" ? "https" : o.destination.protocol,
},
}))
}
/>
<p className="mt-6 font-medium leading-snug">Share</p>
<div className="mt-2.5 flex flex-col gap-2.5 stroke-green-800">
<ShareRadioButton
title="Within your tailnet"
description="Everyone within your tailnet can access (Tailscale Serve)."
icon={<Home />}
selected={data.shareType === "serve"}
onSelect={() => setData((o) => ({ ...o, shareType: "serve" }))}
readonly={!canEditServe}
/>
<ShareRadioButton
title="On the internet"
description="Anyone with the URL can access (Tailscale Funnel)."
icon={<Globe />}
selected={data.shareType === "funnel"}
onSelect={() => setData((o) => ({ ...o, shareType: "funnel" }))}
readonly={!canEditFunnel}
/>
</div>
<DestinationSection
node={node}
className="mt-6"
target={data.target}
destination={data.destination}
setDestination={(destination) =>
setData((o) => ({ ...o, destination }))
}
/>
<div className="mt-[30px] flex justify-between">
<div>
{/* TODO(ale): Style for error text. */}
{error && (
<p className="mb-2 text-sm leading-tight text-red-400">
Could not share: {capitalize(error)}
</p>
)}
<Button
intent="primary"
disabled={data.target.value === ""}
onClick={onSubmit}
>
Share and copy URL
</Button>
{showCancelButton && (
<Button intent="base" className="ml-3" onClick={onClose}>
Cancel
</Button>
)}
</div>
{initialState && (
<Button
intent="danger"
variant="minimal"
disabled={data.target.value === ""}
onClick={() => onDelete(initialState)}
>
Delete
</Button>
)}
</div>
</Card>
)
}
function ShareRadioButton({
title,
description,
icon,
selected,
onSelect,
readonly,
}: {
title: string
description: string
icon: React.ReactNode
selected: boolean
onSelect: () => void
readonly: boolean
}) {
return (
<label className="flex mt-[10px]">
<input
type="radio"
name={`${title}-radio`}
className="radio mt-1"
disabled={readonly}
checked={selected}
onChange={onSelect}
/>
<div className="ml-3">
<div className="flex items-center">
{icon}
<span className="ml-2 text-gray-800 leading-snug">{title}</span>
</div>
<div className="text-gray-500 text-sm leading-tight">{description}</div>
</div>
</label>
)
}
function TargetSection({
target,
setTarget,
}: {
target: Target
setTarget: (next: Target) => void
}) {
return (
<>
<p className="font-medium leading-snug">Target</p>
<p className="mt-1 text-gray-500 text-sm leading-tight">
The content you want to share.
</p>
<DropdownMenu
asChild
trigger={
<Button className="mt-[10px]" sizeVariant="small">
{target.type === "plainText"
? "Plain text"
: target.type === "localHttpPort"
? "Local http port"
: assertNever(target.type)}
<ChevronDown className="inline ml-2 w-5 h-5 stroke-gray-800" />
</Button>
}
side="bottom"
align="start"
>
<DropdownMenu.RadioGroup
value={target.type}
onValueChange={(t) =>
setTarget({
type: t as TargetType,
value: "", // clear out
})
}
>
<DropdownMenu.RadioItem value="plainText">
Plain text
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="localHttpPort">
Local http port
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu>
<div className="mt-2 flex">
{target.type === "localHttpPort" && (
<div className="px-2 bg-gray-200 text-gray-500 rounded-l border border-r-0 border-gray-300 inline-flex items-center">
http://localhost:
</div>
)}
<Input
className="flex-1"
inputClassName={cx({
"rounded-l-none": target.type === "localHttpPort",
})}
value={target.value}
onChange={(e) => setTarget({ ...target, value: e.target.value })}
placeholder={
target.type === "plainText"
? "Hello world."
: target.type === "localHttpPort"
? "8888"
: assertNever(target.type)
}
/>
</div>
</>
)
}
function DestinationSection({
node,
target,
destination,
setDestination,
className,
}: {
node: NodeData
target: Target
destination: Destination
setDestination: (next: Destination) => void
className?: string
}) {
const [urlPrefix, urlSuffix] = useMemo(() => {
const fullURL = serveItemURL(destination, node)
return fullURL.split(`://${node.DeviceName}`)
}, [destination, node])
return (
<div className={className}>
<Collapsible
trigger="Destination options"
triggerClassName="font-medium leading-snug !text-base text-gray-800 -ml-2"
>
<Card noPadding className="p-4 mt-4">
<p className="text-gray-800 font-medium leading-snug">
Destination protocol and port
</p>
<div className="mt-2 flex gap-2">
<DropdownMenu
asChild
trigger={
<Button sizeVariant="small">
{destination.protocol}
<ChevronDown className="inline ml-2 w-5 h-5 stroke-gray-800" />
</Button>
}
side="bottom"
align="start"
>
<DropdownMenu.RadioGroup
value={destination.protocol}
onValueChange={(p) =>
setDestination({
...destination,
protocol: p as DestinationProtocol,
})
}
>
<DropdownMenu.RadioItem value="https">
https
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="http">
http
</DropdownMenu.RadioItem>
{target.type !== "plainText" && (
<DropdownMenu.RadioItem value="tcp">
tcp
</DropdownMenu.RadioItem>
)}
{target.type !== "plainText" && (
<DropdownMenu.RadioItem value="tls-terminated-tcp">
tls-terminated-tcp
</DropdownMenu.RadioItem>
)}
</DropdownMenu.RadioGroup>
</DropdownMenu>
<DropdownMenu
asChild
trigger={
<Button sizeVariant="small">
{destination.port}
<ChevronDown className="inline ml-2 w-5 h-5 stroke-gray-800" />
</Button>
}
side="bottom"
align="start"
>
{/**
* TODO(ale's thoughts appreciated): port could be any value for serve,
* only funnel is restricted to 443/8443/10000. We could make it an open
* text input for serve if we want...
* */}
<DropdownMenu.RadioGroup
value={`${destination.port}`}
onValueChange={(p) =>
setDestination({
...destination,
port: Number.parseInt(p) as DestinationPort,
})
}
>
<DropdownMenu.RadioItem value="443">443</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="8443">
8443
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="10000">
10000
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu>
</div>
{(destination.protocol === "http" ||
destination.protocol === "https") && (
<>
<p className="mt-4 text-gray-800 font-medium leading-snug">
Destination path
</p>
<p className="text-gray-500 text-sm leading-tight">
A slash-separated URL path appended to the destination url
</p>
<Input
className="mt-2 w-full"
value={destination.path}
onChange={(e) =>
setDestination({ ...destination, path: e.target.value })
}
placeholder="/images/"
/>
</>
)}
</Card>
</Collapsible>
<p className="mt-6 font-medium leading-snug">Preview destination URL</p>
<p className="mt-3 text-gray-500 text-sm leading-tight">
The URL where your content will be available.
</p>
<Card
noPadding
empty
className="mt-2 p-2 text-sm font-medium tracking-wide" // TODO(ale): don't have SF-Mono font so used "tracking-wide"
>
<code className="text-gray-800">
{urlPrefix}
://{node.DeviceName}
</code>
<code className="text-gray-400">{urlSuffix}</code>
</Card>
</div>
)
}

View File

@ -19,7 +19,14 @@ export type AuthResponse = {
export type AuthServerMode = "login" | "readonly" | "manage"
export type PeerCapability = "*" | "ssh" | "subnets" | "exitnodes" | "account"
export type PeerCapability =
| "*"
| "ssh"
| "subnets"
| "exitnodes"
| "serve"
| "funnel"
| "account"
/**
* canEdit reports whether the given auth response specifies that the viewer

View File

@ -192,6 +192,22 @@
@apply text-gray-500 leading-snug;
}
/**
* .radio applies default styles to input[type="radio"] form elements.
*/
.radio {
@apply appearance-none w-4 h-4 rounded-full border border-gray-300 shrink-0 shadow-form;
}
.radio:checked {
@apply border-blue-500 border-[5px];
}
.radio:focus {
@apply focus-visible:ring focus-visible:outline-none;
}
/**
* .toggle applies "Toggle" UI styles to input[type="checkbox"] form elements.
* You can use the -large and -small modifiers for size variants.
@ -455,6 +471,40 @@
.link-underline:hover {
@apply opacity-75;
}
/**
* .dropdown applies styles for the dropdown-menu.tsx component.
*/
.dropdown {
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
box-shadow: 0 0 0 1px rgba(136, 152, 170, 0.1),
0 15px 35px 0 rgba(49, 49, 93, 0.1), 0 5px 15px 0 rgba(0, 0, 0, 0.08);
}
.dropdown[data-state="open"] {
@apply animate-scale-in;
}
.dropdown[data-state="closed"] {
@apply animate-scale-out;
}
/**
* .tooltip wraps all the styles for hover tooltips
*/
.tooltip {
@apply flex flex-col gap-2;
@apply font-sans font-normal tracking-normal normal-case text-gray-700;
@apply rounded-md border border-gray-300/75 bg-gray-0 px-3 py-2;
@apply z-50 max-w-[18rem] text-[0.8rem];
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.04);
}
.tooltip code {
@apply text-xs;
}
}
@layer utilities {

View File

@ -13,6 +13,7 @@ import { createRoot } from "react-dom/client"
import { swrConfig } from "src/api"
import App from "src/components/app"
import ToastProvider from "src/ui/toaster"
import Tooltip from "src/ui/tooltip"
import { SWRConfig } from "swr"
declare var window: any
@ -29,9 +30,11 @@ const root = createRoot(rootEl)
root.render(
<React.StrictMode>
<SWRConfig value={swrConfig}>
<ToastProvider>
<App />
</ToastProvider>
<Tooltip.Provider>
<ToastProvider>
<App />
</ToastProvider>
</Tooltip.Provider>
</SWRConfig>
</React.StrictMode>
)

View File

@ -84,9 +84,11 @@ export type Feature =
| "advertise-routes"
| "use-exit-node"
| "ssh"
| "serve"
| "funnel"
| "auto-update"
export const featureDescription = (f: Feature) => {
export const featureLongName = (f: Feature) => {
switch (f) {
case "advertise-exit-node":
return "Advertising as an exit node"
@ -96,6 +98,10 @@ export const featureDescription = (f: Feature) => {
return "Using an exit node"
case "ssh":
return "Running a Tailscale SSH server"
case "serve":
return "Sharing local content"
case "funnel":
return "Sharing local content over the internet"
case "auto-update":
return "Auto updating client versions"
default:
@ -111,3 +117,31 @@ export type VersionInfo = {
RunningLatest: boolean
LatestVersion?: string
}
export type ServeData = {
target: Target
destination: Destination
shareType: ShareType
isForeground?: boolean // only populated for "GET"
isEdit?: boolean // only populated for "PATCH"
}
export type Target = {
type: TargetType
value: string
}
export type Destination = {
protocol: DestinationProtocol
port: DestinationPort
path: string
}
export type TargetType = "plainText" | "localHttpPort"
export type DestinationProtocol =
| "https"
| "http"
| "tcp"
| "tls-terminated-tcp"
export type DestinationPort = 443 | 8443 | 10000
export type ShareType = "serve" | "funnel"

View File

@ -2,18 +2,20 @@
// SPDX-License-Identifier: BSD-3-Clause
import * as Primitive from "@radix-ui/react-collapsible"
import cx from "classnames"
import React, { useState } from "react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
type CollapsibleProps = {
trigger?: string
triggerClassName?: string
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
export default function Collapsible(props: CollapsibleProps) {
const { children, trigger, onOpenChange } = props
const { children, trigger, onOpenChange, triggerClassName } = props
const [open, setOpen] = useState(props.open)
return (
@ -24,7 +26,12 @@ export default function Collapsible(props: CollapsibleProps) {
onOpenChange?.(open)
}}
>
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-gray-100 rounded text-sm font-medium pr-3 py-1 transition-colors">
<Primitive.Trigger
className={cx(
"inline-flex items-center text-gray-600 cursor-pointer hover:bg-gray-100 rounded text-sm font-medium pr-3 py-1 transition-colors",
triggerClassName
)}
>
<span className="ml-2 mr-1.5 group-hover:text-gray-500 -rotate-90 state-open:rotate-0">
<ChevronDown strokeWidth={3} className="stroke-gray-400 w-4" />
</span>

View File

@ -0,0 +1,187 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import * as MenuPrimitive from "@radix-ui/react-dropdown-menu"
import cx from "classnames"
import React from "react"
import Check from "src/assets/icons/check.svg?react"
import PortalContainerContext from "src/ui/portal-container-context"
type Props = {
children: React.ReactNode
asChild?: boolean
trigger: React.ReactNode
disabled?: boolean
} & Pick<
MenuPrimitive.MenuContentProps,
"side" | "sideOffset" | "align" | "alignOffset" | "onCloseAutoFocus"
> &
Pick<MenuPrimitive.DropdownMenuProps, "open" | "onOpenChange">
/**
* DropdownMenu is a floating menu with actions. It should be used to provide
* additional actions for users that don't warrant a top-level button.
*/
export default function DropdownMenu(props: Props) {
const {
children,
asChild,
trigger,
side,
sideOffset,
align,
alignOffset,
open,
disabled,
onOpenChange,
onCloseAutoFocus,
} = props
return disabled ? (
<>{trigger}</>
) : (
<MenuPrimitive.Root open={open} onOpenChange={onOpenChange} dir="ltr">
<MenuPrimitive.Trigger asChild={asChild}>{trigger}</MenuPrimitive.Trigger>
<PortalContainerContext.Consumer>
{(portalContainer) => (
<MenuPrimitive.Portal container={portalContainer}>
<MenuPrimitive.Content
className="dropdown bg-white rounded-md py-1 z-50"
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
collisionPadding={12}
onCloseAutoFocus={onCloseAutoFocus}
>
{children}
</MenuPrimitive.Content>
</MenuPrimitive.Portal>
)}
</PortalContainerContext.Consumer>
</MenuPrimitive.Root>
)
}
DropdownMenu.defaultProps = {
sideOffset: 10,
}
DropdownMenu.Group = DropdownMenuGroup
DropdownMenu.Item = DropdownMenuItem
DropdownMenu.RadioGroup = MenuPrimitive.RadioGroup
DropdownMenu.RadioItem = DropdownMenuRadioItem
/**
* DropdownMenu.Separator should be used to divide items into sections within a
* DropdownMenu.
*/
DropdownMenu.Separator = DropdownSeparator
export const dropdownMenuItemClasses = "block px-4 py-2"
export const dropdownMenuItemInteractiveClasses =
"cursor-pointer hover:enabled:bg-bg-menu-item-hover focus:outline-none focus:bg-bg-menu-item-hover"
type CommonMenuItemProps = {
className?: string
disabled?: boolean
/**
* hidden determines whether or not the menu item should appear. It's exposed as
* a convenience for menus with many nested conditionals.
*/
hidden?: boolean
}
type DropdownMenuGroupProps = CommonMenuItemProps & MenuPrimitive.MenuGroupProps
function DropdownMenuGroup(props: DropdownMenuGroupProps) {
const { className, ...rest } = props
return (
<MenuPrimitive.Group
className={cx(className, dropdownMenuItemClasses)}
{...rest}
/>
)
}
type DropdownMenuItemProps = {
intent?: "danger"
stopPropagation?: boolean
} & CommonMenuItemProps &
Omit<MenuPrimitive.MenuItemProps, "onClick">
function DropdownMenuItem(props: DropdownMenuItemProps) {
const { className, disabled, intent, stopPropagation, hidden, ...rest } =
props
if (hidden === true) {
return null
}
return (
<MenuPrimitive.Item
className={cx(
className,
dropdownMenuItemClasses,
dropdownMenuItemInteractiveClasses,
{
"text-red-400": intent === "danger",
"text-gray-400 bg-white cursor-default": disabled,
}
)}
disabled={disabled}
onClick={stopPropagation ? (e) => e.stopPropagation() : undefined}
{...rest}
/>
)
}
type DropdownMenuRadioItemProps = CommonMenuItemProps &
MenuPrimitive.MenuRadioItemProps
function DropdownMenuRadioItem(props: DropdownMenuRadioItemProps) {
const { className, disabled, hidden, children, ...rest } = props
if (hidden === true) {
return null
}
return (
<MenuPrimitive.RadioItem
className={cx(
className,
dropdownMenuItemClasses,
dropdownMenuItemInteractiveClasses,
"pl-9 relative flex items-center",
{
"text-gray-400 bg-white cursor-default": disabled,
}
)}
disabled={disabled}
{...rest}
>
<MenuPrimitive.ItemIndicator>
<Check className="relative -ml-6" width="1em" height="1em" />
</MenuPrimitive.ItemIndicator>
{children}
</MenuPrimitive.RadioItem>
)
}
type DropdownSeparatorProps = Omit<CommonMenuItemProps, "disabled"> &
MenuPrimitive.MenuSeparatorProps
function DropdownSeparator(props: DropdownSeparatorProps) {
const { className, hidden, ...rest } = props
if (hidden === true) {
return null
}
return (
<MenuPrimitive.Separator
className={cx("my-1 border-b", className)}
{...rest}
/>
)
}

View File

@ -0,0 +1,48 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import React from "react"
import PortalContainerContext from "src/ui/portal-container-context"
type Props = {
side?: "top" | "right" | "bottom" | "left"
align?: "start" | "center" | "end"
delay?: number
content: React.ReactNode
children: React.ReactNode
asChild?: boolean // when true, renders the tooltip trigger as a child; defaults to true
}
export default function Tooltip(props: Props) {
const { delay = 150, side, align, content, children, asChild = true } = props
return (
<TooltipPrimitive.Root delayDuration={delay}>
<TooltipPrimitive.TooltipTrigger asChild={asChild}>
{asChild ? <span>{children}</span> : children}
</TooltipPrimitive.TooltipTrigger>
{content && (
<PortalContainerContext.Consumer>
{(portalContainer) => (
<TooltipPrimitive.Portal container={portalContainer}>
<TooltipPrimitive.Content
className="tooltip"
role="tooltip"
sideOffset={10}
side={side}
align={align}
aria-live="polite"
collisionPadding={12}
>
{content}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)}
</PortalContainerContext.Consumer>
)}
</TooltipPrimitive.Root>
)
}
Tooltip.Provider = TooltipPrimitive.Provider

View File

@ -21,6 +21,16 @@ export function isObject(val: unknown): val is object {
return Boolean(val && typeof val === "object" && val.constructor === Object)
}
/**
* capitalize returns the given string with the first letter capitalized.
*/
export function capitalize(str: string): string {
if (!str) {
return str // don't do anything to empty strings
}
return str.charAt(0).toUpperCase() + str.slice(1)
}
/**
* pluralize is a very simple function that returns either
* the singular or plural form of a string based on the given

View File

@ -12,12 +12,15 @@ import (
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
@ -397,7 +400,7 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
// All requests must be made over tailscale.
http.Error(w, "must access over tailscale", http.StatusUnauthorized)
return false
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
case (r.URL.Path == "/api/data" || r.URL.Path == "/api/serve/items") && r.Method == httpm.GET: // TODO: maybe allow all GET?
// Readonly endpoint allowed without valid browser session.
return true
case r.URL.Path == "/api/device-details-click" && r.Method == httpm.POST:
@ -431,11 +434,15 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
// serveLoginAPI serves requests for the web login client.
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
// which protects the handler using gorilla csrf.
// Endpoints here should be readonly endpoints, as users are only able
// to obtain an edit session on the management client (handled by serveAPI).
func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", csrf.Token(r))
switch {
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
s.serveGetNodeData(w, r)
case r.URL.Path == "/api/serve/items" && r.Method == httpm.GET:
s.serveGetServeItems(w, r)
case r.URL.Path == "/api/up" && r.Method == httpm.POST:
s.serveTailscaleUp(w, r)
case r.URL.Path == "/api/device-details-click" && r.Method == httpm.POST:
@ -618,6 +625,29 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/serve/items":
peerAllowed := func(data serveItem, peer peerCapabilities) bool {
if data.ShareType == "serve" && !peer.canEdit(capFeatureServe) {
return false
} else if data.ShareType == "funnel" && !peer.canEdit(capFeatureFunnel) {
return false
}
return true
}
switch r.Method {
case httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveGetServeItems)
case httpm.PATCH:
newHandler[serveItem](s, w, r, peerAllowed).
handleJSON(s.servePatchServeItem)
case httpm.DELETE:
newHandler[serveItem](s, w, r, peerAllowed).
handleJSON(s.serveDeleteServeItem)
default:
http.Error(w, "invalid endpoint", http.StatusNotFound)
}
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
}
@ -880,7 +910,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
ControlAdminURL: prefs.AdminPageURL(),
LicensesURL: licenses.LicensesURL(),
Features: availableFeatures(),
Features: availableFeatures(st.Self),
ACLAllowsAnyIncomingTraffic: s.aclsAllowAccess(filterRules),
}
@ -958,13 +988,15 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
writeJSON(w, *data)
}
func availableFeatures() map[string]bool {
func availableFeatures(self *ipnstate.PeerStatus) map[string]bool {
env := hostinfo.GetEnvType()
features := map[string]bool{
"advertise-exit-node": true, // available on all platforms
"advertise-routes": true, // available on all platforms
"use-exit-node": canUseExitNode(env) == nil,
"ssh": envknob.CanRunTailscaleSSH() == nil,
"serve": ipn.NodeCanServe(self) == nil, // TODO: anything else to check for this (and line below)?
"funnel": ipn.NodeCanFunnel(self) == nil,
"auto-update": version.IsUnstableBuild() && clientupdate.CanAutoUpdate(),
}
if env == hostinfo.HomeAssistantAddOn {
@ -1108,6 +1140,285 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
return err
}
type serveItem struct {
Target serveTarget `json:"target"`
Destination serveDestination `json:"destination"`
ShareType string `json:"shareType"` // "serve" or "funnel"
IsForeground bool `json:"isForeground,omitempty"` // only populated by "GET", empty for "PATCH"/"DELETE"
IsEdit bool `json:"isEdit,omitempty"` // only populated by "PATCH", true when editing an existing item, false when adding a new one
}
type serveTarget struct {
Type string `json:"type"` // "plainText" or "localHttpPort"
Value string `json:"value"` // Any text if type is "plainText"; port number if type is "localHttpPort"
}
type serveDestination struct {
Protocol string `json:"protocol"` // "https", "http", "tcp", or "tls-terminated-tcp"
Port uint16 `json:"port"` // 443 or 8443 or 10000
Path string `json:"path"` // e.g. /images/dogs; only for "https" or "http"
}
func (s *Server) serveGetServeItems(w http.ResponseWriter, r *http.Request) {
sc, err := s.lc.GetServeConfig(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
st, err := s.lc.StatusWithoutPeers(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var serveItems []*serveItem
if sc == nil {
writeJSON(w, serveItems)
return
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
shareType := func(sc *ipn.ServeConfig, hp ipn.HostPort) string {
if sc.AllowFunnel[hp] {
return "funnel"
}
return "serve"
}
addWebItem := func(sc *ipn.ServeConfig, hp ipn.HostPort, mount string, h *ipn.HTTPHandler, isForeground bool) {
port, err := hp.Port()
if err != nil {
return
}
var target serveTarget
if h.Text != "" {
target = serveTarget{
Type: "plainText",
Value: h.Text,
}
} else {
target = serveTarget{
Type: "localHttpPort",
Value: h.Proxy,
}
}
protocol := "https"
if sc.IsServingHTTP(port) {
protocol = "http"
}
serveItems = append(serveItems, &serveItem{
Target: target,
Destination: serveDestination{
Path: mount,
Protocol: protocol,
Port: port,
},
ShareType: shareType(sc, hp),
IsForeground: isForeground,
})
}
addTCPItem := func(sc *ipn.ServeConfig, port uint16, h *ipn.TCPPortHandler, isForeground bool) {
if h.TCPForward == "" {
return // skip, this is a web item
}
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(port))))
protocol := "tcp"
if h.TerminateTLS != "" {
protocol = "tls-terminated-tcp"
}
serveItems = append(serveItems, &serveItem{
Target: serveTarget{
Type: "localHttpPort",
Value: fmt.Sprint(port),
},
Destination: serveDestination{
Protocol: protocol,
Port: port,
},
ShareType: shareType(sc, hp),
IsForeground: isForeground,
})
}
for port, h := range sc.TCP {
addTCPItem(sc, port, h, false)
}
for hp, config := range sc.Web {
for mount, h := range config.Handlers {
addWebItem(sc, hp, mount, h, false)
}
}
// Also add foreground items.
for _, sc := range sc.Foreground {
for port, h := range sc.TCP {
addTCPItem(sc, port, h, true)
}
for hp, config := range sc.Web {
for mount, h := range config.Handlers {
addWebItem(sc, hp, mount, h, true)
}
}
}
// Sort by ":port/path", with foreground always pushed to end.
slices.SortFunc(serveItems, func(a *serveItem, b *serveItem) int {
if a.IsForeground && !b.IsForeground {
return 1
} else if b.IsForeground && !a.IsForeground {
return -1
}
aKey := fmt.Sprintf("%d%s", a.Destination.Port, a.Destination.Path)
bKey := fmt.Sprintf("%d%s", b.Destination.Port, b.Destination.Path)
return strings.Compare(aKey, bKey)
})
writeJSON(w, serveItems)
}
func (s *Server) servePatchServeItem(ctx context.Context, data serveItem) error {
st, err := s.lc.StatusWithoutPeers(ctx)
if err != nil {
return err
}
sc, err := s.lc.GetServeConfig(ctx)
if err != nil {
return err
}
if sc == nil {
sc = new(ipn.ServeConfig)
}
// First, validate the requested update.
if data.ShareType == "funnel" {
if err := ipn.CheckFunnelAccess(data.Destination.Port, st.Self); err != nil {
return err
}
}
if sc, foreground := sc.FindConfig(data.Destination.Port); sc != nil && foreground {
return errors.New("port already in use by foreground process") // never allowed to edit a foreground config
} else if sc != nil && !data.IsEdit {
return errors.New("port already in use")
} else if sc == nil && data.IsEdit {
return errors.New("no current configuration at port")
}
// Next, make the update.
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
switch data.Destination.Protocol {
case "https", "http":
h := new(ipn.HTTPHandler)
switch data.Target.Type {
case "plainText":
h.Text = data.Target.Value
case "localHttpPort":
t, err := ipn.ExpandProxyTargetValue(data.Target.Value, []string{"http", "https", "https+insecure"}, "http")
if err != nil {
return err
}
h.Proxy = t
default:
return errors.New("unknown target type")
}
// Clean the mount path.
p := data.Destination.Path
if p == "" {
p = "/"
} else if !strings.HasPrefix(p, "/") {
p = "/" + p
}
c := path.Clean(p)
if p != c && p != c+"/" {
return fmt.Errorf("invalid mount point %q", p)
}
sc.SetWebHandler(h, dnsName, data.Destination.Port, p, data.Destination.Protocol == "https")
case "tcp", "tls-terminated-tcp":
t, err := ipn.ExpandProxyTargetValue(data.Target.Value, []string{"tcp"}, "tcp")
if err != nil {
return err
}
tUrl, err := url.Parse(t)
if err != nil {
return err
}
if data.IsEdit {
// Remove old web config at port if existant.
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(data.Destination.Port))))
delete(sc.Web, hp)
}
sc.SetTCPForwarding(data.Destination.Port, tUrl.Host, data.Destination.Protocol == "tls-terminated-tcp", dnsName)
default:
return errors.New("unsupported protocol type")
}
sc.SetFunnel(dnsName, data.Destination.Port, data.ShareType == "funnel")
if err := s.lc.SetServeConfig(ctx, sc); err != nil {
return err
}
return nil
}
func (s *Server) serveDeleteServeItem(ctx context.Context, data serveItem) error {
sc, err := s.lc.GetServeConfig(ctx)
if err != nil {
return err
}
st, err := s.lc.StatusWithoutPeers(ctx)
if err != nil {
return err
}
if sc, foreground := sc.FindConfig(data.Destination.Port); sc == nil {
return errors.New("port not being served")
} else if foreground {
return errors.New("cannot delete a foreground port")
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(data.Destination.Port))))
if data.Destination.Path == "" {
data.Destination.Path = "/"
}
deleteWeb := func() {
delete(sc.Web[hp].Handlers, data.Destination.Path)
if len(sc.Web[hp].Handlers) == 0 { // no more handlers left
delete(sc.Web, hp)
delete(sc.AllowFunnel, hp)
delete(sc.TCP, data.Destination.Port)
}
}
deleteTCP := func() {
delete(sc.TCP, data.Destination.Port)
delete(sc.AllowFunnel, hp)
}
switch data.Destination.Protocol {
case "http":
if !sc.IsServingHTTP(data.Destination.Port) {
return errors.New("not serving http on given port")
}
deleteWeb()
case "https":
if !sc.IsServingHTTPS(data.Destination.Port) {
return errors.New("not serving https on given port")
}
deleteWeb()
case "tcp", "tls-terminated-tcp":
if !sc.IsTCPForwardingOnPort(data.Destination.Port) {
return errors.New("not serving tcp on given port")
}
deleteTCP()
default:
return errors.New("unsupported protocol")
}
if err := s.lc.SetServeConfig(ctx, sc); err != nil {
return err
}
return nil
}
// tailscaleUp starts the daemon with the provided options.
// If reauthentication has been requested, an authURL is returned to complete device registration.
func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tailscaleUpOptions) (authURL string, retErr error) {

View File

@ -99,8 +99,8 @@ func TestServeAPI(t *testing.T) {
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t,
map[string]*apitype.WhoIsResponse{
localapi := mockLocalAPI(t, mockLocalAPIOpts{
whoIs: map[string]*apitype.WhoIsResponse{
remoteIPWithAllCapabilities: {
Node: &tailcfg.Node{StableID: "node1"},
UserProfile: remoteUser,
@ -111,10 +111,9 @@ func TestServeAPI(t *testing.T) {
UserProfile: remoteUser,
},
},
func() *ipnstate.PeerStatus { return self },
func() *ipn.Prefs { return prefs },
nil,
)
self: func() *ipnstate.PeerStatus { return self },
prefs: func() *ipn.Prefs { return prefs },
})
defer localapi.Close()
go localapi.Serve(lal)
@ -282,7 +281,10 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil, nil)
localapi := mockLocalAPI(t, mockLocalAPIOpts{
whoIs: tailnetNodes,
self: func() *ipnstate.PeerStatus { return selfNode },
})
defer localapi.Close()
go localapi.Serve(lal)
@ -446,12 +448,10 @@ func TestAuthorizeRequest(t *testing.T) {
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t,
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
func() *ipnstate.PeerStatus { return self },
nil,
nil,
)
localapi := mockLocalAPI(t, mockLocalAPIOpts{
whoIs: map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
self: func() *ipnstate.PeerStatus { return self },
})
defer localapi.Close()
go localapi.Serve(lal)
@ -555,14 +555,11 @@ func TestServeAuth(t *testing.T) {
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t,
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
func() *ipnstate.PeerStatus { return self },
func() *ipn.Prefs {
return &ipn.Prefs{ControlURL: *testControlURL}
},
nil,
)
localapi := mockLocalAPI(t, mockLocalAPIOpts{
whoIs: map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
self: func() *ipnstate.PeerStatus { return self },
prefs: func() *ipn.Prefs { return &ipn.Prefs{ControlURL: *testControlURL} },
})
defer localapi.Close()
go localapi.Serve(lal)
@ -896,16 +893,12 @@ func TestServeAPIAuthMetricLogging(t *testing.T) {
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t,
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode, localIP: localNode, otherIP: otherNode, localTaggedIP: localTaggedNode, remoteTaggedIP: remoteTaggedNode},
func() *ipnstate.PeerStatus { return self },
func() *ipn.Prefs {
return &ipn.Prefs{ControlURL: *testControlURL}
},
func(metricName string) {
loggedMetrics = append(loggedMetrics, metricName)
},
)
localapi := mockLocalAPI(t, mockLocalAPIOpts{
whoIs: map[string]*apitype.WhoIsResponse{remoteIP: remoteNode, localIP: localNode, otherIP: otherNode, localTaggedIP: localTaggedNode, remoteTaggedIP: remoteTaggedNode},
self: func() *ipnstate.PeerStatus { return self },
prefs: func() *ipn.Prefs { return &ipn.Prefs{ControlURL: *testControlURL} },
metricCapture: func(metricName string) { loggedMetrics = append(loggedMetrics, metricName) },
})
defer localapi.Close()
go localapi.Serve(lal)
@ -1120,7 +1113,7 @@ func TestRequireTailscaleIP(t *testing.T) {
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil, nil)
localapi := mockLocalAPI(t, mockLocalAPIOpts{self: func() *ipnstate.PeerStatus { return self }})
defer localapi.Close()
go localapi.Serve(lal)
@ -1407,6 +1400,525 @@ func TestPeerCapabilities(t *testing.T) {
}
}
func TestServeItemsEndpoints(t *testing.T) {
ctx := context.Background()
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
fg := map[string]*ipn.ServeConfig{
"sessionID": {TCP: map[uint16]*ipn.TCPPortHandler{
4443: {TCPForward: "http://127.0.0.1:3001"},
}},
}
localapi := mockLocalAPI(t, mockLocalAPIOpts{
self: func() *ipnstate.PeerStatus {
return &ipnstate.PeerStatus{
DNSName: "s",
Capabilities: []tailcfg.NodeCapability{
tailcfg.CapabilityFunnelPorts + "?ports=80,443,8080,10000",
tailcfg.CapabilityHTTPS,
tailcfg.NodeAttrFunnel,
}}
},
// Starting config with a foreground session.
serveConfig: &ipn.ServeConfig{Foreground: fg},
})
defer localapi.Close()
go localapi.Serve(lal)
lc := &tailscale.LocalClient{Dial: lal.Dial}
s := &Server{lc: lc}
tests := []struct {
name string
action string // PATCH or DELETE
item serveItem
wantErr string
wantServeConfig ipn.ServeConfig
}{
{
name: "add-a-new-serve-web-item",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "3000"},
Destination: serveDestination{Protocol: "https", Port: 443},
ShareType: "serve",
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:443"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:3000"}}},
},
},
}, {
name: "add-a-new-funnel-web-item-with-invalid-port",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "4000"},
Destination: serveDestination{Protocol: "https", Port: 8000},
ShareType: "funnel",
},
wantErr: "port 8000 is not allowed for funnel; allowed ports are: 80,443,8080,10000", // TODO(sonia): should be piping the allowed ports to the FE dropdown
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:443"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:3000"}}},
},
},
}, {
name: "add-a-new-funnel-web-item",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "4000"},
Destination: serveDestination{Protocol: "https", Port: 8080},
ShareType: "funnel",
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
8080: {HTTPS: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:443"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:3000"}}},
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:4000"}}},
},
AllowFunnel: map[ipn.HostPort]bool{
ipn.HostPort("s:8080"): true,
},
},
}, {
name: "add-a-new-tcp-serve-item",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "5000"},
Destination: serveDestination{Protocol: "tcp", Port: 8999, Path: "/something"}, // path should be ignored
ShareType: "serve",
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
8080: {HTTPS: true},
8999: {TCPForward: "127.0.0.1:5000"},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:443"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:3000"}}},
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:4000"}}},
},
AllowFunnel: map[ipn.HostPort]bool{
ipn.HostPort("s:8080"): true,
},
},
}, {
name: "add-a-new-plaintext-item",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "plainText", Value: "hello world"},
Destination: serveDestination{Protocol: "http", Port: 80, Path: "/hi"},
ShareType: "serve",
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
8080: {HTTPS: true},
8999: {TCPForward: "127.0.0.1:5000"},
80: {HTTP: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:443"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:3000"}}},
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:4000"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: map[ipn.HostPort]bool{
ipn.HostPort("s:8080"): true,
},
},
}, {
name: "port-already-in-use",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "plainText", Value: "another text"},
Destination: serveDestination{Protocol: "http", Port: 8080, Path: "/"},
ShareType: "serve",
},
wantErr: "port already in use",
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
8080: {HTTPS: true},
8999: {TCPForward: "127.0.0.1:5000"},
80: {HTTP: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:443"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:3000"}}},
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:4000"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: map[ipn.HostPort]bool{
ipn.HostPort("s:8080"): true,
},
},
}, {
name: "edit-existing-item",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "plainText", Value: "another text"},
Destination: serveDestination{Protocol: "http", Port: 8080, Path: "/"},
ShareType: "serve", // was previously "funnel"
IsEdit: true,
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
8080: {HTTP: true},
8999: {TCPForward: "127.0.0.1:5000"},
80: {HTTP: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:443"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:3000"}}},
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: nil,
},
}, {
name: "switch-serve-to-funnel",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "3000"},
Destination: serveDestination{Protocol: "https", Port: 443, Path: "/"},
ShareType: "funnel", // was previously "serve"
IsEdit: true,
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
8080: {HTTP: true},
8999: {TCPForward: "127.0.0.1:5000"},
80: {HTTP: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:443"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:3000"}}},
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: map[ipn.HostPort]bool{
ipn.HostPort("s:443"): true,
},
},
}, {
name: "delete-existing-item-wrong-protocol",
action: "DELETE",
item: serveItem{
Target: serveTarget{Type: "localHttpServer", Value: "3000"},
Destination: serveDestination{Protocol: "http", Port: 443, Path: "/"},
ShareType: "serve",
},
wantErr: "not serving http on given port",
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
8080: {HTTP: true},
8999: {TCPForward: "127.0.0.1:5000"},
80: {HTTP: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:443"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:3000"}}},
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: map[ipn.HostPort]bool{
ipn.HostPort("s:443"): true,
},
},
}, {
name: "delete-existing-funnel-item",
action: "DELETE",
item: serveItem{
Target: serveTarget{Type: "localHttpServer", Value: "3000"},
Destination: serveDestination{Protocol: "https", Port: 443, Path: "/"},
ShareType: "funnel",
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
8080: {HTTP: true},
8999: {TCPForward: "127.0.0.1:5000"},
80: {HTTP: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: nil,
},
}, {
name: "delete-existing-serve-item",
action: "DELETE",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "5000"},
Destination: serveDestination{Protocol: "tcp", Port: 8999},
ShareType: "serve",
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
8080: {HTTP: true},
80: {HTTP: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: nil,
},
}, {
name: "add-a-new-terminated-tls-tcp-item",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "5000"},
Destination: serveDestination{Protocol: "tls-terminated-tcp", Port: 443, Path: "/something"}, // path should be ignored
ShareType: "funnel",
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
8080: {HTTP: true},
80: {HTTP: true},
443: {TCPForward: "127.0.0.1:5000", TerminateTLS: "s"},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: map[ipn.HostPort]bool{
ipn.HostPort("s:443"): true,
},
},
}, {
name: "switch-tls-terminated-to-non-tls-terminated",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "5000"},
Destination: serveDestination{Protocol: "tcp", Port: 443, Path: "/something"}, // path should be ignored
ShareType: "funnel",
IsEdit: true,
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
8080: {HTTP: true},
80: {HTTP: true},
443: {TCPForward: "127.0.0.1:5000"},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: map[ipn.HostPort]bool{
ipn.HostPort("s:443"): true,
},
},
}, {
name: "switch-tcp-to-web",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "5000"},
Destination: serveDestination{Protocol: "https", Port: 443, Path: "/something"},
ShareType: "serve",
IsEdit: true,
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
8080: {HTTP: true},
80: {HTTP: true},
443: {HTTPS: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
ipn.HostPort("s:443"): {Handlers: map[string]*ipn.HTTPHandler{"/something": {Proxy: "http://127.0.0.1:5000"}}},
},
AllowFunnel: nil,
},
}, {
name: "switch-web-to-tcp",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "5000"},
Destination: serveDestination{Protocol: "tcp", Port: 443},
ShareType: "serve",
IsEdit: true,
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
8080: {HTTP: true},
80: {HTTP: true},
443: {TCPForward: "127.0.0.1:5000"},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: nil,
},
}, {
name: "edit-port-that-does-not-exist",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "5000"},
Destination: serveDestination{Protocol: "tcp", Port: 4444},
ShareType: "serve",
IsEdit: true,
},
wantErr: "no current configuration at port",
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
8080: {HTTP: true},
80: {HTTP: true},
443: {TCPForward: "127.0.0.1:5000"},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: nil,
},
}, {
name: "add-port-that-foreground-is-using",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "5000"},
Destination: serveDestination{Protocol: "tcp", Port: 4443},
ShareType: "serve",
},
wantErr: "port already in use by foreground process",
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
8080: {HTTP: true},
80: {HTTP: true},
443: {TCPForward: "127.0.0.1:5000"},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: nil,
},
}, {
name: "edit-port-that-foreground-is-using",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "5000"},
Destination: serveDestination{Protocol: "tcp", Port: 4443},
ShareType: "serve",
IsEdit: true,
},
wantErr: "port already in use by foreground process",
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
8080: {HTTP: true},
80: {HTTP: true},
443: {TCPForward: "127.0.0.1:5000"},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: nil,
},
}, {
name: "invalid-path-web-serve",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "5000"},
Destination: serveDestination{Protocol: "https", Port: 4445, Path: "."},
ShareType: "serve",
},
wantErr: "invalid mount point \"/.\"",
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
8080: {HTTP: true},
80: {HTTP: true},
443: {TCPForward: "127.0.0.1:5000"},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
},
AllowFunnel: nil,
},
}, {
name: "path-gets-slash-prefix-added",
action: "PATCH",
item: serveItem{
Target: serveTarget{Type: "localHttpPort", Value: "5000"},
Destination: serveDestination{Protocol: "https", Port: 4445, Path: "my-path"},
ShareType: "serve",
},
wantServeConfig: ipn.ServeConfig{
Foreground: fg,
TCP: map[uint16]*ipn.TCPPortHandler{
8080: {HTTP: true},
80: {HTTP: true},
443: {TCPForward: "127.0.0.1:5000"},
4445: {HTTPS: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("s:8080"): {Handlers: map[string]*ipn.HTTPHandler{"/": {Text: "another text"}}},
ipn.HostPort("s:80"): {Handlers: map[string]*ipn.HTTPHandler{"/hi": {Text: "hello world"}}},
ipn.HostPort("s:4445"): {Handlers: map[string]*ipn.HTTPHandler{"/my-path": {Proxy: "http://127.0.0.1:5000"}}},
},
AllowFunnel: nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotErr string
switch tt.action {
case "PATCH":
if err := s.servePatchServeItem(ctx, tt.item); err != nil {
gotErr = err.Error()
}
case "DELETE":
if err := s.serveDeleteServeItem(ctx, tt.item); err != nil {
gotErr = err.Error()
}
}
if tt.wantErr != gotErr {
t.Errorf("wrong error; want=%q, got=%q", tt.wantErr, gotErr)
}
gotServeConfig, err := lc.GetServeConfig(ctx)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(*gotServeConfig, tt.wantServeConfig); diff != "" {
t.Errorf("wrong serve config; (-got+want):%v", diff)
}
})
}
}
var (
defaultControlURL = "https://controlplane.tailscale.com"
testAuthPath = "/a/12345"
@ -1414,13 +1926,21 @@ var (
testAuthPathError = "/a/will-error"
)
type mockLocalAPIOpts struct {
whoIs map[string]*apitype.WhoIsResponse
self func() *ipnstate.PeerStatus
prefs func() *ipn.Prefs
serveConfig *ipn.ServeConfig
metricCapture func(string)
}
// mockLocalAPI constructs a test localapi handler that can be used
// to simulate localapi responses without a functioning tailnet.
//
// self accepts a function that resolves to a self node status,
// so that tests may swap out the /localapi/v0/status response
// as desired.
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs, metricCapture func(string)) *http.Server {
func mockLocalAPI(t *testing.T, opts mockLocalAPIOpts) *http.Server {
return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/localapi/v0/whois":
@ -1428,17 +1948,17 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
if addr == "" {
t.Fatalf("/whois call missing \"addr\" query")
}
if node := whoIs[addr]; node != nil {
if node := opts.whoIs[addr]; node != nil {
writeJSON(w, &node)
return
}
http.Error(w, "not a node", http.StatusUnauthorized)
return
case "/localapi/v0/status":
writeJSON(w, ipnstate.Status{Self: self()})
writeJSON(w, ipnstate.Status{Self: opts.self()})
return
case "/localapi/v0/prefs":
writeJSON(w, prefs())
writeJSON(w, opts.prefs())
return
case "/localapi/v0/upload-client-metrics":
type metricName struct {
@ -1450,12 +1970,25 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
metricCapture(metricNames[0].Name)
opts.metricCapture(metricNames[0].Name)
writeJSON(w, struct{}{})
return
case "/localapi/v0/logout":
fmt.Fprintf(w, "success")
return
case "/localapi/v0/serve-config":
switch r.Method {
case httpm.GET:
writeJSON(w, opts.serveConfig)
return
case httpm.POST:
opts.serveConfig = &ipn.ServeConfig{}
if err := json.NewDecoder(r.Body).Decode(&opts.serveConfig); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
return
}
default:
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
}

View File

@ -20,7 +20,7 @@
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.10", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.22.5", "@babel/code-frame@^7.23.4":
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.4.tgz#03ae5af150be94392cb5c7ccd97db5a19a5da6aa"
integrity sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==
@ -63,7 +63,7 @@
eslint-visitor-keys "^2.1.0"
semver "^6.3.1"
"@babel/generator@^7.22.10", "@babel/generator@^7.23.0", "@babel/generator@^7.23.3", "@babel/generator@^7.23.4":
"@babel/generator@^7.23.3", "@babel/generator@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.4.tgz#4a41377d8566ec18f807f42962a7f3551de83d1c"
integrity sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==
@ -87,7 +87,7 @@
dependencies:
"@babel/types" "^7.22.15"
"@babel/helper-compilation-targets@^7.22.10", "@babel/helper-compilation-targets@^7.22.15", "@babel/helper-compilation-targets@^7.22.6":
"@babel/helper-compilation-targets@^7.22.15", "@babel/helper-compilation-targets@^7.22.6":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz#0698fc44551a26cf29f18d4662d5bf545a6cfc52"
integrity sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==
@ -160,14 +160,14 @@
dependencies:
"@babel/types" "^7.23.0"
"@babel/helper-module-imports@^7.22.15", "@babel/helper-module-imports@^7.22.5":
"@babel/helper-module-imports@^7.22.15":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0"
integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==
dependencies:
"@babel/types" "^7.22.15"
"@babel/helper-module-transforms@^7.22.9", "@babel/helper-module-transforms@^7.23.3":
"@babel/helper-module-transforms@^7.23.3":
version "7.23.3"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz#d7d12c3c5d30af5b3c0fcab2a6d5217773e2d0f1"
integrity sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==
@ -229,17 +229,17 @@
dependencies:
"@babel/types" "^7.22.5"
"@babel/helper-string-parser@^7.22.5", "@babel/helper-string-parser@^7.23.4":
"@babel/helper-string-parser@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83"
integrity sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==
"@babel/helper-validator-identifier@^7.22.20", "@babel/helper-validator-identifier@^7.22.5":
"@babel/helper-validator-identifier@^7.22.20":
version "7.22.20"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
"@babel/helper-validator-option@^7.22.15", "@babel/helper-validator-option@^7.22.5":
"@babel/helper-validator-option@^7.22.15":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz#694c30dfa1d09a6534cdfcafbe56789d36aba040"
integrity sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==
@ -253,7 +253,7 @@
"@babel/template" "^7.22.15"
"@babel/types" "^7.22.19"
"@babel/helpers@^7.22.10", "@babel/helpers@^7.23.2":
"@babel/helpers@^7.23.2":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.23.4.tgz#7d2cfb969aa43222032193accd7329851facf3c1"
integrity sha512-HfcMizYz10cr3h29VqyfGL6ZWIjTwWfvYBMsBVGwpcbhNGe3wQ1ZXZRPzZoAHhd9OqHadHqjQ89iVKINXnbzuw==
@ -262,7 +262,7 @@
"@babel/traverse" "^7.23.4"
"@babel/types" "^7.23.4"
"@babel/highlight@^7.22.10", "@babel/highlight@^7.22.13", "@babel/highlight@^7.23.4":
"@babel/highlight@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b"
integrity sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==
@ -271,7 +271,7 @@
chalk "^2.4.2"
js-tokens "^4.0.0"
"@babel/parser@^7.22.10", "@babel/parser@^7.22.15", "@babel/parser@^7.22.5", "@babel/parser@^7.23.0", "@babel/parser@^7.23.3", "@babel/parser@^7.23.4":
"@babel/parser@^7.22.15", "@babel/parser@^7.23.3", "@babel/parser@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.4.tgz#409fbe690c333bb70187e2de4021e1e47a026661"
integrity sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==
@ -1093,7 +1093,7 @@
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.22.15", "@babel/template@^7.22.5":
"@babel/template@^7.22.15":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==
@ -1102,7 +1102,7 @@
"@babel/parser" "^7.22.15"
"@babel/types" "^7.22.15"
"@babel/traverse@^7.22.10", "@babel/traverse@^7.23.3", "@babel/traverse@^7.23.4":
"@babel/traverse@^7.23.3", "@babel/traverse@^7.23.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.4.tgz#c2790f7edf106d059a0098770fe70801417f3f85"
integrity sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==
@ -1118,7 +1118,7 @@
debug "^4.1.0"
globals "^11.1.0"
"@babel/types@^7.21.3", "@babel/types@^7.22.10", "@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.23.3", "@babel/types@^7.23.4", "@babel/types@^7.4.4":
"@babel/types@^7.21.3", "@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.23.3", "@babel/types@^7.23.4", "@babel/types@^7.4.4":
version "7.23.4"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.4.tgz#7206a1810fc512a7f7f7d4dace4cb4c1c9dbfb8e"
integrity sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==
@ -1422,6 +1422,17 @@
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-collection@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"
integrity sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-compose-refs@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
@ -1457,6 +1468,13 @@
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-direction@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b"
integrity sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-dismissable-layer@1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4"
@ -1469,6 +1487,20 @@
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown" "1.0.3"
"@radix-ui/react-dropdown-menu@^2.0.5":
version "2.0.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz#cdf13c956c5e263afe4e5f3587b3071a25755b63"
integrity sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-menu" "2.0.6"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-focus-guards@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad"
@ -1494,6 +1526,31 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-menu@2.0.6":
version "2.0.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.0.6.tgz#2c9e093c1a5d5daa87304b2a2f884e32288ae79e"
integrity sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-collection" "1.0.3"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-direction" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.5"
"@radix-ui/react-focus-guards" "1.0.1"
"@radix-ui/react-focus-scope" "1.0.4"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-popper" "1.1.3"
"@radix-ui/react-portal" "1.0.4"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-roving-focus" "1.0.4"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-use-callback-ref" "1.0.1"
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-popover@^1.0.6":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c"
@ -1558,6 +1615,22 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-roving-focus@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974"
integrity sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-collection" "1.0.3"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-direction" "1.0.1"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-slot@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
@ -1566,6 +1639,25 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-tooltip@^1.0.6":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz#8f55070f852e7e7450cc1d9210b793d2e5a7686e"
integrity sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.5"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-popper" "1.1.3"
"@radix-ui/react-portal" "1.0.4"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-visually-hidden" "1.0.3"
"@radix-ui/react-use-callback-ref@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
@ -1612,6 +1704,14 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-visually-hidden@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz#51aed9dd0fe5abcad7dee2a234ad36106a6984ac"
integrity sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/rect@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.0.1.tgz#bf8e7d947671996da2e30f4904ece343bc4a883f"
@ -2474,7 +2574,7 @@ camelcase@^6.2.0:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520, caniuse-lite@^1.0.30001541:
caniuse-lite@^1.0.30001520, caniuse-lite@^1.0.30001541:
version "1.0.30001565"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz#a528b253c8a2d95d2b415e11d8b9942acc100c4f"
integrity sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w==
@ -2587,11 +2687,6 @@ confusing-browser-globals@^1.0.11:
resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81"
integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==
convert-source-map@^1.7.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
convert-source-map@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
@ -2772,7 +2867,7 @@ dot-case@^3.0.4:
no-case "^3.0.4"
tslib "^2.0.3"
electron-to-chromium@^1.4.477, electron-to-chromium@^1.4.535:
electron-to-chromium@^1.4.535:
version "1.4.596"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.596.tgz#6752d1aa795d942d49dfc5d3764d6ea283fab1d7"
integrity sha512-zW3zbZ40Icb2BCWjm47nxwcFGYlIgdXkAx85XDO7cyky9J4QQfq8t0W19/TLZqq3JPQXtlv8BPIGmfa9Jb4scg==
@ -3323,7 +3418,7 @@ gensync@^1.0.0-beta.2:
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
get-func-name@^2.0.0, get-func-name@^2.0.1, get-func-name@^2.0.2:
get-func-name@^2.0.1, get-func-name@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41"
integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==
@ -3486,13 +3581,6 @@ has-tostringtag@^1.0.0:
dependencies:
has-symbols "^1.0.2"
has@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
dependencies:
function-bind "^1.1.1"
hasown@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c"
@ -4087,7 +4175,7 @@ mz@^2.7.0:
object-assign "^4.0.1"
thenify-all "^1.0.0"
nanoid@^3.3.6, nanoid@^3.3.7:
nanoid@^3.3.7:
version "3.3.7"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
@ -5121,7 +5209,7 @@ typescript@^5.3.3:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
ufo@^1.1.2, ufo@^1.3.2:
ufo@^1.3.2:
version "1.4.0"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.4.0.tgz#39845b31be81b4f319ab1d99fd20c56cac528d32"
integrity sha512-Hhy+BhRBleFjpJ2vchUNN40qgkh0366FWJGqVLYBHev0vpHTrXSA0ryT+74UiW6KWsldNurQMKGqCm1M2zBciQ==
@ -5169,7 +5257,7 @@ universalify@^0.2.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"
integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==
update-browserslist-db@^1.0.11, update-browserslist-db@^1.0.13:
update-browserslist-db@^1.0.13:
version "1.0.13"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4"
integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==

View File

@ -394,6 +394,15 @@ func CheckFunnelAccess(port uint16, node *ipnstate.PeerStatus) error {
return CheckFunnelPort(port, node)
}
// NodeCanServe returns an error if the given node is not configured to allow
// for Tailscale Serve usage.
func NodeCanServe(node *ipnstate.PeerStatus) error {
if !node.HasCap(tailcfg.CapabilityHTTPS) {
return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https.")
}
return nil
}
// NodeCanFunnel returns an error if the given node is not configured to allow
// for Tailscale Funnel usage.
func NodeCanFunnel(node *ipnstate.PeerStatus) error {