Skip to content

Commit f427e77

Browse files
committed
Merge remote-tracking branch 'origin/hwh33/tsnet-services-support'
2 parents fa45d65 + 2c2b2f8 commit f427e77

7 files changed

Lines changed: 616 additions & 42 deletions

File tree

ipn/ipnlocal/cert.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertK
107107
// If a cert is expired, or expires sooner than minValidity, it will be renewed
108108
// synchronously. Otherwise it will be renewed asynchronously.
109109
func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string, minValidity time.Duration) (*TLSCertKeyPair, error) {
110+
b.mu.Lock()
111+
getCertForTest := b.getCertForTest
112+
b.mu.Unlock()
113+
114+
if getCertForTest != nil {
115+
testenv.AssertInTest()
116+
return getCertForTest(domain)
117+
}
118+
110119
if !validLookingCertDomain(domain) {
111120
return nil, errors.New("invalid domain")
112121
}
@@ -303,6 +312,16 @@ func (b *LocalBackend) getCertStore() (certStore, error) {
303312
return certFileStore{dir: dir, testRoots: testX509Roots}, nil
304313
}
305314

315+
// ConfigureCertsForTest sets a certificate retrieval function to be used by
316+
// this local backend, skipping the usual ACME certificate registration. Should
317+
// only be used in tests.
318+
func (b *LocalBackend) ConfigureCertsForTest(getCert func(hostname string) (*TLSCertKeyPair, error)) {
319+
testenv.AssertInTest()
320+
b.mu.Lock()
321+
b.getCertForTest = getCert
322+
b.mu.Unlock()
323+
}
324+
306325
// certFileStore implements certStore by storing the cert & key files in the named directory.
307326
type certFileStore struct {
308327
dir string

ipn/ipnlocal/local.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,10 @@ type LocalBackend struct {
399399
// hardwareAttested is whether backend should use a hardware-backed key to
400400
// bind the node identity to this device.
401401
hardwareAttested atomic.Bool
402+
403+
// getCertForTest is used to retrieve TLS certificates in tests.
404+
// See [LocalBackend.ConfigureCertsForTest].
405+
getCertForTest func(hostname string) (*TLSCertKeyPair, error)
402406
}
403407

404408
// SetHardwareAttested enables hardware attestation key signatures in map

ipn/serve.go

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
"tailscale.com/ipn/ipnstate"
1919
"tailscale.com/tailcfg"
20+
"tailscale.com/types/bools"
2021
"tailscale.com/types/ipproto"
2122
"tailscale.com/util/dnsname"
2223
"tailscale.com/util/mak"
@@ -433,24 +434,35 @@ func (sc *ServeConfig) SetTCPForwarding(port uint16, fwdAddr string, terminateTL
433434
if sc == nil {
434435
sc = new(ServeConfig)
435436
}
436-
tcpPortHandler := &sc.TCP
437-
if svcName := tailcfg.AsServiceName(host); svcName != "" {
438-
svcConfig, ok := sc.Services[svcName]
439-
if !ok {
440-
svcConfig = new(ServiceConfig)
441-
mak.Set(&sc.Services, svcName, svcConfig)
442-
}
443-
tcpPortHandler = &svcConfig.TCP
444-
}
445-
446437
handler := &TCPPortHandler{
447438
TCPForward: fwdAddr,
448439
ProxyProtocol: proxyProtocol, // can be 0
449440
}
450441
if terminateTLS {
451442
handler.TerminateTLS = host
452443
}
453-
mak.Set(tcpPortHandler, port, handler)
444+
mak.Set(&sc.TCP, port, handler)
445+
}
446+
447+
// SetTCPForwardingForService sets the fwdAddr (IP:port form) to which to
448+
// forward connections from the given port on the service. If terminateTLS
449+
// is true, TLS connections are terminated, with only the FQDN that corresponds
450+
// to the given service being permitted, before passing them to the fwdAddr.
451+
func (sc *ServeConfig) SetTCPForwardingForService(port uint16, fwdAddr string, svcName tailcfg.ServiceName, terminateTLS bool, proxyProtocol int, magicDNSSuffix string) {
452+
if sc == nil {
453+
sc = new(ServeConfig)
454+
}
455+
svcConfig, ok := sc.Services[svcName]
456+
if !ok {
457+
svcConfig = new(ServiceConfig)
458+
mak.Set(&sc.Services, svcName, svcConfig)
459+
}
460+
fqdn := svcName.WithoutPrefix() + "." + magicDNSSuffix
461+
mak.Set(&svcConfig.TCP, port, &TCPPortHandler{
462+
TCPForward: fwdAddr,
463+
ProxyProtocol: proxyProtocol,
464+
TerminateTLS: bools.IfElse(terminateTLS, fqdn, ""),
465+
})
454466
}
455467

456468
// SetFunnel sets the sc.AllowFunnel value for the given host and port.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
// The tsnet-services example demonstrates how to use tsnet with Services.
5+
// TODO:
6+
// - explain that a Service must be defined for the tailent and link to KB on
7+
// defining a Service
8+
// - recommend using an auth key with associated tags
9+
// - recommend an auto-approval rule for service tags
10+
//
11+
// To use it, generate an auth key from the Tailscale admin panel and
12+
// run the demo with the key:
13+
//
14+
// TS_AUTHKEY=<yourkey> go run tsnet-services.go
15+
package main
16+
17+
import (
18+
"flag"
19+
"fmt"
20+
"log"
21+
"net/http"
22+
23+
"tailscale.com/tailcfg"
24+
"tailscale.com/tsnet"
25+
)
26+
27+
var (
28+
svcName = flag.String("service", "", "the name of your Service, e.g. svc:demo-service")
29+
)
30+
31+
// TODO: this worked several times, then my host got stuck in 'Partially configured: has-config, config-valid'
32+
33+
func main() {
34+
flag.Parse()
35+
if *svcName == "" {
36+
log.Fatal("a Service name must be provided")
37+
}
38+
39+
const port uint16 = 443
40+
41+
s := &tsnet.Server{
42+
Dir: "./services-demo-config",
43+
Hostname: "tsnet-services-demo",
44+
}
45+
defer s.Close()
46+
47+
ln, err := s.ListenService(*svcName, port, tsnet.ServiceOptionTerminateTLS())
48+
if err != nil {
49+
log.Fatal(err)
50+
}
51+
defer ln.Close()
52+
53+
// TODO: provide access to FQDN from listener and use that instead
54+
fmt.Printf("Listening on https://%v\n", tailcfg.AsServiceName(*svcName).WithoutPrefix())
55+
56+
err = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57+
fmt.Fprintln(w, "<html><body><h1>Hello, tailnet!</h1>")
58+
}))
59+
log.Fatal(err)
60+
}

tsnet/tsnet.go

Lines changed: 184 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import (
5252
"tailscale.com/net/proxymux"
5353
"tailscale.com/net/socks5"
5454
"tailscale.com/net/tsdial"
55+
"tailscale.com/tailcfg"
5556
"tailscale.com/tsd"
5657
"tailscale.com/types/bools"
5758
"tailscale.com/types/logger"
@@ -158,8 +159,6 @@ type Server struct {
158159
// that the control server will allow the node to adopt that tag.
159160
AdvertiseTags []string
160161

161-
getCertForTesting func(*tls.ClientHelloInfo) (*tls.Certificate, error)
162-
163162
initOnce sync.Once
164163
initErr error
165164
lb *ipnlocal.LocalBackend
@@ -1101,9 +1100,6 @@ func (s *Server) RegisterFallbackTCPHandler(cb FallbackTCPHandler) func() {
11011100
// It calls GetCertificate on the localClient, passing in the ClientHelloInfo.
11021101
// For testing, if s.getCertForTesting is set, it will call that instead.
11031102
func (s *Server) getCert(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
1104-
if s.getCertForTesting != nil {
1105-
return s.getCertForTesting(hi)
1106-
}
11071103
lc, err := s.LocalClient()
11081104
if err != nil {
11091105
return nil, err
@@ -1239,6 +1235,189 @@ func (s *Server) ListenFunnel(network, addr string, opts ...FunnelOption) (net.L
12391235
return tls.NewListener(ln, tlsConfig), nil
12401236
}
12411237

1238+
// TODO: doc
1239+
type ServiceOption interface {
1240+
serviceOption()
1241+
}
1242+
1243+
type serviceOptionTerminateTLS struct{}
1244+
1245+
func (serviceOptionTerminateTLS) serviceOption() {}
1246+
1247+
// TODO: doc
1248+
func ServiceOptionTerminateTLS() ServiceOption {
1249+
return serviceOptionTerminateTLS{}
1250+
}
1251+
1252+
type serviceOptionPROXYProtocol struct {
1253+
version int
1254+
}
1255+
1256+
func (serviceOptionPROXYProtocol) serviceOption() {}
1257+
1258+
// TODO: doc
1259+
func ServiceOptionPROXYProtocol(version int) ServiceOption {
1260+
return serviceOptionPROXYProtocol{version}
1261+
}
1262+
1263+
type serviceOptionAppCapabilities struct {
1264+
path string
1265+
caps []string
1266+
}
1267+
1268+
func (serviceOptionAppCapabilities) serviceOption() {}
1269+
1270+
// TODO: doc
1271+
func ServiceOptionAppCapabilities(capabilities ...string) ServiceOption {
1272+
return ServiceOptionAppCapabilitiesForPath("/", capabilities...)
1273+
}
1274+
1275+
// TODO: doc
1276+
func ServiceOptionAppCapabilitiesForPath(path string, capabilities ...string) ServiceOption {
1277+
return serviceOptionAppCapabilities{path, capabilities}
1278+
}
1279+
1280+
type serviceOptionWithHeaders struct{}
1281+
1282+
func (serviceOptionWithHeaders) serviceOption() {}
1283+
1284+
// TODO: doc
1285+
func ServiceOptionWithHeaders() ServiceOption {
1286+
return serviceOptionWithHeaders{}
1287+
}
1288+
1289+
// ErrUntaggedServiceHost is returned by ListenService when run on a node
1290+
// without any ACL tags. A node must use a tag-based identity to act as a
1291+
// Service host. For more information, see:
1292+
// https://tailscale.com/kb/1552/tailscale-services#prerequisites
1293+
var ErrUntaggedServiceHost = errors.New("service hosts must be tagged nodes")
1294+
1295+
// TODO: doc
1296+
// TODO: tailcfg.ServiceName?
1297+
func (s *Server) ListenService(name string, port uint16, opts ...ServiceOption) (net.Listener, error) {
1298+
if err := tailcfg.ServiceName(name).Validate(); err != nil {
1299+
return nil, err
1300+
}
1301+
svcName := name
1302+
1303+
// TODO:
1304+
// - create example for a Service with multiple ports
1305+
// - support web handlers?
1306+
// - at least app capabilities need to work
1307+
// - everything else could serve directly or use http.ReverseProxy, right?
1308+
// - maybe worth an http.ReverseProxy example?
1309+
// - support TUN mode
1310+
1311+
// Process options.
1312+
terminateTLS := false
1313+
proxyProtocol := 0
1314+
capsMap := map[string][]tailcfg.PeerCapability{} // mount point => caps
1315+
isHTTP := false
1316+
for _, o := range opts {
1317+
switch opt := o.(type) {
1318+
case serviceOptionTerminateTLS:
1319+
terminateTLS = true
1320+
case serviceOptionPROXYProtocol:
1321+
proxyProtocol = opt.version
1322+
case serviceOptionWithHeaders:
1323+
isHTTP = true
1324+
case serviceOptionAppCapabilities:
1325+
isHTTP = true
1326+
caps := make([]tailcfg.PeerCapability, 0, len(opt.caps))
1327+
for _, c := range opt.caps {
1328+
caps = append(caps, tailcfg.PeerCapability(c))
1329+
}
1330+
capsMap[opt.path] = append(capsMap[opt.path], caps...)
1331+
default:
1332+
return nil, fmt.Errorf("unknown opts FunnelOption type %T", o)
1333+
}
1334+
}
1335+
1336+
ctx := context.Background()
1337+
_, err := s.Up(ctx)
1338+
if err != nil {
1339+
return nil, err
1340+
}
1341+
1342+
lc := s.localClient
1343+
1344+
st, err := lc.StatusWithoutPeers(ctx)
1345+
if err != nil {
1346+
return nil, fmt.Errorf("fetching ACL tags: %w", err)
1347+
}
1348+
if st.Self.Tags == nil || st.Self.Tags.Len() == 0 {
1349+
return nil, ErrUntaggedServiceHost
1350+
}
1351+
1352+
prefs, err := lc.GetPrefs(ctx)
1353+
if err != nil {
1354+
return nil, fmt.Errorf("fetching node preferences: %w", err)
1355+
}
1356+
if !slices.Contains(prefs.AdvertiseServices, svcName) {
1357+
// TODO: do we need to undo this edit on error?
1358+
_, err = lc.EditPrefs(ctx, &ipn.MaskedPrefs{
1359+
AdvertiseServicesSet: true,
1360+
Prefs: ipn.Prefs{
1361+
AdvertiseServices: append(prefs.AdvertiseServices, svcName),
1362+
},
1363+
})
1364+
if err != nil {
1365+
return nil, fmt.Errorf("updating advertised Services: %w", err)
1366+
}
1367+
}
1368+
1369+
srvConfig, err := lc.GetServeConfig(ctx)
1370+
if err != nil {
1371+
return nil, fmt.Errorf("fetching node serve config: %w", err)
1372+
}
1373+
if srvConfig == nil {
1374+
srvConfig = new(ipn.ServeConfig)
1375+
}
1376+
1377+
// Start listening on a TCP socket.
1378+
ln, err := net.Listen("tcp", "localhost:0")
1379+
if err != nil {
1380+
return nil, fmt.Errorf("starting local listener: %w", err)
1381+
}
1382+
1383+
if isHTTP {
1384+
useTLS := false // TODO: set correctly
1385+
mds := st.CurrentTailnet.MagicDNSSuffix
1386+
setHandler := func(h ipn.HTTPHandler, path string) {
1387+
// TODO: do we need to add the path to the end of the proxy value?
1388+
h.Proxy = ln.Addr().String()
1389+
srvConfig.SetWebHandler(&h, svcName, port, path, useTLS, mds)
1390+
}
1391+
// Set a web handler for every mount point in the caps map. If we don't
1392+
// end up with a root handler after that, we need to set one.
1393+
haveRootHandler := false
1394+
for path, caps := range capsMap {
1395+
if path == "/" {
1396+
haveRootHandler = true
1397+
}
1398+
setHandler(ipn.HTTPHandler{AcceptAppCaps: caps}, path)
1399+
}
1400+
if !haveRootHandler {
1401+
setHandler(ipn.HTTPHandler{}, "/")
1402+
}
1403+
} else {
1404+
// Forward all connections from service-hostname:port to our socket.
1405+
srvConfig.SetTCPForwardingForService(
1406+
port, ln.Addr().String(), tailcfg.ServiceName(svcName),
1407+
terminateTLS, proxyProtocol, st.CurrentTailnet.MagicDNSSuffix)
1408+
}
1409+
1410+
if err := lc.SetServeConfig(ctx, srvConfig); err != nil {
1411+
ln.Close()
1412+
return nil, err
1413+
}
1414+
1415+
// TODO: wrap returned listener such that Close stops advertising the
1416+
// Service (should update prefs, serve config, etc.)
1417+
1418+
return ln, nil
1419+
}
1420+
12421421
type listenOn string
12431422

12441423
const (

0 commit comments

Comments
 (0)