tailscale/cli: add interactive flow for enabling Funnel
Updates tailscale/corp#10577 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
committed by
Sonia Appasamy
parent
90081a25ca
commit
7815fbe17a
@@ -13,7 +13,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
@@ -91,9 +94,10 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
|
||||
}
|
||||
port := uint16(port64)
|
||||
|
||||
if err := ipn.CheckFunnelAccess(port, st.Self.Capabilities); err != nil {
|
||||
if err := e.verifyFunnelEnabled(ctx, st, port); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||
hp := ipn.HostPort(dnsName + ":" + strconv.Itoa(int(port)))
|
||||
if on == sc.AllowFunnel[hp] {
|
||||
@@ -117,6 +121,49 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyFunnelEnabled verifies that the self node is allowed to use Funnel.
|
||||
//
|
||||
// If Funnel is not yet enabled by the current node capabilities,
|
||||
// the user is sent through an interactive flow to enable the feature.
|
||||
// Once enabled, verifyFunnelEnabled checks that the given port is allowed
|
||||
// with Funnel.
|
||||
//
|
||||
// If an error is reported, the CLI should stop execution and return the error.
|
||||
//
|
||||
// verifyFunnelEnabled may refresh the local state and modify the st input.
|
||||
func (e *serveEnv) verifyFunnelEnabled(ctx context.Context, st *ipnstate.Status, port uint16) error {
|
||||
hasFunnelAttrs := func(attrs []string) bool {
|
||||
hasHTTPS := slices.Contains(attrs, tailcfg.CapabilityHTTPS)
|
||||
hasFunnel := slices.Contains(attrs, tailcfg.NodeAttrFunnel)
|
||||
return hasHTTPS && hasFunnel
|
||||
}
|
||||
if hasFunnelAttrs(st.Self.Capabilities) {
|
||||
return nil // already enabled
|
||||
}
|
||||
enableErr := e.enableFeatureInteractive(ctx, "funnel", hasFunnelAttrs)
|
||||
st, statusErr := e.getLocalClientStatus(ctx) // get updated status; interactive flow may block
|
||||
switch {
|
||||
case statusErr != nil:
|
||||
return fmt.Errorf("getting client status: %w", statusErr)
|
||||
case enableErr != nil:
|
||||
// enableFeatureInteractive is a new flow behind a control server
|
||||
// feature flag. If anything caused it to error, fallback to using
|
||||
// the old CheckFunnelAccess call. Likely this domain does not have
|
||||
// the feature flag on.
|
||||
// TODO(sonia,tailscale/corp#10577): Remove this fallback once the
|
||||
// control flag is turned on for all domains.
|
||||
if err := ipn.CheckFunnelAccess(port, st.Self.Capabilities); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// Done with enablement, make sure the requested port is allowed.
|
||||
if err := ipn.CheckFunnelPort(port, st.Self.Capabilities); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printFunnelWarning prints a warning if the Funnel is on but there is no serve
|
||||
// config for its host:port.
|
||||
func printFunnelWarning(sc *ipn.ServeConfig) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -129,7 +131,8 @@ type localServeClient interface {
|
||||
Status(context.Context) (*ipnstate.Status, error)
|
||||
GetServeConfig(context.Context) (*ipn.ServeConfig, error)
|
||||
SetServeConfig(context.Context, *ipn.ServeConfig) error
|
||||
QueryFeature(context.Context, string) (*tailcfg.QueryFeatureResponse, error)
|
||||
QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error)
|
||||
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error)
|
||||
}
|
||||
|
||||
// serveEnv is the environment the serve command runs within. All I/O should be
|
||||
@@ -766,3 +769,70 @@ func parseServePort(s string) (uint16, error) {
|
||||
}
|
||||
return uint16(p), nil
|
||||
}
|
||||
|
||||
// enableFeatureInteractive sends the node's user through an interactive
|
||||
// flow to enable a feature, such as Funnel, on their tailnet.
|
||||
//
|
||||
// hasRequiredCapabilities should be provided as a function that checks
|
||||
// whether a slice of node capabilities encloses the necessary values
|
||||
// needed to use the feature.
|
||||
//
|
||||
// If err is returned empty, the feature has been successfully enabled.
|
||||
//
|
||||
// If err is returned non-empty, the client failed to query the control
|
||||
// server for information about how to enable the feature.
|
||||
//
|
||||
// If the feature cannot be enabled, enableFeatureInteractive terminates
|
||||
// the CLI process.
|
||||
//
|
||||
// 2023-08-09: The only valid feature values are "serve" and "funnel".
|
||||
// This can be moved to some CLI lib when expanded past serve/funnel.
|
||||
func (e *serveEnv) enableFeatureInteractive(ctx context.Context, feature string, hasRequiredCapabilities func(caps []string) bool) (err error) {
|
||||
info, err := e.lc.QueryFeature(ctx, feature)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Complete {
|
||||
return nil // already enabled
|
||||
}
|
||||
if info.Text != "" {
|
||||
fmt.Fprintln(os.Stdout, info.Text)
|
||||
}
|
||||
if info.URL != "" {
|
||||
fmt.Fprintln(os.Stdout, "\n "+info.URL)
|
||||
}
|
||||
if !info.ShouldWait {
|
||||
// The feature has not been enabled yet,
|
||||
// but the CLI should not block on user action.
|
||||
// Once info.Text is printed, exit the CLI.
|
||||
os.Exit(0)
|
||||
}
|
||||
// Block until feature is enabled.
|
||||
watchCtx, cancelWatch := context.WithCancel(ctx)
|
||||
defer cancelWatch()
|
||||
watcher, err := e.lc.WatchIPNBus(watchCtx, 0)
|
||||
if err != nil {
|
||||
// If we fail to connect to the IPN notification bus,
|
||||
// don't block. We still present the URL in the CLI,
|
||||
// then close the process. Swallow the error.
|
||||
log.Fatalf("lost connection to tailscaled: %v", err)
|
||||
return err
|
||||
}
|
||||
defer watcher.Close()
|
||||
for {
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
// Stop blocking if we error.
|
||||
// Let the user finish enablement then rerun their
|
||||
// command themselves.
|
||||
log.Fatalf("lost connection to tailscaled: %v", err)
|
||||
return err
|
||||
}
|
||||
if nm := n.NetMap; nm != nil && nm.SelfNode != nil {
|
||||
if hasRequiredCapabilities(nm.SelfNode.Capabilities) {
|
||||
fmt.Fprintln(os.Stdout, "\nSuccess.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package cli
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -745,14 +747,105 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyFunnelEnabled(t *testing.T) {
|
||||
lc := &fakeLocalServeClient{}
|
||||
var stdout bytes.Buffer
|
||||
var flagOut bytes.Buffer
|
||||
e := &serveEnv{
|
||||
lc: lc,
|
||||
testFlagOut: &flagOut,
|
||||
testStdout: &stdout,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// queryFeatureResponse is the mock response desired from the
|
||||
// call made to lc.QueryFeature by verifyFunnelEnabled.
|
||||
queryFeatureResponse mockQueryFeatureResponse
|
||||
caps []string // optionally set at fakeStatus.Capabilities
|
||||
wantErr string
|
||||
wantPanic string
|
||||
}{
|
||||
{
|
||||
name: "enabled",
|
||||
queryFeatureResponse: mockQueryFeatureResponse{resp: &tailcfg.QueryFeatureResponse{Complete: true}, err: nil},
|
||||
wantErr: "", // no error, success
|
||||
},
|
||||
{
|
||||
name: "fallback-to-non-interactive-flow",
|
||||
queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")},
|
||||
wantErr: "Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https.",
|
||||
},
|
||||
{
|
||||
name: "fallback-flow-missing-acl-rule",
|
||||
queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")},
|
||||
caps: []string{tailcfg.CapabilityHTTPS},
|
||||
wantErr: `Funnel not available; "funnel" node attribute not set. See https://tailscale.com/s/no-funnel.`,
|
||||
},
|
||||
{
|
||||
name: "fallback-flow-enabled",
|
||||
queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")},
|
||||
caps: []string{tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel},
|
||||
wantErr: "", // no error, success
|
||||
},
|
||||
{
|
||||
name: "not-allowed-to-enable",
|
||||
queryFeatureResponse: mockQueryFeatureResponse{resp: &tailcfg.QueryFeatureResponse{
|
||||
Complete: false,
|
||||
Text: "You don't have permission to enable this feature.",
|
||||
ShouldWait: false,
|
||||
}, err: nil},
|
||||
wantErr: "",
|
||||
wantPanic: "unexpected call to os.Exit(0) during test", // os.Exit(0) should be called to end process
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
lc.setQueryFeatureResponse(tt.queryFeatureResponse)
|
||||
|
||||
if tt.caps != nil {
|
||||
oldCaps := fakeStatus.Self.Capabilities
|
||||
defer func() { fakeStatus.Self.Capabilities = oldCaps }() // reset after test
|
||||
fakeStatus.Self.Capabilities = tt.caps
|
||||
}
|
||||
st, err := e.getLocalClientStatus(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
r := recover()
|
||||
var gotPanic string
|
||||
if r != nil {
|
||||
gotPanic = fmt.Sprint(r)
|
||||
}
|
||||
if gotPanic != tt.wantPanic {
|
||||
t.Errorf("wrong panic; got=%s, want=%s", gotPanic, tt.wantPanic)
|
||||
}
|
||||
}()
|
||||
gotErr := e.verifyFunnelEnabled(ctx, st, 443)
|
||||
var got string
|
||||
if gotErr != nil {
|
||||
got = gotErr.Error()
|
||||
}
|
||||
if got != tt.wantErr {
|
||||
t.Errorf("wrong error; got=%s, want=%s", gotErr, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLocalServeClient is a fake tailscale.LocalClient for tests.
|
||||
// It's not a full implementation, just enough to test the serve command.
|
||||
//
|
||||
// The fake client is stateful, and is used to test manipulating
|
||||
// ServeConfig state. This implementation cannot be used concurrently.
|
||||
type fakeLocalServeClient struct {
|
||||
config *ipn.ServeConfig
|
||||
setCount int // counts calls to SetServeConfig
|
||||
config *ipn.ServeConfig
|
||||
setCount int // counts calls to SetServeConfig
|
||||
queryFeatureResponse *mockQueryFeatureResponse // mock response to QueryFeature calls
|
||||
}
|
||||
|
||||
// fakeStatus is a fake ipnstate.Status value for tests.
|
||||
@@ -782,7 +875,24 @@ func (lc *fakeLocalServeClient) SetServeConfig(ctx context.Context, config *ipn.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lc *fakeLocalServeClient) QueryFeature(context.Context, string) (*tailcfg.QueryFeatureResponse, error) {
|
||||
type mockQueryFeatureResponse struct {
|
||||
resp *tailcfg.QueryFeatureResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (lc *fakeLocalServeClient) setQueryFeatureResponse(resp mockQueryFeatureResponse) {
|
||||
lc.queryFeatureResponse = &resp
|
||||
}
|
||||
|
||||
func (lc *fakeLocalServeClient) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) {
|
||||
if resp := lc.queryFeatureResponse; resp != nil {
|
||||
// If we're testing QueryFeature, use the response value set for the test.
|
||||
return resp.resp, resp.err
|
||||
}
|
||||
return &tailcfg.QueryFeatureResponse{Complete: true}, nil // fallback to already enabled
|
||||
}
|
||||
|
||||
func (lc *fakeLocalServeClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user