ipn/ipnlocal: Support TCP and Web VIP services

This commit intend to provide support for TCP and Web VIP services and also allow user to use Tun
for VIP services if they want to.
The commit includes:
1.Setting TCP intercept function for VIP Services.
2.Update netstack to send packet written from WG to netStack handler for VIP service.
3.Return correct TCP hander for VIP services when netstack acceptTCP.

This commit also includes unit tests for if the local backend setServeConfig would set correct TCP intercept
function and test if a hander gets returned when getting TCPHandlerForDst. The shouldProcessInbound
check is not unit tested since the test result just depends on mocked functions. There should be an integration
test to cover  shouldProcessInbound and if the returned TCP handler actually does what the serveConfig says.

Updates tailscale/corp#24604

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>
This commit is contained in:
KevinLiang10 2025-01-20 12:02:53 -05:00
parent cb3b1a1dcf
commit 8c8750f1b3
9 changed files with 783 additions and 21 deletions

View File

@ -228,10 +228,11 @@ type LocalBackend struct {
// is never called. // is never called.
getTCPHandlerForFunnelFlow func(srcAddr netip.AddrPort, dstPort uint16) (handler func(net.Conn)) getTCPHandlerForFunnelFlow func(srcAddr netip.AddrPort, dstPort uint16) (handler func(net.Conn))
filterAtomic atomic.Pointer[filter.Filter] filterAtomic atomic.Pointer[filter.Filter]
containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool] containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool]
shouldInterceptTCPPortAtomic syncs.AtomicValue[func(uint16) bool] shouldInterceptTCPPortAtomic syncs.AtomicValue[func(uint16) bool]
numClientStatusCalls atomic.Uint32 shouldInterceptVIPServicesTCPPortAtomic syncs.AtomicValue[func(netip.AddrPort) bool]
numClientStatusCalls atomic.Uint32
// goTracker accounts for all goroutines started by LocalBacked, primarily // goTracker accounts for all goroutines started by LocalBacked, primarily
// for testing and graceful shutdown purposes. // for testing and graceful shutdown purposes.
@ -317,8 +318,9 @@ type LocalBackend struct {
offlineAutoUpdateCancel func() offlineAutoUpdateCancel func()
// ServeConfig fields. (also guarded by mu) // ServeConfig fields. (also guarded by mu)
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
serveConfig ipn.ServeConfigView // or !Valid if none serveConfig ipn.ServeConfigView // or !Valid if none
ipVIPServiceMap netmap.IPServiceMappings // map of VIPService IPs to their corresponding service names
webClient webClient webClient webClient
webClientListeners map[netip.AddrPort]*localListener // listeners for local web client traffic webClientListeners map[netip.AddrPort]*localListener // listeners for local web client traffic
@ -523,6 +525,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
b.e.SetJailedFilter(noneFilter) b.e.SetJailedFilter(noneFilter)
b.setTCPPortsIntercepted(nil) b.setTCPPortsIntercepted(nil)
b.setVIPServicesTCPPortsIntercepted(nil)
b.statusChanged = sync.NewCond(&b.statusLock) b.statusChanged = sync.NewCond(&b.statusLock)
b.e.SetStatusCallback(b.setWgengineStatus) b.e.SetStatusCallback(b.setWgengineStatus)
@ -3362,10 +3365,7 @@ func (b *LocalBackend) clearMachineKeyLocked() error {
return nil return nil
} }
// setTCPPortsIntercepted populates b.shouldInterceptTCPPortAtomic with an func generateInterceptTCPPortFunc(ports []uint16) func(uint16) bool {
// efficient func for ShouldInterceptTCPPort to use, which is called on every
// incoming packet.
func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
slices.Sort(ports) slices.Sort(ports)
ports = slices.Compact(ports) ports = slices.Compact(ports)
var f func(uint16) bool var f func(uint16) bool
@ -3396,7 +3396,61 @@ func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
} }
} }
} }
b.shouldInterceptTCPPortAtomic.Store(f) return f
}
// setTCPPortsIntercepted populates b.shouldInterceptTCPPortAtomic with an
// efficient func for ShouldInterceptTCPPort to use, which is called on every
// incoming packet.
func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
b.shouldInterceptTCPPortAtomic.Store(generateInterceptTCPPortFunc(ports))
}
func generateInterceptVIPServicesTCPPortFunc(svcAddrPorts map[netip.Addr]func(uint16) bool) func(netip.AddrPort) bool {
return func(ap netip.AddrPort) bool {
if f, ok := svcAddrPorts[ap.Addr()]; ok {
return f(ap.Port())
}
return false
}
}
// setVIPServicesTCPPortsIntercepted populates b.shouldInterceptVIPServicesTCPPortAtomic with an
// efficient func for ShouldInterceptTCPPort to use, which is called on every incoming packet.
func (b *LocalBackend) setVIPServicesTCPPortsIntercepted(svcPorts map[string][]uint16) {
b.mu.Lock()
defer b.mu.Unlock()
b.setVIPServicesTCPPortsInterceptedLocked(svcPorts)
}
func (b *LocalBackend) setVIPServicesTCPPortsInterceptedLocked(svcPorts map[string][]uint16) {
if len(svcPorts) == 0 {
b.shouldInterceptVIPServicesTCPPortAtomic.Store(func(netip.AddrPort) bool { return false })
return
}
nm := b.netMap
if nm == nil {
b.logf("can't set intercept function for Service TCP Ports, netMap is nil")
return
}
vipServiceIPMap := nm.GetVIPServiceIPMap()
if len(vipServiceIPMap) == 0 {
// No approved VIP Services
return
}
svcAddrPorts := make(map[netip.Addr]func(uint16) bool)
// Only set the intercept function if the service has been assigned a VIP.
for svcName, ports := range svcPorts {
if addrs, ok := vipServiceIPMap[svcName]; ok {
interceptFn := generateInterceptTCPPortFunc(ports)
for _, addr := range addrs {
svcAddrPorts[addr] = interceptFn
}
}
}
b.shouldInterceptVIPServicesTCPPortAtomic.Store(generateInterceptVIPServicesTCPPortFunc(svcAddrPorts))
} }
// setAtomicValuesFromPrefsLocked populates sshAtomicBool, containsViaIPFuncAtomic, // setAtomicValuesFromPrefsLocked populates sshAtomicBool, containsViaIPFuncAtomic,
@ -3409,6 +3463,7 @@ func (b *LocalBackend) setAtomicValuesFromPrefsLocked(p ipn.PrefsView) {
if !p.Valid() { if !p.Valid() {
b.containsViaIPFuncAtomic.Store(ipset.FalseContainsIPFunc()) b.containsViaIPFuncAtomic.Store(ipset.FalseContainsIPFunc())
b.setTCPPortsIntercepted(nil) b.setTCPPortsIntercepted(nil)
b.setVIPServicesTCPPortsInterceptedLocked(nil)
b.lastServeConfJSON = mem.B(nil) b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{} b.serveConfig = ipn.ServeConfigView{}
} else { } else {
@ -4159,6 +4214,11 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
} }
} }
// TODO(corp#26001): Get handler for VIP services and Local IPs using
// the same function.
if handler := b.tcpHandlerForVIPService(dst, src); handler != nil {
return handler, opts
}
// Then handle external connections to the local IP. // Then handle external connections to the local IP.
if !b.isLocalIP(dst.Addr()) { if !b.isLocalIP(dst.Addr()) {
return nil, nil return nil, nil
@ -5676,6 +5736,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
netns.SetDisableBindConnToInterface(nm.HasCap(tailcfg.CapabilityDebugDisableBindConnToInterface)) netns.SetDisableBindConnToInterface(nm.HasCap(tailcfg.CapabilityDebugDisableBindConnToInterface))
b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs()) b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs())
b.ipVIPServiceMap = nm.GetIPVIPServiceMap()
if nm == nil { if nm == nil {
b.nodeByAddr = nil b.nodeByAddr = nil
@ -5962,6 +6023,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
// b.mu must be held. // b.mu must be held.
func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.PrefsView) { func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.PrefsView) {
handlePorts := make([]uint16, 0, 4) handlePorts := make([]uint16, 0, 4)
vipServicesPorts := make(map[string][]uint16)
if prefs.Valid() && prefs.RunSSH() && envknob.CanSSHD() { if prefs.Valid() && prefs.RunSSH() && envknob.CanSSHD() {
handlePorts = append(handlePorts, 22) handlePorts = append(handlePorts, 22)
@ -5985,6 +6047,20 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
} }
handlePorts = append(handlePorts, servePorts...) handlePorts = append(handlePorts, servePorts...)
for svc, cfg := range b.serveConfig.Services().All() {
servicePorts := make([]uint16, 0, 3)
for port := range cfg.TCP().All() {
if port > 0 {
servicePorts = append(servicePorts, uint16(port))
}
}
if _, ok := vipServicesPorts[svc]; !ok {
vipServicesPorts[svc] = servicePorts
} else {
vipServicesPorts[svc] = append(vipServicesPorts[svc], servicePorts...)
}
}
b.setServeProxyHandlersLocked() b.setServeProxyHandlersLocked()
// don't listen on netmap addresses if we're in userspace mode // don't listen on netmap addresses if we're in userspace mode
@ -5996,6 +6072,7 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
// Update funnel info in hostinfo and kick off control update if needed. // Update funnel info in hostinfo and kick off control update if needed.
b.updateIngressLocked() b.updateIngressLocked()
b.setTCPPortsIntercepted(handlePorts) b.setTCPPortsIntercepted(handlePorts)
b.setVIPServicesTCPPortsInterceptedLocked(vipServicesPorts)
} }
// updateIngressLocked updates the hostinfo.WireIngress and hostinfo.IngressEnabled fields and kicks off a Hostinfo // updateIngressLocked updates the hostinfo.WireIngress and hostinfo.IngressEnabled fields and kicks off a Hostinfo
@ -6854,6 +6931,12 @@ func (b *LocalBackend) ShouldInterceptTCPPort(port uint16) bool {
return b.shouldInterceptTCPPortAtomic.Load()(port) return b.shouldInterceptTCPPortAtomic.Load()(port)
} }
// ShouldInterceptVIPServiceTCPPort reports whether the given TCP port number
// to a VIP service should be intercepted by Tailscaled and handled in-process.
func (b *LocalBackend) ShouldInterceptVIPServiceTCPPort(ap netip.AddrPort) bool {
return b.shouldInterceptVIPServicesTCPPortAtomic.Load()(ap)
}
// SwitchProfile switches to the profile with the given id. // SwitchProfile switches to the profile with the given id.
// It will restart the backend on success. // It will restart the backend on success.
// If the profile is not known, it returns an errProfileNotFound. // If the profile is not known, it returns an errProfileNotFound.

View File

@ -2615,6 +2615,150 @@ func TestOnTailnetDefaultAutoUpdate(t *testing.T) {
func TestTCPHandlerForDst(t *testing.T) { func TestTCPHandlerForDst(t *testing.T) {
b := newTestBackend(t) b := newTestBackend(t)
tests := []struct {
desc string
dst string
intercept bool
}{
{
desc: "intercept port 80 (Web UI) on quad100 IPv4",
dst: "100.100.100.100:80",
intercept: true,
},
{
desc: "intercept port 80 (Web UI) on quad100 IPv6",
dst: "[fd7a:115c:a1e0::53]:80",
intercept: true,
},
{
desc: "don't intercept port 80 on local ip",
dst: "100.100.103.100:80",
intercept: false,
},
{
desc: "intercept port 8080 (Taildrive) on quad100 IPv4",
dst: "[fd7a:115c:a1e0::53]:8080",
intercept: true,
},
{
desc: "don't intercept port 8080 on local ip",
dst: "100.100.103.100:8080",
intercept: false,
},
{
desc: "don't intercept port 9080 on quad100 IPv4",
dst: "100.100.100.100:9080",
intercept: false,
},
{
desc: "don't intercept port 9080 on quad100 IPv6",
dst: "[fd7a:115c:a1e0::53]:9080",
intercept: false,
},
{
desc: "don't intercept port 9080 on local ip",
dst: "100.100.103.100:9080",
intercept: false,
},
}
for _, tt := range tests {
t.Run(tt.dst, func(t *testing.T) {
t.Log(tt.desc)
src := netip.MustParseAddrPort("100.100.102.100:51234")
h, _ := b.TCPHandlerForDst(src, netip.MustParseAddrPort(tt.dst))
if !tt.intercept && h != nil {
t.Error("intercepted traffic we shouldn't have")
} else if tt.intercept && h == nil {
t.Error("failed to intercept traffic we should have")
}
})
}
}
func TestTCPHandlerForDstWithVIPService(t *testing.T) {
b := newTestBackend(t)
svcIPMap := tailcfg.ServiceIPMappings{
"svc:foo": []netip.Addr{
netip.MustParseAddr("100.101.101.101"),
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:6565:6565"),
},
"svc:bar": []netip.Addr{
netip.MustParseAddr("100.99.99.99"),
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:626b:628b"),
},
"svc:baz": []netip.Addr{
netip.MustParseAddr("100.133.133.133"),
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:8585:8585"),
},
}
svcIPMapJSON, err := json.Marshal(svcIPMap)
if err != nil {
t.Fatal(err)
}
b.setNetMapLocked(
&netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "example.ts.net",
CapMap: tailcfg.NodeCapMap{
tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{tailcfg.RawMessage(svcIPMapJSON)},
},
}).View(),
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
tailcfg.UserID(1): {
LoginName: "someone@example.com",
DisplayName: "Some One",
ProfilePicURL: "https://example.com/photo.jpg",
},
},
},
)
err = b.setServeConfigLocked(
&ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
882: {HTTP: true},
883: {HTTPS: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.example.ts.net:882": {
Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
},
},
"foo.example.ts.net:883": {
Handlers: map[string]*ipn.HTTPHandler{
"/": {Text: "test"},
},
},
},
},
"svc:bar": {
TCP: map[uint16]*ipn.TCPPortHandler{
990: {TCPForward: "127.0.0.1:8443"},
991: {TCPForward: "127.0.0.1:5432", TerminateTLS: "bar.test.ts.net"},
},
},
"svc:qux": {
TCP: map[uint16]*ipn.TCPPortHandler{
600: {HTTPS: true},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"qux.example.ts.net:600": {
Handlers: map[string]*ipn.HTTPHandler{
"/": {Text: "qux"},
},
},
},
},
},
},
"",
)
if err != nil {
t.Fatal(err)
}
tests := []struct { tests := []struct {
desc string desc string
@ -2666,6 +2810,77 @@ func TestTCPHandlerForDst(t *testing.T) {
dst: "100.100.103.100:9080", dst: "100.100.103.100:9080",
intercept: false, intercept: false,
}, },
// VIP service destinations
{
desc: "intercept port 882 (HTTP) on service foo IPv4",
dst: "100.101.101.101:882",
intercept: true,
},
{
desc: "intercept port 882 (HTTP) on service foo IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:882",
intercept: true,
},
{
desc: "intercept port 883 (HTTPS) on service foo IPv4",
dst: "100.101.101.101:883",
intercept: true,
},
{
desc: "intercept port 883 (HTTPS) on service foo IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:883",
intercept: true,
},
{
desc: "intercept port 990 (TCPForward) on service bar IPv4",
dst: "100.99.99.99:990",
intercept: true,
},
{
desc: "intercept port 990 (TCPForward) on service bar IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:990",
intercept: true,
},
{
desc: "intercept port 991 (TCPForward with TerminateTLS) on service bar IPv4",
dst: "100.99.99.99:990",
intercept: true,
},
{
desc: "intercept port 991 (TCPForward with TerminateTLS) on service bar IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:990",
intercept: true,
},
{
desc: "don't intercept port 4444 on service foo IPv4",
dst: "100.101.101.101:4444",
intercept: false,
},
{
desc: "don't intercept port 4444 on service foo IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:4444",
intercept: false,
},
{
desc: "don't intercept port 600 on unknown service IPv4",
dst: "100.22.22.22:883",
intercept: false,
},
{
desc: "don't intercept port 600 on unknown service IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:883",
intercept: false,
},
{
desc: "don't intercept port 600 (HTTPS) on service baz IPv4",
dst: "100.133.133.133:600",
intercept: false,
},
{
desc: "don't intercept port 600 (HTTPS) on service baz IPv6",
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:8585:8585]:600",
intercept: false,
},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@ -54,8 +54,9 @@ var ErrETagMismatch = errors.New("etag mismatch")
var serveHTTPContextKey ctxkey.Key[*serveHTTPContext] var serveHTTPContextKey ctxkey.Key[*serveHTTPContext]
type serveHTTPContext struct { type serveHTTPContext struct {
SrcAddr netip.AddrPort SrcAddr netip.AddrPort
DestPort uint16 ForVIPService bool
DestPort uint16
// provides funnel-specific context, nil if not funneled // provides funnel-specific context, nil if not funneled
Funnel *funnelFlow Funnel *funnelFlow
@ -275,6 +276,12 @@ func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string
return errors.New("can't reconfigure tailscaled when using a config file; config file is locked") return errors.New("can't reconfigure tailscaled when using a config file; config file is locked")
} }
if config != nil {
if err := config.CheckValidServicesConfig(); err != nil {
return err
}
}
nm := b.netMap nm := b.netMap
if nm == nil { if nm == nil {
return errors.New("netMap is nil") return errors.New("netMap is nil")
@ -432,6 +439,105 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target
handler(c) handler(c)
} }
// tcpHandlerForVIPService returns a handler for a TCP connection to a VIP service
// that is being served via the ipn.ServeConfig. It returns nil if the destination
// address is not a VIP service or if the VIP service does not have a TCP handler set.
func (b *LocalBackend) tcpHandlerForVIPService(dstAddr, srcAddr netip.AddrPort) (handler func(net.Conn) error) {
b.mu.Lock()
sc := b.serveConfig
ipVIPServiceMap := b.ipVIPServiceMap
b.mu.Unlock()
if !sc.Valid() {
return nil
}
dport := dstAddr.Port()
dstSvc, ok := ipVIPServiceMap[dstAddr.Addr()]
if !ok {
return nil
}
tcph, ok := sc.FindServiceTCP(dstSvc, dstAddr.Port())
if !ok {
b.logf("The destination service doesn't have a TCP handler set.")
return nil
}
if tcph.HTTPS() || tcph.HTTP() {
hs := &http.Server{
Handler: http.HandlerFunc(b.serveWebHandler),
BaseContext: func(_ net.Listener) context.Context {
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
SrcAddr: srcAddr,
ForVIPService: true,
DestPort: dport,
})
},
}
if tcph.HTTPS() {
// TODO(kevinliang10): just leaving this TLS cert creation as if we don't have other
// hostnames, but for services this getTLSServeCetForPort will need a version that also take
// in the hostname. How to store the TLS cert is still being discussed.
hs.TLSConfig = &tls.Config{
GetCertificate: b.getTLSServeCertForPort(dport, true),
}
return func(c net.Conn) error {
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
}
}
return func(c net.Conn) error {
return hs.Serve(netutil.NewOneConnListener(c, nil))
}
}
if backDst := tcph.TCPForward(); backDst != "" {
return func(conn net.Conn) error {
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
cancel()
if err != nil {
b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
return nil
}
defer backConn.Close()
if sni := tcph.TerminateTLS(); sni != "" {
conn = tls.Server(conn, &tls.Config{
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
pair, err := b.GetCertPEM(ctx, sni)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
if err != nil {
return nil, err
}
return &cert, nil
},
})
}
errc := make(chan error, 1)
go func() {
_, err := io.Copy(backConn, conn)
errc <- err
}()
go func() {
_, err := io.Copy(conn, backConn)
errc <- err
}()
return <-errc
}
}
return nil
}
// tcpHandlerForServe returns a handler for a TCP connection to be served via // tcpHandlerForServe returns a handler for a TCP connection to be served via
// the ipn.ServeConfig. The funnelFlow can be nil if this is not a funneled // the ipn.ServeConfig. The funnelFlow can be nil if this is not a funneled
// connection. // connection.
@ -462,7 +568,7 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
} }
if tcph.HTTPS() { if tcph.HTTPS() {
hs.TLSConfig = &tls.Config{ hs.TLSConfig = &tls.Config{
GetCertificate: b.getTLSServeCertForPort(dport), GetCertificate: b.getTLSServeCertForPort(dport, false),
} }
return func(c net.Conn) error { return func(c net.Conn) error {
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "") return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
@ -542,7 +648,7 @@ func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView,
b.logf("[unexpected] localbackend: no serveHTTPContext in request") b.logf("[unexpected] localbackend: no serveHTTPContext in request")
return z, "", false return z, "", false
} }
wsc, ok := b.webServerConfig(hostname, sctx.DestPort) wsc, ok := b.webServerConfig(hostname, sctx.ForVIPService, sctx.DestPort)
if !ok { if !ok {
return z, "", false return z, "", false
} }
@ -900,7 +1006,7 @@ func allNumeric(s string) bool {
return s != "" return s != ""
} }
func (b *LocalBackend) webServerConfig(hostname string, port uint16) (c ipn.WebServerConfigView, ok bool) { func (b *LocalBackend) webServerConfig(hostname string, forVIPService bool, port uint16) (c ipn.WebServerConfigView, ok bool) {
key := ipn.HostPort(fmt.Sprintf("%s:%v", hostname, port)) key := ipn.HostPort(fmt.Sprintf("%s:%v", hostname, port))
b.mu.Lock() b.mu.Lock()
@ -909,15 +1015,18 @@ func (b *LocalBackend) webServerConfig(hostname string, port uint16) (c ipn.WebS
if !b.serveConfig.Valid() { if !b.serveConfig.Valid() {
return c, false return c, false
} }
if forVIPService {
return b.serveConfig.FindServiceWeb(key)
}
return b.serveConfig.FindWeb(key) return b.serveConfig.FindWeb(key)
} }
func (b *LocalBackend) getTLSServeCertForPort(port uint16) func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { func (b *LocalBackend) getTLSServeCertForPort(port uint16, forVIPService bool) func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { return func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if hi == nil || hi.ServerName == "" { if hi == nil || hi.ServerName == "" {
return nil, errors.New("no SNI ServerName") return nil, errors.New("no SNI ServerName")
} }
_, ok := b.webServerConfig(hi.ServerName, port) _, ok := b.webServerConfig(hi.ServerName, forVIPService, port)
if !ok { if !ok {
return nil, errors.New("no webserver configured for name/port") return nil, errors.New("no webserver configured for name/port")
} }

View File

@ -296,6 +296,203 @@ func TestServeConfigForeground(t *testing.T) {
} }
} }
// TestServeConfigServices tests the side effects of setting the
// Services field in a ServeConfig. The Services field is a map
// of all services the current service host is serving. Unlike what we
// serve for node itself, there is no foreground and no local handlers
// for the services. So the only things we need to test are if the
// services configured are valid and if they correctly set intercept
// functions for netStack.
func TestServeConfigServices(t *testing.T) {
b := newTestBackend(t)
svcIPMap := tailcfg.ServiceIPMappings{
"svc:foo": []netip.Addr{
netip.MustParseAddr("100.101.101.101"),
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:6565:6565"),
},
"svc:bar": []netip.Addr{
netip.MustParseAddr("100.99.99.99"),
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:626b:628b"),
},
}
svcIPMapJSON, err := json.Marshal(svcIPMap)
if err != nil {
t.Fatal(err)
}
b.netMap = &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
Name: "example.ts.net",
CapMap: tailcfg.NodeCapMap{
tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{tailcfg.RawMessage(svcIPMapJSON)},
},
}).View(),
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
tailcfg.UserID(1): {
LoginName: "someone@example.com",
DisplayName: "Some One",
ProfilePicURL: "https://example.com/photo.jpg",
},
},
}
tests := []struct {
name string
conf *ipn.ServeConfig
expectedErr error
packetDstAddrPort []netip.AddrPort
intercepted bool
}{
{
name: "no-services",
conf: &ipn.ServeConfig{},
packetDstAddrPort: []netip.AddrPort{
netip.MustParseAddrPort("100.101.101.101:443"),
},
intercepted: false,
},
{
name: "one-incorrectly-configured-service",
conf: &ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
},
Tun: true,
},
},
},
expectedErr: ipn.ErrServiceConfigHasBothTCPAndTun,
},
{
// one correctly configured service with packet should be intercepted
name: "one-service-intercept-packet",
conf: &ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
},
},
},
},
packetDstAddrPort: []netip.AddrPort{
netip.MustParseAddrPort("100.101.101.101:80"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:80"),
},
intercepted: true,
},
{
// one correctly configured service with packet should not be intercepted
name: "one-service-not-intercept-packet",
conf: &ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
},
},
},
},
packetDstAddrPort: []netip.AddrPort{
netip.MustParseAddrPort("100.99.99.99:80"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"),
netip.MustParseAddrPort("100.101.101.101:82"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:82"),
},
intercepted: false,
},
{
//multiple correctly configured service with packet should be intercepted
name: "multiple-service-intercept-packet",
conf: &ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
},
},
"svc:bar": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
82: {HTTPS: true},
},
},
},
},
packetDstAddrPort: []netip.AddrPort{
netip.MustParseAddrPort("100.99.99.99:80"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"),
netip.MustParseAddrPort("100.101.101.101:81"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:81"),
},
intercepted: true,
},
{
// multiple correctly configured service with packet should not be intercepted
name: "multiple-service-not-intercept-packet",
conf: &ipn.ServeConfig{
Services: map[string]*ipn.ServiceConfig{
"svc:foo": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
},
},
"svc:bar": {
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
81: {HTTPS: true},
82: {HTTPS: true},
},
},
},
},
packetDstAddrPort: []netip.AddrPort{
// ips in capmap but port is not hosting service
netip.MustParseAddrPort("100.99.99.99:77"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:77"),
netip.MustParseAddrPort("100.101.101.101:85"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:85"),
// ips not in capmap
netip.MustParseAddrPort("100.102.102.102:80"),
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6666:6666]:80"),
},
intercepted: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := b.SetServeConfig(tt.conf, "")
if err != nil && tt.expectedErr != nil {
if !errors.Is(err, tt.expectedErr) {
t.Fatalf("expected error %v,\n got %v", tt.expectedErr, err)
}
return
}
if err != nil {
t.Fatal(err)
}
for _, addrPort := range tt.packetDstAddrPort {
if tt.intercepted != b.ShouldInterceptVIPServiceTCPPort(addrPort) {
if tt.intercepted {
t.Fatalf("expected packet to be intercepted")
} else {
t.Fatalf("expected packet not to be intercepted")
}
}
}
})
}
}
func TestServeConfigETag(t *testing.T) { func TestServeConfigETag(t *testing.T) {
b := newTestBackend(t) b := newTestBackend(t)

View File

@ -55,8 +55,8 @@ type ServeConfig struct {
// keyed by mount point ("/", "/foo", etc) // keyed by mount point ("/", "/foo", etc)
Web map[HostPort]*WebServerConfig `json:",omitempty"` Web map[HostPort]*WebServerConfig `json:",omitempty"`
// Services maps from service name to a ServiceConfig. Which describes the // Services maps from service name (in the form "svc:dns-label") to a ServiceConfig.
// L3, L4, and L7 forwarding information for the service. // Which describes the L3, L4, and L7 forwarding information for the service.
Services map[string]*ServiceConfig `json:",omitempty"` Services map[string]*ServiceConfig `json:",omitempty"`
// AllowFunnel is the set of SNI:port values for which funnel // AllowFunnel is the set of SNI:port values for which funnel
@ -607,9 +607,34 @@ func (v ServeConfigView) Webs() iter.Seq2[HostPort, WebServerConfigView] {
} }
} }
} }
for _, service := range v.Services().All() {
for k, v := range service.Web().All() {
if !yield(k, v) {
return
}
}
}
} }
} }
// FindServiceTCP return the TCPPortHandlerView for the given service name and port.
func (v ServeConfigView) FindServiceTCP(svcName string, port uint16) (res TCPPortHandlerView, ok bool) {
svcCfg, ok := v.Services().GetOk(svcName)
if !ok {
return res, ok
}
return svcCfg.TCP().GetOk(port)
}
func (v ServeConfigView) FindServiceWeb(hp HostPort) (res WebServerConfigView, ok bool) {
for _, service := range v.Services().All() {
if res, ok := service.Web().GetOk(hp); ok {
return res, ok
}
}
return res, ok
}
// FindTCP returns the first TCP that matches with the given port. It // FindTCP returns the first TCP that matches with the given port. It
// prefers a foreground match first followed by a background search if none // prefers a foreground match first followed by a background search if none
// existed. // existed.
@ -662,6 +687,17 @@ func (v ServeConfigView) HasFunnelForTarget(target HostPort) bool {
return false return false
} }
// CheckValidServicesConfig reports whether the ServeConfig has
// invalid service configurations.
func (sc *ServeConfig) CheckValidServicesConfig() error {
for svcName, service := range sc.Services {
if err := service.checkValidConfig(); err != nil {
return fmt.Errorf("invalid service configuration for %q: %w", svcName, err)
}
}
return nil
}
// ServicePortRange returns the list of tailcfg.ProtoPortRange that represents // ServicePortRange returns the list of tailcfg.ProtoPortRange that represents
// the proto/ports pairs that are being served by the service. // the proto/ports pairs that are being served by the service.
// //
@ -699,3 +735,17 @@ func (v ServiceConfigView) ServicePortRange() []tailcfg.ProtoPortRange {
} }
return ranges return ranges
} }
// ErrServiceConfigHasBothTCPAndTun signals that a service
// in Tun mode cannot also has TCP or Web handlers set.
var ErrServiceConfigHasBothTCPAndTun = errors.New("the VIP Service configuration can not set TUN at the same time as TCP or Web")
// checkValidConfig checks if the service configuration is valid.
// Currently, the only invalid configuration is when the service is in Tun mode
// and has TCP or Web handlers.
func (v *ServiceConfig) checkValidConfig() error {
if v.Tun && (len(v.TCP) > 0 || len(v.Web) > 0) {
return ErrServiceConfigHasBothTCPAndTun
}
return nil
}

View File

@ -2997,3 +2997,19 @@ const LBHeader = "Ts-Lb"
// correspond to those IPs. Any services that don't correspond to a service // correspond to those IPs. Any services that don't correspond to a service
// this client is hosting can be ignored. // this client is hosting can be ignored.
type ServiceIPMappings map[string][]netip.Addr type ServiceIPMappings map[string][]netip.Addr
// IPServiceMappings maps IP addresses to service names. This is the inverse of
// [ServiceIPMappings], and is used to inform clients which services is an VIP
// address associated with. This is set to b.ipVIPServiceMap every time the
// netmap is updated. This is used to reduce the cost for looking up the service
// name for the dst IP address in the netStack packet processing workflow.
//
// This is of the form:
//
// {
// "100.65.32.1": "svc:samba",
// "fd7a:115c:a1e0::1234": "svc:samba",
// "100.102.42.3": "svc:web",
// "fd7a:115c:a1e0::abcd": "svc:web",
// }
type IPServiceMappings map[netip.Addr]string

View File

@ -0,0 +1,19 @@
package netmap
import "net/netip"
// IPServiceMappings maps IP addresses to service names. This is the inverse of
// [ServiceIPMappings], and is used to inform clients which services is an VIP
// address associated with. This is set to b.ipVIPServiceMap every time the
// netmap is updated. This is used to reduce the cost for looking up the service
// name for the dst IP address in the netStack packet processing workflow.
//
// This is of the form:
//
// {
// "100.65.32.1": "svc:samba",
// "fd7a:115c:a1e0::1234": "svc:samba",
// "100.102.42.3": "svc:web",
// "fd7a:115c:a1e0::abcd": "svc:web",
// }
type IPServiceMappings map[netip.Addr]string

View File

@ -101,6 +101,54 @@ func (nm *NetworkMap) GetAddresses() views.Slice[netip.Prefix] {
return nm.SelfNode.Addresses() return nm.SelfNode.Addresses()
} }
// GetVIPServiceIPMap returns a map of service names to the slice of
// VIP addresses that correspond to the service. The service names are
// with the prefix "svc:".
//
// TODO(corp##25997): cache the result of decoding the capmap so that
// we don't have to decode it multiple times after each netmap update.
func (nm *NetworkMap) GetVIPServiceIPMap() tailcfg.ServiceIPMappings {
if nm == nil {
return nil
}
if !nm.SelfNode.Valid() {
return nil
}
ipMaps, err := tailcfg.UnmarshalNodeCapJSON[tailcfg.ServiceIPMappings](nm.SelfNode.CapMap().AsMap(), tailcfg.NodeAttrServiceHost)
if len(ipMaps) != 1 || err != nil {
return nil
}
return ipMaps[0]
}
// GetIPVIPServiceMap returns a map of VIP addresses to the service
// names that has the VIP address. The service names are with the
// prefix "svc:".
func (nm *NetworkMap) GetIPVIPServiceMap() IPServiceMappings {
var res IPServiceMappings
if nm == nil {
return res
}
if !nm.SelfNode.Valid() {
return res
}
serviceIPMap := nm.GetVIPServiceIPMap()
if serviceIPMap == nil {
return res
}
res = make(IPServiceMappings)
for svc, addrs := range serviceIPMap {
for _, addr := range addrs {
res[addr] = svc
}
}
return res
}
// AnyPeersAdvertiseRoutes reports whether any peer is advertising non-exit node routes. // AnyPeersAdvertiseRoutes reports whether any peer is advertising non-exit node routes.
func (nm *NetworkMap) AnyPeersAdvertiseRoutes() bool { func (nm *NetworkMap) AnyPeersAdvertiseRoutes() bool {
for _, p := range nm.Peers { for _, p := range nm.Peers {

View File

@ -50,6 +50,7 @@ import (
"tailscale.com/types/netmap" "tailscale.com/types/netmap"
"tailscale.com/types/nettype" "tailscale.com/types/nettype"
"tailscale.com/util/clientmetric" "tailscale.com/util/clientmetric"
"tailscale.com/util/set"
"tailscale.com/version" "tailscale.com/version"
"tailscale.com/wgengine" "tailscale.com/wgengine"
"tailscale.com/wgengine/filter" "tailscale.com/wgengine/filter"
@ -200,6 +201,8 @@ type Impl struct {
// updates. // updates.
atomicIsLocalIPFunc syncs.AtomicValue[func(netip.Addr) bool] atomicIsLocalIPFunc syncs.AtomicValue[func(netip.Addr) bool]
atomicIsVIPServiceIPFunc syncs.AtomicValue[func(netip.Addr) bool]
// forwardDialFunc, if non-nil, is the net.Dialer.DialContext-style // forwardDialFunc, if non-nil, is the net.Dialer.DialContext-style
// function that is used to make outgoing connections when forwarding a // function that is used to make outgoing connections when forwarding a
// TCP connection to another host (e.g. in subnet router mode). // TCP connection to another host (e.g. in subnet router mode).
@ -387,6 +390,7 @@ func Create(logf logger.Logf, tundev *tstun.Wrapper, e wgengine.Engine, mc *magi
} }
ns.ctx, ns.ctxCancel = context.WithCancel(context.Background()) ns.ctx, ns.ctxCancel = context.WithCancel(context.Background())
ns.atomicIsLocalIPFunc.Store(ipset.FalseContainsIPFunc()) ns.atomicIsLocalIPFunc.Store(ipset.FalseContainsIPFunc())
ns.atomicIsVIPServiceIPFunc.Store(ipset.FalseContainsIPFunc())
ns.tundev.PostFilterPacketInboundFromWireGuard = ns.injectInbound ns.tundev.PostFilterPacketInboundFromWireGuard = ns.injectInbound
ns.tundev.PreFilterPacketOutboundToWireGuardNetstackIntercept = ns.handleLocalPackets ns.tundev.PreFilterPacketOutboundToWireGuardNetstackIntercept = ns.handleLocalPackets
stacksForMetrics.Store(ns, struct{}{}) stacksForMetrics.Store(ns, struct{}{})
@ -532,7 +536,7 @@ func (ns *Impl) wrapTCPProtocolHandler(h protocolHandlerFunc) protocolHandlerFun
// Dynamically reconfigure ns's subnet addresses as needed for // Dynamically reconfigure ns's subnet addresses as needed for
// outbound traffic. // outbound traffic.
if !ns.isLocalIP(localIP) { if !ns.isLocalIP(localIP) && !ns.isVIPServiceIP(localIP) {
ns.addSubnetAddress(localIP) ns.addSubnetAddress(localIP)
} }
@ -621,10 +625,17 @@ var v4broadcast = netaddr.IPv4(255, 255, 255, 255)
func (ns *Impl) UpdateNetstackIPs(nm *netmap.NetworkMap) { func (ns *Impl) UpdateNetstackIPs(nm *netmap.NetworkMap) {
var selfNode tailcfg.NodeView var selfNode tailcfg.NodeView
if nm != nil { if nm != nil {
vipServiceIPMap := nm.GetVIPServiceIPMap()
serviceAddrSet := set.Set[netip.Addr]{}
for _, addrs := range vipServiceIPMap {
serviceAddrSet.AddSlice(addrs)
}
ns.atomicIsLocalIPFunc.Store(ipset.NewContainsIPFunc(nm.GetAddresses())) ns.atomicIsLocalIPFunc.Store(ipset.NewContainsIPFunc(nm.GetAddresses()))
ns.atomicIsVIPServiceIPFunc.Store(serviceAddrSet.Contains)
selfNode = nm.SelfNode selfNode = nm.SelfNode
} else { } else {
ns.atomicIsLocalIPFunc.Store(ipset.FalseContainsIPFunc()) ns.atomicIsLocalIPFunc.Store(ipset.FalseContainsIPFunc())
ns.atomicIsVIPServiceIPFunc.Store(ipset.FalseContainsIPFunc())
} }
oldPfx := make(map[netip.Prefix]bool) oldPfx := make(map[netip.Prefix]bool)
@ -952,6 +963,12 @@ func (ns *Impl) isLocalIP(ip netip.Addr) bool {
return ns.atomicIsLocalIPFunc.Load()(ip) return ns.atomicIsLocalIPFunc.Load()(ip)
} }
// isVIPServiceIP reports whether ip is an IP address that's
// assigned to a VIP service.
func (ns *Impl) isVIPServiceIP(ip netip.Addr) bool {
return ns.atomicIsVIPServiceIPFunc.Load()(ip)
}
func (ns *Impl) peerAPIPortAtomic(ip netip.Addr) *atomic.Uint32 { func (ns *Impl) peerAPIPortAtomic(ip netip.Addr) *atomic.Uint32 {
if ip.Is4() { if ip.Is4() {
return &ns.peerapiPort4Atomic return &ns.peerapiPort4Atomic
@ -968,6 +985,7 @@ func (ns *Impl) shouldProcessInbound(p *packet.Parsed, t *tstun.Wrapper) bool {
// Handle incoming peerapi connections in netstack. // Handle incoming peerapi connections in netstack.
dstIP := p.Dst.Addr() dstIP := p.Dst.Addr()
isLocal := ns.isLocalIP(dstIP) isLocal := ns.isLocalIP(dstIP)
isService := ns.isVIPServiceIP(dstIP)
// Handle TCP connection to the Tailscale IP(s) in some cases: // Handle TCP connection to the Tailscale IP(s) in some cases:
if ns.lb != nil && p.IPProto == ipproto.TCP && isLocal { if ns.lb != nil && p.IPProto == ipproto.TCP && isLocal {
@ -990,6 +1008,13 @@ func (ns *Impl) shouldProcessInbound(p *packet.Parsed, t *tstun.Wrapper) bool {
return true return true
} }
} }
if ns.lb != nil && p.IPProto == ipproto.TCP && isService {
// An assumption holds for this to work: when tun mode is on for a service,
// its tcp and web are not set. This is enforced in b.setServeConfigLocked.
if ns.lb.ShouldInterceptVIPServiceTCPPort(p.Dst) {
return true
}
}
if p.IPVersion == 6 && !isLocal && viaRange.Contains(dstIP) { if p.IPVersion == 6 && !isLocal && viaRange.Contains(dstIP) {
return ns.lb != nil && ns.lb.ShouldHandleViaIP(dstIP) return ns.lb != nil && ns.lb.ShouldHandleViaIP(dstIP)
} }