diff options
| author | Sonia Appasamy <sonia@tailscale.com> | 2024-03-05 11:06:02 -0500 |
|---|---|---|
| committer | Sonia Appasamy <sonia@tailscale.com> | 2024-03-11 14:25:46 -0400 |
| commit | a483a7fb2551ac10832555ea3c0296b071661f87 (patch) | |
| tree | 9ab95f2bfcdc048f3bb666bfe3923bc9cedb97cc | |
| parent | 7429e8912acb74a61c7928852a02ceaf3c144e81 (diff) | |
| download | tailscale-soniaappasamy/serve-funnel-ui.tar.xz tailscale-soniaappasamy/serve-funnel-ui.zip | |
client/web: add serve/funnel viewsoniaappasamy/serve-funnel-ui
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>
| -rw-r--r-- | client/web/auth.go | 4 | ||||
| -rw-r--r-- | client/web/package.json | 2 | ||||
| -rw-r--r-- | client/web/src/api.ts | 28 | ||||
| -rw-r--r-- | client/web/src/assets/icons/copy.svg | 4 | ||||
| -rw-r--r-- | client/web/src/assets/icons/globe.svg | 12 | ||||
| -rw-r--r-- | client/web/src/assets/icons/home.svg | 4 | ||||
| -rw-r--r-- | client/web/src/components/address-copy-card.tsx | 2 | ||||
| -rw-r--r-- | client/web/src/components/app.tsx | 9 | ||||
| -rw-r--r-- | client/web/src/components/views/home-view.tsx | 64 | ||||
| -rw-r--r-- | client/web/src/components/views/serve-view.tsx | 685 | ||||
| -rw-r--r-- | client/web/src/hooks/auth.ts | 9 | ||||
| -rw-r--r-- | client/web/src/index.css | 50 | ||||
| -rw-r--r-- | client/web/src/index.tsx | 9 | ||||
| -rw-r--r-- | client/web/src/types.ts | 36 | ||||
| -rw-r--r-- | client/web/src/ui/collapsible.tsx | 11 | ||||
| -rw-r--r-- | client/web/src/ui/dropdown-menu.tsx | 187 | ||||
| -rw-r--r-- | client/web/src/ui/tooltip.tsx | 48 | ||||
| -rw-r--r-- | client/web/src/utils/util.ts | 10 | ||||
| -rw-r--r-- | client/web/web.go | 317 | ||||
| -rw-r--r-- | client/web/web_test.go | 607 | ||||
| -rw-r--r-- | client/web/yarn.lock | 152 | ||||
| -rw-r--r-- | ipn/serve.go | 9 |
22 files changed, 2156 insertions, 103 deletions
diff --git a/client/web/auth.go b/client/web/auth.go index c95cba1e9..74bf19f81 100644 --- a/client/web/auth.go +++ b/client/web/auth.go @@ -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, } diff --git a/client/web/package.json b/client/web/package.json index f1e5a488c..98168f7fc 100644 --- a/client/web/package.json +++ b/client/web/package.json @@ -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", diff --git a/client/web/src/api.ts b/client/web/src/api.ts index 9414e2d5d..08bbccf0f 100644 --- a/client/web/src/api.ts +++ b/client/web/src/api.ts @@ -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) diff --git a/client/web/src/assets/icons/copy.svg b/client/web/src/assets/icons/copy.svg index 01b732081..8aa9a19d8 100644 --- a/client/web/src/assets/icons/copy.svg +++ b/client/web/src/assets/icons/copy.svg @@ -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> diff --git a/client/web/src/assets/icons/globe.svg b/client/web/src/assets/icons/globe.svg new file mode 100644 index 000000000..c47c54ca7 --- /dev/null +++ b/client/web/src/assets/icons/globe.svg @@ -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> diff --git a/client/web/src/assets/icons/home.svg b/client/web/src/assets/icons/home.svg new file mode 100644 index 000000000..348d23a67 --- /dev/null +++ b/client/web/src/assets/icons/home.svg @@ -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> diff --git a/client/web/src/components/address-copy-card.tsx b/client/web/src/components/address-copy-card.tsx index 6b4f25bed..ff8b34097 100644 --- a/client/web/src/components/address-copy-card.tsx +++ b/client/web/src/components/address-copy-card.tsx @@ -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> diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 981dd8889..c81ede69a 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -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.`} /> diff --git a/client/web/src/components/views/home-view.tsx b/client/web/src/components/views/home-view.tsx index 807382346..6bd6bae69 100644 --- a/client/web/src/components/views/home-view.tsx +++ b/client/web/src/components/views/home-view.tsx @@ -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> diff --git a/client/web/src/components/views/serve-view.tsx b/client/web/src/components/views/serve-view.tsx new file mode 100644 index 000000000..d0c261d10 --- /dev/null +++ b/client/web/src/components/views/serve-view.tsx @@ -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> + ) +} diff --git a/client/web/src/hooks/auth.ts b/client/web/src/hooks/auth.ts index 51eb0c400..cfe7182e9 100644 --- a/client/web/src/hooks/auth.ts +++ b/client/web/src/hooks/auth.ts @@ -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 diff --git a/client/web/src/index.css b/client/web/src/index.css index 7da1ad10d..1b3a5311f 100644 --- a/client/web/src/index.css +++ b/client/web/src/index.css @@ -193,6 +193,22 @@ } /** + * .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 { diff --git a/client/web/src/index.tsx b/client/web/src/index.tsx index 31ac7890f..96f8fcb29 100644 --- a/client/web/src/index.tsx +++ b/client/web/src/index.tsx @@ -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> ) diff --git a/client/web/src/types.ts b/client/web/src/types.ts index 62fa4c59f..803e45f54 100644 --- a/client/web/src/types.ts +++ b/client/web/src/types.ts @@ -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" diff --git a/client/web/src/ui/collapsible.tsx b/client/web/src/ui/collapsible.tsx index 6aa8c0b9f..3d08eb9ad 100644 --- a/client/web/src/ui/collapsible.tsx +++ b/client/web/src/ui/collapsible.tsx @@ -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> diff --git a/client/web/src/ui/dropdown-menu.tsx b/client/web/src/ui/dropdown-menu.tsx new file mode 100644 index 000000000..1adf80b1e --- /dev/null +++ b/client/web/src/ui/dropdown-menu.tsx @@ -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} + /> + ) +} diff --git a/client/web/src/ui/tooltip.tsx b/client/web/src/ui/tooltip.tsx new file mode 100644 index 000000000..8617cde3e --- /dev/null +++ b/client/web/src/ui/tooltip.tsx @@ -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 diff --git a/client/web/src/utils/util.ts b/client/web/src/utils/util.ts index 5f8eda7b7..4abde7726 100644 --- a/client/web/src/utils/util.ts +++ b/client/web/src/utils/util.ts @@ -22,6 +22,16 @@ export function isObject(val: unknown): val is 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 * quantity. diff --git a/client/web/web.go b/client/web/web.go index b0aefc589..8d6e50e2c 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -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) { diff --git a/client/web/web_test.go b/client/web/web_test.go index 3c5543c12..6db1134c3 100644 --- a/client/web/web_test.go +++ b/client/web/web_test.go @@ -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) } diff --git a/client/web/yarn.lock b/client/web/yarn.lock index 99ee451fc..eaac80179 100644 --- a/client/web/yarn.lock +++ b/client/web/yarn.lock @@ -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== diff --git a/ipn/serve.go b/ipn/serve.go index 89ed6e556..70e1ae467 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -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 { |
