cmd/tailscale/cli: Add service flag to serve command (#16191)

* cmd/tailscale/cli: Add service flag to serve command

This commit adds the service flag to serve command which allows serving a service and add the service
to the advertisedServices field in prefs (What advertise command does that will be removed later).

When adding proxies, TCP proxies and WEB proxies work the same way as normal serve, just under a
different DNSname. There is a services specific L3 serving mode called Tun, can be set via --tun flag.
Serving a service is always in --bg mode. If --bg is explicitly set t o false, an error message will
be sent out. The restriction on proxy target being localhost or 127.0.0.1 also applies to services.

When removing proxies, TCP proxies can be removed with type and port flag and off argument. Web proxies
can be removed with type, port, setPath flag and off argument. To align with normal serve, when setPath
is not set, all handler under the hostport will be removed. When flags are not set but off argument was
passed by user, it will be a noop. Removing all config for a service will be available later with a new
subcommand clear.

Updates tailscale/corp#22954

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: fix ai comments and fix a test

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: Add a test for addServiceToPrefs

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: fix comment

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* add dnsName in error message

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* change the cli input flag variable type

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* replace FindServiceConfig with map lookup

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* some code simplification and add asServiceName

This commit cotains code simplification for IsServingHTTPS, SetWebHandler, SetTCPForwarding

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* replace IsServiceName with tailcfg.AsServiceName

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* replace all assemble of host name for service with strings.Join

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: adjust parameter order and update output message

This commit updates the parameter order for IsTCPForwardingOnPort and SetWebHandler.
Also updated the message msgServiceIPNotAssigned to msgServiceWaitingApproval to adapt to
latest terminologies around services.

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: flip bool condition

This commit fixes a previous bug added that throws error when serve funnel without service.
It should've been the opposite, which throws error when serve funnel with service.

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: change parameter of IsTCPForwardingOnPort

This commit changes the dnsName string parameter for IsTCPForwardingOnPort to
svcName tailcfg.ServiceName. This change is made to reduce ambiguity when
a single service might have different dnsNames

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* ipn/ipnlocal: replace the key to webHandler for services

This commit changes the way we get the webhandler for vipServices. It used to use the host name
from request to find the webHandler, now everything targeting the vipService IP have the same
set of handlers. This commit also stores service:port instead of FQDN:port as the key in serviceConfig
for Web map.

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: Updated use of service name.

This commit removes serviceName.IsEmpty and use direct comparison to  instead. In legacy code, when an empty service
name needs to be passed, a new constant noService is passed. Removed redundant code for checking service name validity
and string method for serviceNameFlag.

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: Update bgBoolFlag

This commit update field name, set and string method of bgBoolFlag to make code cleaner.

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: remove isDefaultService output from srvTypeAndPortFromFlags

This commit removes the isDefaultService out put as it's no longer needed. Also deleted redundant code.

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: remove unnessesary variable declare in messageForPort

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* replace bool output for AsServiceName with err

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: Replace DNSName with NoService if DNSname only used to identify service

This commit moves noService constant to tailcfg, updates AsServiceName to return tailcfg.NoService if the input
is not a valid service name. This commit also removes using the local DNSName as scvName parameter. When a function
is only using DNSName to identify if it's working with a service, the input in replaced with svcName and expect
caller to pass tailcfg.NoService if it's a local serve. This commit also replaces some use of Sprintf with
net.JoinHostPort for ipn.HostPort creation.

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: Remove the returned error for AsServiceName

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* apply suggested code and comment

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* replace local dnsName in test with tailcfg.NoService

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* cmd/tailscale/cli: move noService back and use  else where

The constant serves the purpose of provide readability for passing as a function parameter. It's
more meaningful comparing to a . It can just be an empty string in other places.

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

* ipn: Make WebHandlerExists and RemoveTCPForwarding accept svcName

This commit replaces two functions' string input with svcName input since they only use the dnsName to
identify service. Also did some minor cleanups

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>

---------

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>
This commit is contained in:
KevinLiang10
2025-07-16 19:37:46 -04:00
committed by GitHub
parent 36aeacb297
commit e7238efafa
9 changed files with 1576 additions and 147 deletions

View File

@@ -1007,8 +1007,6 @@ func allNumeric(s string) bool {
}
func (b *LocalBackend) webServerConfig(hostname string, forVIPService tailcfg.ServiceName, port uint16) (c ipn.WebServerConfigView, ok bool) {
key := ipn.HostPort(fmt.Sprintf("%s:%v", hostname, port))
b.mu.Lock()
defer b.mu.Unlock()
@@ -1016,8 +1014,10 @@ func (b *LocalBackend) webServerConfig(hostname string, forVIPService tailcfg.Se
return c, false
}
if forVIPService != "" {
key := ipn.HostPort(net.JoinHostPort(forVIPService.WithoutPrefix(), fmt.Sprintf("%d", port)))
return b.serveConfig.FindServiceWeb(forVIPService, key)
}
key := ipn.HostPort(net.JoinHostPort(hostname, fmt.Sprintf("%d", port)))
return b.serveConfig.FindWeb(key)
}

View File

@@ -166,26 +166,44 @@ type HTTPHandler struct {
// WebHandlerExists reports whether if the ServeConfig Web handler exists for
// the given host:port and mount point.
func (sc *ServeConfig) WebHandlerExists(hp HostPort, mount string) bool {
h := sc.GetWebHandler(hp, mount)
func (sc *ServeConfig) WebHandlerExists(svcName tailcfg.ServiceName, hp HostPort, mount string) bool {
h := sc.GetWebHandler(svcName, hp, mount)
return h != nil
}
// GetWebHandler returns the HTTPHandler for the given host:port and mount point.
// Returns nil if the handler does not exist.
func (sc *ServeConfig) GetWebHandler(hp HostPort, mount string) *HTTPHandler {
if sc == nil || sc.Web[hp] == nil {
func (sc *ServeConfig) GetWebHandler(svcName tailcfg.ServiceName, hp HostPort, mount string) *HTTPHandler {
if sc == nil {
return nil
}
if svcName != "" {
if svc, ok := sc.Services[svcName]; ok && svc.Web != nil {
if webCfg, ok := svc.Web[hp]; ok {
return webCfg.Handlers[mount]
}
}
return nil
}
if sc.Web[hp] == nil {
return nil
}
return sc.Web[hp].Handlers[mount]
}
// GetTCPPortHandler returns the TCPPortHandler for the given port.
// If the port is not configured, nil is returned.
func (sc *ServeConfig) GetTCPPortHandler(port uint16) *TCPPortHandler {
// GetTCPPortHandler returns the TCPPortHandler for the given port. If the port
// is not configured, nil is returned. Parameter svcName can be tailcfg.NoService
// for local serve or a service name for a service hosted on node.
func (sc *ServeConfig) GetTCPPortHandler(port uint16, svcName tailcfg.ServiceName) *TCPPortHandler {
if sc == nil {
return nil
}
if svcName != "" {
if svc, ok := sc.Services[svcName]; ok && svc != nil {
return svc.TCP[port]
}
return nil
}
return sc.TCP[port]
}
@@ -227,34 +245,78 @@ func (sc *ServeConfig) IsTCPForwardingAny() bool {
return false
}
// IsTCPForwardingOnPort reports whether if ServeConfig is currently forwarding
// in TCPForward mode on the given port. This is exclusive of Web/HTTPS serving.
func (sc *ServeConfig) IsTCPForwardingOnPort(port uint16) bool {
if sc == nil || sc.TCP[port] == nil {
// IsTCPForwardingOnPort reports whether ServeConfig is currently forwarding
// in TCPForward mode on the given port for local or a service. svcName will
// either be noService (empty string) for local serve or a serviceName for service
// hosted on node. Notice TCPForwarding is exclusive with Web/HTTPS serving.
func (sc *ServeConfig) IsTCPForwardingOnPort(port uint16, svcName tailcfg.ServiceName) bool {
if sc == nil {
return false
}
return !sc.IsServingWeb(port)
}
// IsServingWeb reports whether if ServeConfig is currently serving Web
// (HTTP/HTTPS) on the given port. This is exclusive of TCPForwarding.
func (sc *ServeConfig) IsServingWeb(port uint16) bool {
return sc.IsServingHTTP(port) || sc.IsServingHTTPS(port)
}
// IsServingHTTPS reports whether if ServeConfig is currently serving HTTPS on
// the given port. This is exclusive of HTTP and TCPForwarding.
func (sc *ServeConfig) IsServingHTTPS(port uint16) bool {
if sc == nil || sc.TCP[port] == nil {
if svcName != "" {
svc, ok := sc.Services[svcName]
if !ok || svc == nil {
return false
}
if svc.TCP[port] == nil {
return false
}
} else if sc.TCP[port] == nil {
return false
}
return sc.TCP[port].HTTPS
return !sc.IsServingWeb(port, svcName)
}
// IsServingHTTP reports whether if ServeConfig is currently serving HTTP on the
// given port. This is exclusive of HTTPS and TCPForwarding.
func (sc *ServeConfig) IsServingHTTP(port uint16) bool {
if sc == nil || sc.TCP[port] == nil {
// IsServingWeb reports whether ServeConfig is currently serving Web (HTTP/HTTPS)
// on the given port for local or a service. svcName will be either tailcfg.NoService,
// or a serviceName for service hosted on node. This is exclusive with TCPForwarding.
func (sc *ServeConfig) IsServingWeb(port uint16, svcName tailcfg.ServiceName) bool {
return sc.IsServingHTTP(port, svcName) || sc.IsServingHTTPS(port, svcName)
}
// IsServingHTTPS reports whether ServeConfig is currently serving HTTPS on
// the given port for local or a service. svcName will be either tailcfg.NoService
// for local serve, or a serviceName for service hosted on node. This is exclusive
// with HTTP and TCPForwarding.
func (sc *ServeConfig) IsServingHTTPS(port uint16, svcName tailcfg.ServiceName) bool {
if sc == nil {
return false
}
var tcpHandlers map[uint16]*TCPPortHandler
if svcName != "" {
if svc := sc.Services[svcName]; svc != nil {
tcpHandlers = svc.TCP
}
} else {
tcpHandlers = sc.TCP
}
th := tcpHandlers[port]
if th == nil {
return false
}
return th.HTTPS
}
// IsServingHTTP reports whether ServeConfig is currently serving HTTP on the
// given port for local or a service. svcName will be either tailcfg.NoService for
// local serve, or a serviceName for service hosted on node. This is exclusive
// with HTTPS and TCPForwarding.
func (sc *ServeConfig) IsServingHTTP(port uint16, svcName tailcfg.ServiceName) bool {
if sc == nil {
return false
}
if svcName != "" {
if svc := sc.Services[svcName]; svc != nil {
if svc.TCP[port] != nil {
return svc.TCP[port].HTTP
}
}
return false
}
if sc.TCP[port] == nil {
return false
}
return sc.TCP[port].HTTP
@@ -280,21 +342,37 @@ func (sc *ServeConfig) FindConfig(port uint16) (*ServeConfig, bool) {
// SetWebHandler sets the given HTTPHandler at the specified host, port,
// and mount in the serve config. sc.TCP is also updated to reflect web
// serving usage of the given port.
// serving usage of the given port. The st argument is needed when setting
// a web handler for a service, otherwise it can be nil.
func (sc *ServeConfig) SetWebHandler(handler *HTTPHandler, host string, port uint16, mount string, useTLS bool) {
if sc == nil {
sc = new(ServeConfig)
}
mak.Set(&sc.TCP, port, &TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
hp := HostPort(net.JoinHostPort(host, strconv.Itoa(int(port))))
if _, ok := sc.Web[hp]; !ok {
mak.Set(&sc.Web, hp, new(WebServerConfig))
tcpMap := &sc.TCP
webServerMap := &sc.Web
hostName := host
if svcName := tailcfg.AsServiceName(host); svcName != "" {
hostName = svcName.WithoutPrefix()
svc, ok := sc.Services[svcName]
if !ok {
svc = new(ServiceConfig)
mak.Set(&sc.Services, svcName, svc)
}
tcpMap = &svc.TCP
webServerMap = &svc.Web
}
mak.Set(&sc.Web[hp].Handlers, mount, handler)
mak.Set(tcpMap, port, &TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
hp := HostPort(net.JoinHostPort(hostName, strconv.Itoa(int(port))))
webCfg, ok := (*webServerMap)[hp]
if !ok {
webCfg = new(WebServerConfig)
mak.Set(webServerMap, hp, webCfg)
}
mak.Set(&webCfg.Handlers, mount, handler)
// TODO(tylersmalley): handle multiple web handlers from foreground mode
for k, v := range sc.Web[hp].Handlers {
for k, v := range webCfg.Handlers {
if v == handler {
continue
}
@@ -305,7 +383,7 @@ func (sc *ServeConfig) SetWebHandler(handler *HTTPHandler, host string, port uin
m1 := strings.TrimSuffix(mount, "/")
m2 := strings.TrimSuffix(k, "/")
if m1 == m2 {
delete(sc.Web[hp].Handlers, k)
delete(webCfg.Handlers, k)
}
}
}
@@ -318,9 +396,19 @@ func (sc *ServeConfig) SetTCPForwarding(port uint16, fwdAddr string, terminateTL
if sc == nil {
sc = new(ServeConfig)
}
mak.Set(&sc.TCP, port, &TCPPortHandler{TCPForward: fwdAddr})
tcpPortHandler := &sc.TCP
if svcName := tailcfg.AsServiceName(host); svcName != "" {
svcConfig, ok := sc.Services[svcName]
if !ok {
svcConfig = new(ServiceConfig)
mak.Set(&sc.Services, svcName, svcConfig)
}
tcpPortHandler = &svcConfig.TCP
}
mak.Set(tcpPortHandler, port, &TCPPortHandler{TCPForward: fwdAddr})
if terminateTLS {
sc.TCP[port].TerminateTLS = host
(*tcpPortHandler)[port].TerminateTLS = host
}
}
@@ -344,9 +432,9 @@ func (sc *ServeConfig) SetFunnel(host string, port uint16, setOn bool) {
}
}
// RemoveWebHandler deletes the web handlers at all of the given mount points
// for the provided host and port in the serve config. If cleanupFunnel is
// true, this also removes the funnel value for this port if no handlers remain.
// RemoveWebHandler deletes the web handlers at all of the given mount points for the
// provided host and port in the serve config for the node (as opposed to a service).
// If cleanupFunnel is true, this also removes the funnel value for this port if no handlers remain.
func (sc *ServeConfig) RemoveWebHandler(host string, port uint16, mounts []string, cleanupFunnel bool) {
hp := HostPort(net.JoinHostPort(host, strconv.Itoa(int(port))))
@@ -374,9 +462,51 @@ func (sc *ServeConfig) RemoveWebHandler(host string, port uint16, mounts []strin
}
}
// RemoveServiceWebHandler deletes the web handlers at all of the given mount points
// for the provided host and port in the serve config for the given service.
func (sc *ServeConfig) RemoveServiceWebHandler(st *ipnstate.Status, svcName tailcfg.ServiceName, port uint16, mounts []string) {
hostName := svcName.WithoutPrefix()
hp := HostPort(net.JoinHostPort(hostName, strconv.Itoa(int(port))))
svc, ok := sc.Services[svcName]
if !ok || svc == nil {
return
}
// Delete existing handler, then cascade delete if empty.
for _, m := range mounts {
delete(svc.Web[hp].Handlers, m)
}
if len(svc.Web[hp].Handlers) == 0 {
delete(svc.Web, hp)
delete(svc.TCP, port)
}
if len(svc.Web) == 0 && len(svc.TCP) == 0 {
delete(sc.Services, svcName)
}
if len(sc.Services) == 0 {
sc.Services = nil
}
}
// RemoveTCPForwarding deletes the TCP forwarding configuration for the given
// port from the serve config.
func (sc *ServeConfig) RemoveTCPForwarding(port uint16) {
func (sc *ServeConfig) RemoveTCPForwarding(svcName tailcfg.ServiceName, port uint16) {
if svcName != "" {
if svc := sc.Services[svcName]; svc != nil {
delete(svc.TCP, port)
if len(svc.TCP) == 0 {
svc.TCP = nil
}
if len(svc.Web) == 0 && len(svc.TCP) == 0 {
delete(sc.Services, svcName)
}
if len(sc.Services) == 0 {
sc.Services = nil
}
}
return
}
delete(sc.TCP, port)
if len(sc.TCP) == 0 {
sc.TCP = nil

View File

@@ -128,6 +128,121 @@ func TestHasPathHandler(t *testing.T) {
}
}
func TestIsTCPForwardingOnPort(t *testing.T) {
tests := []struct {
name string
cfg ServeConfig
svcName tailcfg.ServiceName
port uint16
want bool
}{
{
name: "empty-config",
cfg: ServeConfig{},
svcName: "",
port: 80,
want: false,
},
{
name: "node-tcp-config-match",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
},
svcName: "",
port: 80,
want: true,
},
{
name: "node-tcp-config-no-match",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
},
svcName: "",
port: 443,
want: false,
},
{
name: "node-tcp-config-no-match-with-service",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
},
svcName: "svc:bar",
port: 80,
want: false,
},
{
name: "node-web-config-no-match",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{80: {HTTPS: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:80": {
Handlers: map[string]*HTTPHandler{
"/": {Text: "Hello, world!"},
},
},
},
},
svcName: "",
port: 80,
want: false,
},
{
name: "service-tcp-config-match",
cfg: ServeConfig{
Services: map[tailcfg.ServiceName]*ServiceConfig{
"svc:foo": {
TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
},
},
},
svcName: "svc:foo",
port: 80,
want: true,
},
{
name: "service-tcp-config-no-match",
cfg: ServeConfig{
Services: map[tailcfg.ServiceName]*ServiceConfig{
"svc:foo": {
TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
},
},
},
svcName: "svc:bar",
port: 80,
want: false,
},
{
name: "service-web-config-no-match",
cfg: ServeConfig{
Services: map[tailcfg.ServiceName]*ServiceConfig{
"svc:foo": {
TCP: map[uint16]*TCPPortHandler{80: {HTTPS: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:80": {
Handlers: map[string]*HTTPHandler{
"/": {Text: "Hello, world!"},
},
},
},
},
},
},
svcName: "svc:foo",
port: 80,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.cfg.IsTCPForwardingOnPort(tt.port, tt.svcName)
if tt.want != got {
t.Errorf("IsTCPForwardingOnPort() = %v, want %v", got, tt.want)
}
})
}
}
func TestExpandProxyTargetDev(t *testing.T) {
tests := []struct {
name string