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 <will@tailscale.com>
This commit is contained in:
Will Norris 2024-01-31 15:52:10 -08:00 committed by Will Norris
parent 9f0eaa4464
commit 128c99d4ae
4 changed files with 46 additions and 14 deletions

View File

@ -162,7 +162,19 @@ function LoginPopoverContent({
</div> </div>
{!auth.canManageNode && ( {!auth.canManageNode && (
<> <>
{!auth.viewerIdentity ? ( {auth.serverMode === "readonly" ? (
<p className="text-gray-500 text-xs">
This web interface is running in read-only mode.{" "}
<a
href="https://tailscale.com/s/web-client-read-only"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
) : !auth.viewerIdentity ? (
// User is not connected over Tailscale. // User is not connected over Tailscale.
// These states are only possible on the login client. // These states are only possible on the login client.
<> <>

View File

@ -12,7 +12,7 @@ export enum AuthType {
export type AuthResponse = { export type AuthResponse = {
authNeeded?: AuthType authNeeded?: AuthType
canManageNode: boolean canManageNode: boolean
serverMode: "login" | "manage" serverMode: "login" | "readonly" | "manage"
viewerIdentity?: { viewerIdentity?: {
loginName: string loginName: string
nodeName: string nodeName: string

View File

@ -95,6 +95,14 @@ type Server struct {
// In this mode, API calls are authenticated via platform auth. // In this mode, API calls are authenticated via platform auth.
LoginServerMode ServerMode = "login" 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 // ManageServerMode serves a management client for editing tailscale
// settings of a node. // settings of a node.
// //
@ -154,7 +162,7 @@ type ServerOpts struct {
// and not the lifespan of the web server. // and not the lifespan of the web server.
func NewServer(opts ServerOpts) (s *Server, err error) { func NewServer(opts ServerOpts) (s *Server, err error) {
switch opts.Mode { switch opts.Mode {
case LoginServerMode, ManageServerMode: case LoginServerMode, ReadOnlyServerMode, ManageServerMode:
// valid types // valid types
case "": case "":
return nil, fmt.Errorf("must specify a Mode") 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, // The client is secured by limiting the interface it listens on,
// or by authenticating requests before they reach the web client. // or by authenticating requests before they reach the web client.
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false)) csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
if s.mode == LoginServerMode { switch s.mode {
case LoginServerMode:
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI)) s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
metric = "web_login_client_initialization" 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)) s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
metric = "web_client_initialization" metric = "web_client_initialization"
} }
@ -483,7 +495,7 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
// First verify platform auth. // First verify platform auth.
// If platform auth is needed, this should happen first. // If platform auth is needed, this should happen first.
if s.mode == LoginServerMode { if s.mode == LoginServerMode || s.mode == ReadOnlyServerMode {
switch distro.Get() { switch distro.Get() {
case distro.Synology: case distro.Synology:
authorized, err := authorizeSynology(r) authorized, err := authorizeSynology(r)

View File

@ -42,15 +42,17 @@
webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic") 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.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.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 return webf
})(), })(),
Exec: runWeb, Exec: runWeb,
} }
var webArgs struct { var webArgs struct {
listen string listen string
cgi bool cgi bool
prefix string prefix string
readonly bool
} }
func tlsConfigFromEnvironment() *tls.Config { func tlsConfigFromEnvironment() *tls.Config {
@ -94,20 +96,26 @@ func runWeb(ctx context.Context, args []string) error {
if prefs, err := localClient.GetPrefs(ctx); err == nil { if prefs, err := localClient.GetPrefs(ctx); err == nil {
existingWebClient = prefs.RunWebClient existingWebClient = prefs.RunWebClient
} }
if !existingWebClient { var startedManagementClient bool // we started the management client
if !existingWebClient && !webArgs.readonly {
// Also start full client in tailscaled. // Also start full client in tailscaled.
log.Printf("starting tailscaled web client at %s:%d\n", selfIP.String(), web.ListenPort) log.Printf("starting tailscaled web client at %s:%d\n", selfIP.String(), web.ListenPort)
if err := setRunWebClient(ctx, true); err != nil { if err := setRunWebClient(ctx, true); err != nil {
return fmt.Errorf("starting web client in tailscaled: %w", err) return fmt.Errorf("starting web client in tailscaled: %w", err)
} }
startedManagementClient = true
} }
webServer, err := web.NewServer(web.ServerOpts{ opts := web.ServerOpts{
Mode: web.LoginServerMode, Mode: web.LoginServerMode,
CGIMode: webArgs.cgi, CGIMode: webArgs.cgi,
PathPrefix: webArgs.prefix, PathPrefix: webArgs.prefix,
LocalClient: &localClient, LocalClient: &localClient,
}) }
if webArgs.readonly {
opts.Mode = web.ReadOnlyServerMode
}
webServer, err := web.NewServer(opts)
if err != nil { if err != nil {
log.Printf("tailscale.web: %v", err) log.Printf("tailscale.web: %v", err)
return err return err
@ -117,10 +125,10 @@ func runWeb(ctx context.Context, args []string) error {
case <-ctx.Done(): case <-ctx.Done():
// Shutdown the server. // Shutdown the server.
webServer.Shutdown() webServer.Shutdown()
if !webArgs.cgi && !existingWebClient { if !webArgs.cgi && startedManagementClient {
log.Println("stopping tailscaled web client") log.Println("stopping tailscaled web client")
// When not in cgi mode, shut down the tailscaled // 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 { if err := setRunWebClient(context.Background(), false); err != nil {
log.Printf("stopping tailscaled web client: %v", err) log.Printf("stopping tailscaled web client: %v", err)
} }