cmd/tailscale/cli: error when advertising a Service from an untagged node (#17577)

Service hosts must be tagged nodes, meaning it is only valid to
advertise a Service from a machine which has at least one ACL tag.

Fixes tailscale/corp#33197

Signed-off-by: Harry Harpham <harry@tailscale.com>
This commit is contained in:
Harry Harpham
2025-10-20 15:36:31 -05:00
committed by GitHub
parent ab435ce3a6
commit 675b1c6d54
3 changed files with 62 additions and 6 deletions

View File

@@ -860,6 +860,7 @@ type fakeLocalServeClient struct {
setCount int // counts calls to SetServeConfig
queryFeatureResponse *mockQueryFeatureResponse // mock response to QueryFeature calls
prefs *ipn.Prefs // fake preferences, used to test GetPrefs and SetPrefs
statusWithoutPeers *ipnstate.Status // nil for fakeStatus
}
// fakeStatus is a fake ipnstate.Status value for tests.
@@ -880,7 +881,10 @@ var fakeStatus = &ipnstate.Status{
}
func (lc *fakeLocalServeClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
return fakeStatus, nil
if lc.statusWithoutPeers == nil {
return fakeStatus, nil
}
return lc.statusWithoutPeers, nil
}
func (lc *fakeLocalServeClient) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) {

View File

@@ -420,6 +420,10 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
svcName = e.service
dnsName = e.service.String()
}
tagged := st.Self.Tags != nil && st.Self.Tags.Len() > 0
if forService && !tagged && !turnOff {
return errors.New("service hosts must be tagged nodes")
}
if !forService && srvType == serveTypeTUN {
return errors.New("tun mode is only supported for services")
}

View File

@@ -22,6 +22,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
)
func TestServeDevConfigMutations(t *testing.T) {
@@ -33,10 +34,11 @@ func TestServeDevConfigMutations(t *testing.T) {
}
// group is a group of steps that share the same
// config mutation, but always starts from an empty config
// config mutation
type group struct {
name string
steps []step
name string
steps []step
initialState fakeLocalServeClient // use the zero value for empty config
}
// creaet a temporary directory for path-based destinations
@@ -814,17 +816,58 @@ func TestServeDevConfigMutations(t *testing.T) {
},
},
},
{
name: "advertise_service",
initialState: fakeLocalServeClient{
statusWithoutPeers: &ipnstate.Status{
BackendState: ipn.Running.String(),
Self: &ipnstate.PeerStatus{
DNSName: "foo.test.ts.net",
CapMap: tailcfg.NodeCapMap{
tailcfg.NodeAttrFunnel: nil,
tailcfg.CapabilityFunnelPorts + "?ports=443,8443": nil,
},
Tags: ptrToReadOnlySlice([]string{"some-tag"}),
},
CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"},
},
},
steps: []step{{
command: cmd("serve --service=svc:foo --http=80 text:foo"),
want: &ipn.ServeConfig{
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Text: "foo"},
}},
},
},
},
},
}},
},
{
name: "advertise_service_from_untagged_node",
steps: []step{{
command: cmd("serve --service=svc:foo --http=80 text:foo"),
wantErr: anyErr(),
}},
},
}
for _, group := range groups {
t.Run(group.name, func(t *testing.T) {
lc := &fakeLocalServeClient{}
lc := group.initialState
for i, st := range group.steps {
var stderr bytes.Buffer
var stdout bytes.Buffer
var flagOut bytes.Buffer
e := &serveEnv{
lc: lc,
lc: &lc,
testFlagOut: &flagOut,
testStdout: &stdout,
testStderr: &stderr,
@@ -2249,3 +2292,8 @@ func exactErrMsg(want error) func(error) string {
return fmt.Sprintf("\ngot: %v\nwant: %v\n", got, want)
}
}
func ptrToReadOnlySlice[T any](s []T) *views.Slice[T] {
vs := views.SliceOf(s)
return &vs
}