From f9b0628263072409494e00b569eaa1f9c59dfb2e Mon Sep 17 00:00:00 2001 From: Sonia Appasamy Date: Tue, 5 Mar 2024 11:06:02 -0500 Subject: [PATCH] client/web: add serve/funnel view Adds a new view to the web client for managing serve/funnel. The view is permissioned by the "serve" and "funnel" grants, and allows for http/https/tcp proxy and plain text serving. Updates #10261 Signed-off-by: Sonia Appasamy --- client/web/auth.go | 4 + client/web/package.json | 1 + client/web/src/api.ts | 28 +- client/web/src/assets/icons/copy.svg | 4 +- client/web/src/assets/icons/globe.svg | 12 + client/web/src/assets/icons/home.svg | 4 + .../web/src/components/address-copy-card.tsx | 2 +- client/web/src/components/app.tsx | 9 +- client/web/src/components/views/home-view.tsx | 13 +- .../web/src/components/views/serve-view.tsx | 563 ++++++++++++++++++ client/web/src/hooks/auth.ts | 9 +- client/web/src/index.css | 34 ++ client/web/src/types.ts | 29 +- client/web/src/ui/collapsible.tsx | 11 +- client/web/src/ui/dropdown-menu.tsx | 187 ++++++ client/web/web.go | 288 ++++++++- client/web/yarn.lock | 73 +++ cmd/tailscale/cli/serve_legacy.go | 2 + cmd/tailscale/cli/serve_v2.go | 79 +-- cmd/tailscale/cli/serve_v2_test.go | 57 -- ipn/serve.go | 141 ++++- ipn/serve_test.go | 57 ++ 22 files changed, 1451 insertions(+), 156 deletions(-) create mode 100644 client/web/src/assets/icons/globe.svg create mode 100644 client/web/src/assets/icons/home.svg create mode 100644 client/web/src/components/views/serve-view.tsx create mode 100644 client/web/src/ui/dropdown-menu.tsx diff --git a/client/web/auth.go b/client/web/auth.go index c95cba1e99347..74bf19f8178df 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 0539d15f75096..d36c4c5902696 100644 --- a/client/web/package.json +++ b/client/web/package.json @@ -10,6 +10,7 @@ "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", "classnames": "^2.3.1", "react": "^18.2.0", diff --git a/client/web/src/api.ts b/client/web/src/api.ts index 9414e2d5d7e16..08bbccf0f5454 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( endpoint: string, - method: "GET" | "POST" | "PATCH", + method: "GET" | "POST" | "PATCH" | "DELETE", body?: any ): Promise { 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 01b732081fb6c..8aa9a19d881b7 100644 --- a/client/web/src/assets/icons/copy.svg +++ b/client/web/src/assets/icons/copy.svg @@ -1,4 +1,4 @@ - - + + diff --git a/client/web/src/assets/icons/globe.svg b/client/web/src/assets/icons/globe.svg new file mode 100644 index 0000000000000..453e9d0f53045 --- /dev/null +++ b/client/web/src/assets/icons/globe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/web/src/assets/icons/home.svg b/client/web/src/assets/icons/home.svg new file mode 100644 index 0000000000000..68e5917a88792 --- /dev/null +++ b/client/web/src/assets/icons/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/web/src/components/address-copy-card.tsx b/client/web/src/components/address-copy-card.tsx index 6b4f25bed73f4..ff8b3409774e0 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" )} > - + diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 981dd8889c4b2..c81ede69af7a6 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({ - {/* Share local content */} + + + diff --git a/client/web/src/components/views/home-view.tsx b/client/web/src/components/views/home-view.tsx index 8073823466b34..435014bd02f16 100644 --- a/client/web/src/components/views/home-view.tsx +++ b/client/web/src/components/views/home-view.tsx @@ -122,12 +122,13 @@ export default function HomeView({ } /> )} - {/* TODO(sonia,will): hiding unimplemented settings pages until implemented */} - {/* */} + {node.Features["serve"] && ( + + )} ) 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 0000000000000..31a074ef1b4f3 --- /dev/null +++ b/client/web/src/components/views/serve-view.tsx @@ -0,0 +1,563 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import cx from "classnames" +import React, { useEffect, 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 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 { copyText } from "src/utils/clipboard" +import { assertNever } 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("/serve/items") + const toaster = useToaster() + + const hasItems = (data?.length || 0) > 0 + + const canEditServe = canEdit("serve", auth) + const canEditFunnel = canEdit("funnel", auth) + const readonly = !canEditServe && !canEditFunnel + + const [editorOpen, setEditorOpen] = useState(!hasItems) + const [editingItem, setEditingItem] = useState() + + useEffect(() => setEditorOpen(!hasItems), [hasItems]) + + return ( + <> +

Share local content

+

+ Share local ports, services, and content to your Tailscale network or to + the broader internet.{" "} + + Learn more → + +

+
+ {!readonly && + (editorOpen ? ( + + api({ + action: "patch-serve-item", + data: { ...d, isEdit: editingItem !== undefined }, + }).then(() => { + mutate() + setEditorOpen(false) + setEditingItem(undefined) + copyText(serveItemURL(d, node)) + .then(() => + toaster.show({ message: "Copied url to clipboard" }) + ) + .catch(() => + toaster.show({ + message: "Failed to copy url", + variant: "danger", + }) + ) + }) + } + onDelete={(d: ServeData) => + api({ + action: "delete-serve-item", + data: d, + }).then(() => { + mutate() + setEditorOpen(false) + setEditingItem(undefined) + toaster.show({ message: "Deleted item" }) + }) + } + onCancel={ + hasItems + ? () => { + setEditorOpen(false) + setEditingItem(undefined) + } + : undefined + } + /> + ) : ( + + ))} + {!data || data.length === 0 ? ( + + + + ) : ( +
+ {data.map((d) => { + const url = serveItemURL(d, node) + return ( + (!editingItem || url !== serveItemURL(editingItem, node)) && ( + { + setEditingItem(d) + setEditorOpen(true) + }} + /> + ) + ) + })} +
+ )} +
+ + ) +} + +function serveItemURL(data: ServeData, node: NodeData): string { + return `${data.destination.protocol}://${node.DeviceName}.${ + node.TailnetName + }${data.destination.port !== 443 ? `:${data.destination.port}` : ""}${ + data.destination.path + }` +} + +function ServeItemCard({ + data, + url, + disabled, + onEditSelect, +}: { + data: ServeData + url: string + disabled: boolean + onEditSelect: () => void +}) { + return ( + +

+ {data.target.type === "plainText" + ? `Text “${data.target.value}”` + : data.target.type === "localHttpPort" + ? data.target.value // todo: path here? + : assertNever(data.target.type)} +

+

Shared at

+ + {url} + + +
+ + +
+
+ ) +} + +function ServeEditorCard({ + node, + canEditServe, + canEditFunnel, + initialState, + onSubmit, + onCancel, + onDelete, + className, +}: { + node: NodeData + canEditServe: boolean + canEditFunnel: boolean + initialState?: ServeData // editing existing config + onSubmit: (d: ServeData) => void + onDelete: (d: ServeData) => void + onCancel?: () => void + className?: string +}) { + const [target, setTarget] = useState( + initialState?.target || { + type: "localHttpPort", + value: "", + } + ) + const [shareType, setShareType] = useState( + initialState?.shareType || "serve" + ) + const [destination, setDestination] = useState( + initialState?.destination || { + protocol: "https", + port: 443, + path: "", + } + ) + + return ( + + +

Share

+
+ } + selected={shareType === "serve"} + onSelect={() => setShareType("serve")} + readonly={!canEditServe} + /> + } + selected={shareType === "funnel"} + onSelect={() => setShareType("funnel")} + readonly={!canEditFunnel} + /> +
+ +
+
+ + {onCancel && ( + + )} +
+ {initialState && ( + + )} +
+
+ ) +} + +function ShareRadioButton({ + title, + description, + icon, + selected, + onSelect, + readonly, +}: { + title: string + description: string + icon: React.ReactNode + selected: boolean + onSelect: () => void + readonly: boolean +}) { + return ( + + ) +} + +function TargetSection({ + target, + setTarget, +}: { + target: Target + setTarget: (next: Target) => void +}) { + return ( + <> +

Target

+

+ The content you want to share. +

+ + {target.type === "plainText" + ? "Plain text" + : target.type === "localHttpPort" + ? "Local http port" + : assertNever(target.type)} + + + } + side="bottom" + align="start" + > + + setTarget({ + type: t as TargetType, + value: "", // clear out + }) + } + > + + Plain text + + + Local http port + + + +
+
+ {target.type === "plainText" + ? "Text" + : target.type === "localHttpPort" + ? "http://localhost:" + : assertNever(target.type)} +
+ setTarget({ ...target, value: e.target.value })} + placeholder={ + target.type === "plainText" + ? "Hello world." + : target.type === "localHttpPort" + ? "8888" + : assertNever(target.type) + } + /> +
+ + ) +} + +function DestinationSection({ + node, + destination, + setDestination, + className, +}: { + node: NodeData + destination: Destination + setDestination: (next: Destination) => void + className?: string +}) { + return ( +
+ + +

+ Destination protocol and port +

+
+ + {destination.protocol} + + + } + side="bottom" + align="start" + > + + setDestination({ + ...destination, + protocol: p as DestinationProtocol, + }) + } + > + + https + + + http + + tcp + + + + {destination.port} + + + } + side="bottom" + align="start" + > + + setDestination({ + ...destination, + port: Number.parseInt(p) as DestinationPort, + }) + } + > + 443 + + 8443 + + + 10000 + + + +
+

+ Destination path +

+

+ A slash-separated URL path appended to the destination url +

+ {/* TODO: path(and plaintext) should not be configurable for TCP? */} + + setDestination({ ...destination, path: e.target.value }) + } + placeholder="/images/" + /> +
+
+

Preview destination URL

+

+ The URL where your content will be available. +

+ + + {destination.protocol}://{node.DeviceName} + + + .{node.TailnetName} + {destination.port !== 443 ? `:${destination.port}` : ""} + {destination.path} + + {/* todo: port */} + +
+ ) +} diff --git a/client/web/src/hooks/auth.ts b/client/web/src/hooks/auth.ts index 51eb0c400bae9..cfe7182e9a9e0 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 7da1ad10d8a1e..e8a753286f2b2 100644 --- a/client/web/src/index.css +++ b/client/web/src/index.css @@ -192,6 +192,22 @@ @apply text-gray-500 leading-snug; } + /** + * .radio applies default styles to input[type="radio"] form elements. + */ + + .radio { + @apply appearance-none w-4 h-4 rounded-full border border-gray-300 shrink-0 shadow-form; + } + + .radio:checked { + @apply border-blue-500 border-[5px]; + } + + .radio:focus { + @apply focus-visible:ring focus-visible:outline-none; + } + /** * .toggle applies "Toggle" UI styles to input[type="checkbox"] form elements. * You can use the -large and -small modifiers for size variants. @@ -455,6 +471,24 @@ .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; + } } @layer utilities { diff --git a/client/web/src/types.ts b/client/web/src/types.ts index 62fa4c59f1fbf..c3b4094b08ad2 100644 --- a/client/web/src/types.ts +++ b/client/web/src/types.ts @@ -84,9 +84,10 @@ export type Feature = | "advertise-routes" | "use-exit-node" | "ssh" + | "serve" | "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 +97,8 @@ 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 "auto-update": return "Auto updating client versions" default: @@ -111,3 +114,27 @@ 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" // todo: http relevant? +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 6aa8c0b9f5ca1..3d08eb9ad34f1 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) }} > - + diff --git a/client/web/src/ui/dropdown-menu.tsx b/client/web/src/ui/dropdown-menu.tsx new file mode 100644 index 0000000000000..1adf80b1e6160 --- /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 + +/** + * 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} + ) : ( + + {trigger} + + {(portalContainer) => ( + + + {children} + + + )} + + + ) +} + +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 ( + + ) +} + +type DropdownMenuItemProps = { + intent?: "danger" + stopPropagation?: boolean +} & CommonMenuItemProps & + Omit + +function DropdownMenuItem(props: DropdownMenuItemProps) { + const { className, disabled, intent, stopPropagation, hidden, ...rest } = + props + + if (hidden === true) { + return null + } + + return ( + 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 ( + + + + + {children} + + ) +} + +type DropdownSeparatorProps = Omit & + MenuPrimitive.MenuSeparatorProps + +function DropdownSeparator(props: DropdownSeparatorProps) { + const { className, hidden, ...rest } = props + + if (hidden === true) { + return null + } + + return ( + + ) +} diff --git a/client/web/web.go b/client/web/web.go index b0aefc5890e86..15e52d7fb82b7 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": true, // TODO: IMPLEMENT + "funnel": ipn.NodeCanFunnel(self) == nil, // TODO: use, also anything else to check? "auto-update": version.IsUnstableBuild() && clientupdate.CanAutoUpdate(), } if env == hostinfo.HomeAssistantAddOn { @@ -1108,6 +1140,256 @@ 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"` // only populated by "GET", empty for "PATCH"/"DELETE" + IsEdit bool `json:"isEdit"` // 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" or "http" or "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)))) + serveItems = append(serveItems, &serveItem{ + Target: serveTarget{ + Type: "localHttpPort", + Value: fmt.Sprint(port), + }, + Destination: serveDestination{ + Protocol: "tcp", + 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) + } + } + } + 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 && !data.IsEdit { + return errors.New("port already in use") + } else if 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("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") + } + if data.Destination.Path == "" { + data.Destination.Path = "/" + } + sc.SetWebHandler(dnsName, data.Destination.Port, data.Destination.Protocol == "https", data.Destination.Path, h) + case "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 + } + sc.SetTCPHandler(dnsName, data.Destination.Port, false, &ipn.TCPPortHandler{TCPForward: tUrl.Host}) + 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": + 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/yarn.lock b/client/web/yarn.lock index db4ea0e8c3e8f..350d164d5d506 100644 --- a/client/web/yarn.lock +++ b/client/web/yarn.lock @@ -1620,6 +1620,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" @@ -1655,6 +1666,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" @@ -1667,6 +1685,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" @@ -1692,6 +1724,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" @@ -1756,6 +1813,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" diff --git a/cmd/tailscale/cli/serve_legacy.go b/cmd/tailscale/cli/serve_legacy.go index e6e18669da0fe..9d7579c757a5e 100644 --- a/cmd/tailscale/cli/serve_legacy.go +++ b/cmd/tailscale/cli/serve_legacy.go @@ -642,6 +642,8 @@ func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error { // Examples: // - tailscale status // - tailscale status --json +// +// TODO(tyler,marwan,sonia): `status` should also report foreground configs. func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error { sc, err := e.lc.GetServeConfig(ctx) if err != nil { diff --git a/cmd/tailscale/cli/serve_v2.go b/cmd/tailscale/cli/serve_v2.go index 0cafbc50e3143..1050bac4ac430 100644 --- a/cmd/tailscale/cli/serve_v2.go +++ b/cmd/tailscale/cli/serve_v2.go @@ -18,7 +18,6 @@ import ( "os/signal" "path" "path/filepath" - "slices" "sort" "strconv" "strings" @@ -334,7 +333,7 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { const backgroundExistsMsg = "background configuration already exists, use `tailscale %s --%s=%d off` to remove the existing configuration" func (e *serveEnv) validateConfig(sc *ipn.ServeConfig, port uint16, wantServe serveType) error { - sc, isFg := findConfig(sc, port) + sc, isFg := sc.FindConfig(port) if sc == nil { return nil } @@ -366,24 +365,6 @@ func serveFromPortHandler(tcp *ipn.TCPPortHandler) serveType { } } -// findConfig finds a config that contains the given port, which can be -// the top level background config or an inner foreground one. The second -// result is true if it's foreground -func findConfig(sc *ipn.ServeConfig, port uint16) (*ipn.ServeConfig, bool) { - if sc == nil { - return nil, false - } - if _, ok := sc.TCP[port]; ok { - return sc, false - } - for _, sc := range sc.Foreground { - if _, ok := sc.TCP[port]; ok { - return sc, true - } - } - return nil, false -} - func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool) error { // update serve config based on the type switch srvType { @@ -535,7 +516,7 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui } h.Path = target default: - t, err := expandProxyTargetDev(target, []string{"http", "https", "https+insecure"}, "http") + t, err := ipn.ExpandProxyTargetValue(target, []string{"http", "https", "https+insecure"}, "http") if err != nil { return err } @@ -585,7 +566,7 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se return fmt.Errorf("invalid TCP target %q", target) } - targetURL, err := expandProxyTargetDev(target, []string{"tcp"}, "tcp") + targetURL, err := ipn.ExpandProxyTargetValue(target, []string{"tcp"}, "tcp") if err != nil { return fmt.Errorf("unable to expand target: %v", err) } @@ -865,60 +846,6 @@ func (e *serveEnv) removeTCPServe(sc *ipn.ServeConfig, src uint16) error { return nil } -// expandProxyTargetDev expands the supported target values to be proxied -// allowing for input values to be a port number, a partial URL, or a full URL -// including a path. -// -// examples: -// - 3000 -// - localhost:3000 -// - tcp://localhost:3000 -// - http://localhost:3000 -// - https://localhost:3000 -// - https-insecure://localhost:3000 -// - https-insecure://localhost:3000/foo -func expandProxyTargetDev(target string, supportedSchemes []string, defaultScheme string) (string, error) { - const host = "127.0.0.1" - - // support target being a port number - if port, err := strconv.ParseUint(target, 10, 16); err == nil { - return fmt.Sprintf("%s://%s:%d", defaultScheme, host, port), nil - } - - // prepend scheme if not present - if !strings.Contains(target, "://") { - target = defaultScheme + "://" + target - } - - // make sure we can parse the target - u, err := url.ParseRequestURI(target) - if err != nil { - return "", fmt.Errorf("invalid URL %w", err) - } - - // ensure a supported scheme - if !slices.Contains(supportedSchemes, u.Scheme) { - return "", fmt.Errorf("must be a URL starting with one of the supported schemes: %v", supportedSchemes) - } - - // validate the host. - switch u.Hostname() { - case "localhost", "127.0.0.1": - default: - return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported") - } - - // validate the port - port, err := strconv.ParseUint(u.Port(), 10, 16) - if err != nil || port == 0 { - return "", fmt.Errorf("invalid port %q", u.Port()) - } - - u.Host = fmt.Sprintf("%s:%d", host, port) - - return u.String(), nil -} - // cleanURLPath ensures the path is clean and has a leading "/". func cleanURLPath(urlPath string) (string, error) { if urlPath == "" { diff --git a/cmd/tailscale/cli/serve_v2_test.go b/cmd/tailscale/cli/serve_v2_test.go index 8634d5b83871b..7515222ffbc0c 100644 --- a/cmd/tailscale/cli/serve_v2_test.go +++ b/cmd/tailscale/cli/serve_v2_test.go @@ -1041,63 +1041,6 @@ func TestSrcTypeFromFlags(t *testing.T) { } } -func TestExpandProxyTargetDev(t *testing.T) { - tests := []struct { - name string - input string - defaultScheme string - supportedSchemes []string - expected string - wantErr bool - }{ - {name: "port-only", input: "8080", expected: "http://127.0.0.1:8080"}, - {name: "hostname+port", input: "localhost:8080", expected: "http://127.0.0.1:8080"}, - {name: "convert-localhost", input: "http://localhost:8080", expected: "http://127.0.0.1:8080"}, - {name: "no-change", input: "http://127.0.0.1:8080", expected: "http://127.0.0.1:8080"}, - {name: "include-path", input: "http://127.0.0.1:8080/foo", expected: "http://127.0.0.1:8080/foo"}, - {name: "https-scheme", input: "https://localhost:8080", expected: "https://127.0.0.1:8080"}, - {name: "https+insecure-scheme", input: "https+insecure://localhost:8080", expected: "https+insecure://127.0.0.1:8080"}, - {name: "change-default-scheme", input: "localhost:8080", defaultScheme: "https", expected: "https://127.0.0.1:8080"}, - {name: "change-supported-schemes", input: "localhost:8080", defaultScheme: "tcp", supportedSchemes: []string{"tcp"}, expected: "tcp://127.0.0.1:8080"}, - - // errors - {name: "invalid-port", input: "localhost:9999999", wantErr: true}, - {name: "unsupported-scheme", input: "ftp://localhost:8080", expected: "", wantErr: true}, - {name: "not-localhost", input: "https://tailscale.com:8080", expected: "", wantErr: true}, - {name: "empty-input", input: "", expected: "", wantErr: true}, - } - - for _, tt := range tests { - defaultScheme := "http" - supportedSchemes := []string{"http", "https", "https+insecure"} - - if tt.supportedSchemes != nil { - supportedSchemes = tt.supportedSchemes - } - if tt.defaultScheme != "" { - defaultScheme = tt.defaultScheme - } - - t.Run(tt.name, func(t *testing.T) { - actual, err := expandProxyTargetDev(tt.input, supportedSchemes, defaultScheme) - - if tt.wantErr == true && err == nil { - t.Errorf("Expected an error but got none") - return - } - - if tt.wantErr == false && err != nil { - t.Errorf("Got an error, but didn't expect one: %v", err) - return - } - - if actual != tt.expected { - t.Errorf("Got: %q; expected: %q", actual, tt.expected) - } - }) - } -} - func TestCleanURLPath(t *testing.T) { tests := []struct { input string diff --git a/ipn/serve.go b/ipn/serve.go index 84db09d1dd97f..53f7fa37e1faf 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -9,11 +9,13 @@ import ( "net" "net/netip" "net/url" + "slices" "strconv" "strings" "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" + "tailscale.com/util/mak" ) // ServeConfigKey returns a StateKey that stores the @@ -234,6 +236,78 @@ func (sc *ServeConfig) IsServingHTTP(port uint16) bool { return sc.TCP[port].HTTP } +// FindConfig finds a config that contains the given port, which can be +// the top level background config or an inner foreground one. +// The second result is true if it's foreground. +func (sc *ServeConfig) FindConfig(port uint16) (*ServeConfig, bool) { + if sc == nil { + return nil, false + } + if _, ok := sc.TCP[port]; ok { + return sc, false + } + for _, sc := range sc.Foreground { + if _, ok := sc.TCP[port]; ok { + return sc, true + } + } + return nil, false +} + +// TODO: docs + +func (sc *ServeConfig) SetWebHandler(dnsName string, port uint16, useTLS bool, mount string, h *HTTPHandler) { + mak.Set(&sc.TCP, port, &TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS}) + + hp := HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(port)))) + if _, ok := sc.Web[hp]; !ok { + mak.Set(&sc.Web, hp, new(WebServerConfig)) + } + mak.Set(&sc.Web[hp].Handlers, mount, h) + + // TODO(tylersmalley): handle multiple web handlers from foreground mode + for k, v := range sc.Web[hp].Handlers { + if v == h { + continue + } + // If the new mount point ends in / and another mount point + // shares the same prefix, remove the other handler. + // (e.g. /foo/ overwrites /foo) + // The opposite example is also handled. + m1 := strings.TrimSuffix(mount, "/") + m2 := strings.TrimSuffix(k, "/") + if m1 == m2 { + delete(sc.Web[hp].Handlers, k) + } + } +} + +func (sc *ServeConfig) SetTCPHandler(dnsName string, port uint16, terminateTLS bool, h *TCPPortHandler) { + mak.Set(&sc.TCP, port, h) + + // Clean up web config if previously set. + hp := HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(port)))) + if _, exists := sc.Web[hp]; exists { + delete(sc.Web, hp) + } +} + +func (sc *ServeConfig) SetFunnel(dnsName string, port uint16, setOn bool) { + if sc == nil { + sc = new(ServeConfig) + } + + hp := HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(port)))) + + // TODO(tylersmalley): should ensure there is no other conflicting funnel + // TODO(tylersmalley): add error handling for if toggling for existing sc + if setOn { + mak.Set(&sc.AllowFunnel, hp, true) + } else if _, exists := sc.AllowFunnel[hp]; exists { + delete(sc.AllowFunnel, hp) + } +} + // IsFunnelOn reports whether if ServeConfig is currently allowing funnel // traffic for any host:port. // @@ -257,19 +331,28 @@ func (sc *ServeConfig) IsFunnelOn() bool { // CheckFunnelAccess checks whether Funnel access is allowed for the given node // and port. // It checks: -// 1. HTTPS is enabled on the Tailnet +// 1. HTTPS is enabled on the tailnet // 2. the node has the "funnel" nodeAttr // 3. the port is allowed for Funnel // // The node arg should be the ipnstate.Status.Self node. func CheckFunnelAccess(port uint16, node *ipnstate.PeerStatus) error { + if err := NodeCanFunnel(node); err != nil { + return err + } + return CheckFunnelPort(port, node) +} + +// NodeCanFunnel returns an error if the given node is not configured to allow +// for Tailscale Funnel usage. +func NodeCanFunnel(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.") } if !node.HasCap(tailcfg.NodeAttrFunnel) { return errors.New("Funnel not available; \"funnel\" node attribute not set. See https://tailscale.com/s/no-funnel.") } - return CheckFunnelPort(port, node) + return nil } // CheckFunnelPort checks whether the given port is allowed for Funnel. @@ -355,6 +438,60 @@ func CheckFunnelPort(wantedPort uint16, node *ipnstate.PeerStatus) error { return deny(portsStr) } +// ExpandProxyTargetValue expands the supported target values to be proxied +// allowing for input values to be a port number, a partial URL, or a full URL +// including a path. +// +// examples: +// - 3000 +// - localhost:3000 +// - tcp://localhost:3000 +// - http://localhost:3000 +// - https://localhost:3000 +// - https-insecure://localhost:3000 +// - https-insecure://localhost:3000/foo +func ExpandProxyTargetValue(target string, supportedSchemes []string, defaultScheme string) (string, error) { + const host = "127.0.0.1" + + // support target being a port number + if port, err := strconv.ParseUint(target, 10, 16); err == nil { + return fmt.Sprintf("%s://%s:%d", defaultScheme, host, port), nil + } + + // prepend scheme if not present + if !strings.Contains(target, "://") { + target = defaultScheme + "://" + target + } + + // make sure we can parse the target + u, err := url.ParseRequestURI(target) + if err != nil { + return "", fmt.Errorf("invalid URL %w", err) + } + + // ensure a supported scheme + if !slices.Contains(supportedSchemes, u.Scheme) { + return "", fmt.Errorf("must be a URL starting with one of the supported schemes: %v", supportedSchemes) + } + + // validate the host. + switch u.Hostname() { + case "localhost", "127.0.0.1": + default: + return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported") + } + + // validate the port + port, err := strconv.ParseUint(u.Port(), 10, 16) + if err != nil || port == 0 { + return "", fmt.Errorf("invalid port %q", u.Port()) + } + + u.Host = fmt.Sprintf("%s:%d", host, port) + + return u.String(), nil +} + // RangeOverTCPs ranges over both background and foreground TCPs. // If the returned bool from the given f is false, then this function stops // iterating immediately and does not check other foreground configs. diff --git a/ipn/serve_test.go b/ipn/serve_test.go index 38a158f3f4327..a9fe4a4627195 100644 --- a/ipn/serve_test.go +++ b/ipn/serve_test.go @@ -126,3 +126,60 @@ func TestHasPathHandler(t *testing.T) { }) } } + +func TestExpandProxyTargetDev(t *testing.T) { + tests := []struct { + name string + input string + defaultScheme string + supportedSchemes []string + expected string + wantErr bool + }{ + {name: "port-only", input: "8080", expected: "http://127.0.0.1:8080"}, + {name: "hostname+port", input: "localhost:8080", expected: "http://127.0.0.1:8080"}, + {name: "convert-localhost", input: "http://localhost:8080", expected: "http://127.0.0.1:8080"}, + {name: "no-change", input: "http://127.0.0.1:8080", expected: "http://127.0.0.1:8080"}, + {name: "include-path", input: "http://127.0.0.1:8080/foo", expected: "http://127.0.0.1:8080/foo"}, + {name: "https-scheme", input: "https://localhost:8080", expected: "https://127.0.0.1:8080"}, + {name: "https+insecure-scheme", input: "https+insecure://localhost:8080", expected: "https+insecure://127.0.0.1:8080"}, + {name: "change-default-scheme", input: "localhost:8080", defaultScheme: "https", expected: "https://127.0.0.1:8080"}, + {name: "change-supported-schemes", input: "localhost:8080", defaultScheme: "tcp", supportedSchemes: []string{"tcp"}, expected: "tcp://127.0.0.1:8080"}, + + // errors + {name: "invalid-port", input: "localhost:9999999", wantErr: true}, + {name: "unsupported-scheme", input: "ftp://localhost:8080", expected: "", wantErr: true}, + {name: "not-localhost", input: "https://tailscale.com:8080", expected: "", wantErr: true}, + {name: "empty-input", input: "", expected: "", wantErr: true}, + } + + for _, tt := range tests { + defaultScheme := "http" + supportedSchemes := []string{"http", "https", "https+insecure"} + + if tt.supportedSchemes != nil { + supportedSchemes = tt.supportedSchemes + } + if tt.defaultScheme != "" { + defaultScheme = tt.defaultScheme + } + + t.Run(tt.name, func(t *testing.T) { + actual, err := ExpandProxyTargetValue(tt.input, supportedSchemes, defaultScheme) + + if tt.wantErr == true && err == nil { + t.Errorf("Expected an error but got none") + return + } + + if tt.wantErr == false && err != nil { + t.Errorf("Got an error, but didn't expect one: %v", err) + return + } + + if actual != tt.expected { + t.Errorf("Got: %q; expected: %q", actual, tt.expected) + } + }) + } +}