From 89953b015bee3a1a7f7e2e231df3aacf5dd75776 Mon Sep 17 00:00:00 2001 From: Sonia Appasamy Date: Wed, 11 Oct 2023 14:35:22 -0400 Subject: [PATCH] ipn/ipnlocal,client/web: add web client to tailscaled Allows for serving the web interface from tailscaled, with the ability to start and stop the server via localapi endpoints (/web/start and /web/stop). This will be used to run the new full management web client, which will only be accessible over Tailscale (with an extra auth check step over noise) from the daemon. This switch also allows us to run the web interface as a long-lived service in environments where the CLI version is restricted to CGI, allowing us to manage certain auth state in memory. ipn/ipnlocal/web is stubbed out in ipn/ipnlocal/web_stub for ios builds to satisfy ios restriction from adding "text/template" and "html/template" dependencies. Updates tailscale/corp#14335 Signed-off-by: Sonia Appasamy --- client/web/web.go | 23 +++- cmd/tailscale/cli/web.go | 8 +- cmd/tailscaled/depaware.txt | 14 ++- cmd/tailscaled/tailscaled.go | 4 + ipn/ipnlocal/local.go | 2 + ipn/ipnlocal/web.go | 110 ++++++++++++++++++ ipn/ipnlocal/web_stub.go | 22 ++++ ipn/localapi/localapi.go | 28 +++++ tsnet/example/web-client/web-client.go | 7 +- .../tailscaled_deps_test_darwin.go | 1 + .../tailscaled_deps_test_freebsd.go | 1 + .../integration/tailscaled_deps_test_linux.go | 1 + .../tailscaled_deps_test_openbsd.go | 1 + .../tailscaled_deps_test_windows.go | 1 + 14 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 ipn/ipnlocal/web.go create mode 100644 ipn/ipnlocal/web_stub.go diff --git a/client/web/web.go b/client/web/web.go index 43707821a..24c303ee0 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -49,8 +49,9 @@ type Server struct { cgiMode bool pathPrefix string - assetsHandler http.Handler // serves frontend assets apiHandler http.Handler // serves api endpoints; csrf-protected + assetsHandler http.Handler // serves frontend assets + assetsCleanup func() // called from Server.Shutdown // browserSessions is an in-memory cache of browser sessions for the // full management web client, which is only accessible over Tailscale. @@ -143,7 +144,10 @@ type ServerOpts struct { } // NewServer constructs a new Tailscale web client server. -func NewServer(opts ServerOpts) (s *Server, cleanup func()) { +// If err is empty, s is always non-nil. +// ctx is only required to live the duration of the NewServer call, +// and not the lifespan of the web server. +func NewServer(opts ServerOpts) (s *Server, err error) { if opts.LocalClient == nil { opts.LocalClient = &tailscale.LocalClient{} } @@ -162,7 +166,7 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) { s.logf = log.Printf } s.tsDebugMode = s.debugMode() - s.assetsHandler, cleanup = assetsHandler(opts.DevMode) + s.assetsHandler, s.assetsCleanup = assetsHandler(opts.DevMode) var metric string // clientmetric to report on startup @@ -182,14 +186,21 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) { metric = "web_client_initialization" } - // Report metric in separate go routine with 5 second timeout. - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + // Don't block startup on reporting metric. + // Report in separate go routine with 5 second timeout. go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() s.lc.IncrementCounter(ctx, metric, 1) }() - return s, cleanup + return s, nil +} + +func (s *Server) Shutdown() { + if s.assetsCleanup != nil { + s.assetsCleanup() + } } // debugMode returns the debug mode the web client is being run in. diff --git a/cmd/tailscale/cli/web.go b/cmd/tailscale/cli/web.go index 7e5cfddf5..437cd85f3 100644 --- a/cmd/tailscale/cli/web.go +++ b/cmd/tailscale/cli/web.go @@ -80,13 +80,17 @@ func runWeb(ctx context.Context, args []string) error { return fmt.Errorf("too many non-flag arguments: %q", args) } - webServer, cleanup := web.NewServer(web.ServerOpts{ + webServer, err := web.NewServer(web.ServerOpts{ DevMode: webArgs.dev, CGIMode: webArgs.cgi, PathPrefix: webArgs.prefix, LocalClient: &localClient, }) - defer cleanup() + if err != nil { + log.Printf("tailscale.web: %v", err) + return err + } + defer webServer.Shutdown() if webArgs.cgi { if err := cgi.Serve(webServer); err != nil { diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 2a8a5b838..40d4ab0fb 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -95,6 +95,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+ L github.com/google/nftables/xt from github.com/google/nftables/expr+ github.com/google/uuid from tailscale.com/clientupdate + github.com/gorilla/csrf from tailscale.com/client/web + github.com/gorilla/securecookie from github.com/gorilla/csrf github.com/hdevalence/ed25519consensus from tailscale.com/tka+ L 💣 github.com/illarion/gonotify from tailscale.com/net/dns L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun @@ -128,6 +130,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de L github.com/pierrec/lz4/v4/internal/lz4errors from github.com/pierrec/lz4/v4+ L github.com/pierrec/lz4/v4/internal/lz4stream from github.com/pierrec/lz4/v4 L github.com/pierrec/lz4/v4/internal/xxh32 from github.com/pierrec/lz4/v4/internal/lz4stream + github.com/pkg/errors from github.com/gorilla/csrf LD github.com/pkg/sftp from tailscale.com/ssh/tailssh LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient @@ -149,6 +152,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp github.com/tailscale/hujson from tailscale.com/ipn/conffile L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router+ + github.com/tailscale/web-client-prebuilt from tailscale.com/client/web 💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+ W 💣 github.com/tailscale/wireguard-go/conn/winrio from github.com/tailscale/wireguard-go/conn 💣 github.com/tailscale/wireguard-go/device from tailscale.com/net/tstun+ @@ -219,8 +223,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com from tailscale.com/version tailscale.com/atomicfile from tailscale.com/ipn+ LD tailscale.com/chirp from tailscale.com/cmd/tailscaled - tailscale.com/client/tailscale from tailscale.com/derp + tailscale.com/client/tailscale from tailscale.com/derp+ tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnlocal+ + tailscale.com/client/web from tailscale.com/ipn/ipnlocal tailscale.com/clientupdate from tailscale.com/ipn/ipnlocal tailscale.com/clientupdate/distsign from tailscale.com/clientupdate tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+ @@ -251,6 +256,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store tailscale.com/ipn/store/mem from tailscale.com/ipn/store+ L tailscale.com/kube from tailscale.com/ipn/store/kubestore + tailscale.com/licenses from tailscale.com/client/web tailscale.com/log/filelogger from tailscale.com/logpolicy tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+ @@ -339,7 +345,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+ tailscale.com/util/dnsname from tailscale.com/hostinfo+ tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal - tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth + tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth+ 💣 tailscale.com/util/hashx from tailscale.com/util/deephash tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+ tailscale.com/util/httpm from tailscale.com/client/tailscale+ @@ -468,6 +474,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de encoding/base32 from tailscale.com/tka+ encoding/base64 from encoding/json+ encoding/binary from compress/gzip+ + encoding/gob from github.com/gorilla/securecookie encoding/hex from crypto/x509+ encoding/json from expvar+ encoding/pem from crypto/tls+ @@ -482,6 +489,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de hash/fnv from tailscale.com/wgengine/magicsock+ hash/maphash from go4.org/mem html from tailscale.com/ipn/ipnlocal+ + html/template from github.com/gorilla/csrf io from bufio+ io/fs from crypto/x509+ io/ioutil from github.com/godbus/dbus/v5+ @@ -526,6 +534,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de sync/atomic from context+ syscall from crypto/rand+ text/tabwriter from runtime/pprof + text/template from html/template + text/template/parse from html/template+ time from compress/gzip+ unicode from bytes+ unicode/utf16 from crypto/x509+ diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index 29daf7113..923c2d63f 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -29,6 +29,7 @@ import ( "syscall" "time" + "tailscale.com/client/tailscale" "tailscale.com/cmd/tailscaled/childproc" "tailscale.com/control/controlclient" "tailscale.com/envknob" @@ -569,6 +570,9 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID if root := lb.TailscaleVarRoot(); root != "" { dnsfallback.SetCachePath(filepath.Join(root, "derpmap.cached.json"), logf) } + if envknob.Bool("TS_DEBUG_WEB_UI") { + lb.SetWebLocalClient(&tailscale.LocalClient{Socket: args.socketpath, UseSocketOnly: args.socketpath != ""}) + } configureTaildrop(logf, lb) if err := ns.Start(lb); err != nil { log.Fatalf("failed to start netstack: %v", err) diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 8338c7f6b..c81df8af2 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -205,6 +205,7 @@ type LocalBackend struct { httpTestClient *http.Client // for controlclient. nil by default, used by tests. ccGen clientGen // function for producing controlclient; lazily populated sshServer SSHServer // or nil, initialized lazily. + web webServer notify func(ipn.Notify) cc controlclient.Client ccAuto *controlclient.Auto // if cc is of type *controlclient.Auto @@ -643,6 +644,7 @@ func (b *LocalBackend) Shutdown() { b.debugSink = nil } b.mu.Unlock() + b.WebShutdown() if b.sockstatLogger != nil { b.sockstatLogger.Shutdown() diff --git a/ipn/ipnlocal/web.go b/ipn/ipnlocal/web.go new file mode 100644 index 000000000..676120e28 --- /dev/null +++ b/ipn/ipnlocal/web.go @@ -0,0 +1,110 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ios && !android + +package ipnlocal + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + + "tailscale.com/client/tailscale" + "tailscale.com/client/web" + "tailscale.com/envknob" +) + +// webServer holds state for the web interface for managing +// this tailscale instance. The web interface is not used by +// default, but initialized by calling LocalBackend.WebOrInit. +type webServer struct { + ws *web.Server // or nil, initialized lazily + httpServer *http.Server // or nil, initialized lazily + + // lc optionally specifies a LocalClient to use to connect + // to the localapi for this tailscaled instance. + // If nil, a default is used. + lc *tailscale.LocalClient + + wg sync.WaitGroup +} + +// SetWebLocalClient sets the b.web.lc function. +// If lc is provided as nil, b.web.lc is cleared out. +func (b *LocalBackend) SetWebLocalClient(lc *tailscale.LocalClient) { + b.mu.Lock() + defer b.mu.Unlock() + b.web.lc = lc +} + +// WebInit initializes the web interface for managing +// this tailscaled instance. If the web interface is +// already running, WebInit is a no-op. +func (b *LocalBackend) WebInit() (err error) { + if !envknob.Bool("TS_DEBUG_WEB_UI") { + return errors.New("web ui flag unset") + } + + b.mu.Lock() + defer b.mu.Unlock() + if b.web.ws != nil { + return nil + } + + b.logf("WebInit: initializing web ui") + if b.web.ws, err = web.NewServer(web.ServerOpts{ + // TODO(sonia): allow passing back dev mode flag + LocalClient: b.web.lc, + Logf: b.logf, + }); err != nil { + return fmt.Errorf("web.NewServer: %w", err) + } + + // Start up the server. + b.web.wg.Add(1) + go func() { + defer b.web.wg.Done() + // TODO(sonia/will): only listen on Tailscale IP addresses + addr := ":5252" + b.web.httpServer = &http.Server{ + Addr: addr, + Handler: http.HandlerFunc(b.web.ws.ServeHTTP), + } + b.logf("WebInit: serving web ui on %s", addr) + if err := b.web.httpServer.ListenAndServe(); err != nil { + if err != http.ErrServerClosed { + b.logf("[unexpected] WebInit: %v", err) + } + } + }() + + b.logf("WebInit: started web ui") + return nil +} + +// WebShutdown shuts down any running b.web servers and +// clears out b.web state (besides the b.web.lc field, +// which is left untouched because required for future +// web startups). +// WebShutdown obtains the b.mu lock. +func (b *LocalBackend) WebShutdown() { + b.mu.Lock() + webS := b.web.ws + httpS := b.web.httpServer + b.web.ws = nil + b.web.httpServer = nil + b.mu.Unlock() // release lock before shutdown + if webS != nil { + b.web.ws.Shutdown() + } + if httpS != nil { + if err := b.web.httpServer.Shutdown(context.Background()); err != nil { + b.logf("[unexpected] WebShutdown: %v", err) + } + } + b.web.wg.Wait() + b.logf("WebShutdown: shut down web ui") +} diff --git a/ipn/ipnlocal/web_stub.go b/ipn/ipnlocal/web_stub.go new file mode 100644 index 000000000..e3b77826b --- /dev/null +++ b/ipn/ipnlocal/web_stub.go @@ -0,0 +1,22 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build ios || android + +package ipnlocal + +import ( + "errors" + + "tailscale.com/client/tailscale" +) + +type webServer struct{} + +func (b *LocalBackend) SetWebLocalClient(lc *tailscale.LocalClient) {} + +func (b *LocalBackend) WebInit() error { + return errors.New("not implemented") +} + +func (b *LocalBackend) WebShutdown() {} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 51a84a62c..ec6b97265 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -66,6 +66,7 @@ var handler = map[string]localAPIHandler{ "file-put/": (*Handler).serveFilePut, "files/": (*Handler).serveFiles, "profiles/": (*Handler).serveProfiles, + "web/": (*Handler).serveWeb, // The other /localapi/v0/NAME handlers are exact matches and contain only NAME // without a trailing slash: @@ -2233,6 +2234,33 @@ func (h *Handler) serveDebugWebClient(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") } +func (h *Handler) serveWeb(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "access denied", http.StatusForbidden) + return + } + if r.Method != httpm.POST { + http.Error(w, "use POST", http.StatusMethodNotAllowed) + return + } + switch r.URL.Path { + case "/localapi/v0/web/start": + if err := h.b.WebInit(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + return + case "/localapi/v0/web/stop": + h.b.WebShutdown() + w.WriteHeader(http.StatusOK) + return + default: + http.Error(w, "invalid action", http.StatusBadRequest) + return + } +} + func defBool(a string, def bool) bool { if a == "" { return def diff --git a/tsnet/example/web-client/web-client.go b/tsnet/example/web-client/web-client.go index 903088f23..6ed802d92 100644 --- a/tsnet/example/web-client/web-client.go +++ b/tsnet/example/web-client/web-client.go @@ -30,11 +30,14 @@ func main() { } // Serve the Tailscale web client. - ws, cleanup := web.NewServer(web.ServerOpts{ + ws, err := web.NewServer(web.ServerOpts{ DevMode: *devMode, LocalClient: lc, }) - defer cleanup() + if err != nil { + log.Fatal(err) + } + defer ws.Shutdown() log.Printf("Serving Tailscale web client on http://%s", *addr) if err := http.ListenAndServe(*addr, ws); err != nil { if err != http.ErrServerClosed { diff --git a/tstest/integration/tailscaled_deps_test_darwin.go b/tstest/integration/tailscaled_deps_test_darwin.go index fb39930f2..10c69cb48 100644 --- a/tstest/integration/tailscaled_deps_test_darwin.go +++ b/tstest/integration/tailscaled_deps_test_darwin.go @@ -11,6 +11,7 @@ import ( // transitive deps when we run "go install tailscaled" in a child // process and can cache a prior success when a dependency changes. _ "tailscale.com/chirp" + _ "tailscale.com/client/tailscale" _ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/control/controlclient" _ "tailscale.com/derp/derphttp" diff --git a/tstest/integration/tailscaled_deps_test_freebsd.go b/tstest/integration/tailscaled_deps_test_freebsd.go index fb39930f2..10c69cb48 100644 --- a/tstest/integration/tailscaled_deps_test_freebsd.go +++ b/tstest/integration/tailscaled_deps_test_freebsd.go @@ -11,6 +11,7 @@ import ( // transitive deps when we run "go install tailscaled" in a child // process and can cache a prior success when a dependency changes. _ "tailscale.com/chirp" + _ "tailscale.com/client/tailscale" _ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/control/controlclient" _ "tailscale.com/derp/derphttp" diff --git a/tstest/integration/tailscaled_deps_test_linux.go b/tstest/integration/tailscaled_deps_test_linux.go index fb39930f2..10c69cb48 100644 --- a/tstest/integration/tailscaled_deps_test_linux.go +++ b/tstest/integration/tailscaled_deps_test_linux.go @@ -11,6 +11,7 @@ import ( // transitive deps when we run "go install tailscaled" in a child // process and can cache a prior success when a dependency changes. _ "tailscale.com/chirp" + _ "tailscale.com/client/tailscale" _ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/control/controlclient" _ "tailscale.com/derp/derphttp" diff --git a/tstest/integration/tailscaled_deps_test_openbsd.go b/tstest/integration/tailscaled_deps_test_openbsd.go index fb39930f2..10c69cb48 100644 --- a/tstest/integration/tailscaled_deps_test_openbsd.go +++ b/tstest/integration/tailscaled_deps_test_openbsd.go @@ -11,6 +11,7 @@ import ( // transitive deps when we run "go install tailscaled" in a child // process and can cache a prior success when a dependency changes. _ "tailscale.com/chirp" + _ "tailscale.com/client/tailscale" _ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/control/controlclient" _ "tailscale.com/derp/derphttp" diff --git a/tstest/integration/tailscaled_deps_test_windows.go b/tstest/integration/tailscaled_deps_test_windows.go index dd3bdcd31..762ca38e0 100644 --- a/tstest/integration/tailscaled_deps_test_windows.go +++ b/tstest/integration/tailscaled_deps_test_windows.go @@ -18,6 +18,7 @@ import ( _ "golang.org/x/sys/windows/svc/mgr" _ "golang.zx2c4.com/wintun" _ "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" + _ "tailscale.com/client/tailscale" _ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/control/controlclient" _ "tailscale.com/derp/derphttp"