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:
parent
7429e8912a
commit
a483a7fb25
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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>
|
||||
|
|
|
@ -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.`}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 →
|
||||
</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 it’s shared in a
|
||||
foreground session started on the machine’s 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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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==
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue