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"