diff --git a/client/web/web.go b/client/web/web.go index 2560bfbb1..217c1c686 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -37,13 +37,13 @@ import ( // Server is the backend server for a Tailscale web client. type Server struct { + mode ServerMode + logf logger.Logf lc *tailscale.LocalClient timeNow func() time.Time - devMode bool - tsDebugMode string - + devMode bool cgiMode bool pathPrefix string @@ -65,6 +65,31 @@ type Server struct { browserSessions sync.Map } +// ServerMode specifies the mode of a running web.Server. +type ServerMode string + +const ( + // LoginServerMode serves a readonly login client for logging a + // node into a tailnet, and viewing a readonly interface of the + // node's current Tailscale settings. + // + // In this mode, API calls are authenticated via platform auth. + LoginServerMode ServerMode = "login" + + // ManageServerMode serves a management client for editing tailscale + // settings of a node. + // + // This mode restricts the app to only being assessible over Tailscale, + // and API calls are authenticated via browser sessions associated with + // the source's Tailscale identity. If the source browser does not have + // a valid session, a readonly version of the app is displayed. + ManageServerMode ServerMode = "manage" + + // LegacyServerMode serves the legacy web client, visible to users + // prior to release of tailscale/corp#14335. + LegacyServerMode ServerMode = "legacy" +) + var ( exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0") exitNodeRouteV6 = netip.MustParsePrefix("::/0") @@ -72,6 +97,12 @@ var ( // ServerOpts contains options for constructing a new Server. type ServerOpts struct { + // Mode specifies the mode of web client being constructed. + Mode ServerMode + + // DevMode indicates that the server should be started with frontend + // assets served by a Vite dev server, allowing for local development + // on the web client frontend. DevMode bool // CGIMode indicates if the server is running as a CGI script. @@ -88,6 +119,8 @@ type ServerOpts struct { // time.Now is used as default. TimeNow func() time.Time + // Logf optionally provides a logger function. + // log.Printf is used as default. Logf logger.Logf } @@ -96,10 +129,19 @@ type ServerOpts struct { // 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) { + switch opts.Mode { + case LoginServerMode, ManageServerMode, LegacyServerMode: + // valid types + case "": + return nil, fmt.Errorf("must specify a Mode") + default: + return nil, fmt.Errorf("invalid Mode provided") + } if opts.LocalClient == nil { opts.LocalClient = &tailscale.LocalClient{} } s = &Server{ + mode: opts.Mode, logf: opts.Logf, devMode: opts.DevMode, lc: opts.LocalClient, @@ -113,7 +155,6 @@ func NewServer(opts ServerOpts) (s *Server, err error) { if s.logf == nil { s.logf = log.Printf } - s.tsDebugMode = s.debugMode() s.assetsHandler, s.assetsCleanup = assetsHandler(opts.DevMode) var metric string // clientmetric to report on startup @@ -124,9 +165,7 @@ func NewServer(opts ServerOpts) (s *Server, err error) { // The client is secured by limiting the interface it listens on, // or by authenticating requests before they reach the web client. csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false)) - if s.tsDebugMode == "login" { - // For the login client, we don't serve the full web client API, - // only the login endpoints. + if s.mode == LoginServerMode { s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI)) metric = "web_login_client_initialization" } else { @@ -146,25 +185,12 @@ func NewServer(opts ServerOpts) (s *Server, err error) { } func (s *Server) Shutdown() { + s.logf("web.Server: shutting down") if s.assetsCleanup != nil { s.assetsCleanup() } } -// debugMode returns the debug mode the web client is being run in. -// The empty string is returned in the case that this instance is -// not running in any debug mode. -func (s *Server) debugMode() string { - if !s.devMode { - return "" // debug modes only available in dev - } - switch mode := os.Getenv("TS_DEBUG_WEB_CLIENT_MODE"); mode { - case "login", "full": // valid debug modes - return mode - } - return "" -} - // ServeHTTP processes all requests for the Tailscale web client. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { handler := s.serve @@ -203,7 +229,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) { // authorizeRequest manages writing out any relevant authorization // errors to the ResponseWriter itself. func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) { - if s.tsDebugMode == "full" { // client using tailscale auth + if s.mode == ManageServerMode { // client using tailscale auth _, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) switch { case err != nil: @@ -256,11 +282,9 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) { case httpm.GET: // TODO(soniaappasamy): we may want a minimal node data response here s.serveGetNodeData(w, r) - case httpm.POST: - // TODO(soniaappasamy): implement - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return } + http.Error(w, "invalid endpoint", http.StatusNotFound) return } @@ -404,6 +428,12 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { profile := st.User[st.Self.UserID] deviceName := strings.Split(st.Self.DNSName, ".")[0] versionShort := strings.Split(st.Version, "-")[0] + var debugMode string + if s.mode == ManageServerMode { + debugMode = "full" + } else if s.mode == LoginServerMode { + debugMode = "login" + } data := &nodeData{ Profile: profile, Status: st.BackendState, @@ -415,7 +445,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { IsUnraid: distro.Get() == distro.Unraid, UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"), IPNVersion: versionShort, - DebugMode: s.tsDebugMode, + DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly? } for _, r := range prefs.AdvertiseRoutes { if r == exitNodeRouteV4 || r == exitNodeRouteV6 { diff --git a/client/web/web_test.go b/client/web/web_test.go index d1efb1abd..9d0441e00 100644 --- a/client/web/web_test.go +++ b/client/web/web_test.go @@ -337,9 +337,9 @@ func TestAuthorizeRequest(t *testing.T) { go localapi.Serve(lal) s := &Server{ - lc: &tailscale.LocalClient{Dial: lal.Dial}, - tsDebugMode: "full", - timeNow: time.Now, + mode: ManageServerMode, + lc: &tailscale.LocalClient{Dial: lal.Dial}, + timeNow: time.Now, } validCookie := "ts-cookie" s.browserSessions.Store(validCookie, &browserSession{ @@ -428,9 +428,9 @@ func TestServeTailscaleAuth(t *testing.T) { sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2) s := &Server{ - lc: &tailscale.LocalClient{Dial: lal.Dial}, - tsDebugMode: "full", - timeNow: func() time.Time { return timeNow }, + mode: ManageServerMode, + lc: &tailscale.LocalClient{Dial: lal.Dial}, + timeNow: func() time.Time { return timeNow }, } successCookie := "ts-cookie-success" diff --git a/cmd/tailscale/cli/web.go b/cmd/tailscale/cli/web.go index 437cd85f3..7c92c38ed 100644 --- a/cmd/tailscale/cli/web.go +++ b/cmd/tailscale/cli/web.go @@ -14,10 +14,13 @@ import ( "net/http" "net/http/cgi" "os" + "os/signal" "strings" "github.com/peterbourgon/ff/v3/ffcli" "tailscale.com/client/web" + "tailscale.com/ipn" + "tailscale.com/tailcfg" "tailscale.com/util/cmpx" ) @@ -76,11 +79,31 @@ func tlsConfigFromEnvironment() *tls.Config { } func runWeb(ctx context.Context, args []string) error { + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + defer cancel() + if len(args) > 0 { return fmt.Errorf("too many non-flag arguments: %q", args) } + st, err := localClient.StatusWithoutPeers(ctx) + if err != nil { + return fmt.Errorf("getting client status: %w", err) + } + hasPreviewCap := st.Self.HasCap(tailcfg.CapabilityPreviewWebClient) + + cliServerMode := web.LegacyServerMode + if hasPreviewCap { + cliServerMode = web.LoginServerMode + // Also start full client in tailscaled. + log.Printf("starting tailscaled web client at %s:5252\n", st.Self.TailscaleIPs[0]) + if err := setRunWebClient(ctx, true); err != nil { + return fmt.Errorf("starting web client in tailscaled: %w", err) + } + } + webServer, err := web.NewServer(web.ServerOpts{ + Mode: cliServerMode, DevMode: webArgs.dev, CGIMode: webArgs.cgi, PathPrefix: webArgs.prefix, @@ -90,24 +113,35 @@ func runWeb(ctx context.Context, args []string) error { log.Printf("tailscale.web: %v", err) return err } - defer webServer.Shutdown() + go func() { + select { + case <-ctx.Done(): + // Shutdown the server. + webServer.Shutdown() + if hasPreviewCap && !webArgs.cgi { + log.Println("stopping tailscaled web client") + // When not in cgi mode, shut down the tailscaled + // web client on cli termination. + if err := setRunWebClient(context.Background(), false); err != nil { + log.Printf("stopping tailscaled web client: %v", err) + } + } + } + os.Exit(0) + }() if webArgs.cgi { if err := cgi.Serve(webServer); err != nil { log.Printf("tailscale.cgi: %v", err) - return err } return nil - } - - tlsConfig := tlsConfigFromEnvironment() - if tlsConfig != nil { + } else if tlsConfig := tlsConfigFromEnvironment(); tlsConfig != nil { server := &http.Server{ Addr: webArgs.listen, TLSConfig: tlsConfig, Handler: webServer, } - + defer server.Shutdown(ctx) log.Printf("web server running on: https://%s", server.Addr) return server.ListenAndServeTLS("", "") } else { @@ -116,6 +150,14 @@ func runWeb(ctx context.Context, args []string) error { } } +func setRunWebClient(ctx context.Context, val bool) error { + _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{RunWebClient: val}, + RunWebClientSet: true, + }) + return err +} + // urlOfListenAddr parses a given listen address into a formatted URL func urlOfListenAddr(addr string) string { host, port, _ := net.SplitHostPort(addr) diff --git a/ipn/ipnlocal/web_client.go b/ipn/ipnlocal/web_client.go index bf48648fb..0b20a1cbc 100644 --- a/ipn/ipnlocal/web_client.go +++ b/ipn/ipnlocal/web_client.go @@ -52,6 +52,7 @@ func (b *LocalBackend) WebClientInit() (err error) { b.logf("WebClientInit: initializing web ui") if b.webClient.server, err = web.NewServer(web.ServerOpts{ + Mode: web.ManageServerMode, // TODO(sonia): allow passing back dev mode flag LocalClient: b.webClient.lc, Logf: b.logf, diff --git a/tsnet/example/web-client/web-client.go b/tsnet/example/web-client/web-client.go index 6ed802d92..c85dfc9d4 100644 --- a/tsnet/example/web-client/web-client.go +++ b/tsnet/example/web-client/web-client.go @@ -31,6 +31,7 @@ func main() { // Serve the Tailscale web client. ws, err := web.NewServer(web.ServerOpts{ + Mode: web.LegacyServerMode, DevMode: *devMode, LocalClient: lc, })