diff --git a/tsnet/example/tsnet-services/tsnet-services.go b/tsnet/example/tsnet-services/tsnet-services.go new file mode 100644 index 000000000..afc5cf641 --- /dev/null +++ b/tsnet/example/tsnet-services/tsnet-services.go @@ -0,0 +1,62 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// The tsnet-services example demonstrates how to use tsnet with Services. +// TODO: explain that a Service must be defined for the tailent and link to KB +// on defining a Service +// +// To use it, generate an auth key from the Tailscale admin panel and +// run the demo with the key: +// +// TS_AUTHKEY= go run tsnet-services.go +package main + +import ( + "flag" + "fmt" + "log" + "math" + "net/http" + + "tailscale.com/tailcfg" + "tailscale.com/tsnet" +) + +var ( + svcName = flag.String("service", "", "the name of your Service, e.g. svc:demo-service") + port = flag.Uint("port", 0, "the port to listen on") +) + +func main() { + flag.Parse() + if *svcName == "" { + log.Fatal("a Service name must be provided") + } + if *port == 0 { + log.Fatal("the listening port must be provided") + } + if *port > math.MaxUint16 { + log.Fatal("invalid port number") + } + + s := &tsnet.Server{ + Dir: "./services-demo-config", + Hostname: "tsnet-services-demo", + } + defer s.Close() + + ln, err := s.ListenService(*svcName, uint16(*port)) + if err != nil { + log.Fatal(err) + } + defer ln.Close() + + fmt.Printf("Listening on http://%v\n", tailcfg.AsServiceName(*svcName).WithoutPrefix()) + + // TODO: maybe just respond to TCP connections? (since we don't know the port) + // Actually, let's hard-code port 80 and provide an example Service definition to use + err = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "

Hello, tailnet!

") + })) + log.Fatal(err) +} diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index ea165e932..263171256 100644 --- a/tsnet/tsnet.go +++ b/tsnet/tsnet.go @@ -52,6 +52,7 @@ import ( "tailscale.com/net/proxymux" "tailscale.com/net/socks5" "tailscale.com/net/tsdial" + "tailscale.com/tailcfg" "tailscale.com/tsd" "tailscale.com/types/bools" "tailscale.com/types/logger" @@ -1239,6 +1240,103 @@ func (s *Server) ListenFunnel(network, addr string, opts ...FunnelOption) (net.L return tls.NewListener(ln, tlsConfig), nil } +// TODO: doc +// TODO: name? +// TODO: can this mirror the format accepted by set-config? +// For now, configures a single endpoint +// Maybe this should be an interface, with implementations like ListenTCPService, etc. +type ListenServiceConfig struct { + Port uint16 + PortHandler ipn.TCPPortHandler // TODO: what about UDP support in the future? + + // TODO: could be HTTP-specific if this config becomes an interface + WebHandlers map[ipn.HostPort]*ipn.WebServerConfig + + // TODO: maybe something like this for things like PROXY protocol support? + // L4Options +} + +// TODO: do we actually need this? +type ServiceOption interface { + serviceOption() +} + +// TODO: doc +// TODO: tailcfg.ServiceName? +func (s *Server) ListenService(name string, port uint16) (net.Listener, error) { + if err := tailcfg.ServiceName(name).Validate(); err != nil { + return nil, err + } + + // TODO: + // - get existing serve config + // - make changes and update + // - pipe to local TCP listener + + // TODO: + // - try above with simple TCP listener first + // - handle Services with multiple ports defined + // - support web handlers + // - make sure extras like PROXY mode are supported + // - support TUN mode + + ctx := context.Background() + _, err := s.Up(ctx) + if err != nil { + return nil, err + } + + lc := s.localClient + + // TODO: check for ACL tags + + prefs, err := lc.GetPrefs(ctx) + if err != nil { + return nil, fmt.Errorf("fetching node preferences: %w", err) + } + if !slices.Contains(prefs.AdvertiseServices, name) { + _, err = lc.EditPrefs(ctx, &ipn.MaskedPrefs{ + AdvertiseServicesSet: true, + Prefs: ipn.Prefs{ + AdvertiseServices: append(prefs.AdvertiseServices, name), + }, + }) + if err != nil { + return nil, fmt.Errorf("updating advertised Services: %w", err) + } + } + + srvConfig, err := lc.GetServeConfig(ctx) + if err != nil { + return nil, fmt.Errorf("fetching node serve config: %w", err) + } + if srvConfig == nil { + srvConfig = new(ipn.ServeConfig) + } + + // Start listening on a TCP socket. We will direct the local client to + // forward connections to this listener. + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, fmt.Errorf("starting local listener: %w", err) + } + + // TODO: + // - Handle terminateTLS + // - Handle proxyProtocol + srvConfig.SetTCPForwarding(port, ln.Addr().String(), false, 0, name) + + if err := lc.SetServeConfig(ctx, srvConfig); err != nil { + ln.Close() + return nil, err + } + + // TODO: wrap returned listener such that Close stops advertising the + // Service (should update prefs, serve config, etc.) + + return ln, nil +} + type listenOn string const (