tsnet: change format of configuration to ListenService

Signed-off-by: Harry Harpham <harry@tailscale.com>
This commit is contained in:
Harry Harpham 2026-01-05 12:10:27 -07:00
parent 0d16a0bf32
commit b4ebe5ae70
No known key found for this signature in database
3 changed files with 93 additions and 97 deletions

View File

@ -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)
}

View File

@ -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 {

View File

@ -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)