diff --git a/tsnet/example/tsnet-services/tsnet-services.go b/tsnet/example/tsnet-services/tsnet-services.go index da0e1b0b2..eeb95e317 100644 --- a/tsnet/example/tsnet-services/tsnet-services.go +++ b/tsnet/example/tsnet-services/tsnet-services.go @@ -44,7 +44,8 @@ func main() { } defer s.Close() - ln, err := s.ListenService(*svcName, port, tsnet.ServiceOptionTerminateTLS()) + // TODO: use HTTPS instead + ln, err := s.ListenService(*svcName, port, tsnet.ServiceTCPOptions{TerminateTLS: true}) if err != nil { log.Fatal(err) } diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index b5da962a8..1c087cd64 100644 --- a/tsnet/tsnet.go +++ b/tsnet/tsnet.go @@ -1236,54 +1236,37 @@ func (s *Server) ListenFunnel(network, addr string, opts ...FunnelOption) (net.L } // TODO: doc -type ServiceOption interface { - serviceOption() +type ServiceTransportOptions interface { + serviceTransportOptions() } -type serviceOptionTerminateTLS struct{} - -func (serviceOptionTerminateTLS) serviceOption() {} - // TODO: doc -func ServiceOptionTerminateTLS() ServiceOption { - return serviceOptionTerminateTLS{} +type ServiceTCPOptions struct { + TerminateTLS bool + PROXYProtocol int } -type serviceOptionPROXYProtocol struct { - version int -} - -func (serviceOptionPROXYProtocol) serviceOption() {} +func (ServiceTCPOptions) serviceTransportOptions() {} // TODO: doc -func ServiceOptionPROXYProtocol(version int) ServiceOption { - return serviceOptionPROXYProtocol{version} +// TODO: it's not super intutive that an empty struct here sends identity headers +type ServiceHTTPOptions struct { + HTTPS bool + AcceptAppCaps map[string][]string } -type serviceOptionAppCapabilities struct { - path string - caps []string -} +func (ServiceHTTPOptions) serviceTransportOptions() {} -func (serviceOptionAppCapabilities) serviceOption() {} - -// TODO: doc -func ServiceOptionAppCapabilities(capabilities ...string) ServiceOption { - return ServiceOptionAppCapabilitiesForPath("/", capabilities...) -} - -// TODO: doc; include info on this overriding handlers at earlier paths -func ServiceOptionAppCapabilitiesForPath(path string, capabilities ...string) ServiceOption { - return serviceOptionAppCapabilities{path, capabilities} -} - -type serviceOptionWithHeaders struct{} - -func (serviceOptionWithHeaders) serviceOption() {} - -// TODO: doc -func ServiceOptionWithHeaders() ServiceOption { - return serviceOptionWithHeaders{} +func (opts ServiceHTTPOptions) capsMap() map[string][]tailcfg.PeerCapability { + capsMap := map[string][]tailcfg.PeerCapability{} + for path, capNames := range opts.AcceptAppCaps { + caps := make([]tailcfg.PeerCapability, 0, len(capNames)) + for _, c := range capNames { + caps = append(caps, tailcfg.PeerCapability(c)) + } + capsMap[path] = caps + } + return capsMap } // ErrUntaggedServiceHost is returned by ListenService when run on a node @@ -1294,7 +1277,8 @@ var ErrUntaggedServiceHost = errors.New("service hosts must be tagged nodes") // TODO: doc // TODO: tailcfg.ServiceName? -func (s *Server) ListenService(name string, port uint16, opts ...ServiceOption) (net.Listener, error) { +// TODO: does this API allow room for growth? Can everything fit into opts? +func (s *Server) ListenService(name string, port uint16, opts ServiceTransportOptions) (net.Listener, error) { if err := tailcfg.ServiceName(name).Validate(); err != nil { return nil, err } @@ -1308,31 +1292,6 @@ func (s *Server) ListenService(name string, port uint16, opts ...ServiceOption) // - maybe worth an http.ReverseProxy example? // - support TUN mode - // Process options. - terminateTLS := false - proxyProtocol := 0 - capsMap := map[string][]tailcfg.PeerCapability{} // mount point => caps - isHTTP := false - for _, o := range opts { - switch opt := o.(type) { - case serviceOptionTerminateTLS: - terminateTLS = true - case serviceOptionPROXYProtocol: - proxyProtocol = opt.version - case serviceOptionWithHeaders: - isHTTP = true - case serviceOptionAppCapabilities: - isHTTP = true - caps := make([]tailcfg.PeerCapability, 0, len(opt.caps)) - for _, c := range opt.caps { - caps = append(caps, tailcfg.PeerCapability(c)) - } - capsMap[opt.path] = append(capsMap[opt.path], caps...) - default: - return nil, fmt.Errorf("unknown opts FunnelOption type %T", o) - } - } - ctx := context.Background() _, err := s.Up(ctx) if err != nil { @@ -1380,33 +1339,42 @@ func (s *Server) ListenService(name string, port uint16, opts ...ServiceOption) return nil, fmt.Errorf("starting local listener: %w", err) } - if isHTTP { - useTLS := false // TODO: set correctly - mds := st.CurrentTailnet.MagicDNSSuffix - setHandler := func(h ipn.HTTPHandler, path string) { - h.Proxy = ln.Addr().String() - if path != "/" { - h.Proxy += path - } - srvConfig.SetWebHandler(&h, svcName, port, path, useTLS, mds) - } - // Set a web handler for every mount point in the caps map. If we don't - // end up with a root handler after that, we need to set one. - haveRootHandler := false - for path, caps := range capsMap { - if path == "/" { - haveRootHandler = true - } - setHandler(ipn.HTTPHandler{AcceptAppCaps: caps}, path) - } - if !haveRootHandler { - setHandler(ipn.HTTPHandler{}, "/") - } - } else { + if opts == nil { + opts = ServiceTCPOptions{} + } + switch o := opts.(type) { + case ServiceTCPOptions: // Forward all connections from service-hostname:port to our socket. srvConfig.SetTCPForwardingForService( port, ln.Addr().String(), tailcfg.ServiceName(svcName), - terminateTLS, proxyProtocol, st.CurrentTailnet.MagicDNSSuffix) + o.TerminateTLS, o.PROXYProtocol, st.CurrentTailnet.MagicDNSSuffix) + + case ServiceHTTPOptions: + mds := st.CurrentTailnet.MagicDNSSuffix + haveRootHandler := false + for path, caps := range o.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, port, path, o.HTTPS, mds) + } + if !haveRootHandler { + h := ipn.HTTPHandler{Proxy: ln.Addr().String()} + srvConfig.SetWebHandler(&h, svcName, port, "/", o.HTTPS, mds) + } + + default: + ln.Close() + return nil, fmt.Errorf("unknown ServiceTransportOptions type %T", opts) } if err := lc.SetServeConfig(ctx, srvConfig); err != nil { diff --git a/tsnet/tsnet_test.go b/tsnet/tsnet_test.go index 3a3dd959a..a1d2c8605 100644 --- a/tsnet/tsnet_test.go +++ b/tsnet/tsnet_test.go @@ -828,7 +828,7 @@ func TestListenService(t *testing.T) { tests := []struct { name string port uint16 - opts []ServiceOption + opts ServiceTransportOptions extraSetup func(t *testing.T, serviceHost, peer *Server, control *testcontrol.Server) @@ -851,7 +851,7 @@ func TestListenService(t *testing.T) { }, { name: "TLS_terminated_TCP", - opts: []ServiceOption{ServiceOptionTerminateTLS()}, + opts: ServiceTCPOptions{TerminateTLS: true}, port: 443, run: func(t *testing.T, serviceListener net.Listener, peer *Server, serviceFQDN string) { go acceptAndEcho(t, serviceListener) @@ -868,7 +868,7 @@ func TestListenService(t *testing.T) { }, { name: "identity_headers", - opts: []ServiceOption{ServiceOptionWithHeaders()}, + opts: ServiceHTTPOptions{}, port: 80, run: func(t *testing.T, serviceListener net.Listener, peer *Server, serviceFQDN string) { expectHeader := "Tailscale-User-Name" @@ -880,11 +880,39 @@ func TestListenService(t *testing.T) { assertEchoHTTP(t, serviceFQDN, "", peer.Dial) }, }, + { + name: "identity_headers_TLS", + opts: ServiceHTTPOptions{HTTPS: true}, + port: 80, + run: func(t *testing.T, serviceListener net.Listener, peer *Server, serviceFQDN string) { + expectHeader := "Tailscale-User-Name" + go checkAndEcho(t, serviceListener, 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: serviceFQDN, + RootCAs: testCertRoot.Pool(), + }), nil + } + + assertEchoHTTP(t, serviceFQDN, "", dial) + }, + }, { name: "app_capabilities", - opts: []ServiceOption{ - ServiceOptionAppCapabilities("example.com/cap/all-paths"), - ServiceOptionAppCapabilitiesForPath("/foo", "example.com/cap/all-paths", "example.com/cap/foo"), + opts: ServiceHTTPOptions{ + AcceptAppCaps: map[string][]string{ + "/": {"example.com/cap/all-paths"}, + "/foo": {"example.com/cap/all-paths", "example.com/cap/foo"}, + }, }, port: 80, extraSetup: func(t *testing.T, serviceHost, peer *Server, control *testcontrol.Server) { @@ -936,7 +964,6 @@ func TestListenService(t *testing.T) { // - TLS-terminated-TCP // - Service with multiple ports // - TUN Service - // - web handlers // Error cases: // - Untagged node } @@ -1013,7 +1040,7 @@ func TestListenService(t *testing.T) { // == Done setting up mock state == // Start a Service listener. - ln := must.Get(serviceHost.ListenService(serviceName.String(), tt.port, tt.opts...)) + ln := must.Get(serviceHost.ListenService(serviceName.String(), tt.port, tt.opts)) defer ln.Close() tt.run(t, ln, serviceClient, serviceFQDN)