mirror of
				https://github.com/tailscale/tailscale.git
				synced 2025-10-31 00:01:40 +01:00 
			
		
		
		
	Saves 139 KB. Also Synology support, which I saw had its own large-ish proxy parsing support on Linux, but support for proxies without Synology proxy support is reasonable, so I pulled that out as its own thing. Updates #12614 Change-Id: I22de285a3def7be77fdcf23e2bec7c83c9655593 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
		
			
				
	
	
		
			1385 lines
		
	
	
		
			44 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			1385 lines
		
	
	
		
			44 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Copyright (c) Tailscale Inc & AUTHORS
 | |
| // SPDX-License-Identifier: BSD-3-Clause
 | |
| 
 | |
| // Package web provides the Tailscale client for web.
 | |
| package web
 | |
| 
 | |
| import (
 | |
| 	"cmp"
 | |
| 	"context"
 | |
| 	"encoding/json"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"io"
 | |
| 	"log"
 | |
| 	"net/http"
 | |
| 	"net/netip"
 | |
| 	"net/url"
 | |
| 	"os"
 | |
| 	"path"
 | |
| 	"slices"
 | |
| 	"strings"
 | |
| 	"sync"
 | |
| 	"time"
 | |
| 
 | |
| 	"tailscale.com/client/local"
 | |
| 	"tailscale.com/client/tailscale/apitype"
 | |
| 	"tailscale.com/envknob"
 | |
| 	"tailscale.com/envknob/featureknob"
 | |
| 	"tailscale.com/feature"
 | |
| 	"tailscale.com/feature/buildfeatures"
 | |
| 	"tailscale.com/hostinfo"
 | |
| 	"tailscale.com/ipn"
 | |
| 	"tailscale.com/ipn/ipnstate"
 | |
| 	"tailscale.com/licenses"
 | |
| 	"tailscale.com/net/netutil"
 | |
| 	"tailscale.com/net/tsaddr"
 | |
| 	"tailscale.com/tailcfg"
 | |
| 	"tailscale.com/types/logger"
 | |
| 	"tailscale.com/types/views"
 | |
| 	"tailscale.com/util/httpm"
 | |
| 	"tailscale.com/util/syspolicy/policyclient"
 | |
| 	"tailscale.com/version"
 | |
| 	"tailscale.com/version/distro"
 | |
| )
 | |
| 
 | |
| // ListenPort is the static port used for the web client when run inside tailscaled.
 | |
| // (5252 are the numbers above the letters "TSTS" on a qwerty keyboard.)
 | |
| const ListenPort = 5252
 | |
| 
 | |
| // Server is the backend server for a Tailscale web client.
 | |
| type Server struct {
 | |
| 	mode ServerMode
 | |
| 
 | |
| 	logf    logger.Logf
 | |
| 	polc    policyclient.Client // must be non-nil
 | |
| 	lc      *local.Client
 | |
| 	timeNow func() time.Time
 | |
| 
 | |
| 	// devMode indicates that the server run with frontend assets
 | |
| 	// served by a Vite dev server, allowing for local development
 | |
| 	// on the web client frontend.
 | |
| 	devMode    bool
 | |
| 	cgiMode    bool
 | |
| 	pathPrefix string
 | |
| 
 | |
| 	// originOverride is the origin that the web UI is accessible from.
 | |
| 	// This value is used in the fallback CSRF checks when Sec-Fetch-Site is not
 | |
| 	// available. In this case the application will compare Host and Origin
 | |
| 	// header values to determine if the request is from the same origin.
 | |
| 	originOverride string
 | |
| 
 | |
| 	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.
 | |
| 	//
 | |
| 	// Users obtain a valid browser session by connecting to the web client
 | |
| 	// over Tailscale and verifying their identity by authenticating on the
 | |
| 	// control server.
 | |
| 	//
 | |
| 	// browserSessions get reset on every Server restart.
 | |
| 	//
 | |
| 	// The map provides a lookup of the session by cookie value
 | |
| 	// (browserSession.ID => browserSession).
 | |
| 	browserSessions sync.Map
 | |
| 
 | |
| 	// newAuthURL creates a new auth URL that can be used to validate
 | |
| 	// a browser session to manage this web client.
 | |
| 	newAuthURL func(ctx context.Context, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error)
 | |
| 	// waitWebClientAuthURL blocks until the associated auth URL has
 | |
| 	// been completed by its user, or until ctx is canceled.
 | |
| 	waitAuthURL func(ctx context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error)
 | |
| }
 | |
| 
 | |
| // ServerMode specifies the mode of a running web.Server.
 | |
| type ServerMode string
 | |
| 
 | |
| const (
 | |
| 	// LoginServerMode serves a read-only login client for logging a
 | |
| 	// node into a tailnet, and viewing a read-only interface of the
 | |
| 	// node's current Tailscale settings.
 | |
| 	//
 | |
| 	// 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.
 | |
| 	//
 | |
| 	// 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 read-only version of the app is displayed.
 | |
| 	ManageServerMode ServerMode = "manage"
 | |
| )
 | |
| 
 | |
| // ServerOpts contains options for constructing a new Server.
 | |
| type ServerOpts struct {
 | |
| 	// Mode specifies the mode of web client being constructed.
 | |
| 	Mode ServerMode
 | |
| 
 | |
| 	// CGIMode indicates if the server is running as a CGI script.
 | |
| 	CGIMode bool
 | |
| 
 | |
| 	// PathPrefix is the URL prefix added to requests by CGI or reverse proxy.
 | |
| 	PathPrefix string
 | |
| 
 | |
| 	// LocalClient is the local.Client to use for this web server.
 | |
| 	// If nil, a new one will be created.
 | |
| 	LocalClient *local.Client
 | |
| 
 | |
| 	// TimeNow optionally provides a time function.
 | |
| 	// time.Now is used as default.
 | |
| 	TimeNow func() time.Time
 | |
| 
 | |
| 	// Logf optionally provides a logger function.
 | |
| 	// If nil, log.Printf is used as default.
 | |
| 	Logf logger.Logf
 | |
| 
 | |
| 	// PolicyClient, if non-nil, will be used to fetch policy settings.
 | |
| 	// If nil, the default policy client will be used.
 | |
| 	PolicyClient policyclient.Client
 | |
| 
 | |
| 	// The following two fields are required and used exclusively
 | |
| 	// in ManageServerMode to facilitate the control server login
 | |
| 	// check step for authorizing browser sessions.
 | |
| 
 | |
| 	// NewAuthURL should be provided as a function that generates
 | |
| 	// a new tailcfg.WebClientAuthResponse.
 | |
| 	// This field is required for ManageServerMode mode.
 | |
| 	NewAuthURL func(ctx context.Context, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error)
 | |
| 	// WaitAuthURL should be provided as a function that blocks until
 | |
| 	// the associated tailcfg.WebClientAuthResponse has been marked
 | |
| 	// as completed.
 | |
| 	// This field is required for ManageServerMode mode.
 | |
| 	WaitAuthURL func(ctx context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error)
 | |
| 
 | |
| 	// OriginOverride specifies the origin that the web UI will be accessible from if hosted behind a reverse proxy or CGI.
 | |
| 	OriginOverride string
 | |
| }
 | |
| 
 | |
| // NewServer constructs a new Tailscale web client server.
 | |
| // 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) {
 | |
| 	switch opts.Mode {
 | |
| 	case LoginServerMode, ReadOnlyServerMode, ManageServerMode:
 | |
| 		// 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 = &local.Client{}
 | |
| 	}
 | |
| 	s = &Server{
 | |
| 		mode:           opts.Mode,
 | |
| 		polc:           cmp.Or(opts.PolicyClient, policyclient.Get()),
 | |
| 		logf:           opts.Logf,
 | |
| 		devMode:        envknob.Bool("TS_DEBUG_WEB_CLIENT_DEV"),
 | |
| 		lc:             opts.LocalClient,
 | |
| 		cgiMode:        opts.CGIMode,
 | |
| 		pathPrefix:     opts.PathPrefix,
 | |
| 		timeNow:        opts.TimeNow,
 | |
| 		newAuthURL:     opts.NewAuthURL,
 | |
| 		waitAuthURL:    opts.WaitAuthURL,
 | |
| 		originOverride: opts.OriginOverride,
 | |
| 	}
 | |
| 	if opts.PathPrefix != "" {
 | |
| 		// Enforce that path prefix always has a single leading '/'
 | |
| 		// so that it is treated as a relative URL path.
 | |
| 		// We strip multiple leading '/' to prevent schema-less offsite URLs like "//example.com".
 | |
| 		//
 | |
| 		// See https://github.com/tailscale/corp/issues/16268.
 | |
| 		s.pathPrefix = "/" + strings.TrimLeft(path.Clean(opts.PathPrefix), "/\\")
 | |
| 	}
 | |
| 	if s.mode == ManageServerMode {
 | |
| 		if opts.NewAuthURL == nil {
 | |
| 			return nil, fmt.Errorf("must provide a NewAuthURL implementation")
 | |
| 		}
 | |
| 		if opts.WaitAuthURL == nil {
 | |
| 			return nil, fmt.Errorf("must provide WaitAuthURL implementation")
 | |
| 		}
 | |
| 	}
 | |
| 	if s.timeNow == nil {
 | |
| 		s.timeNow = time.Now
 | |
| 	}
 | |
| 	if s.logf == nil {
 | |
| 		s.logf = log.Printf
 | |
| 	}
 | |
| 	s.assetsHandler, s.assetsCleanup = assetsHandler(s.devMode)
 | |
| 
 | |
| 	var metric string
 | |
| 	s.apiHandler, metric = s.modeAPIHandler(s.mode)
 | |
| 	s.apiHandler = s.csrfProtect(s.apiHandler)
 | |
| 
 | |
| 	// 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, nil
 | |
| }
 | |
| 
 | |
| func (s *Server) csrfProtect(h http.Handler) http.Handler {
 | |
| 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | |
| 		// CSRF is not required for GET, HEAD, or OPTIONS requests.
 | |
| 		if slices.Contains([]string{"GET", "HEAD", "OPTIONS"}, r.Method) {
 | |
| 			h.ServeHTTP(w, r)
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		// first attempt to use Sec-Fetch-Site header (sent by all modern
 | |
| 		// browsers to "potentially trustworthy" origins i.e. localhost or those
 | |
| 		// served over HTTPS)
 | |
| 		secFetchSite := r.Header.Get("Sec-Fetch-Site")
 | |
| 		if secFetchSite == "same-origin" {
 | |
| 			h.ServeHTTP(w, r)
 | |
| 			return
 | |
| 		} else if secFetchSite != "" {
 | |
| 			http.Error(w, fmt.Sprintf("CSRF request denied with Sec-Fetch-Site %q", secFetchSite), http.StatusForbidden)
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		// if Sec-Fetch-Site is not available we presume we are operating over HTTP.
 | |
| 		// We fall back to comparing the Origin & Host headers.
 | |
| 
 | |
| 		// use the Host header to determine the expected origin
 | |
| 		// (use the override if set to allow for reverse proxying)
 | |
| 		host := r.Host
 | |
| 		if host == "" {
 | |
| 			http.Error(w, "CSRF request denied with no Host header", http.StatusForbidden)
 | |
| 			return
 | |
| 		}
 | |
| 		if s.originOverride != "" {
 | |
| 			host = s.originOverride
 | |
| 		}
 | |
| 
 | |
| 		originHeader := r.Header.Get("Origin")
 | |
| 		if originHeader == "" {
 | |
| 			http.Error(w, "CSRF request denied with no Origin header", http.StatusForbidden)
 | |
| 			return
 | |
| 		}
 | |
| 		parsedOrigin, err := url.Parse(originHeader)
 | |
| 		if err != nil {
 | |
| 			http.Error(w, fmt.Sprintf("CSRF request denied with invalid Origin %q", r.Header.Get("Origin")), http.StatusForbidden)
 | |
| 			return
 | |
| 		}
 | |
| 		origin := parsedOrigin.Host
 | |
| 		if origin == "" {
 | |
| 			http.Error(w, "CSRF request denied with no host in the Origin header", http.StatusForbidden)
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		if origin != host {
 | |
| 			http.Error(w, fmt.Sprintf("CSRF request denied with mismatched Origin %q and Host %q", origin, host), http.StatusForbidden)
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		h.ServeHTTP(w, r)
 | |
| 
 | |
| 	})
 | |
| }
 | |
| 
 | |
| func (s *Server) modeAPIHandler(mode ServerMode) (http.Handler, string) {
 | |
| 	switch mode {
 | |
| 	case LoginServerMode:
 | |
| 		return http.HandlerFunc(s.serveLoginAPI), "web_login_client_initialization"
 | |
| 	case ReadOnlyServerMode:
 | |
| 		return http.HandlerFunc(s.serveLoginAPI), "web_readonly_client_initialization"
 | |
| 	case ManageServerMode:
 | |
| 		return http.HandlerFunc(s.serveAPI), "web_client_initialization"
 | |
| 	default: // invalid mode
 | |
| 		log.Fatalf("invalid mode: %v", mode)
 | |
| 	}
 | |
| 	return nil, ""
 | |
| }
 | |
| 
 | |
| func (s *Server) Shutdown() {
 | |
| 	s.logf("web.Server: shutting down")
 | |
| 	if s.assetsCleanup != nil {
 | |
| 		s.assetsCleanup()
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // ServeHTTP processes all requests for the Tailscale web client.
 | |
| func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 | |
| 	handler := s.serve
 | |
| 
 | |
| 	// if path prefix is defined, strip it from requests.
 | |
| 	if s.cgiMode && s.pathPrefix != "" {
 | |
| 		handler = enforcePrefix(s.pathPrefix, handler)
 | |
| 	}
 | |
| 
 | |
| 	handler(w, r)
 | |
| }
 | |
| 
 | |
| func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
 | |
| 	if s.mode == ManageServerMode {
 | |
| 		// In manage mode, requests must be sent directly to the bare Tailscale IP address.
 | |
| 		// If a request comes in on any other hostname, redirect.
 | |
| 		if s.requireTailscaleIP(w, r) {
 | |
| 			return // user was redirected
 | |
| 		}
 | |
| 
 | |
| 		// serve HTTP 204 on /ok requests as connectivity check
 | |
| 		if r.Method == httpm.GET && r.URL.Path == "/ok" {
 | |
| 			w.WriteHeader(http.StatusNoContent)
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		if !s.devMode {
 | |
| 			// This hash corresponds to the inline script in index.html that runs when the react app is unavailable.
 | |
| 			// It was generated from https://csplite.com/csp/sha/.
 | |
| 			// If the contents of the script are changed, this hash must be updated.
 | |
| 			const indexScriptHash = "sha384-CW2AYVfS14P7QHZN27thEkMLKiCj3YNURPoLc1elwiEkMVHeuYTWkJOEki1F3nZc"
 | |
| 
 | |
| 			w.Header().Set("X-Frame-Options", "DENY")
 | |
| 			w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src * data:; script-src 'self' '"+indexScriptHash+"'")
 | |
| 			w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if r.URL.Path == "/metrics" {
 | |
| 		r.URL.Path = "/api/local/v0/usermetrics"
 | |
| 		s.proxyRequestToLocalAPI(w, r)
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	if strings.HasPrefix(r.URL.Path, "/api/") {
 | |
| 		switch {
 | |
| 		case r.URL.Path == "/api/auth" && r.Method == httpm.GET:
 | |
| 			s.serveAPIAuth(w, r) // serve auth status
 | |
| 			return
 | |
| 		case r.URL.Path == "/api/auth/session/new" && r.Method == httpm.GET:
 | |
| 			s.serveAPIAuthSessionNew(w, r) // create new session
 | |
| 			return
 | |
| 		case r.URL.Path == "/api/auth/session/wait" && r.Method == httpm.GET:
 | |
| 			s.serveAPIAuthSessionWait(w, r) // wait for session to be authorized
 | |
| 			return
 | |
| 		}
 | |
| 		if ok := s.authorizeRequest(w, r); !ok {
 | |
| 			http.Error(w, "not authorized", http.StatusUnauthorized)
 | |
| 			return
 | |
| 		}
 | |
| 		// Pass API requests through to the API handler.
 | |
| 		s.apiHandler.ServeHTTP(w, r)
 | |
| 		return
 | |
| 	}
 | |
| 	s.assetsHandler.ServeHTTP(w, r)
 | |
| }
 | |
| 
 | |
| // requireTailscaleIP redirects an incoming request if the HTTP request was not made to a bare Tailscale IP address.
 | |
| // The request will be redirected to the Tailscale IP, port 5252, with the original request path.
 | |
| // This allows any custom hostname to be used to access the device, but protects against DNS rebinding attacks.
 | |
| // Returns true if the request has been fully handled, either be returning a redirect or an HTTP error.
 | |
| func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (handled bool) {
 | |
| 	const (
 | |
| 		ipv4ServiceHost = tsaddr.TailscaleServiceIPString
 | |
| 		ipv6ServiceHost = "[" + tsaddr.TailscaleServiceIPv6String + "]"
 | |
| 	)
 | |
| 	// allow requests on quad-100 (or ipv6 equivalent)
 | |
| 	host := strings.TrimSuffix(r.Host, ":80")
 | |
| 	if host == ipv4ServiceHost || host == ipv6ServiceHost {
 | |
| 		return false
 | |
| 	}
 | |
| 
 | |
| 	st, err := s.lc.StatusWithoutPeers(r.Context())
 | |
| 	if err != nil {
 | |
| 		s.logf("error getting status: %v", err)
 | |
| 		http.Error(w, "internal error", http.StatusInternalServerError)
 | |
| 		return true
 | |
| 	}
 | |
| 
 | |
| 	ipv4, ipv6 := s.selfNodeAddresses(r, st)
 | |
| 	if r.Host == fmt.Sprintf("%s:%d", ipv4.String(), ListenPort) {
 | |
| 		return false // already accessing over Tailscale IP
 | |
| 	}
 | |
| 	if r.Host == fmt.Sprintf("[%s]:%d", ipv6.String(), ListenPort) {
 | |
| 		return false // already accessing over Tailscale IP
 | |
| 	}
 | |
| 
 | |
| 	// Not currently accessing via Tailscale IP,
 | |
| 	// redirect them.
 | |
| 
 | |
| 	var preferV6 bool
 | |
| 	if ap, err := netip.ParseAddrPort(r.Host); err == nil {
 | |
| 		// If Host was already ipv6, keep them on same protocol.
 | |
| 		preferV6 = ap.Addr().Is6()
 | |
| 	}
 | |
| 
 | |
| 	newURL := *r.URL
 | |
| 	if (preferV6 && ipv6.IsValid()) || !ipv4.IsValid() {
 | |
| 		newURL.Host = fmt.Sprintf("[%s]:%d", ipv6.String(), ListenPort)
 | |
| 	} else {
 | |
| 		newURL.Host = fmt.Sprintf("%s:%d", ipv4.String(), ListenPort)
 | |
| 	}
 | |
| 	http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently)
 | |
| 	return true
 | |
| }
 | |
| 
 | |
| // selfNodeAddresses return the Tailscale IPv4 and IPv6 addresses for the self node.
 | |
| // st is expected to be a status with peers included.
 | |
| func (s *Server) selfNodeAddresses(r *http.Request, st *ipnstate.Status) (ipv4, ipv6 netip.Addr) {
 | |
| 	for _, ip := range st.Self.TailscaleIPs {
 | |
| 		if ip.Is4() {
 | |
| 			ipv4 = ip
 | |
| 		} else if ip.Is6() {
 | |
| 			ipv6 = ip
 | |
| 		}
 | |
| 		if ipv4.IsValid() && ipv6.IsValid() {
 | |
| 			break // found both IPs
 | |
| 		}
 | |
| 	}
 | |
| 	if whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil {
 | |
| 		// The source peer connecting to this node may know it by a different
 | |
| 		// IP than the node knows itself as. Specifically, this may be the case
 | |
| 		// if the peer is coming from a different tailnet (sharee node), as IPs
 | |
| 		// are specific to each tailnet.
 | |
| 		// Here, we check if the source peer knows the node by a different IP,
 | |
| 		// and return the peer's version if so.
 | |
| 		if knownIPv4 := whois.Node.SelfNodeV4MasqAddrForThisPeer; knownIPv4 != nil {
 | |
| 			ipv4 = *knownIPv4
 | |
| 		}
 | |
| 		if knownIPv6 := whois.Node.SelfNodeV6MasqAddrForThisPeer; knownIPv6 != nil {
 | |
| 			ipv6 = *knownIPv6
 | |
| 		}
 | |
| 	}
 | |
| 	return ipv4, ipv6
 | |
| }
 | |
| 
 | |
| // authorizeRequest reports whether the request from the web client
 | |
| // is authorized to be completed.
 | |
| // It reports true if the request is authorized, and false otherwise.
 | |
| // 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.mode == ManageServerMode { // client using tailscale auth
 | |
| 		session, _, _, err := s.getSession(r)
 | |
| 		switch {
 | |
| 		case errors.Is(err, errNotUsingTailscale):
 | |
| 			// All requests must be made over tailscale.
 | |
| 			http.Error(w, "must access over tailscale", http.StatusUnauthorized)
 | |
| 			return false
 | |
| 		case r.URL.Path == "/api/data" && r.Method == httpm.GET:
 | |
| 			// Readonly endpoint allowed without valid browser session.
 | |
| 			return true
 | |
| 		case r.URL.Path == "/api/device-details-click" && r.Method == httpm.POST:
 | |
| 			// Special case metric endpoint that is allowed without a browser session.
 | |
| 			return true
 | |
| 		case strings.HasPrefix(r.URL.Path, "/api/"):
 | |
| 			// All other /api/ endpoints require a valid browser session.
 | |
| 			if err != nil || !session.isAuthorized(s.timeNow()) {
 | |
| 				http.Error(w, "no valid session", http.StatusUnauthorized)
 | |
| 				return false
 | |
| 			}
 | |
| 			return true
 | |
| 		default:
 | |
| 			// No additional auth on non-api (assets, index.html, etc).
 | |
| 			return true
 | |
| 		}
 | |
| 	}
 | |
| 	// Client using system-specific auth.
 | |
| 	switch distro.Get() {
 | |
| 	case distro.Synology:
 | |
| 		if !buildfeatures.HasSynology {
 | |
| 			// Synology support not built in.
 | |
| 			return false
 | |
| 		}
 | |
| 		authorized, _ := authorizeSynology(r)
 | |
| 		return authorized
 | |
| 	case distro.QNAP:
 | |
| 		authorized, _ := authorizeQNAP(r)
 | |
| 		return authorized
 | |
| 	default:
 | |
| 		return true // no additional auth for this distro
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // serveLoginAPI serves requests for the web login client.
 | |
| // It should only be called by Server.ServeHTTP, via Server.apiHandler,
 | |
| // which protects the handler using gorilla csrf.
 | |
| func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
 | |
| 	switch {
 | |
| 	case r.URL.Path == "/api/data" && r.Method == httpm.GET:
 | |
| 		s.serveGetNodeData(w, r)
 | |
| 	case r.URL.Path == "/api/up" && r.Method == httpm.POST:
 | |
| 		s.serveTailscaleUp(w, r)
 | |
| 	case r.URL.Path == "/api/device-details-click" && r.Method == httpm.POST:
 | |
| 		s.serveDeviceDetailsClick(w, r)
 | |
| 	default:
 | |
| 		http.Error(w, "invalid endpoint or method", http.StatusNotFound)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| type apiHandler[data any] struct {
 | |
| 	s *Server
 | |
| 	w http.ResponseWriter
 | |
| 	r *http.Request
 | |
| 
 | |
| 	// permissionCheck allows for defining whether a requesting peer's
 | |
| 	// capabilities grant them access to make the given data update.
 | |
| 	// If permissionCheck reports false, the request fails as unauthorized.
 | |
| 	permissionCheck func(data data, peer peerCapabilities) bool
 | |
| }
 | |
| 
 | |
| // newHandler constructs a new api handler which restricts the given request
 | |
| // to the specified permission check. If the permission check fails for
 | |
| // the peer associated with the request, an unauthorized error is returned
 | |
| // to the client.
 | |
| func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
 | |
| 	return &apiHandler[data]{
 | |
| 		s:               s,
 | |
| 		w:               w,
 | |
| 		r:               r,
 | |
| 		permissionCheck: permissionCheck,
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // alwaysAllowed can be passed as the permissionCheck argument to newHandler
 | |
| // for requests that are always allowed to complete regardless of a peer's
 | |
| // capabilities.
 | |
| func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
 | |
| 
 | |
| func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
 | |
| 	// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
 | |
| 	// WhoIs when originally checking for a session from authorizeRequest.
 | |
| 	// Would be nice if we could pipe those through to here so we don't end
 | |
| 	// up having to re-call them to grab the peer capabilities.
 | |
| 	status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	peer, err := toPeerCapabilities(status, whois)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	return peer, nil
 | |
| }
 | |
| 
 | |
| type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
 | |
| 
 | |
| // handle runs the given handler if the source peer satisfies the
 | |
| // constraints for running this request.
 | |
| //
 | |
| // handle is expected for use when `data` type is empty, or set to
 | |
| // `noBodyData` in practice. For requests that expect JSON body data
 | |
| // to be attached, use handleJSON instead.
 | |
| func (a *apiHandler[data]) handle(h http.HandlerFunc) {
 | |
| 	peer, err := a.getPeer()
 | |
| 	if err != nil {
 | |
| 		http.Error(a.w, err.Error(), http.StatusInternalServerError)
 | |
| 		return
 | |
| 	}
 | |
| 	var body data // not used
 | |
| 	if !a.permissionCheck(body, peer) {
 | |
| 		http.Error(a.w, "not allowed", http.StatusUnauthorized)
 | |
| 		return
 | |
| 	}
 | |
| 	h(a.w, a.r)
 | |
| }
 | |
| 
 | |
| // handleJSON manages decoding the request's body JSON and passing
 | |
| // it on to the provided function if the source peer satisfies the
 | |
| // constraints for running this request.
 | |
| func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
 | |
| 	defer a.r.Body.Close()
 | |
| 	var body data
 | |
| 	if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
 | |
| 		http.Error(a.w, err.Error(), http.StatusInternalServerError)
 | |
| 		return
 | |
| 	}
 | |
| 	peer, err := a.getPeer()
 | |
| 	if err != nil {
 | |
| 		http.Error(a.w, err.Error(), http.StatusInternalServerError)
 | |
| 		return
 | |
| 	}
 | |
| 	if !a.permissionCheck(body, peer) {
 | |
| 		http.Error(a.w, "not allowed", http.StatusUnauthorized)
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	if err := h(a.r.Context(), body); err != nil {
 | |
| 		http.Error(a.w, err.Error(), http.StatusInternalServerError)
 | |
| 		return
 | |
| 	}
 | |
| 	a.w.WriteHeader(http.StatusOK)
 | |
| }
 | |
| 
 | |
| // serveAPI serves requests for the web client api.
 | |
| // It should only be called by Server.ServeHTTP, via Server.apiHandler,
 | |
| // which protects the handler using gorilla csrf.
 | |
| func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
 | |
| 	if r.Method == httpm.PATCH {
 | |
| 		// Enforce that PATCH requests are always application/json.
 | |
| 		if ct := r.Header.Get("Content-Type"); ct != "application/json" {
 | |
| 			http.Error(w, "invalid request", http.StatusBadRequest)
 | |
| 			return
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	path := strings.TrimPrefix(r.URL.Path, "/api")
 | |
| 	switch {
 | |
| 	case path == "/data" && r.Method == httpm.GET:
 | |
| 		newHandler[noBodyData](s, w, r, alwaysAllowed).
 | |
| 			handle(s.serveGetNodeData)
 | |
| 		return
 | |
| 	case path == "/exit-nodes" && r.Method == httpm.GET:
 | |
| 		newHandler[noBodyData](s, w, r, alwaysAllowed).
 | |
| 			handle(s.serveGetExitNodes)
 | |
| 		return
 | |
| 	case path == "/routes" && r.Method == httpm.POST:
 | |
| 		peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
 | |
| 			if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
 | |
| 				return false
 | |
| 			} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
 | |
| 				return false
 | |
| 			}
 | |
| 			return true
 | |
| 		}
 | |
| 		newHandler[postRoutesRequest](s, w, r, peerAllowed).
 | |
| 			handleJSON(s.servePostRoutes)
 | |
| 		return
 | |
| 	case path == "/device-details-click" && r.Method == httpm.POST:
 | |
| 		newHandler[noBodyData](s, w, r, alwaysAllowed).
 | |
| 			handle(s.serveDeviceDetailsClick)
 | |
| 		return
 | |
| 	case path == "/local/v0/logout" && r.Method == httpm.POST:
 | |
| 		peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
 | |
| 			return peer.canEdit(capFeatureAccount)
 | |
| 		}
 | |
| 		newHandler[noBodyData](s, w, r, peerAllowed).
 | |
| 			handle(s.proxyRequestToLocalAPI)
 | |
| 		return
 | |
| 	case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
 | |
| 		peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
 | |
| 			if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
 | |
| 				return false
 | |
| 			}
 | |
| 			return true
 | |
| 		}
 | |
| 		newHandler[maskedPrefs](s, w, r, peerAllowed).
 | |
| 			handleJSON(s.serveUpdatePrefs)
 | |
| 		return
 | |
| 	case path == "/local/v0/update/check" && r.Method == httpm.GET:
 | |
| 		newHandler[noBodyData](s, w, r, alwaysAllowed).
 | |
| 			handle(s.proxyRequestToLocalAPI)
 | |
| 		return
 | |
| 	case path == "/local/v0/update/check" && r.Method == httpm.POST:
 | |
| 		peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
 | |
| 			return peer.canEdit(capFeatureAccount)
 | |
| 		}
 | |
| 		newHandler[noBodyData](s, w, r, peerAllowed).
 | |
| 			handle(s.proxyRequestToLocalAPI)
 | |
| 		return
 | |
| 	case path == "/local/v0/update/progress" && r.Method == httpm.POST:
 | |
| 		newHandler[noBodyData](s, w, r, alwaysAllowed).
 | |
| 			handle(s.proxyRequestToLocalAPI)
 | |
| 		return
 | |
| 	case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
 | |
| 		newHandler[noBodyData](s, w, r, alwaysAllowed).
 | |
| 			handle(s.proxyRequestToLocalAPI)
 | |
| 		return
 | |
| 	}
 | |
| 	http.Error(w, "invalid endpoint", http.StatusNotFound)
 | |
| }
 | |
| 
 | |
| type authResponse struct {
 | |
| 	ServerMode     ServerMode      `json:"serverMode"`
 | |
| 	Authorized     bool            `json:"authorized"` // has an authorized management session
 | |
| 	ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
 | |
| 	NeedsSynoAuth  bool            `json:"needsSynoAuth,omitempty"`
 | |
| }
 | |
| 
 | |
| // viewerIdentity is the Tailscale identity of the source node
 | |
| // connected to this web client.
 | |
| type viewerIdentity struct {
 | |
| 	LoginName     string           `json:"loginName"`
 | |
| 	NodeName      string           `json:"nodeName"`
 | |
| 	NodeIP        string           `json:"nodeIP"`
 | |
| 	ProfilePicURL string           `json:"profilePicUrl,omitempty"`
 | |
| 	Capabilities  peerCapabilities `json:"capabilities"` // features peer is allowed to edit
 | |
| }
 | |
| 
 | |
| // serverAPIAuth handles requests to the /api/auth endpoint
 | |
| // and returns an authResponse indicating the current auth state and any steps the user needs to take.
 | |
| func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
 | |
| 	var resp authResponse
 | |
| 	resp.ServerMode = s.mode
 | |
| 	session, whois, status, sErr := s.getSession(r)
 | |
| 	var caps peerCapabilities
 | |
| 
 | |
| 	if whois != nil {
 | |
| 		var err error
 | |
| 		caps, err = toPeerCapabilities(status, whois)
 | |
| 		if err != nil {
 | |
| 			http.Error(w, sErr.Error(), http.StatusInternalServerError)
 | |
| 			return
 | |
| 		}
 | |
| 		resp.ViewerIdentity = &viewerIdentity{
 | |
| 			LoginName:     whois.UserProfile.LoginName,
 | |
| 			NodeName:      whois.Node.Name,
 | |
| 			ProfilePicURL: whois.UserProfile.ProfilePicURL,
 | |
| 			Capabilities:  caps,
 | |
| 		}
 | |
| 		if addrs := whois.Node.Addresses; len(addrs) > 0 {
 | |
| 			resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// First verify platform auth.
 | |
| 	// If platform auth is needed, this should happen first.
 | |
| 	if s.mode == LoginServerMode || s.mode == ReadOnlyServerMode {
 | |
| 		switch distro.Get() {
 | |
| 		case distro.Synology:
 | |
| 			authorized, err := authorizeSynology(r)
 | |
| 			if err != nil {
 | |
| 				http.Error(w, err.Error(), http.StatusUnauthorized)
 | |
| 				return
 | |
| 			}
 | |
| 			if !authorized {
 | |
| 				resp.NeedsSynoAuth = true
 | |
| 				writeJSON(w, resp)
 | |
| 				return
 | |
| 			}
 | |
| 		case distro.QNAP:
 | |
| 			if _, err := authorizeQNAP(r); err != nil {
 | |
| 				http.Error(w, err.Error(), http.StatusUnauthorized)
 | |
| 				return
 | |
| 			}
 | |
| 		default:
 | |
| 			// no additional auth for this distro
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	switch {
 | |
| 	case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
 | |
| 		s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
 | |
| 		resp.Authorized = false // restricted to the read-only view
 | |
| 	case sErr != nil && errors.Is(sErr, errNotOwner):
 | |
| 		s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1)
 | |
| 		resp.Authorized = false // restricted to the read-only view
 | |
| 	case sErr != nil && errors.Is(sErr, errTaggedLocalSource):
 | |
| 		s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1)
 | |
| 		resp.Authorized = false // restricted to the read-only view
 | |
| 	case sErr != nil && errors.Is(sErr, errTaggedRemoteSource):
 | |
| 		s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1)
 | |
| 		resp.Authorized = false // restricted to the read-only view
 | |
| 	case sErr != nil && !errors.Is(sErr, errNoSession):
 | |
| 		// Any other error.
 | |
| 		http.Error(w, sErr.Error(), http.StatusInternalServerError)
 | |
| 		return
 | |
| 	case session.isAuthorized(s.timeNow()):
 | |
| 		if whois.Node.StableID == status.Self.ID {
 | |
| 			s.lc.IncrementCounter(r.Context(), "web_client_managing_local", 1)
 | |
| 		} else {
 | |
| 			s.lc.IncrementCounter(r.Context(), "web_client_managing_remote", 1)
 | |
| 		}
 | |
| 		// User has a valid session. They're now authorized to edit if they
 | |
| 		// have any edit capabilities. In practice, they won't be sent through
 | |
| 		// the auth flow if they don't have edit caps, but their ACL granted
 | |
| 		// permissions may change at any time. The frontend views and backend
 | |
| 		// endpoints are always restricted to their current capabilities in
 | |
| 		// addition to a valid session.
 | |
| 		//
 | |
| 		// But, we also check the caps here for a better user experience on
 | |
| 		// the frontend login toggle, which uses resp.Authorized to display
 | |
| 		// "viewing" vs "managing" copy. If they don't have caps, we want to
 | |
| 		// display "viewing" even if they have a valid session.
 | |
| 		resp.Authorized = !caps.isEmpty()
 | |
| 	default:
 | |
| 		if whois == nil || (whois.Node.StableID == status.Self.ID) {
 | |
| 			// whois being nil implies local as the request did not come over Tailscale.
 | |
| 			s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
 | |
| 		} else {
 | |
| 			s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote", 1)
 | |
| 		}
 | |
| 		resp.Authorized = false // not yet authorized
 | |
| 	}
 | |
| 
 | |
| 	writeJSON(w, resp)
 | |
| }
 | |
| 
 | |
| type newSessionAuthResponse struct {
 | |
| 	AuthURL string `json:"authUrl,omitempty"`
 | |
| }
 | |
| 
 | |
| // serveAPIAuthSessionNew handles requests to the /api/auth/session/new endpoint.
 | |
| func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request) {
 | |
| 	session, whois, _, err := s.getSession(r)
 | |
| 	if err != nil && !errors.Is(err, errNoSession) {
 | |
| 		// Source associated with request not allowed to create
 | |
| 		// a session for this web client.
 | |
| 		http.Error(w, err.Error(), http.StatusUnauthorized)
 | |
| 		return
 | |
| 	}
 | |
| 	if session == nil {
 | |
| 		// Create a new session.
 | |
| 		// If one already existed, we return that authURL rather than creating a new one.
 | |
| 		session, err = s.newSession(r.Context(), whois)
 | |
| 		if err != nil {
 | |
| 			http.Error(w, err.Error(), http.StatusInternalServerError)
 | |
| 			return
 | |
| 		}
 | |
| 		// Set the cookie on browser.
 | |
| 		http.SetCookie(w, &http.Cookie{
 | |
| 			Name:     sessionCookieName,
 | |
| 			Value:    session.ID,
 | |
| 			Raw:      session.ID,
 | |
| 			Path:     "/",
 | |
| 			HttpOnly: true,
 | |
| 			SameSite: http.SameSiteStrictMode,
 | |
| 			Expires:  session.expires(),
 | |
| 			// We can't set Secure to true because we serve over HTTP
 | |
| 			// (but only on Tailscale IPs, hence over encrypted
 | |
| 			// connections that a LAN-local attacker cannot sniff).
 | |
| 			// In the future, we could support HTTPS requests using
 | |
| 			// the full MagicDNS hostname, and could set this.
 | |
| 			// Secure:  true,
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	writeJSON(w, newSessionAuthResponse{AuthURL: session.AuthURL})
 | |
| }
 | |
| 
 | |
| // serveAPIAuthSessionWait handles requests to the /api/auth/session/wait endpoint.
 | |
| func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request) {
 | |
| 	session, _, _, err := s.getSession(r)
 | |
| 	if err != nil {
 | |
| 		http.Error(w, err.Error(), http.StatusUnauthorized)
 | |
| 		return
 | |
| 	}
 | |
| 	if session.isAuthorized(s.timeNow()) {
 | |
| 		return // already authorized
 | |
| 	}
 | |
| 	if err := s.awaitUserAuth(r.Context(), session); err != nil {
 | |
| 		http.Error(w, err.Error(), http.StatusUnauthorized)
 | |
| 		return
 | |
| 	}
 | |
| }
 | |
| 
 | |
| type nodeData struct {
 | |
| 	ID          tailcfg.StableNodeID
 | |
| 	Status      string
 | |
| 	DeviceName  string
 | |
| 	TailnetName string // TLS cert name
 | |
| 	DomainName  string
 | |
| 	IPv4        netip.Addr
 | |
| 	IPv6        netip.Addr
 | |
| 	OS          string
 | |
| 	IPNVersion  string
 | |
| 
 | |
| 	Profile  tailcfg.UserProfile
 | |
| 	IsTagged bool
 | |
| 	Tags     []string
 | |
| 
 | |
| 	KeyExpiry  string // time.RFC3339
 | |
| 	KeyExpired bool
 | |
| 
 | |
| 	TUNMode     bool
 | |
| 	IsSynology  bool
 | |
| 	DSMVersion  int // 6 or 7, if IsSynology=true
 | |
| 	IsUnraid    bool
 | |
| 	UnraidToken string
 | |
| 	URLPrefix   string // if set, the URL prefix the client is served behind
 | |
| 
 | |
| 	UsingExitNode               *exitNode
 | |
| 	AdvertisingExitNode         bool
 | |
| 	AdvertisingExitNodeApproved bool          // whether running this node as an exit node has been approved by an admin
 | |
| 	AdvertisedRoutes            []subnetRoute // excludes exit node routes
 | |
| 	RunningSSHServer            bool
 | |
| 
 | |
| 	ClientVersion *tailcfg.ClientVersion
 | |
| 
 | |
| 	// whether tailnet ACLs allow access to port 5252 on this device
 | |
| 	ACLAllowsAnyIncomingTraffic bool
 | |
| 
 | |
| 	ControlAdminURL string
 | |
| 	LicensesURL     string
 | |
| 
 | |
| 	// Features is the set of available features for use on the
 | |
| 	// current platform. e.g. "ssh", "advertise-exit-node", etc.
 | |
| 	// Map value is true if the given feature key is available.
 | |
| 	//
 | |
| 	// See web.availableFeatures func for population of this field.
 | |
| 	// Contents are expected to match values defined in node-data.ts
 | |
| 	// on the frontend.
 | |
| 	Features map[string]bool
 | |
| }
 | |
| 
 | |
| type subnetRoute struct {
 | |
| 	Route    string
 | |
| 	Approved bool // approved by control server
 | |
| }
 | |
| 
 | |
| func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
 | |
| 	st, err := s.lc.Status(r.Context())
 | |
| 	if err != nil {
 | |
| 		http.Error(w, err.Error(), http.StatusInternalServerError)
 | |
| 		return
 | |
| 	}
 | |
| 	prefs, err := s.lc.GetPrefs(r.Context())
 | |
| 	if err != nil {
 | |
| 		http.Error(w, err.Error(), http.StatusInternalServerError)
 | |
| 		return
 | |
| 	}
 | |
| 	filterRules, _ := s.lc.DebugPacketFilterRules(r.Context())
 | |
| 	ipv4, ipv6 := s.selfNodeAddresses(r, st)
 | |
| 
 | |
| 	data := &nodeData{
 | |
| 		ID:               st.Self.ID,
 | |
| 		Status:           st.BackendState,
 | |
| 		DeviceName:       strings.Split(st.Self.DNSName, ".")[0],
 | |
| 		IPv4:             ipv4,
 | |
| 		IPv6:             ipv6,
 | |
| 		OS:               st.Self.OS,
 | |
| 		IPNVersion:       strings.Split(st.Version, "-")[0],
 | |
| 		Profile:          st.User[st.Self.UserID],
 | |
| 		IsTagged:         st.Self.IsTagged(),
 | |
| 		KeyExpired:       st.Self.Expired,
 | |
| 		TUNMode:          st.TUN,
 | |
| 		IsSynology:       distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
 | |
| 		DSMVersion:       distro.DSMVersion(),
 | |
| 		IsUnraid:         distro.Get() == distro.Unraid,
 | |
| 		UnraidToken:      os.Getenv("UNRAID_CSRF_TOKEN"),
 | |
| 		RunningSSHServer: prefs.RunSSH,
 | |
| 		URLPrefix:        strings.TrimSuffix(s.pathPrefix, "/"),
 | |
| 		ControlAdminURL:  prefs.AdminPageURL(s.polc),
 | |
| 		LicensesURL:      licenses.LicensesURL(),
 | |
| 		Features:         availableFeatures(),
 | |
| 
 | |
| 		ACLAllowsAnyIncomingTraffic: s.aclsAllowAccess(filterRules),
 | |
| 	}
 | |
| 
 | |
| 	if hostinfo.GetEnvType() == hostinfo.HomeAssistantAddOn && data.URLPrefix == "" {
 | |
| 		// X-Ingress-Path is the path prefix in use for Home Assistant
 | |
| 		// https://developers.home-assistant.io/docs/add-ons/presentation#ingress
 | |
| 		data.URLPrefix = r.Header.Get("X-Ingress-Path")
 | |
| 	}
 | |
| 
 | |
| 	cv, err := s.lc.CheckUpdate(r.Context())
 | |
| 	if err != nil {
 | |
| 		s.logf("could not check for updates: %v", err)
 | |
| 	} else {
 | |
| 		data.ClientVersion = cv
 | |
| 	}
 | |
| 
 | |
| 	profile, _, err := s.lc.ProfileStatus(r.Context())
 | |
| 	if err != nil {
 | |
| 		s.logf("error fetching profiles: %v", err)
 | |
| 		// If for some reason we can't fetch profiles,
 | |
| 		// continue to use st.CurrentTailnet if set.
 | |
| 		if st.CurrentTailnet != nil {
 | |
| 			data.TailnetName = st.CurrentTailnet.MagicDNSSuffix
 | |
| 			data.DomainName = st.CurrentTailnet.Name
 | |
| 		}
 | |
| 	} else {
 | |
| 		data.TailnetName = profile.NetworkProfile.MagicDNSName
 | |
| 		data.DomainName = profile.NetworkProfile.DisplayNameOrDefault()
 | |
| 	}
 | |
| 	if st.Self.Tags != nil {
 | |
| 		data.Tags = st.Self.Tags.AsSlice()
 | |
| 	}
 | |
| 	if st.Self.KeyExpiry != nil {
 | |
| 		data.KeyExpiry = st.Self.KeyExpiry.Format(time.RFC3339)
 | |
| 	}
 | |
| 
 | |
| 	routeApproved := func(route netip.Prefix) bool {
 | |
| 		if st.Self == nil || st.Self.AllowedIPs == nil {
 | |
| 			return false
 | |
| 		}
 | |
| 		return st.Self.AllowedIPs.ContainsFunc(func(p netip.Prefix) bool {
 | |
| 			return p == route
 | |
| 		})
 | |
| 	}
 | |
| 	data.AdvertisingExitNodeApproved = routeApproved(tsaddr.AllIPv4()) || routeApproved(tsaddr.AllIPv6())
 | |
| 
 | |
| 	for _, r := range prefs.AdvertiseRoutes {
 | |
| 		if tsaddr.IsExitRoute(r) {
 | |
| 			data.AdvertisingExitNode = true
 | |
| 		} else {
 | |
| 			data.AdvertisedRoutes = append(data.AdvertisedRoutes, subnetRoute{
 | |
| 				Route:    r.String(),
 | |
| 				Approved: routeApproved(r),
 | |
| 			})
 | |
| 		}
 | |
| 	}
 | |
| 	if e := st.ExitNodeStatus; e != nil {
 | |
| 		data.UsingExitNode = &exitNode{
 | |
| 			ID:     e.ID,
 | |
| 			Online: e.Online,
 | |
| 		}
 | |
| 		for _, ps := range st.Peer {
 | |
| 			if ps.ID == e.ID {
 | |
| 				data.UsingExitNode.Name = ps.DNSName
 | |
| 				data.UsingExitNode.Location = ps.Location
 | |
| 				break
 | |
| 			}
 | |
| 		}
 | |
| 		if data.UsingExitNode.Name == "" {
 | |
| 			// Falling back to TailscaleIP/StableNodeID when the peer
 | |
| 			// is no longer included in status.
 | |
| 			if len(e.TailscaleIPs) > 0 {
 | |
| 				data.UsingExitNode.Name = e.TailscaleIPs[0].Addr().String()
 | |
| 			} else {
 | |
| 				data.UsingExitNode.Name = string(e.ID)
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 	writeJSON(w, *data)
 | |
| }
 | |
| 
 | |
| func availableFeatures() map[string]bool {
 | |
| 	features := map[string]bool{
 | |
| 		"advertise-exit-node": true, // available on all platforms
 | |
| 		"advertise-routes":    true, // available on all platforms
 | |
| 		"use-exit-node":       featureknob.CanUseExitNode() == nil,
 | |
| 		"ssh":                 featureknob.CanRunTailscaleSSH() == nil,
 | |
| 		"auto-update":         version.IsUnstableBuild() && feature.CanAutoUpdate(),
 | |
| 	}
 | |
| 	return features
 | |
| }
 | |
| 
 | |
| // aclsAllowAccess returns whether tailnet ACLs (as expressed in the provided filter rules)
 | |
| // permit any devices to access the local web client.
 | |
| // This does not currently check whether a specific device can connect, just any device.
 | |
| func (s *Server) aclsAllowAccess(rules []tailcfg.FilterRule) bool {
 | |
| 	for _, rule := range rules {
 | |
| 		for _, dp := range rule.DstPorts {
 | |
| 			if dp.Ports.Contains(ListenPort) {
 | |
| 				return true
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 	return false
 | |
| }
 | |
| 
 | |
| type exitNode struct {
 | |
| 	ID       tailcfg.StableNodeID
 | |
| 	Name     string
 | |
| 	Location *tailcfg.Location
 | |
| 	Online   bool
 | |
| }
 | |
| 
 | |
| func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
 | |
| 	st, err := s.lc.Status(r.Context())
 | |
| 	if err != nil {
 | |
| 		http.Error(w, err.Error(), http.StatusInternalServerError)
 | |
| 		return
 | |
| 	}
 | |
| 	var exitNodes []*exitNode
 | |
| 	for _, ps := range st.Peer {
 | |
| 		if !ps.ExitNodeOption {
 | |
| 			continue
 | |
| 		}
 | |
| 		exitNodes = append(exitNodes, &exitNode{
 | |
| 			ID:       ps.ID,
 | |
| 			Name:     ps.DNSName,
 | |
| 			Location: ps.Location,
 | |
| 			Online:   ps.Online,
 | |
| 		})
 | |
| 	}
 | |
| 	writeJSON(w, exitNodes)
 | |
| }
 | |
| 
 | |
| // maskedPrefs is the subset of ipn.MaskedPrefs that are
 | |
| // allowed to be editable via the web UI.
 | |
| type maskedPrefs struct {
 | |
| 	RunSSHSet bool
 | |
| 	RunSSH    bool
 | |
| }
 | |
| 
 | |
| func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
 | |
| 	_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
 | |
| 		RunSSHSet: prefs.RunSSHSet,
 | |
| 		Prefs: ipn.Prefs{
 | |
| 			RunSSH: prefs.RunSSH,
 | |
| 		},
 | |
| 	})
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| type postRoutesRequest struct {
 | |
| 	SetExitNode       bool // when set, UseExitNode and AdvertiseExitNode values are applied
 | |
| 	SetRoutes         bool // when set, AdvertiseRoutes value is applied
 | |
| 	UseExitNode       tailcfg.StableNodeID
 | |
| 	AdvertiseExitNode bool
 | |
| 	AdvertiseRoutes   []string
 | |
| }
 | |
| 
 | |
| func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
 | |
| 	prefs, err := s.lc.GetPrefs(ctx)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	var currNonExitRoutes []string
 | |
| 	var currAdvertisingExitNode bool
 | |
| 	for _, r := range prefs.AdvertiseRoutes {
 | |
| 		if tsaddr.IsExitRoute(r) {
 | |
| 			currAdvertisingExitNode = true
 | |
| 			continue
 | |
| 		}
 | |
| 		currNonExitRoutes = append(currNonExitRoutes, r.String())
 | |
| 	}
 | |
| 	// Set non-edited fields to their current values.
 | |
| 	if data.SetExitNode {
 | |
| 		data.AdvertiseRoutes = currNonExitRoutes
 | |
| 	} else if data.SetRoutes {
 | |
| 		data.AdvertiseExitNode = currAdvertisingExitNode
 | |
| 		data.UseExitNode = prefs.ExitNodeID
 | |
| 	}
 | |
| 
 | |
| 	// Calculate routes.
 | |
| 	routesStr := strings.Join(data.AdvertiseRoutes, ",")
 | |
| 	routes, err := netutil.CalcAdvertiseRoutes(routesStr, data.AdvertiseExitNode)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	if !data.UseExitNode.IsZero() && tsaddr.ContainsExitRoutes(views.SliceOf(routes)) {
 | |
| 		return errors.New("cannot use and advertise exit node at same time")
 | |
| 	}
 | |
| 
 | |
| 	// Make prefs update.
 | |
| 	p := &ipn.MaskedPrefs{
 | |
| 		AdvertiseRoutesSet: true,
 | |
| 		ExitNodeIDSet:      true,
 | |
| 		Prefs: ipn.Prefs{
 | |
| 			ExitNodeID:      data.UseExitNode,
 | |
| 			AdvertiseRoutes: routes,
 | |
| 		},
 | |
| 	}
 | |
| 	_, err = s.lc.EditPrefs(ctx, p)
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| // tailscaleUp starts the daemon with the provided options.
 | |
| // If reauthentication has been requested, an authURL is returned to complete device registration.
 | |
| func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tailscaleUpOptions) (authURL string, retErr error) {
 | |
| 	origAuthURL := st.AuthURL
 | |
| 	isRunning := st.BackendState == ipn.Running.String()
 | |
| 
 | |
| 	if !opt.Reauthenticate {
 | |
| 		switch {
 | |
| 		case origAuthURL != "":
 | |
| 			return origAuthURL, nil
 | |
| 		case isRunning:
 | |
| 			return "", nil
 | |
| 		case st.BackendState == ipn.Stopped.String():
 | |
| 			// stopped and not reauthenticating, so just start running
 | |
| 			_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
 | |
| 				Prefs: ipn.Prefs{
 | |
| 					WantRunning: true,
 | |
| 				},
 | |
| 				WantRunningSet: true,
 | |
| 			})
 | |
| 			return "", err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// printAuthURL reports whether we should print out the
 | |
| 	// provided auth URL from an IPN notify.
 | |
| 	printAuthURL := func(url string) bool {
 | |
| 		return url != origAuthURL
 | |
| 	}
 | |
| 
 | |
| 	watchCtx, cancelWatch := context.WithCancel(ctx)
 | |
| 	defer cancelWatch()
 | |
| 	watcher, err := s.lc.WatchIPNBus(watchCtx, 0)
 | |
| 	if err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 	defer watcher.Close()
 | |
| 
 | |
| 	go func() {
 | |
| 		if !isRunning {
 | |
| 			ipnOptions := ipn.Options{AuthKey: opt.AuthKey}
 | |
| 			if opt.ControlURL != "" {
 | |
| 				_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
 | |
| 					Prefs: ipn.Prefs{
 | |
| 						ControlURL: opt.ControlURL,
 | |
| 					},
 | |
| 					ControlURLSet: true,
 | |
| 				})
 | |
| 				if err != nil {
 | |
| 					s.logf("edit prefs: %v", err)
 | |
| 				}
 | |
| 			}
 | |
| 			if err := s.lc.Start(ctx, ipnOptions); err != nil {
 | |
| 				s.logf("start: %v", err)
 | |
| 			}
 | |
| 		}
 | |
| 		if opt.Reauthenticate {
 | |
| 			if err := s.lc.StartLoginInteractive(ctx); err != nil {
 | |
| 				s.logf("startLogin: %v", err)
 | |
| 			}
 | |
| 		}
 | |
| 	}()
 | |
| 
 | |
| 	for {
 | |
| 		n, err := watcher.Next()
 | |
| 		if err != nil {
 | |
| 			return "", err
 | |
| 		}
 | |
| 		if n.State != nil && *n.State == ipn.Running {
 | |
| 			return "", nil
 | |
| 		}
 | |
| 		if n.ErrMessage != nil {
 | |
| 			msg := *n.ErrMessage
 | |
| 			return "", fmt.Errorf("backend error: %v", msg)
 | |
| 		}
 | |
| 		if url := n.BrowseToURL; url != nil && printAuthURL(*url) {
 | |
| 			return *url, nil
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| type tailscaleUpOptions struct {
 | |
| 	// If true, force reauthentication of the client.
 | |
| 	// Otherwise simply reconnect, the same as running `tailscale up`.
 | |
| 	Reauthenticate bool
 | |
| 
 | |
| 	ControlURL string
 | |
| 	AuthKey    string
 | |
| }
 | |
| 
 | |
| // serveTailscaleUp serves requests to /api/up.
 | |
| // If the user needs to authenticate, an authURL is provided in the response.
 | |
| func (s *Server) serveTailscaleUp(w http.ResponseWriter, r *http.Request) {
 | |
| 	defer r.Body.Close()
 | |
| 
 | |
| 	st, err := s.lc.Status(r.Context())
 | |
| 	if err != nil {
 | |
| 		http.Error(w, err.Error(), http.StatusInternalServerError)
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	var opt tailscaleUpOptions
 | |
| 	type mi map[string]any
 | |
| 	if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
 | |
| 		w.WriteHeader(400)
 | |
| 		json.NewEncoder(w).Encode(mi{"error": err.Error()})
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	w.Header().Set("Content-Type", "application/json")
 | |
| 	s.logf("tailscaleUp(reauth=%v) ...", opt.Reauthenticate)
 | |
| 	url, err := s.tailscaleUp(r.Context(), st, opt)
 | |
| 	s.logf("tailscaleUp = (URL %v, %v)", url != "", err)
 | |
| 	if err != nil {
 | |
| 		w.WriteHeader(http.StatusInternalServerError)
 | |
| 		json.NewEncoder(w).Encode(mi{"error": err.Error()})
 | |
| 		return
 | |
| 	}
 | |
| 	if url != "" {
 | |
| 		json.NewEncoder(w).Encode(mi{"url": url})
 | |
| 	} else {
 | |
| 		io.WriteString(w, "{}")
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // serveDeviceDetailsClick increments the web_client_device_details_click metric
 | |
| // by one.
 | |
| //
 | |
| // Metric logging from the frontend typically is proxied to the localapi. This event
 | |
| // has been special cased as access to the localapi is gated upon having a valid
 | |
| // session which is not always the case when we want to be logging this metric (e.g.,
 | |
| // when in readonly mode).
 | |
| //
 | |
| // Other metrics should not be logged in this way without a good reason.
 | |
| func (s *Server) serveDeviceDetailsClick(w http.ResponseWriter, r *http.Request) {
 | |
| 	s.lc.IncrementCounter(r.Context(), "web_client_device_details_click", 1)
 | |
| 
 | |
| 	io.WriteString(w, "{}")
 | |
| }
 | |
| 
 | |
| // proxyRequestToLocalAPI proxies the web API request to the localapi.
 | |
| //
 | |
| // The web API request path is expected to exactly match a localapi path,
 | |
| // with prefix /api/local/ rather than /localapi/.
 | |
| func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request) {
 | |
| 	path := strings.TrimPrefix(r.URL.Path, "/api/local")
 | |
| 	if r.URL.Path == path { // missing prefix
 | |
| 		http.Error(w, "invalid request", http.StatusBadRequest)
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
 | |
| 	req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
 | |
| 	if err != nil {
 | |
| 		http.Error(w, "failed to construct request", http.StatusInternalServerError)
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	// Make request to tailscaled localapi.
 | |
| 	resp, err := s.lc.DoLocalRequest(req)
 | |
| 	if err != nil {
 | |
| 		http.Error(w, err.Error(), resp.StatusCode)
 | |
| 		return
 | |
| 	}
 | |
| 	defer resp.Body.Close()
 | |
| 
 | |
| 	// Send response back to web frontend.
 | |
| 	w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
 | |
| 	w.WriteHeader(resp.StatusCode)
 | |
| 	if _, err := io.Copy(w, resp.Body); err != nil {
 | |
| 		http.Error(w, err.Error(), http.StatusInternalServerError)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // enforcePrefix returns a HandlerFunc that enforces a given path prefix is used in requests,
 | |
| // then strips it before invoking h.
 | |
| // Unlike http.StripPrefix, it does not return a 404 if the prefix is not present.
 | |
| // Instead, it returns a redirect to the prefix path.
 | |
| func enforcePrefix(prefix string, h http.HandlerFunc) http.HandlerFunc {
 | |
| 	if prefix == "" {
 | |
| 		return h
 | |
| 	}
 | |
| 
 | |
| 	// ensure that prefix always has both a leading and trailing slash so
 | |
| 	// that relative links for JS and CSS assets work correctly.
 | |
| 	if !strings.HasPrefix(prefix, "/") {
 | |
| 		prefix = "/" + prefix
 | |
| 	}
 | |
| 	if !strings.HasSuffix(prefix, "/") {
 | |
| 		prefix += "/"
 | |
| 	}
 | |
| 
 | |
| 	return func(w http.ResponseWriter, r *http.Request) {
 | |
| 		if !strings.HasPrefix(r.URL.Path, prefix) {
 | |
| 			http.Redirect(w, r, prefix, http.StatusFound)
 | |
| 			return
 | |
| 		}
 | |
| 		prefix = strings.TrimSuffix(prefix, "/")
 | |
| 		http.StripPrefix(prefix, h).ServeHTTP(w, r)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func writeJSON(w http.ResponseWriter, data any) {
 | |
| 	w.Header().Set("Content-Type", "application/json")
 | |
| 	if err := json.NewEncoder(w).Encode(data); err != nil {
 | |
| 		w.Header().Set("Content-Type", "text/plain")
 | |
| 		http.Error(w, err.Error(), http.StatusInternalServerError)
 | |
| 		return
 | |
| 	}
 | |
| }
 |