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

Signed-off-by: Naman Sood <mail@nsood.in>
This commit is contained in:
Naman Sood 2024-10-16 19:08:06 -04:00 committed by GitHub
parent d32d742af0
commit 22c89fcb19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 130 additions and 2 deletions

View File

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

View File

@ -177,7 +177,7 @@ func newRootCmd() *ffcli.Command {
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 @@ func newRootCmd() *ffcli.Command {
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 {

View File

@ -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.

View File

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

View File

@ -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 @@ func (src *Prefs) Clone() *Prefs {
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

View File

@ -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 @@ func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.
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

View File

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

View File

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

View File

@ -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 {