Skip to content

Commit ffa2785

Browse files
committed
module: add Tailscale Services support for TCP and TLS listeners
Use bind address format "tailscale/node+servicelabel" to listen on a Tailscale Service. For example, "bind tailscale/app+plex" uses the tsnet node "app" to advertise service "svc:plex". Signed-off-by: Evan Champion <306651197+evan314159@users.noreply.github.com>
1 parent e181dcd commit ffa2785

1 file changed

Lines changed: 73 additions & 14 deletions

File tree

module.go

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"go.uber.org/zap"
2929
"tailscale.com/client/local"
3030
"tailscale.com/hostinfo"
31+
"tailscale.com/tailcfg"
3132
"tailscale.com/tsnet"
3233
)
3334

@@ -45,12 +46,26 @@ func init() {
4546
hostinfo.SetApp("caddy")
4647
}
4748

49+
// parseServiceBind splits a bind host into a node name and optional service name.
50+
// The format is "nodename+servicelabel", where the "+..." suffix is optional.
51+
// For example, "app+plex" returns ("app", "svc:plex") and "app" returns ("app", "").
52+
func parseServiceBind(host string) (nodeName string, serviceName tailcfg.ServiceName) {
53+
nodeName, svcLabel, ok := strings.Cut(host, "+")
54+
if ok && svcLabel != "" {
55+
return nodeName, tailcfg.ServiceName("svc:" + svcLabel)
56+
}
57+
return host, ""
58+
}
59+
4860
func getTCPListener(c context.Context, network string, host string, portRange string, portOffset uint, _ net.ListenConfig) (any, error) {
4961
ctx, ok := c.(caddy.Context)
5062
if !ok {
5163
return nil, fmt.Errorf("context is not a caddy.Context: %T", c)
5264
}
5365

66+
nodeName, svcName := parseServiceBind(host)
67+
host = nodeName
68+
5469
na, err := caddy.ParseNetworkAddress(caddy.JoinNetworkAddress(network, host, portRange))
5570
if err != nil {
5671
return nil, err
@@ -73,12 +88,30 @@ func getTCPListener(c context.Context, network string, host string, portRange st
7388
}
7489

7590
// Follow Caddy's standard listener pooling mechanism
76-
lnKey := fmt.Sprintf("tailscale/%s:%s:%s", host, network, port)
91+
lnKey := "tailscale/" + host
92+
if svcName != "" {
93+
lnKey += "+" + string(svcName)
94+
}
95+
lnKey += ":" + network + ":" + port
7796

7897
sharedLn, _, err := tailscaleListeners.LoadOrNew(lnKey, func() (caddy.Destructor, error) {
79-
ln, err := node.Listen(network, ":"+port)
80-
if err != nil {
81-
return nil, err
98+
var ln net.Listener
99+
if svcName != "" {
100+
portNum, err := strconv.ParseUint(port, 10, 16)
101+
if err != nil {
102+
return nil, fmt.Errorf("invalid port %q: %w", port, err)
103+
}
104+
sl, err := node.ListenService(string(svcName), tsnet.ServiceModeTCP{Port: uint16(portNum)})
105+
if err != nil {
106+
return nil, err
107+
}
108+
ln = sl
109+
} else {
110+
var err error
111+
ln, err = node.Listen(network, ":"+port)
112+
if err != nil {
113+
return nil, err
114+
}
82115
}
83116

84117
return &tailscaleSharedListener{
@@ -102,6 +135,9 @@ func getTLSListener(c context.Context, network string, host string, portRange st
102135
return nil, fmt.Errorf("context is not a caddy.Context: %T", c)
103136
}
104137

138+
nodeName, svcName := parseServiceBind(host)
139+
host = nodeName
140+
105141
na, err := caddy.ParseNetworkAddress(caddy.JoinNetworkAddress(network, host, portRange))
106142
if err != nil {
107143
return nil, err
@@ -124,21 +160,39 @@ func getTLSListener(c context.Context, network string, host string, portRange st
124160
}
125161

126162
// Follow Caddy's standard listener pooling mechanism
127-
lnKey := fmt.Sprintf("tailscale+tls/%s:%s:%s", host, network, port)
163+
lnKey := "tailscale+tls/" + host
164+
if svcName != "" {
165+
lnKey += "+" + string(svcName)
166+
}
167+
lnKey += ":" + network + ":" + port
128168

129169
sharedLn, _, err := tailscaleListeners.LoadOrNew(lnKey, func() (caddy.Destructor, error) {
130-
ln, err := node.Listen(network, ":"+port)
131-
if err != nil {
132-
return nil, err
133-
}
170+
var ln net.Listener
171+
if svcName != "" {
172+
portNum, err := strconv.ParseUint(port, 10, 16)
173+
if err != nil {
174+
return nil, fmt.Errorf("invalid port %q: %w", port, err)
175+
}
176+
sl, err := node.ListenService(string(svcName), tsnet.ServiceModeTCP{Port: uint16(portNum)})
177+
if err != nil {
178+
return nil, err
179+
}
180+
ln = sl
181+
} else {
182+
var err error
183+
ln, err = node.Listen(network, ":"+port)
184+
if err != nil {
185+
return nil, err
186+
}
134187

135-
localClient, _ := node.LocalClient()
136-
tlsLn := tls.NewListener(ln, &tls.Config{
137-
GetCertificate: localClient.GetCertificate,
138-
})
188+
localClient, _ := node.LocalClient()
189+
ln = tls.NewListener(ln, &tls.Config{
190+
GetCertificate: localClient.GetCertificate,
191+
})
192+
}
139193

140194
return &tailscaleSharedListener{
141-
Listener: tlsLn,
195+
Listener: ln,
142196
key: lnKey,
143197
}, nil
144198
})
@@ -158,6 +212,11 @@ func getUDPListener(c context.Context, network string, host string, portRange st
158212
return nil, fmt.Errorf("context is not a caddy.Context: %T", c)
159213
}
160214

215+
// Strip any service suffix from the host (e.g. "app+plex" -> "app")
216+
// so we can find the correct node. UDP service listeners are not yet
217+
// supported, so we always use a plain ListenPacket on the node.
218+
host, _, _ = strings.Cut(host, "+")
219+
161220
na, err := caddy.ParseNetworkAddress(caddy.JoinNetworkAddress(network, host, portRange))
162221
if err != nil {
163222
return nil, err

0 commit comments

Comments
 (0)