@@ -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+
4860func 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