tsnet: add support for Services
This change allows tsnet nodes to act as Service hosts by adding a new function, tsnet.Server.ListenService. Invoking this function will advertise the node as a host for the Service and create a listener to receive traffic for the Service. Fixes #17697 Fixes tailscale/corp#27200 Signed-off-by: Harry Harpham <harry@tailscale.com>
This commit is contained in:
@@ -107,6 +107,15 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertK
|
|||||||
// If a cert is expired, or expires sooner than minValidity, it will be renewed
|
// If a cert is expired, or expires sooner than minValidity, it will be renewed
|
||||||
// synchronously. Otherwise it will be renewed asynchronously.
|
// synchronously. Otherwise it will be renewed asynchronously.
|
||||||
func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string, minValidity time.Duration) (*TLSCertKeyPair, error) {
|
func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string, minValidity time.Duration) (*TLSCertKeyPair, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
getCertForTest := b.getCertForTest
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
if getCertForTest != nil {
|
||||||
|
testenv.AssertInTest()
|
||||||
|
return getCertForTest(domain)
|
||||||
|
}
|
||||||
|
|
||||||
if !validLookingCertDomain(domain) {
|
if !validLookingCertDomain(domain) {
|
||||||
return nil, errors.New("invalid domain")
|
return nil, errors.New("invalid domain")
|
||||||
}
|
}
|
||||||
@@ -303,6 +312,16 @@ func (b *LocalBackend) getCertStore() (certStore, error) {
|
|||||||
return certFileStore{dir: dir, testRoots: testX509Roots}, nil
|
return certFileStore{dir: dir, testRoots: testX509Roots}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfigureCertsForTest sets a certificate retrieval function to be used by
|
||||||
|
// this local backend, skipping the usual ACME certificate registration. Should
|
||||||
|
// only be used in tests.
|
||||||
|
func (b *LocalBackend) ConfigureCertsForTest(getCert func(hostname string) (*TLSCertKeyPair, error)) {
|
||||||
|
testenv.AssertInTest()
|
||||||
|
b.mu.Lock()
|
||||||
|
b.getCertForTest = getCert
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// certFileStore implements certStore by storing the cert & key files in the named directory.
|
// certFileStore implements certStore by storing the cert & key files in the named directory.
|
||||||
type certFileStore struct {
|
type certFileStore struct {
|
||||||
dir string
|
dir string
|
||||||
|
|||||||
@@ -399,6 +399,10 @@ type LocalBackend struct {
|
|||||||
// hardwareAttested is whether backend should use a hardware-backed key to
|
// hardwareAttested is whether backend should use a hardware-backed key to
|
||||||
// bind the node identity to this device.
|
// bind the node identity to this device.
|
||||||
hardwareAttested atomic.Bool
|
hardwareAttested atomic.Bool
|
||||||
|
|
||||||
|
// getCertForTest is used to retrieve TLS certificates in tests.
|
||||||
|
// See [LocalBackend.ConfigureCertsForTest].
|
||||||
|
getCertForTest func(hostname string) (*TLSCertKeyPair, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetHardwareAttested enables hardware attestation key signatures in map
|
// SetHardwareAttested enables hardware attestation key signatures in map
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// The tsnet-services example demonstrates how to use tsnet with Services.
|
||||||
|
//
|
||||||
|
// To run this example yourself:
|
||||||
|
//
|
||||||
|
// 1. Add access controls which (i) define a new ACL tag, (ii) allow the demo
|
||||||
|
// node to host the Service, and (iii) allow peers on the tailnet to reach
|
||||||
|
// the Service. A sample ACL policy is provided below.
|
||||||
|
//
|
||||||
|
// 2. [Generate an auth key] using the Tailscale admin panel. When doing so, add
|
||||||
|
// your new tag to your key (Service hosts must be tagged nodes).
|
||||||
|
//
|
||||||
|
// 3. [Define a Service]. For the purposes of this demo, it must be defined to
|
||||||
|
// listen on TCP port 443. Note that you only need to follow Step 1 in the
|
||||||
|
// linked document.
|
||||||
|
//
|
||||||
|
// 4. Run the demo on the command line:
|
||||||
|
//
|
||||||
|
// TS_AUTHKEY=<yourkey> go run tsnet-services.go -service <service-name>
|
||||||
|
//
|
||||||
|
// The following is a sample ACL policy for step 1:
|
||||||
|
//
|
||||||
|
// "tagOwners": {
|
||||||
|
// "tag:tsnet-demo-host": ["autogroup:member"],
|
||||||
|
// },
|
||||||
|
// "autoApprovers": {
|
||||||
|
// "services": {
|
||||||
|
// "svc:tsnet-demo": ["tag:tsnet-demo-host"],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// "grants": [
|
||||||
|
// "src": ["*"],
|
||||||
|
// "dst": ["svc:tsnet-demo"],
|
||||||
|
// "ip": ["*"],
|
||||||
|
// ],
|
||||||
|
//
|
||||||
|
// [Define a Service]: https://tailscale.com/kb/1552/tailscale-services#step-1-define-a-tailscale-service
|
||||||
|
// [Generate an auth key]: https://tailscale.com/kb/1085/auth-keys#generate-an-auth-key
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"tailscale.com/tsnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
svcName = flag.String("service", "", "the name of your Service, e.g. svc:tsnet-demo")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
if *svcName == "" {
|
||||||
|
log.Fatal("a Service name must be provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &tsnet.Server{
|
||||||
|
Hostname: "tsnet-services-demo",
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
ln, err := s.ListenService(*svcName, tsnet.ServiceModeHTTP{
|
||||||
|
HTTPS: true,
|
||||||
|
Port: 443,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
log.Printf("Listening on https://%v\n", ln.FQDN)
|
||||||
|
|
||||||
|
err = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintln(w, "<html><body><h1>Hello, tailnet!</h1>")
|
||||||
|
}))
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package tsnet_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
_ "net/http/pprof"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"tailscale.com/tsnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This example function is in a separate file for the "net/http/pprof" import.
|
||||||
|
|
||||||
|
// ExampleServer_ListenService_multiplePorts demonstrates how to advertise a
|
||||||
|
// Service on multiple ports. In this example, we run an HTTPS server on 443 and
|
||||||
|
// an HTTP server handling pprof requests to the same runtime on 6060.
|
||||||
|
func ExampleServer_ListenService_multiplePorts() {
|
||||||
|
s := &tsnet.Server{
|
||||||
|
Hostname: "tsnet-services-demo",
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
ln, err := s.ListenService("svc:my-service", tsnet.ServiceModeHTTP{
|
||||||
|
HTTPS: true,
|
||||||
|
Port: 443,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
pprofLn, err := s.ListenService("svc:my-service", tsnet.ServiceModeTCP{
|
||||||
|
Port: 6060,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer pprofLn.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("Listening for pprof requests on http://%v:%d\n", pprofLn.FQDN, 6060)
|
||||||
|
|
||||||
|
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// The pprof listener is separate from our main server, so we can
|
||||||
|
// allow users to leave off the /debug/pprof prefix. We'll just
|
||||||
|
// attach it here, then pass along to the pprof handlers, which have
|
||||||
|
// been added implicitly due to our import of net/http/pprof.
|
||||||
|
if !strings.HasPrefix("/debug/pprof", r.URL.Path) {
|
||||||
|
r.URL.Path = "/debug/pprof" + r.URL.Path
|
||||||
|
}
|
||||||
|
http.DefaultServeMux.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
if err := http.Serve(pprofLn, http.HandlerFunc(handler)); err != nil {
|
||||||
|
log.Fatal("error serving pprof:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("Listening on https://%v\n", ln.FQDN)
|
||||||
|
|
||||||
|
// Specifying a handler here means pprof endpoints will not be served by
|
||||||
|
// this server (since we are not using http.DefaultServeMux).
|
||||||
|
log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintln(w, "<html><body><h1>Hello, tailnet!</h1>")
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -200,3 +202,56 @@ func ExampleServer_ListenFunnel_funnelOnly() {
|
|||||||
fmt.Fprintln(w, "Hi there! Welcome to the tailnet!")
|
fmt.Fprintln(w, "Hi there! Welcome to the tailnet!")
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExampleServer_ListenService demonstrates how to advertise an HTTPS Service.
|
||||||
|
func ExampleServer_ListenService() {
|
||||||
|
s := &tsnet.Server{
|
||||||
|
Hostname: "tsnet-services-demo",
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
ln, err := s.ListenService("svc:my-service", tsnet.ServiceModeHTTP{
|
||||||
|
HTTPS: true,
|
||||||
|
Port: 443,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
log.Printf("Listening on https://%v\n", ln.FQDN)
|
||||||
|
log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintln(w, "<html><body><h1>Hello, tailnet!</h1>")
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleServer_ListenService_reverseProxy demonstrates how to advertise a
|
||||||
|
// Service targeting a reverse proxy. This is useful when the backing server is
|
||||||
|
// external to the tsnet application.
|
||||||
|
func ExampleServer_ListenService_reverseProxy() {
|
||||||
|
// targetAddress represents the address of the backing server.
|
||||||
|
const targetAddress = "1.2.3.4:80"
|
||||||
|
|
||||||
|
// We will use a reverse proxy to direct traffic to the backing server.
|
||||||
|
reverseProxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: targetAddress,
|
||||||
|
})
|
||||||
|
|
||||||
|
s := &tsnet.Server{
|
||||||
|
Hostname: "tsnet-services-demo",
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
ln, err := s.ListenService("svc:my-service", tsnet.ServiceModeHTTP{
|
||||||
|
HTTPS: true,
|
||||||
|
Port: 443,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
log.Printf("Listening on https://%v\n", ln.FQDN)
|
||||||
|
log.Fatal(http.Serve(ln, reverseProxy))
|
||||||
|
}
|
||||||
|
|||||||
+265
-9
@@ -52,6 +52,7 @@ import (
|
|||||||
"tailscale.com/net/proxymux"
|
"tailscale.com/net/proxymux"
|
||||||
"tailscale.com/net/socks5"
|
"tailscale.com/net/socks5"
|
||||||
"tailscale.com/net/tsdial"
|
"tailscale.com/net/tsdial"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/tsd"
|
"tailscale.com/tsd"
|
||||||
"tailscale.com/types/bools"
|
"tailscale.com/types/bools"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
@@ -166,8 +167,6 @@ type Server struct {
|
|||||||
// that the control server will allow the node to adopt that tag.
|
// that the control server will allow the node to adopt that tag.
|
||||||
AdvertiseTags []string
|
AdvertiseTags []string
|
||||||
|
|
||||||
getCertForTesting func(*tls.ClientHelloInfo) (*tls.Certificate, error)
|
|
||||||
|
|
||||||
initOnce sync.Once
|
initOnce sync.Once
|
||||||
initErr error
|
initErr error
|
||||||
lb *ipnlocal.LocalBackend
|
lb *ipnlocal.LocalBackend
|
||||||
@@ -1130,9 +1129,6 @@ func (s *Server) RegisterFallbackTCPHandler(cb FallbackTCPHandler) func() {
|
|||||||
// It calls GetCertificate on the localClient, passing in the ClientHelloInfo.
|
// It calls GetCertificate on the localClient, passing in the ClientHelloInfo.
|
||||||
// For testing, if s.getCertForTesting is set, it will call that instead.
|
// For testing, if s.getCertForTesting is set, it will call that instead.
|
||||||
func (s *Server) getCert(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
func (s *Server) getCert(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
if s.getCertForTesting != nil {
|
|
||||||
return s.getCertForTesting(hi)
|
|
||||||
}
|
|
||||||
lc, err := s.LocalClient()
|
lc, err := s.LocalClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1283,6 +1279,259 @@ func (s *Server) ListenFunnel(network, addr string, opts ...FunnelOption) (net.L
|
|||||||
return tls.NewListener(ln, tlsConfig), nil
|
return tls.NewListener(ln, tlsConfig), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceMode defines how a Service is run. Currently supported modes are:
|
||||||
|
// - [ServiceModeTCP]
|
||||||
|
// - [ServiceModeHTTP]
|
||||||
|
//
|
||||||
|
// For more information, see [Server.ListenService].
|
||||||
|
type ServiceMode interface {
|
||||||
|
// network is the network this Service will advertise on. Per Go convention,
|
||||||
|
// this should be lowercase, e.g. 'tcp'.
|
||||||
|
network() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceModeWithPort is a convenience type to extract the port from
|
||||||
|
// ServiceMode types which have one.
|
||||||
|
type serviceModeWithPort interface {
|
||||||
|
ServiceMode
|
||||||
|
port() uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceModeTCP is used to configure a TCP Service via [Server.ListenService].
|
||||||
|
type ServiceModeTCP struct {
|
||||||
|
// Port is the TCP port to advertise. If this Service needs to advertise
|
||||||
|
// multiple ports, call ListenService multiple times.
|
||||||
|
Port uint16
|
||||||
|
|
||||||
|
// TerminateTLS means that TLS connections will be terminated before being
|
||||||
|
// forwarded to the listener. In this case, the only server name indicator
|
||||||
|
// (SNI) permitted is the Service's fully-qualified domain name.
|
||||||
|
TerminateTLS bool
|
||||||
|
|
||||||
|
// PROXYProtocolVersion indicates whether to send a PROXY protocol header
|
||||||
|
// before forwarding the connection to the listener and which version of the
|
||||||
|
// protocol to use.
|
||||||
|
//
|
||||||
|
// For more information, see
|
||||||
|
// https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
|
||||||
|
PROXYProtocolVersion int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ServiceModeTCP) network() string { return "tcp" }
|
||||||
|
|
||||||
|
func (m ServiceModeTCP) port() uint16 { return m.Port }
|
||||||
|
|
||||||
|
// ServiceModeHTTP is used to configure an HTTP Service via
|
||||||
|
// [Server.ListenService].
|
||||||
|
type ServiceModeHTTP struct {
|
||||||
|
// Port is the TCP port to advertise. If this Service needs to advertise
|
||||||
|
// multiple ports, call ListenService multiple times.
|
||||||
|
Port uint16
|
||||||
|
|
||||||
|
// HTTPS, if true, means that the listener should handle connections as
|
||||||
|
// HTTPS connections. In this case, the only server name indicator (SNI)
|
||||||
|
// permitted is the Service's fully-qualified domain name.
|
||||||
|
HTTPS bool
|
||||||
|
|
||||||
|
// AcceptAppCaps defines the app capabilities to forward to the server. The
|
||||||
|
// keys in this map are the mount points for each set of capabilities.
|
||||||
|
//
|
||||||
|
// By example,
|
||||||
|
//
|
||||||
|
// AcceptAppCaps: map[string][]string{
|
||||||
|
// "/": {"example.com/cap/all-paths"},
|
||||||
|
// "/foo": {"example.com/cap/all-paths", "example.com/cap/foo"},
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// would forward example.com/cap/all-paths to all paths on the server and
|
||||||
|
// example.com/cap/foo only to paths beginning with /foo.
|
||||||
|
//
|
||||||
|
// For more information on app capabilities, see
|
||||||
|
// https://tailscale.com/kb/1537/grants-app-capabilities
|
||||||
|
AcceptAppCaps map[string][]string
|
||||||
|
|
||||||
|
// PROXYProtocolVersion indicates whether to send a PROXY protocol header
|
||||||
|
// before forwarding the connection to the listener and which version of the
|
||||||
|
// protocol to use.
|
||||||
|
//
|
||||||
|
// For more information, see
|
||||||
|
// https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
|
||||||
|
PROXYProtocol int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ServiceModeHTTP) network() string { return "tcp" }
|
||||||
|
|
||||||
|
func (m ServiceModeHTTP) port() uint16 { return m.Port }
|
||||||
|
|
||||||
|
func (m ServiceModeHTTP) capsMap() map[string][]tailcfg.PeerCapability {
|
||||||
|
capsMap := map[string][]tailcfg.PeerCapability{}
|
||||||
|
for path, capNames := range m.AcceptAppCaps {
|
||||||
|
caps := make([]tailcfg.PeerCapability, 0, len(capNames))
|
||||||
|
for _, c := range capNames {
|
||||||
|
caps = append(caps, tailcfg.PeerCapability(c))
|
||||||
|
}
|
||||||
|
capsMap[path] = caps
|
||||||
|
}
|
||||||
|
return capsMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ServiceListener is a network listener for a Tailscale Service. For more
|
||||||
|
// information about Services, see
|
||||||
|
// https://tailscale.com/kb/1552/tailscale-services
|
||||||
|
type ServiceListener struct {
|
||||||
|
net.Listener
|
||||||
|
addr addr
|
||||||
|
|
||||||
|
// FQDN is the fully-qualifed domain name of this Service.
|
||||||
|
FQDN string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr returns the listener's network address. This will be the Service's
|
||||||
|
// fully-qualified domain name (FQDN) and the port.
|
||||||
|
//
|
||||||
|
// A hostname is not truly a network address, but Services listen on multiple
|
||||||
|
// addresses (the IPv4 and IPv6 virtual IPs).
|
||||||
|
func (sl ServiceListener) Addr() net.Addr {
|
||||||
|
return sl.addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrUntaggedServiceHost is returned by ListenService when run on a node
|
||||||
|
// without any ACL tags. A node must use a tag-based identity to act as a
|
||||||
|
// Service host. For more information, see:
|
||||||
|
// https://tailscale.com/kb/1552/tailscale-services#prerequisites
|
||||||
|
var ErrUntaggedServiceHost = errors.New("service hosts must be tagged nodes")
|
||||||
|
|
||||||
|
// ListenService creates a network listener for a Tailscale Service. This will
|
||||||
|
// advertise this node as hosting the Service. Note that:
|
||||||
|
// - Approval must still be granted by an admin or by ACL auto-approval rules.
|
||||||
|
// - Service hosts must be tagged nodes.
|
||||||
|
// - A valid Service host must advertise all ports defined for the Service.
|
||||||
|
//
|
||||||
|
// To advertise a Service with multiple ports, run ListenService multiple times.
|
||||||
|
// For more information about Services, see
|
||||||
|
// https://tailscale.com/kb/1552/tailscale-services
|
||||||
|
func (s *Server) ListenService(name string, mode ServiceMode) (*ServiceListener, error) {
|
||||||
|
if err := tailcfg.ServiceName(name).Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mode == nil {
|
||||||
|
return nil, errors.New("mode may not be nil")
|
||||||
|
}
|
||||||
|
svcName := name
|
||||||
|
|
||||||
|
// TODO(hwh33,tailscale/corp#35859): support TUN mode
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := s.Up(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
st := s.lb.StatusWithoutPeers()
|
||||||
|
if st.Self.Tags == nil || st.Self.Tags.Len() == 0 {
|
||||||
|
return nil, ErrUntaggedServiceHost
|
||||||
|
}
|
||||||
|
|
||||||
|
advertisedServices := s.lb.Prefs().AdvertiseServices().AsSlice()
|
||||||
|
if !slices.Contains(advertisedServices, svcName) {
|
||||||
|
// TODO(hwh33,tailscale/corp#35860): clean these prefs up when (a) we
|
||||||
|
// exit early due to error or (b) when the returned listener is closed.
|
||||||
|
_, err = s.lb.EditPrefs(&ipn.MaskedPrefs{
|
||||||
|
AdvertiseServicesSet: true,
|
||||||
|
Prefs: ipn.Prefs{
|
||||||
|
AdvertiseServices: append(advertisedServices, svcName),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("updating advertised Services: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srvConfig := new(ipn.ServeConfig)
|
||||||
|
sc, srvConfigETag, err := s.lb.ServeConfigETag()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching current serve config: %w", err)
|
||||||
|
}
|
||||||
|
if sc.Valid() {
|
||||||
|
srvConfig = sc.AsStruct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fqdn := tailcfg.ServiceName(svcName).WithoutPrefix() + "." + st.CurrentTailnet.MagicDNSSuffix
|
||||||
|
|
||||||
|
// svcAddr is used to implement Addr() on the returned listener.
|
||||||
|
svcAddr := addr{
|
||||||
|
network: mode.network(),
|
||||||
|
// A hostname is not a network address, but Services listen on
|
||||||
|
// multiple addresses (the IPv4 and IPv6 virtual IPs), and there's
|
||||||
|
// no clear winner here between the two. Therefore prefer the FQDN.
|
||||||
|
//
|
||||||
|
// In the case of TCP or HTTP Services, the port will be added below.
|
||||||
|
addr: fqdn,
|
||||||
|
}
|
||||||
|
if m, ok := mode.(serviceModeWithPort); ok {
|
||||||
|
if m.port() == 0 {
|
||||||
|
return nil, errors.New("must specify a port to advertise")
|
||||||
|
}
|
||||||
|
svcAddr.addr += ":" + strconv.Itoa(int(m.port()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start listening on a local TCP socket.
|
||||||
|
ln, err := net.Listen("tcp", "localhost:0")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("starting local listener: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m := mode.(type) {
|
||||||
|
case ServiceModeTCP:
|
||||||
|
// Forward all connections from service-hostname:port to our socket.
|
||||||
|
srvConfig.SetTCPForwardingForService(
|
||||||
|
m.Port, ln.Addr().String(), m.TerminateTLS,
|
||||||
|
tailcfg.ServiceName(svcName), m.PROXYProtocolVersion, st.CurrentTailnet.MagicDNSSuffix)
|
||||||
|
case ServiceModeHTTP:
|
||||||
|
// For HTTP Services, proxy all connections to our socket.
|
||||||
|
mds := st.CurrentTailnet.MagicDNSSuffix
|
||||||
|
haveRootHandler := false
|
||||||
|
// We need to add a separate proxy for each mount point in the caps map.
|
||||||
|
for path, caps := range m.capsMap() {
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
h := ipn.HTTPHandler{
|
||||||
|
AcceptAppCaps: caps,
|
||||||
|
Proxy: ln.Addr().String(),
|
||||||
|
}
|
||||||
|
if path == "/" {
|
||||||
|
haveRootHandler = true
|
||||||
|
} else {
|
||||||
|
h.Proxy += path
|
||||||
|
}
|
||||||
|
srvConfig.SetWebHandler(&h, svcName, m.Port, path, m.HTTPS, mds)
|
||||||
|
}
|
||||||
|
// We always need a root handler.
|
||||||
|
if !haveRootHandler {
|
||||||
|
h := ipn.HTTPHandler{Proxy: ln.Addr().String()}
|
||||||
|
srvConfig.SetWebHandler(&h, svcName, m.Port, "/", m.HTTPS, mds)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
ln.Close()
|
||||||
|
return nil, fmt.Errorf("unknown ServiceMode type %T", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.lb.SetServeConfig(srvConfig, srvConfigETag); err != nil {
|
||||||
|
ln.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(hwh33,tailscale/corp#35860): clean up state (advertising prefs,
|
||||||
|
// serve config changes) when the returned listener is closed.
|
||||||
|
|
||||||
|
return &ServiceListener{
|
||||||
|
Listener: ln,
|
||||||
|
FQDN: fqdn,
|
||||||
|
addr: svcAddr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type listenOn string
|
type listenOn string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -1444,7 +1693,12 @@ func (ln *listener) Accept() (net.Conn, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ln *listener) Addr() net.Addr { return addr{ln} }
|
func (ln *listener) Addr() net.Addr {
|
||||||
|
return addr{
|
||||||
|
network: ln.keys[0].network,
|
||||||
|
addr: ln.addr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (ln *listener) Close() error {
|
func (ln *listener) Close() error {
|
||||||
ln.s.mu.Lock()
|
ln.s.mu.Lock()
|
||||||
@@ -1484,10 +1738,12 @@ func (ln *listener) handle(c net.Conn) {
|
|||||||
// Server returns the tsnet Server associated with the listener.
|
// Server returns the tsnet Server associated with the listener.
|
||||||
func (ln *listener) Server() *Server { return ln.s }
|
func (ln *listener) Server() *Server { return ln.s }
|
||||||
|
|
||||||
type addr struct{ ln *listener }
|
type addr struct {
|
||||||
|
network, addr string
|
||||||
|
}
|
||||||
|
|
||||||
func (a addr) Network() string { return a.ln.keys[0].network }
|
func (a addr) Network() string { return a.network }
|
||||||
func (a addr) String() string { return a.ln.addr }
|
func (a addr) String() string { return a.addr }
|
||||||
|
|
||||||
// cleanupListener wraps a net.Listener with a function to be run on Close.
|
// cleanupListener wraps a net.Listener with a function to be run on Close.
|
||||||
type cleanupListener struct {
|
type cleanupListener struct {
|
||||||
|
|||||||
+400
-23
@@ -14,6 +14,7 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -28,6 +29,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -38,10 +40,12 @@ import (
|
|||||||
dto "github.com/prometheus/client_model/go"
|
dto "github.com/prometheus/client_model/go"
|
||||||
"github.com/prometheus/common/expfmt"
|
"github.com/prometheus/common/expfmt"
|
||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
|
|
||||||
"tailscale.com/client/local"
|
"tailscale.com/client/local"
|
||||||
"tailscale.com/cmd/testwrapper/flakytest"
|
"tailscale.com/cmd/testwrapper/flakytest"
|
||||||
"tailscale.com/internal/client/tailscale"
|
"tailscale.com/internal/client/tailscale"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/ipn/ipnlocal"
|
||||||
"tailscale.com/ipn/store/mem"
|
"tailscale.com/ipn/store/mem"
|
||||||
"tailscale.com/net/netns"
|
"tailscale.com/net/netns"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -51,6 +55,8 @@ import (
|
|||||||
"tailscale.com/tstest/integration/testcontrol"
|
"tailscale.com/tstest/integration/testcontrol"
|
||||||
"tailscale.com/types/key"
|
"tailscale.com/types/key"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
|
"tailscale.com/types/views"
|
||||||
|
"tailscale.com/util/mak"
|
||||||
"tailscale.com/util/must"
|
"tailscale.com/util/must"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,7 +142,7 @@ func startControl(t *testing.T) (controlURL string, control *testcontrol.Server)
|
|||||||
|
|
||||||
type testCertIssuer struct {
|
type testCertIssuer struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
certs map[string]*tls.Certificate
|
certs map[string]ipnlocal.TLSCertKeyPair // keyed by hostname
|
||||||
|
|
||||||
root *x509.Certificate
|
root *x509.Certificate
|
||||||
rootKey *ecdsa.PrivateKey
|
rootKey *ecdsa.PrivateKey
|
||||||
@@ -168,18 +174,18 @@ func newCertIssuer() *testCertIssuer {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
return &testCertIssuer{
|
return &testCertIssuer{
|
||||||
certs: make(map[string]*tls.Certificate),
|
|
||||||
root: rootCA,
|
root: rootCA,
|
||||||
rootKey: rootKey,
|
rootKey: rootKey,
|
||||||
|
certs: map[string]ipnlocal.TLSCertKeyPair{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tci *testCertIssuer) getCert(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
func (tci *testCertIssuer) getCert(hostname string) (*ipnlocal.TLSCertKeyPair, error) {
|
||||||
tci.mu.Lock()
|
tci.mu.Lock()
|
||||||
defer tci.mu.Unlock()
|
defer tci.mu.Unlock()
|
||||||
cert, ok := tci.certs[chi.ServerName]
|
cert, ok := tci.certs[hostname]
|
||||||
if ok {
|
if ok {
|
||||||
return cert, nil
|
return &cert, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
certPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
certPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
@@ -188,7 +194,7 @@ func (tci *testCertIssuer) getCert(chi *tls.ClientHelloInfo) (*tls.Certificate,
|
|||||||
}
|
}
|
||||||
certTmpl := &x509.Certificate{
|
certTmpl := &x509.Certificate{
|
||||||
SerialNumber: big.NewInt(1),
|
SerialNumber: big.NewInt(1),
|
||||||
DNSNames: []string{chi.ServerName},
|
DNSNames: []string{hostname},
|
||||||
NotBefore: time.Now(),
|
NotBefore: time.Now(),
|
||||||
NotAfter: time.Now().Add(time.Hour),
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
}
|
}
|
||||||
@@ -196,12 +202,22 @@ func (tci *testCertIssuer) getCert(chi *tls.ClientHelloInfo) (*tls.Certificate,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cert = &tls.Certificate{
|
keyDER, err := x509.MarshalPKCS8PrivateKey(certPrivKey)
|
||||||
Certificate: [][]byte{certDER, tci.root.Raw},
|
if err != nil {
|
||||||
PrivateKey: certPrivKey,
|
return nil, err
|
||||||
}
|
}
|
||||||
tci.certs[chi.ServerName] = cert
|
cert = ipnlocal.TLSCertKeyPair{
|
||||||
return cert, nil
|
CertPEM: pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: certDER,
|
||||||
|
}),
|
||||||
|
KeyPEM: pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "PRIVATE KEY",
|
||||||
|
Bytes: keyDER,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
tci.certs[hostname] = cert
|
||||||
|
return &cert, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tci *testCertIssuer) Pool() *x509.CertPool {
|
func (tci *testCertIssuer) Pool() *x509.CertPool {
|
||||||
@@ -218,12 +234,11 @@ func startServer(t *testing.T, ctx context.Context, controlURL, hostname string)
|
|||||||
tmp := filepath.Join(t.TempDir(), hostname)
|
tmp := filepath.Join(t.TempDir(), hostname)
|
||||||
os.MkdirAll(tmp, 0755)
|
os.MkdirAll(tmp, 0755)
|
||||||
s := &Server{
|
s := &Server{
|
||||||
Dir: tmp,
|
Dir: tmp,
|
||||||
ControlURL: controlURL,
|
ControlURL: controlURL,
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
Store: new(mem.Store),
|
Store: new(mem.Store),
|
||||||
Ephemeral: true,
|
Ephemeral: true,
|
||||||
getCertForTesting: testCertRoot.getCert,
|
|
||||||
}
|
}
|
||||||
if *verboseNodes {
|
if *verboseNodes {
|
||||||
s.Logf = t.Logf
|
s.Logf = t.Logf
|
||||||
@@ -234,6 +249,8 @@ func startServer(t *testing.T, ctx context.Context, controlURL, hostname string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
s.lb.ConfigureCertsForTest(testCertRoot.getCert)
|
||||||
|
|
||||||
return s, status.TailscaleIPs[0], status.Self.PublicKey
|
return s, status.TailscaleIPs[0], status.Self.PublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,12 +276,11 @@ func TestDialBlocks(t *testing.T) {
|
|||||||
tmp := filepath.Join(t.TempDir(), "s2")
|
tmp := filepath.Join(t.TempDir(), "s2")
|
||||||
os.MkdirAll(tmp, 0755)
|
os.MkdirAll(tmp, 0755)
|
||||||
s2 := &Server{
|
s2 := &Server{
|
||||||
Dir: tmp,
|
Dir: tmp,
|
||||||
ControlURL: controlURL,
|
ControlURL: controlURL,
|
||||||
Hostname: "s2",
|
Hostname: "s2",
|
||||||
Store: new(mem.Store),
|
Store: new(mem.Store),
|
||||||
Ephemeral: true,
|
Ephemeral: true,
|
||||||
getCertForTesting: testCertRoot.getCert,
|
|
||||||
}
|
}
|
||||||
if *verboseNodes {
|
if *verboseNodes {
|
||||||
s2.Logf = log.Printf
|
s2.Logf = log.Printf
|
||||||
@@ -842,6 +858,367 @@ func TestFunnelClose(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListenService(t *testing.T) {
|
||||||
|
// First test an error case which doesn't require all of the fancy setup.
|
||||||
|
t.Run("untagged_node_error", func(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
controlURL, _ := startControl(t)
|
||||||
|
serviceHost, _, _ := startServer(t, ctx, controlURL, "service-host")
|
||||||
|
|
||||||
|
ln, err := serviceHost.ListenService("svc:foo", ServiceModeTCP{Port: 8080})
|
||||||
|
if ln != nil {
|
||||||
|
ln.Close()
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUntaggedServiceHost) {
|
||||||
|
t.Fatalf("expected %v, got %v", ErrUntaggedServiceHost, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Now on to the fancier tests.
|
||||||
|
|
||||||
|
type dialFn func(context.Context, string, string) (net.Conn, error)
|
||||||
|
|
||||||
|
// TCP helpers
|
||||||
|
acceptAndEcho := func(t *testing.T, ln net.Listener) {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Error("accept error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if _, err := io.Copy(conn, conn); err != nil {
|
||||||
|
t.Error("copy error:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEcho := func(t *testing.T, conn net.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
msg := "echo"
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
if _, err := conn.Write([]byte(msg)); err != nil {
|
||||||
|
t.Fatal("write failed:", err)
|
||||||
|
}
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("read failed:", err)
|
||||||
|
}
|
||||||
|
got := string(buf[:n])
|
||||||
|
if got != msg {
|
||||||
|
t.Fatalf("unexpected response:\n\twant: %s\n\tgot: %s", msg, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP helpers
|
||||||
|
checkAndEcho := func(t *testing.T, ln net.Listener, check func(r *http.Request)) {
|
||||||
|
t.Helper()
|
||||||
|
if check == nil {
|
||||||
|
check = func(*http.Request) {}
|
||||||
|
}
|
||||||
|
http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
check(r)
|
||||||
|
if _, err := io.Copy(w, r.Body); err != nil {
|
||||||
|
t.Error("copy error:", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
assertEchoHTTP := func(t *testing.T, hostname, path string, dial dialFn) {
|
||||||
|
t.Helper()
|
||||||
|
c := http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: dial,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msg := "echo"
|
||||||
|
resp, err := c.Post("http://"+hostname+path, "text/plain", strings.NewReader(msg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("posting request:", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("reading body:", err)
|
||||||
|
}
|
||||||
|
got := string(b)
|
||||||
|
if got != msg {
|
||||||
|
t.Fatalf("unexpected response:\n\twant: %s\n\tgot: %s", msg, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
|
||||||
|
// modes is used as input to [Server.ListenService].
|
||||||
|
//
|
||||||
|
// If this slice has multiple modes, then ListenService will be invoked
|
||||||
|
// multiple times. The number of listeners provided to the run function
|
||||||
|
// (below) will always match the number of elements in this slice.
|
||||||
|
modes []ServiceMode
|
||||||
|
|
||||||
|
extraSetup func(t *testing.T, control *testcontrol.Server)
|
||||||
|
|
||||||
|
// run executes the test. This function does not need to close any of
|
||||||
|
// the input resources, but it should close any new resources it opens.
|
||||||
|
// listeners[i] corresponds to inputs[i].
|
||||||
|
run func(t *testing.T, listeners []*ServiceListener, peer *Server)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic_TCP",
|
||||||
|
modes: []ServiceMode{
|
||||||
|
ServiceModeTCP{Port: 99},
|
||||||
|
},
|
||||||
|
run: func(t *testing.T, listeners []*ServiceListener, peer *Server) {
|
||||||
|
go acceptAndEcho(t, listeners[0])
|
||||||
|
|
||||||
|
target := fmt.Sprintf("%s:%d", listeners[0].FQDN, 99)
|
||||||
|
conn := must.Get(peer.Dial(t.Context(), "tcp", target))
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
assertEcho(t, conn)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TLS_terminated_TCP",
|
||||||
|
modes: []ServiceMode{
|
||||||
|
ServiceModeTCP{
|
||||||
|
TerminateTLS: true,
|
||||||
|
Port: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run: func(t *testing.T, listeners []*ServiceListener, peer *Server) {
|
||||||
|
go acceptAndEcho(t, listeners[0])
|
||||||
|
|
||||||
|
target := fmt.Sprintf("%s:%d", listeners[0].FQDN, 443)
|
||||||
|
conn := must.Get(peer.Dial(t.Context(), "tcp", target))
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
assertEcho(t, tls.Client(conn, &tls.Config{
|
||||||
|
ServerName: listeners[0].FQDN,
|
||||||
|
RootCAs: testCertRoot.Pool(),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "identity_headers",
|
||||||
|
modes: []ServiceMode{
|
||||||
|
ServiceModeHTTP{
|
||||||
|
Port: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run: func(t *testing.T, listeners []*ServiceListener, peer *Server) {
|
||||||
|
expectHeader := "Tailscale-User-Name"
|
||||||
|
go checkAndEcho(t, listeners[0], func(r *http.Request) {
|
||||||
|
if _, ok := r.Header[expectHeader]; !ok {
|
||||||
|
t.Error("did not see expected header:", expectHeader)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assertEchoHTTP(t, listeners[0].FQDN, "", peer.Dial)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "identity_headers_TLS",
|
||||||
|
modes: []ServiceMode{
|
||||||
|
ServiceModeHTTP{
|
||||||
|
HTTPS: true,
|
||||||
|
Port: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run: func(t *testing.T, listeners []*ServiceListener, peer *Server) {
|
||||||
|
expectHeader := "Tailscale-User-Name"
|
||||||
|
go checkAndEcho(t, listeners[0], func(r *http.Request) {
|
||||||
|
if _, ok := r.Header[expectHeader]; !ok {
|
||||||
|
t.Error("did not see expected header:", expectHeader)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
dial := func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
tcpConn, err := peer.Dial(ctx, network, addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tls.Client(tcpConn, &tls.Config{
|
||||||
|
ServerName: listeners[0].FQDN,
|
||||||
|
RootCAs: testCertRoot.Pool(),
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEchoHTTP(t, listeners[0].FQDN, "", dial)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "app_capabilities",
|
||||||
|
modes: []ServiceMode{
|
||||||
|
ServiceModeHTTP{
|
||||||
|
Port: 80,
|
||||||
|
AcceptAppCaps: map[string][]string{
|
||||||
|
"/": {"example.com/cap/all-paths"},
|
||||||
|
"/foo": {"example.com/cap/all-paths", "example.com/cap/foo"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraSetup: func(t *testing.T, control *testcontrol.Server) {
|
||||||
|
control.SetGlobalAppCaps(tailcfg.PeerCapMap{
|
||||||
|
"example.com/cap/all-paths": []tailcfg.RawMessage{`true`},
|
||||||
|
"example.com/cap/foo": []tailcfg.RawMessage{`true`},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
run: func(t *testing.T, listeners []*ServiceListener, peer *Server) {
|
||||||
|
allPathsCap := "example.com/cap/all-paths"
|
||||||
|
fooCap := "example.com/cap/foo"
|
||||||
|
checkCaps := func(r *http.Request) {
|
||||||
|
rawCaps, ok := r.Header["Tailscale-App-Capabilities"]
|
||||||
|
if !ok {
|
||||||
|
t.Error("no app capabilities header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(rawCaps) != 1 {
|
||||||
|
t.Error("expected one app capabilities header value, got", len(rawCaps))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var caps map[string][]any
|
||||||
|
if err := json.Unmarshal([]byte(rawCaps[0]), &caps); err != nil {
|
||||||
|
t.Error("error unmarshaling app caps:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := caps[allPathsCap]; !ok {
|
||||||
|
t.Errorf("got app caps, but %v is not present; saw:\n%v", allPathsCap, caps)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/foo") {
|
||||||
|
if _, ok := caps[fooCap]; !ok {
|
||||||
|
t.Errorf("%v should be present for /foo request; saw:\n%v", fooCap, caps)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, ok := caps[fooCap]; ok {
|
||||||
|
t.Errorf("%v should not be present for non-/foo request; saw:\n%v", fooCap, caps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go checkAndEcho(t, listeners[0], checkCaps)
|
||||||
|
assertEchoHTTP(t, listeners[0].FQDN, "", peer.Dial)
|
||||||
|
assertEchoHTTP(t, listeners[0].FQDN, "/foo", peer.Dial)
|
||||||
|
assertEchoHTTP(t, listeners[0].FQDN, "/foo/bar", peer.Dial)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple_ports",
|
||||||
|
modes: []ServiceMode{
|
||||||
|
ServiceModeTCP{
|
||||||
|
Port: 99,
|
||||||
|
},
|
||||||
|
ServiceModeHTTP{
|
||||||
|
Port: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run: func(t *testing.T, listeners []*ServiceListener, peer *Server) {
|
||||||
|
go acceptAndEcho(t, listeners[0])
|
||||||
|
|
||||||
|
target := fmt.Sprintf("%s:%d", listeners[0].FQDN, 99)
|
||||||
|
conn := must.Get(peer.Dial(t.Context(), "tcp", target))
|
||||||
|
defer conn.Close()
|
||||||
|
assertEcho(t, conn)
|
||||||
|
|
||||||
|
go checkAndEcho(t, listeners[1], nil)
|
||||||
|
assertEchoHTTP(t, listeners[1].FQDN, "", peer.Dial)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
// Overview:
|
||||||
|
// - start test control
|
||||||
|
// - start 2 tsnet nodes:
|
||||||
|
// one to act as Service host and a second to act as a peer client
|
||||||
|
// - configure necessary state on control mock
|
||||||
|
// - start a Service listener from the host
|
||||||
|
// - call tt.run with our test bed
|
||||||
|
//
|
||||||
|
// This ends up also testing the Service forwarding logic in
|
||||||
|
// LocalBackend, but that's useful too.
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
controlURL, control := startControl(t)
|
||||||
|
serviceHost, _, _ := startServer(t, ctx, controlURL, "service-host")
|
||||||
|
serviceClient, _, _ := startServer(t, ctx, controlURL, "service-client")
|
||||||
|
|
||||||
|
const serviceName = tailcfg.ServiceName("svc:foo")
|
||||||
|
const serviceVIP = "100.11.22.33"
|
||||||
|
|
||||||
|
// == Set up necessary state in our mock ==
|
||||||
|
|
||||||
|
// The Service host must have the 'service-host' capability, which
|
||||||
|
// is a mapping from the Service name to the Service VIP.
|
||||||
|
var serviceHostCaps map[tailcfg.ServiceName]views.Slice[netip.Addr]
|
||||||
|
mak.Set(&serviceHostCaps, serviceName, views.SliceOf([]netip.Addr{netip.MustParseAddr(serviceVIP)}))
|
||||||
|
j := must.Get(json.Marshal(serviceHostCaps))
|
||||||
|
cm := serviceHost.lb.NetMap().SelfNode.CapMap().AsMap()
|
||||||
|
mak.Set(&cm, tailcfg.NodeAttrServiceHost, []tailcfg.RawMessage{tailcfg.RawMessage(j)})
|
||||||
|
control.SetNodeCapMap(serviceHost.lb.NodeKey(), cm)
|
||||||
|
|
||||||
|
// The Service host must be allowed to advertise the Service VIP.
|
||||||
|
control.SetSubnetRoutes(serviceHost.lb.NodeKey(), []netip.Prefix{
|
||||||
|
netip.MustParsePrefix(serviceVIP + `/32`),
|
||||||
|
})
|
||||||
|
|
||||||
|
// The Service host must be a tagged node (any tag will do).
|
||||||
|
serviceHostNode := control.Node(serviceHost.lb.NodeKey())
|
||||||
|
serviceHostNode.Tags = append(serviceHostNode.Tags, "some-tag")
|
||||||
|
control.UpdateNode(serviceHostNode)
|
||||||
|
|
||||||
|
// The service client must accept routes advertised by other nodes
|
||||||
|
// (RouteAll is equivalent to --accept-routes).
|
||||||
|
must.Get(serviceClient.localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||||
|
RouteAllSet: true,
|
||||||
|
Prefs: ipn.Prefs{
|
||||||
|
RouteAll: true,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Set up DNS for our Service.
|
||||||
|
control.AddDNSRecords(tailcfg.DNSRecord{
|
||||||
|
Name: serviceName.WithoutPrefix() + "." + control.MagicDNSDomain,
|
||||||
|
Value: serviceVIP,
|
||||||
|
})
|
||||||
|
|
||||||
|
if tt.extraSetup != nil {
|
||||||
|
tt.extraSetup(t, control)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force netmap updates to avoid race conditions. The nodes need to
|
||||||
|
// see our control updates before we can start the test.
|
||||||
|
must.Do(control.ForceNetmapUpdate(ctx, serviceHost.lb.NodeKey()))
|
||||||
|
must.Do(control.ForceNetmapUpdate(ctx, serviceClient.lb.NodeKey()))
|
||||||
|
netmapUpToDate := func(s *Server) bool {
|
||||||
|
nm := s.lb.NetMap()
|
||||||
|
return slices.ContainsFunc(nm.DNS.ExtraRecords, func(r tailcfg.DNSRecord) bool {
|
||||||
|
return r.Value == serviceVIP
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for !netmapUpToDate(serviceClient) {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
for !netmapUpToDate(serviceHost) {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Done setting up mock state ==
|
||||||
|
|
||||||
|
// Start the Service listeners.
|
||||||
|
listeners := make([]*ServiceListener, 0, len(tt.modes))
|
||||||
|
for _, input := range tt.modes {
|
||||||
|
ln := must.Get(serviceHost.ListenService(serviceName.String(), input))
|
||||||
|
defer ln.Close()
|
||||||
|
listeners = append(listeners, ln)
|
||||||
|
}
|
||||||
|
|
||||||
|
tt.run(t, listeners, serviceClient)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListenerClose(t *testing.T) {
|
func TestListenerClose(t *testing.T) {
|
||||||
tstest.Shard(t)
|
tstest.Shard(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ type Server struct {
|
|||||||
// nodeCapMaps overrides the capability map sent down to a client.
|
// nodeCapMaps overrides the capability map sent down to a client.
|
||||||
nodeCapMaps map[key.NodePublic]tailcfg.NodeCapMap
|
nodeCapMaps map[key.NodePublic]tailcfg.NodeCapMap
|
||||||
|
|
||||||
|
// globalAppCaps configures global app capabilities, equivalent to:
|
||||||
|
// "grants": [
|
||||||
|
// {
|
||||||
|
// "src": ["*"],
|
||||||
|
// "dst": ["*"],
|
||||||
|
// "app": <contents of the input map>
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
globalAppCaps tailcfg.PeerCapMap
|
||||||
|
|
||||||
// suppressAutoMapResponses is the set of nodes that should not be sent
|
// suppressAutoMapResponses is the set of nodes that should not be sent
|
||||||
// automatic map responses from serveMap. (They should only get manually sent ones)
|
// automatic map responses from serveMap. (They should only get manually sent ones)
|
||||||
suppressAutoMapResponses set.Set[key.NodePublic]
|
suppressAutoMapResponses set.Set[key.NodePublic]
|
||||||
@@ -289,6 +299,43 @@ func (s *Server) addDebugMessage(nodeKeyDst key.NodePublic, msg any) bool {
|
|||||||
return sendUpdate(oldUpdatesCh, updateDebugInjection)
|
return sendUpdate(oldUpdatesCh, updateDebugInjection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceNetmapUpdate waits for the node to get stuck in a map poll and then
|
||||||
|
// sends the current netmap (which may result in a redundant netmap). The
|
||||||
|
// intended use case is ensuring state changes propagate before running tests.
|
||||||
|
//
|
||||||
|
// This should only be called for nodes connected as streaming clients. Calling
|
||||||
|
// this with a non-streaming node will result in non-deterministic behavior.
|
||||||
|
//
|
||||||
|
// This function cannot guarantee that the node has processed the issued update,
|
||||||
|
// so tests should confirm processing by querying the node. By example:
|
||||||
|
//
|
||||||
|
// if err := s.ForceNetmapUpdate(node.Key()); err != nil {
|
||||||
|
// // handle error
|
||||||
|
// }
|
||||||
|
// for !updatesPresent(node.NetMap()) {
|
||||||
|
// time.Sleep(10 * time.Millisecond)
|
||||||
|
// }
|
||||||
|
func (s *Server) ForceNetmapUpdate(ctx context.Context, nodeKey key.NodePublic) error {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if err := s.AwaitNodeInMapRequest(ctx, nodeKey); err != nil {
|
||||||
|
return fmt.Errorf("waiting for node to poll: %w", err)
|
||||||
|
}
|
||||||
|
mr, err := s.MapResponse(&tailcfg.MapRequest{NodeKey: nodeKey})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generating map response: %w", err)
|
||||||
|
}
|
||||||
|
if s.addDebugMessage(nodeKey, mr) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// If we failed to send the map response, loop around and try again.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mark the Node key of every node as expired
|
// Mark the Node key of every node as expired
|
||||||
func (s *Server) SetExpireAllNodes(expired bool) {
|
func (s *Server) SetExpireAllNodes(expired bool) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -531,6 +578,31 @@ func (s *Server) SetNodeCapMap(nodeKey key.NodePublic, capMap tailcfg.NodeCapMap
|
|||||||
s.updateLocked("SetNodeCapMap", s.nodeIDsLocked(0))
|
s.updateLocked("SetNodeCapMap", s.nodeIDsLocked(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetGlobalAppCaps configures global app capabilities. This is equivalent to
|
||||||
|
//
|
||||||
|
// "grants": [
|
||||||
|
// {
|
||||||
|
// "src": ["*"],
|
||||||
|
// "dst": ["*"],
|
||||||
|
// "app": <contents of the input map>
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
func (s *Server) SetGlobalAppCaps(appCaps tailcfg.PeerCapMap) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.globalAppCaps = appCaps
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDNSRecords adds records to the server's DNS config.
|
||||||
|
func (s *Server) AddDNSRecords(records ...tailcfg.DNSRecord) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.DNSConfig == nil {
|
||||||
|
s.DNSConfig = new(tailcfg.DNSConfig)
|
||||||
|
}
|
||||||
|
s.DNSConfig.ExtraRecords = append(s.DNSConfig.ExtraRecords, records...)
|
||||||
|
}
|
||||||
|
|
||||||
// nodeIDsLocked returns the node IDs of all nodes in the server, except
|
// nodeIDsLocked returns the node IDs of all nodes in the server, except
|
||||||
// for the node with the given ID.
|
// for the node with the given ID.
|
||||||
func (s *Server) nodeIDsLocked(except tailcfg.NodeID) []tailcfg.NodeID {
|
func (s *Server) nodeIDsLocked(except tailcfg.NodeID) []tailcfg.NodeID {
|
||||||
@@ -838,6 +910,9 @@ func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request, mkey key.
|
|||||||
CapMap: capMap,
|
CapMap: capMap,
|
||||||
Capabilities: slices.Collect(maps.Keys(capMap)),
|
Capabilities: slices.Collect(maps.Keys(capMap)),
|
||||||
}
|
}
|
||||||
|
if s.MagicDNSDomain != "" {
|
||||||
|
node.Name = node.Name + "." + s.MagicDNSDomain + "."
|
||||||
|
}
|
||||||
s.nodes[nk] = node
|
s.nodes[nk] = node
|
||||||
}
|
}
|
||||||
requireAuth := s.RequireAuth
|
requireAuth := s.RequireAuth
|
||||||
@@ -1261,9 +1336,7 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
|
|||||||
dns := s.DNSConfig
|
dns := s.DNSConfig
|
||||||
if dns != nil && s.MagicDNSDomain != "" {
|
if dns != nil && s.MagicDNSDomain != "" {
|
||||||
dns = dns.Clone()
|
dns = dns.Clone()
|
||||||
dns.CertDomains = []string{
|
dns.CertDomains = append(dns.CertDomains, node.Hostinfo.Hostname()+"."+s.MagicDNSDomain)
|
||||||
node.Hostinfo.Hostname() + "." + s.MagicDNSDomain,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res = &tailcfg.MapResponse{
|
res = &tailcfg.MapResponse{
|
||||||
@@ -1279,6 +1352,7 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
nodeMasqs := s.masquerades[node.Key]
|
nodeMasqs := s.masquerades[node.Key]
|
||||||
jailed := maps.Clone(s.peerIsJailed[node.Key])
|
jailed := maps.Clone(s.peerIsJailed[node.Key])
|
||||||
|
globalAppCaps := s.globalAppCaps
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
for _, p := range s.AllNodes() {
|
for _, p := range s.AllNodes() {
|
||||||
if p.StableID == node.StableID {
|
if p.StableID == node.StableID {
|
||||||
@@ -1330,6 +1404,18 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
|
|||||||
v6Prefix,
|
v6Prefix,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if globalAppCaps != nil {
|
||||||
|
res.PacketFilter = append(res.PacketFilter, tailcfg.FilterRule{
|
||||||
|
SrcIPs: []string{"*"},
|
||||||
|
CapGrant: []tailcfg.CapGrant{
|
||||||
|
{
|
||||||
|
Dsts: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()},
|
||||||
|
CapMap: globalAppCaps,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// If the server is tracking TKA state, and there's a single TKA head,
|
// If the server is tracking TKA state, and there's a single TKA head,
|
||||||
// add it to the MapResponse.
|
// add it to the MapResponse.
|
||||||
if s.tkaStorage != nil {
|
if s.tkaStorage != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user