diff --git a/client/web/web.go b/client/web/web.go index 43707821a..ffabe5f92 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 @@ -189,7 +193,13 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) { 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 3ae8c6bef..30d13e511 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -100,6 +100,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 @@ -133,6 +135,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+ @@ -268,6 +274,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/net/dnsfallback from tailscale.com/control/controlclient+ tailscale.com/net/flowtrack from tailscale.com/net/packet+ 💣 tailscale.com/net/interfaces from tailscale.com/control/controlclient+ + tailscale.com/net/memnet from tailscale.com/ipn/ipnlocal tailscale.com/net/netaddr from tailscale.com/ipn+ tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock tailscale.com/net/neterror from tailscale.com/net/dns/resolver+ @@ -339,7 +346,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+ @@ -469,6 +476,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+ @@ -483,6 +491,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+ @@ -527,6 +536,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/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index ad36fd37b..bd80442b8 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 @@ -635,6 +636,7 @@ func (b *LocalBackend) Shutdown() { b.sshServer.Shutdown() b.sshServer = nil } + b.webShutdownLocked() b.closePeerAPIListenersLocked() if b.debugSink != nil { b.e.InstallCaptureHook(nil) diff --git a/ipn/ipnlocal/web.go b/ipn/ipnlocal/web.go new file mode 100644 index 000000000..8ab9eefd7 --- /dev/null +++ b/ipn/ipnlocal/web.go @@ -0,0 +1,126 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package ipnlocal + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + + "tailscale.com/client/tailscale" + "tailscale.com/client/web" + "tailscale.com/envknob" + "tailscale.com/net/memnet" +) + +// 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 + + // webServer maintains its own localapi server and localclient connected to it + localAPIListener net.Listener // in-memory, used by lc + localAPIServer *http.Server + lc *tailscale.LocalClient + + wg sync.WaitGroup +} + +// WebOrInit gets or initializes the web interface for +// managing this tailscaled instance. +func (b *LocalBackend) WebOrInit(localapiHandler http.Handler) (_ *web.Server, err error) { + if !envknob.Bool("TS_DEBUG_WEB_UI") { + return nil, errors.New("web ui flag unset") + } + + b.mu.Lock() + defer b.mu.Unlock() + if b.web.ws != nil { + return b.web.ws, nil + } + + lal := memnet.Listen("local-tailscaled.sock:80") + b.web.localAPIListener = lal + b.web.localAPIServer = &http.Server{Handler: localapiHandler} + b.web.lc = &tailscale.LocalClient{Dial: lal.Dial} + + go func() { + if err := b.web.localAPIServer.Serve(lal); err != nil { + b.logf("localapi serve error: %v", err) + } + }() + + b.logf("WebOrInit: 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 nil, fmt.Errorf("web.NewServer: %w", err) + } + + // Start up the server. + b.web.wg.Add(1) + go func() { + defer b.web.wg.Done() + addr := ":5252" + b.web.httpServer = &http.Server{ + Addr: addr, + Handler: http.HandlerFunc(b.web.ws.ServeHTTP), + } + b.logf("WebOrInit: serving web ui on %s", addr) + if err := b.web.httpServer.ListenAndServe(); err != nil { + if err != http.ErrServerClosed { + b.logf("[unexpected] WebOrInit: %v", err) + } + } + }() + + b.logf("WebOrInit: started web ui") + return b.web.ws, 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). +func (b *LocalBackend) WebShutdown() { + b.mu.Lock() + defer b.mu.Unlock() + b.webShutdownLocked() +} + +// webShutdownLocked 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). +// +// b.mu must be held. +func (b *LocalBackend) webShutdownLocked() { + if b.web.ws != nil { + b.web.ws.Shutdown() + } + if b.web.httpServer != nil { + if err := b.web.httpServer.Shutdown(context.Background()); err != nil { + b.logf("[unexpected] webShutdownLocked: %v", err) + } + } + if b.web.localAPIServer != nil { + if err := b.web.localAPIServer.Shutdown(context.Background()); err != nil { + b.logf("[unexpected] webShutdownLocked: %v", err) + } + } + if b.web.localAPIListener != nil { + b.web.localAPIListener.Close() + } + b.web.ws = nil + b.web.httpServer = nil + b.web.wg.Wait() + b.logf("webShutdownLocked: shut down web ui") +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 4483efdd3..861523313 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: @@ -2181,6 +2182,30 @@ 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 r.Method != httpm.POST { + http.Error(w, "use POST", http.StatusMethodNotAllowed) + return + } + switch r.URL.Path { + case "/localapi/v0/web/start": + _, err := h.b.WebOrInit(h) + if 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 {