diff --git a/cmd/tailscale/cli/serve.go b/cmd/tailscale/cli/serve.go index 4987064b7..19309340a 100644 --- a/cmd/tailscale/cli/serve.go +++ b/cmd/tailscale/cli/serve.go @@ -685,8 +685,8 @@ func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error { if err != nil { return fmt.Errorf("getting client status: %w", err) } - if !slices.Contains(st.Self.Capabilities, tailcfg.NodeAttrFunnel) { - return errors.New("Funnel not available. See https://tailscale.com/s/no-funnel") + if err := checkHasAccess(st.Self.Capabilities); err != nil { + return err } dnsName := strings.TrimSuffix(st.Self.DNSName, ".") hp := ipn.HostPort(dnsName + ":" + srvPortStr) @@ -709,3 +709,22 @@ func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error { } return nil } + +// checkHasAccess checks three things: 1) an invite was used to join the +// Funnel alpha; 2) HTTPS is enabled; 3) the node has the "funnel" attribute. +// If any of these are false, an error is returned describing the problem. +// +// The nodeAttrs arg should be the node's Self.Capabilities which should contain +// the attribute we're checking for and possibly warning-capabilities for Funnel. +func checkHasAccess(nodeAttrs []string) error { + if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoInvite) { + return errors.New("Funnel not available; an invite is required to join the alpha. See https://tailscale.com/kb/1223/tailscale-funnel/.") + } + if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoHTTPS) { + return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/kb/1153/enabling-https/.") + } + if !slices.Contains(nodeAttrs, tailcfg.NodeAttrFunnel) { + return errors.New("Funnel not available; \"funnel\" node attribute not set. See https://tailscale.com/kb/1223/tailscale-funnel/.") + } + return nil +} diff --git a/cmd/tailscale/cli/serve_test.go b/cmd/tailscale/cli/serve_test.go index e3269b674..6f3fe50cb 100644 --- a/cmd/tailscale/cli/serve_test.go +++ b/cmd/tailscale/cli/serve_test.go @@ -49,6 +49,30 @@ func TestCleanMountPoint(t *testing.T) { } } +func TestCheckHasAccess(t *testing.T) { + tests := []struct { + caps []string + wantErr bool + }{ + {[]string{}, true}, // No "funnel" attribute + {[]string{tailcfg.CapabilityWarnFunnelNoInvite}, true}, + {[]string{tailcfg.CapabilityWarnFunnelNoHTTPS}, true}, + {[]string{tailcfg.NodeAttrFunnel}, false}, + } + for _, tt := range tests { + err := checkHasAccess(tt.caps) + switch { + case err != nil && tt.wantErr, + err == nil && !tt.wantErr: + continue + case tt.wantErr: + t.Fatalf("got no error, want error") + case !tt.wantErr: + t.Fatalf("got error %v, want no error", err) + } + } +} + func TestServeConfigMutations(t *testing.T) { // Stateful mutations, starting from an empty config. type step struct { diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 60f3e4563..38caff99f 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -1722,6 +1722,14 @@ type Oauth2Token struct { CapabilityWakeOnLAN = "https://tailscale.com/cap/wake-on-lan" // CapabilityIngress grants the ability for a peer to send ingress traffic. CapabilityIngress = "https://tailscale.com/cap/ingress" + + // Funnel warning capabilities used for reporting errors to the user. + + // CapabilityWarnFunnelNoInvite indicates an invite has not been accepted for the Funnel alpha. + CapabilityWarnFunnelNoInvite = "https://tailscale.com/cap/warn-funnel-no-invite" + + // CapabilityWarnFunnelNoHTTPS indicates HTTPS has not been enabled for the tailnet. + CapabilityWarnFunnelNoHTTPS = "https://tailscale.com/cap/warn-funnel-no-https" ) const (