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