// 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
	}
}
