SEANK.H.LIAO

tailscale funnel, secure grpc

more than just https

grpc for all?

grpc secure through tailscale funnel

Tailscale Funnel is a feature that lets you expose endpoints in you private tailnet (vpn) publicly via Tailscale operated proxies that do SNI based routing.

tsnet is their library to run your excutable as its own node, so you can have a self contained binary that will connect and register itself with the Tailscale control plane and expose a stable endpoint.

tsnet recently gained a method ListenFunnel(network, addr string, opts ...FunnelOption) (net.Listener, error), which gives you a TLS-terminated net.Listener. Good for HTTP, less nice for something like the canonical gRPC library, which wants to be in control over the TLS handshake.

Thankfully, we can look into the implementation for ListenFunnel, and see it doesn't need anything that isn't publicly available. So...

 1package main
 2
 3import (
 4        "context"
 5        "crypto/tls"
 6        "log"
 7
 8        "github.com/sanity-io/litter"
 9        "google.golang.org/grpc"
10        "google.golang.org/grpc/credentials"
11        "google.golang.org/grpc/health"
12        "google.golang.org/grpc/health/grpc_health_v1"
13        "tailscale.com/ipn"
14        "tailscale.com/tsnet"
15)
16
17func handle(msg, err) {
18        if err != nil {
19                log.Fatalln("up", err)
20        }
21}
22
23func main() {
24        ctx := context.Background()
25        ts := &tsnet.Server{
26                Ephemeral: true,
27        }
28
29        // start the server
30        _, err := ts.Up(ctx)
31
32        // get our FQDN
33        doms := ts.CertDomains()
34
35        // get a local client
36        lc, err := ts.LocalClient()
37        handle("get local client", err)
38
39        // enable funnel
40        sc = &ipn.ServeConfig{
41                AllowFunnel: map[ipn.HostPort]bool{ipn.HostPort(doms[0] + ":443"): true},
42        }
43        err = lc.SetServeConfig(ctx, sc)
44        handle("set serve config", err)
45
46        // listen on the ip network, listener is TCP / unterminated TLS
47        lis, err := ts.Listen("tcp", ":443")
48        handle("listen", err)
49
50        // simple grpc server with the healthcheck endpoint
51        gs := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
52                // use the local client to get the certificate on demand
53                GetCertificate: lc.GetCertificate,
54        })))
55        hs := health.NewServer()
56        hs.SetServingStatus("foo", grpc_health_v1.HealthCheckResponse_SERVING)
57        grpc_health_v1.RegisterHealthServer(gs, hs)
58
59        // run the grpc server
60        log.Println("serve")
61        err = gs.Serve(lis)
62        handle("grpc serve", err)
63}

And of course, we can test it with grpc-health-probe. We can expect the initial requests to fail as it takes time to provision the cert, which only happens on demand.

111:45:08 ~/tmp/grpc-health-probe 0:00:01
2master » go run . -tls -addr testrepo0163.badger-altered.ts.net:443
3timeout: failed to connect service "testrepo0163.abc-def.ts.net:443" within 1s
4exit status 2
5
611:45:10 ~/tmp/grpc-health-probe 0:00:01
7master » go run . -tls -addr testrepo0163.abc-def.ts.net:443
8status: SERVING