Browse Source

cmd/tailscale,ipn,tailcfg: add `tailscale advertise` subcommand behind envknob (#13734)

Signed-off-by: Naman Sood <[email protected]>
Naman Sood 1 year ago
parent
commit
22c89fcb19

+ 78 - 0
cmd/tailscale/cli/advertise.go

@@ -0,0 +1,78 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package cli
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"strings"
+
+	"github.com/peterbourgon/ff/v3/ffcli"
+	"tailscale.com/envknob"
+	"tailscale.com/ipn"
+	"tailscale.com/tailcfg"
+)
+
+var advertiseArgs struct {
+	services string // comma-separated list of services to advertise
+}
+
+// TODO(naman): This flag may move to set.go or serve_v2.go after the WIPCode
+// envknob is not needed.
+var advertiseCmd = &ffcli.Command{
+	Name:       "advertise",
+	ShortUsage: "tailscale advertise --services=<services>",
+	ShortHelp:  "Advertise this node as a destination for a service",
+	Exec:       runAdvertise,
+	FlagSet: (func() *flag.FlagSet {
+		fs := newFlagSet("advertise")
+		fs.StringVar(&advertiseArgs.services, "services", "", "comma-separated services to advertise; each must start with \"svc:\" (e.g. \"svc:idp,svc:nas,svc:database\")")
+		return fs
+	})(),
+}
+
+func maybeAdvertiseCmd() []*ffcli.Command {
+	if !envknob.UseWIPCode() {
+		return nil
+	}
+	return []*ffcli.Command{advertiseCmd}
+}
+
+func runAdvertise(ctx context.Context, args []string) error {
+	if len(args) > 0 {
+		return flag.ErrHelp
+	}
+
+	services, err := parseServiceNames(advertiseArgs.services)
+	if err != nil {
+		return err
+	}
+
+	_, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
+		AdvertiseServicesSet: true,
+		Prefs: ipn.Prefs{
+			AdvertiseServices: services,
+		},
+	})
+	return err
+}
+
+// parseServiceNames takes a comma-separated list of service names
+// (eg. "svc:hello,svc:webserver,svc:catphotos"), splits them into
+// a list and validates each service name. If valid, it returns
+// the service names in a slice of strings.
+func parseServiceNames(servicesArg string) ([]string, error) {
+	var services []string
+	if servicesArg != "" {
+		services = strings.Split(servicesArg, ",")
+		for _, svc := range services {
+			err := tailcfg.CheckServiceName(svc)
+			if err != nil {
+				return nil, fmt.Errorf("service %q: %s", svc, err)
+			}
+		}
+	}
+	return services, nil
+}

+ 2 - 2
cmd/tailscale/cli/cli.go

@@ -177,7 +177,7 @@ For help on subcommands, add --help after: "tailscale status --help".
 This CLI is still under active development. Commands and flags will
 This CLI is still under active development. Commands and flags will
 change in the future.
 change in the future.
 `),
 `),
-		Subcommands: []*ffcli.Command{
+		Subcommands: append([]*ffcli.Command{
 			upCmd,
 			upCmd,
 			downCmd,
 			downCmd,
 			setCmd,
 			setCmd,
@@ -207,7 +207,7 @@ change in the future.
 			debugCmd,
 			debugCmd,
 			driveCmd,
 			driveCmd,
 			idTokenCmd,
 			idTokenCmd,
-		},
+		}, maybeAdvertiseCmd()...),
 		FlagSet: rootfs,
 		FlagSet: rootfs,
 		Exec: func(ctx context.Context, args []string) error {
 		Exec: func(ctx context.Context, args []string) error {
 			if len(args) > 0 {
 			if len(args) > 0 {

+ 4 - 0
cmd/tailscale/cli/cli_test.go

@@ -946,6 +946,10 @@ func TestPrefFlagMapping(t *testing.T) {
 			// Handled by the tailscale share subcommand, we don't want a CLI
 			// Handled by the tailscale share subcommand, we don't want a CLI
 			// flag for this.
 			// flag for this.
 			continue
 			continue
+		case "AdvertiseServices":
+			// Handled by the tailscale advertise subcommand, we don't want a
+			// CLI flag for this.
+			continue
 		case "InternalExitNodePrior":
 		case "InternalExitNodePrior":
 			// Used internally by LocalBackend as part of exit node usage toggling.
 			// Used internally by LocalBackend as part of exit node usage toggling.
 			// No CLI flag for this.
 			// No CLI flag for this.

+ 3 - 0
cmd/tailscale/cli/up.go

@@ -164,6 +164,9 @@ func defaultNetfilterMode() string {
 	return "on"
 	return "on"
 }
 }
 
 
+// upArgsT is the type of upArgs, the argument struct for `tailscale up`.
+// As of 2024-10-08, upArgsT is frozen and no new arguments should be
+// added to it. Add new arguments to setArgsT instead.
 type upArgsT struct {
 type upArgsT struct {
 	qr                     bool
 	qr                     bool
 	reset                  bool
 	reset                  bool

+ 2 - 0
ipn/ipn_clone.go

@@ -27,6 +27,7 @@ func (src *Prefs) Clone() *Prefs {
 	*dst = *src
 	*dst = *src
 	dst.AdvertiseTags = append(src.AdvertiseTags[:0:0], src.AdvertiseTags...)
 	dst.AdvertiseTags = append(src.AdvertiseTags[:0:0], src.AdvertiseTags...)
 	dst.AdvertiseRoutes = append(src.AdvertiseRoutes[:0:0], src.AdvertiseRoutes...)
 	dst.AdvertiseRoutes = append(src.AdvertiseRoutes[:0:0], src.AdvertiseRoutes...)
+	dst.AdvertiseServices = append(src.AdvertiseServices[:0:0], src.AdvertiseServices...)
 	if src.DriveShares != nil {
 	if src.DriveShares != nil {
 		dst.DriveShares = make([]*drive.Share, len(src.DriveShares))
 		dst.DriveShares = make([]*drive.Share, len(src.DriveShares))
 		for i := range dst.DriveShares {
 		for i := range dst.DriveShares {
@@ -61,6 +62,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
 	ForceDaemon            bool
 	ForceDaemon            bool
 	Egg                    bool
 	Egg                    bool
 	AdvertiseRoutes        []netip.Prefix
 	AdvertiseRoutes        []netip.Prefix
+	AdvertiseServices      []string
 	NoSNAT                 bool
 	NoSNAT                 bool
 	NoStatefulFiltering    opt.Bool
 	NoStatefulFiltering    opt.Bool
 	NetfilterMode          preftype.NetfilterMode
 	NetfilterMode          preftype.NetfilterMode

+ 4 - 0
ipn/ipn_view.go

@@ -85,6 +85,9 @@ func (v PrefsView) Egg() bool                                   { return v.ж.Eg
 func (v PrefsView) AdvertiseRoutes() views.Slice[netip.Prefix] {
 func (v PrefsView) AdvertiseRoutes() views.Slice[netip.Prefix] {
 	return views.SliceOf(v.ж.AdvertiseRoutes)
 	return views.SliceOf(v.ж.AdvertiseRoutes)
 }
 }
+func (v PrefsView) AdvertiseServices() views.Slice[string] {
+	return views.SliceOf(v.ж.AdvertiseServices)
+}
 func (v PrefsView) NoSNAT() bool                          { return v.ж.NoSNAT }
 func (v PrefsView) NoSNAT() bool                          { return v.ж.NoSNAT }
 func (v PrefsView) NoStatefulFiltering() opt.Bool         { return v.ж.NoStatefulFiltering }
 func (v PrefsView) NoStatefulFiltering() opt.Bool         { return v.ж.NoStatefulFiltering }
 func (v PrefsView) NetfilterMode() preftype.NetfilterMode { return v.ж.NetfilterMode }
 func (v PrefsView) NetfilterMode() preftype.NetfilterMode { return v.ж.NetfilterMode }
@@ -120,6 +123,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
 	ForceDaemon            bool
 	ForceDaemon            bool
 	Egg                    bool
 	Egg                    bool
 	AdvertiseRoutes        []netip.Prefix
 	AdvertiseRoutes        []netip.Prefix
+	AdvertiseServices      []string
 	NoSNAT                 bool
 	NoSNAT                 bool
 	NoStatefulFiltering    opt.Bool
 	NoStatefulFiltering    opt.Bool
 	NetfilterMode          preftype.NetfilterMode
 	NetfilterMode          preftype.NetfilterMode

+ 11 - 0
ipn/prefs.go

@@ -179,6 +179,12 @@ type Prefs struct {
 	// node.
 	// node.
 	AdvertiseRoutes []netip.Prefix
 	AdvertiseRoutes []netip.Prefix
 
 
+	// AdvertiseServices specifies the list of services that this
+	// node can serve as a destination for. Note that an advertised
+	// service must still go through the approval process from the
+	// control server.
+	AdvertiseServices []string
+
 	// NoSNAT specifies whether to source NAT traffic going to
 	// NoSNAT specifies whether to source NAT traffic going to
 	// destinations in AdvertiseRoutes. The default is to apply source
 	// destinations in AdvertiseRoutes. The default is to apply source
 	// NAT, which makes the traffic appear to come from the router
 	// NAT, which makes the traffic appear to come from the router
@@ -319,6 +325,7 @@ type MaskedPrefs struct {
 	ForceDaemonSet            bool                `json:",omitempty"`
 	ForceDaemonSet            bool                `json:",omitempty"`
 	EggSet                    bool                `json:",omitempty"`
 	EggSet                    bool                `json:",omitempty"`
 	AdvertiseRoutesSet        bool                `json:",omitempty"`
 	AdvertiseRoutesSet        bool                `json:",omitempty"`
+	AdvertiseServicesSet      bool                `json:",omitempty"`
 	NoSNATSet                 bool                `json:",omitempty"`
 	NoSNATSet                 bool                `json:",omitempty"`
 	NoStatefulFilteringSet    bool                `json:",omitempty"`
 	NoStatefulFilteringSet    bool                `json:",omitempty"`
 	NetfilterModeSet          bool                `json:",omitempty"`
 	NetfilterModeSet          bool                `json:",omitempty"`
@@ -527,6 +534,9 @@ func (p *Prefs) pretty(goos string) string {
 	if len(p.AdvertiseTags) > 0 {
 	if len(p.AdvertiseTags) > 0 {
 		fmt.Fprintf(&sb, "tags=%s ", strings.Join(p.AdvertiseTags, ","))
 		fmt.Fprintf(&sb, "tags=%s ", strings.Join(p.AdvertiseTags, ","))
 	}
 	}
+	if len(p.AdvertiseServices) > 0 {
+		fmt.Fprintf(&sb, "services=%s ", strings.Join(p.AdvertiseServices, ","))
+	}
 	if goos == "linux" {
 	if goos == "linux" {
 		fmt.Fprintf(&sb, "nf=%v ", p.NetfilterMode)
 		fmt.Fprintf(&sb, "nf=%v ", p.NetfilterMode)
 	}
 	}
@@ -598,6 +608,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
 		p.ForceDaemon == p2.ForceDaemon &&
 		p.ForceDaemon == p2.ForceDaemon &&
 		compareIPNets(p.AdvertiseRoutes, p2.AdvertiseRoutes) &&
 		compareIPNets(p.AdvertiseRoutes, p2.AdvertiseRoutes) &&
 		compareStrings(p.AdvertiseTags, p2.AdvertiseTags) &&
 		compareStrings(p.AdvertiseTags, p2.AdvertiseTags) &&
+		compareStrings(p.AdvertiseServices, p2.AdvertiseServices) &&
 		p.Persist.Equals(p2.Persist) &&
 		p.Persist.Equals(p2.Persist) &&
 		p.ProfileName == p2.ProfileName &&
 		p.ProfileName == p2.ProfileName &&
 		p.AutoUpdate.Equals(p2.AutoUpdate) &&
 		p.AutoUpdate.Equals(p2.AutoUpdate) &&

+ 11 - 0
ipn/prefs_test.go

@@ -54,6 +54,7 @@ func TestPrefsEqual(t *testing.T) {
 		"ForceDaemon",
 		"ForceDaemon",
 		"Egg",
 		"Egg",
 		"AdvertiseRoutes",
 		"AdvertiseRoutes",
+		"AdvertiseServices",
 		"NoSNAT",
 		"NoSNAT",
 		"NoStatefulFiltering",
 		"NoStatefulFiltering",
 		"NetfilterMode",
 		"NetfilterMode",
@@ -330,6 +331,16 @@ func TestPrefsEqual(t *testing.T) {
 			&Prefs{NetfilterKind: ""},
 			&Prefs{NetfilterKind: ""},
 			false,
 			false,
 		},
 		},
+		{
+			&Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}},
+			&Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}},
+			true,
+		},
+		{
+			&Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}},
+			&Prefs{AdvertiseServices: []string{"svc:tux", "svc:amelie"}},
+			false,
+		},
 	}
 	}
 	for i, tt := range tests {
 	for i, tt := range tests {
 		got := tt.a.Equals(tt.b)
 		got := tt.a.Equals(tt.b)

+ 15 - 0
tailcfg/tailcfg.go

@@ -651,6 +651,21 @@ func CheckTag(tag string) error {
 	return nil
 	return nil
 }
 }
 
 
+// CheckServiceName validates svc for use as a service name.
+// We only allow valid DNS labels, since the expectation is that these will be
+// used as parts of domain names.
+func CheckServiceName(svc string) error {
+	var ok bool
+	svc, ok = strings.CutPrefix(svc, "svc:")
+	if !ok {
+		return errors.New("services must start with 'svc:'")
+	}
+	if svc == "" {
+		return errors.New("service names must not be empty")
+	}
+	return dnsname.ValidLabel(svc)
+}
+
 // CheckRequestTags checks that all of h.RequestTags are valid.
 // CheckRequestTags checks that all of h.RequestTags are valid.
 func (h *Hostinfo) CheckRequestTags() error {
 func (h *Hostinfo) CheckRequestTags() error {
 	if h == nil {
 	if h == nil {