From 128c99d4ae0280ef4038aa948f899e9e6871acd5 Mon Sep 17 00:00:00 2001 From: Will Norris Date: Wed, 31 Jan 2024 15:52:10 -0800 Subject: [PATCH] client/web: add new readonly mode The new read-only mode is only accessible when running `tailscale web` by passing a new `-readonly` flag. This new mode is identical to the existing login mode with two exceptions: - the management client in tailscaled is not started (though if it is already running, it is left alone) - the client does not prompt the user to login or switch to the management client. Instead, a message is shown instructing the user to use other means to manage the device. Updates #10979 Signed-off-by: Will Norris --- client/web/src/components/login-toggle.tsx | 14 ++++++++++++- client/web/src/hooks/auth.ts | 2 +- client/web/web.go | 20 ++++++++++++++---- cmd/tailscale/cli/web.go | 24 ++++++++++++++-------- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/client/web/src/components/login-toggle.tsx b/client/web/src/components/login-toggle.tsx index 4fe416509..7a5032fe6 100644 --- a/client/web/src/components/login-toggle.tsx +++ b/client/web/src/components/login-toggle.tsx @@ -162,7 +162,19 @@ function LoginPopoverContent({ {!auth.canManageNode && ( <> - {!auth.viewerIdentity ? ( + {auth.serverMode === "readonly" ? ( +

+ This web interface is running in read-only mode.{" "} + + Learn more → + +

+ ) : !auth.viewerIdentity ? ( // User is not connected over Tailscale. // These states are only possible on the login client. <> diff --git a/client/web/src/hooks/auth.ts b/client/web/src/hooks/auth.ts index f3d2ea5f4..46b3dcdde 100644 --- a/client/web/src/hooks/auth.ts +++ b/client/web/src/hooks/auth.ts @@ -12,7 +12,7 @@ export enum AuthType { export type AuthResponse = { authNeeded?: AuthType canManageNode: boolean - serverMode: "login" | "manage" + serverMode: "login" | "readonly" | "manage" viewerIdentity?: { loginName: string nodeName: string diff --git a/client/web/web.go b/client/web/web.go index 082b77e2a..b415c404d 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -95,6 +95,14 @@ type Server struct { // In this mode, API calls are authenticated via platform auth. LoginServerMode ServerMode = "login" + // ReadOnlyServerMode is identical to LoginServerMode, + // but does not present a login button to switch to manage mode, + // even if the management client is running and reachable. + // + // This is designed for platforms where the device is configured by other means, + // such as Home Assistant's declarative YAML configuration. + ReadOnlyServerMode ServerMode = "readonly" + // ManageServerMode serves a management client for editing tailscale // settings of a node. // @@ -154,7 +162,7 @@ type ServerOpts struct { // and not the lifespan of the web server. func NewServer(opts ServerOpts) (s *Server, err error) { switch opts.Mode { - case LoginServerMode, ManageServerMode: + case LoginServerMode, ReadOnlyServerMode, ManageServerMode: // valid types case "": return nil, fmt.Errorf("must specify a Mode") @@ -207,10 +215,14 @@ 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.mode == LoginServerMode { + switch s.mode { + case LoginServerMode: s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI)) metric = "web_login_client_initialization" - } else { + case ReadOnlyServerMode: + s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI)) + metric = "web_readonly_client_initialization" + case ManageServerMode: s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI)) metric = "web_client_initialization" } @@ -483,7 +495,7 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) { // First verify platform auth. // If platform auth is needed, this should happen first. - if s.mode == LoginServerMode { + if s.mode == LoginServerMode || s.mode == ReadOnlyServerMode { switch distro.Get() { case distro.Synology: authorized, err := authorizeSynology(r) diff --git a/cmd/tailscale/cli/web.go b/cmd/tailscale/cli/web.go index d5a806153..a4cafc6e0 100644 --- a/cmd/tailscale/cli/web.go +++ b/cmd/tailscale/cli/web.go @@ -42,15 +42,17 @@ webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic") webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script") webf.StringVar(&webArgs.prefix, "prefix", "", "URL prefix added to requests (for cgi or reverse proxies)") + webf.BoolVar(&webArgs.readonly, "readonly", false, "run web UI in read-only mode") return webf })(), Exec: runWeb, } var webArgs struct { - listen string - cgi bool - prefix string + listen string + cgi bool + prefix string + readonly bool } func tlsConfigFromEnvironment() *tls.Config { @@ -94,20 +96,26 @@ func runWeb(ctx context.Context, args []string) error { if prefs, err := localClient.GetPrefs(ctx); err == nil { existingWebClient = prefs.RunWebClient } - if !existingWebClient { + var startedManagementClient bool // we started the management client + if !existingWebClient && !webArgs.readonly { // Also start full client in tailscaled. log.Printf("starting tailscaled web client at %s:%d\n", selfIP.String(), web.ListenPort) if err := setRunWebClient(ctx, true); err != nil { return fmt.Errorf("starting web client in tailscaled: %w", err) } + startedManagementClient = true } - webServer, err := web.NewServer(web.ServerOpts{ + opts := web.ServerOpts{ Mode: web.LoginServerMode, CGIMode: webArgs.cgi, PathPrefix: webArgs.prefix, LocalClient: &localClient, - }) + } + if webArgs.readonly { + opts.Mode = web.ReadOnlyServerMode + } + webServer, err := web.NewServer(opts) if err != nil { log.Printf("tailscale.web: %v", err) return err @@ -117,10 +125,10 @@ func runWeb(ctx context.Context, args []string) error { case <-ctx.Done(): // Shutdown the server. webServer.Shutdown() - if !webArgs.cgi && !existingWebClient { + if !webArgs.cgi && startedManagementClient { log.Println("stopping tailscaled web client") // When not in cgi mode, shut down the tailscaled - // web client on cli termination. + // web client on cli termination if we started it. if err := setRunWebClient(context.Background(), false); err != nil { log.Printf("stopping tailscaled web client: %v", err) }