{ipn/serve,cmd/tailscale/cli}: move some shared funcs to ipn

In preparation for changes to allow configuration of serve/funnel
from the web client, this commit moves some functionality that will
be shared between the CLI and web client to the ipn package's
serve.go file, where some other util funcs are already defined.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
pull/11100/head
Sonia Appasamy 9 months ago committed by Sonia Appasamy
parent 8780e33500
commit 65c3c690cf

@ -642,6 +642,9 @@ func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error {
// Examples: // Examples:
// - tailscale status // - tailscale status
// - tailscale status --json // - tailscale status --json
//
// TODO(tyler,marwan,sonia): `status` should also report foreground configs,
// currently only reports background config.
func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error { func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
sc, err := e.lc.GetServeConfig(ctx) sc, err := e.lc.GetServeConfig(ctx)
if err != nil { if err != nil {

@ -18,7 +18,6 @@ import (
"os/signal" "os/signal"
"path" "path"
"path/filepath" "path/filepath"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "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" 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 { 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 { if sc == nil {
return 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 { 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 // update serve config based on the type
switch srvType { switch srvType {
@ -535,7 +516,7 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui
} }
h.Path = target h.Path = target
default: 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 { if err != nil {
return err 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) 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 { if err != nil {
return fmt.Errorf("unable to expand target: %v", err) 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 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 "/". // cleanURLPath ensures the path is clean and has a leading "/".
func cleanURLPath(urlPath string) (string, error) { func cleanURLPath(urlPath string) (string, error) {
if urlPath == "" { if urlPath == "" {

@ -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) { func TestCleanURLPath(t *testing.T) {
tests := []struct { tests := []struct {
input string input string

@ -9,6 +9,7 @@ import (
"net" "net"
"net/netip" "net/netip"
"net/url" "net/url"
"slices"
"strconv" "strconv"
"strings" "strings"
@ -234,6 +235,24 @@ func (sc *ServeConfig) IsServingHTTP(port uint16) bool {
return sc.TCP[port].HTTP 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
}
// IsFunnelOn reports whether if ServeConfig is currently allowing funnel // IsFunnelOn reports whether if ServeConfig is currently allowing funnel
// traffic for any host:port. // traffic for any host:port.
// //
@ -257,19 +276,28 @@ func (sc *ServeConfig) IsFunnelOn() bool {
// CheckFunnelAccess checks whether Funnel access is allowed for the given node // CheckFunnelAccess checks whether Funnel access is allowed for the given node
// and port. // and port.
// It checks: // It checks:
// 1. HTTPS is enabled on the Tailnet // 1. HTTPS is enabled on the tailnet
// 2. the node has the "funnel" nodeAttr // 2. the node has the "funnel" nodeAttr
// 3. the port is allowed for Funnel // 3. the port is allowed for Funnel
// //
// The node arg should be the ipnstate.Status.Self node. // The node arg should be the ipnstate.Status.Self node.
func CheckFunnelAccess(port uint16, node *ipnstate.PeerStatus) error { 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) { if !node.HasCap(tailcfg.CapabilityHTTPS) {
return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https.") return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https.")
} }
if !node.HasCap(tailcfg.NodeAttrFunnel) { if !node.HasCap(tailcfg.NodeAttrFunnel) {
return errors.New("Funnel not available; \"funnel\" node attribute not set. See https://tailscale.com/s/no-funnel.") 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. // CheckFunnelPort checks whether the given port is allowed for Funnel.
@ -355,6 +383,60 @@ func CheckFunnelPort(wantedPort uint16, node *ipnstate.PeerStatus) error {
return deny(portsStr) 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. // RangeOverTCPs ranges over both background and foreground TCPs.
// If the returned bool from the given f is false, then this function stops // If the returned bool from the given f is false, then this function stops
// iterating immediately and does not check other foreground configs. // iterating immediately and does not check other foreground configs.

@ -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)
}
})
}
}

Loading…
Cancel
Save