Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357// Copyright (c) Tailscale Inc & contributors// 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/tsweb" "tailscale.com/types/logger" "tailscale.com/types/views" "tailscale.com/util/ctxkey" "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) }}
// handleJSON manages decoding the request's body JSON as data and passing it// on to the provided handler function.func handleJSON[data any](h func(ctx context.Context, data data) error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var body data if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if err := h(r.Context(), body); err != nil { if httpErr, ok := errors.AsType[tsweb.HTTPError](err); ok { tsweb.WriteHTTPError(w, r, httpErr) } else { http.Error(w, err.Error(), http.StatusInternalServerError) } return } w.WriteHeader(http.StatusOK) }}
var contextKeyPeer = ctxkey.New("peer-capabilities", peerCapabilities{})
func (s *Server) setPeer(r *http.Request) (*http.Request, 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 := s.lc.StatusWithoutPeers(r.Context()) if err != nil { return nil, err } whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) if err != nil { return nil, err } peer, err := toPeerCapabilities(status, whois) if err != nil { return nil, err } return r.WithContext(contextKeyPeer.WithValue(r.Context(), peer)), nil}
func (s *Server) getPeer(ctx context.Context) peerCapabilities { return contextKeyPeer.Value(ctx)}
// 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 } }
var err error r, err = s.setPeer(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return }
path := strings.TrimPrefix(r.URL.Path, "/api") switch { case path == "/data" && r.Method == httpm.GET: s.serveGetNodeData(w, r) return case path == "/exit-nodes" && r.Method == httpm.GET: s.serveGetExitNodes(w, r) return case path == "/routes" && r.Method == httpm.POST: handleJSON[postRoutesRequest](s.servePostRoutes)(w, r) return case path == "/device-details-click" && r.Method == httpm.POST: s.serveDeviceDetailsClick(w, r) return case path == "/local/v0/logout" && r.Method == httpm.POST: s.proxyRequestToLocalAPI(w, r) return case path == "/local/v0/prefs" && r.Method == httpm.PATCH: handleJSON[maskedPrefs](s.serveUpdatePrefs)(w, r) return case path == "/local/v0/update/check" && r.Method == httpm.GET: s.proxyRequestToLocalAPI(w, r) return case path == "/local/v0/update/check" && r.Method == httpm.POST: s.proxyRequestToLocalAPI(w, r) return case path == "/local/v0/update/progress" && r.Method == httpm.POST: s.proxyRequestToLocalAPI(w, r) return case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST: s.proxyRequestToLocalAPI(w, r) 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 } }
// We might have a session for which we haven't awaited the result yet. // This can happen when the AuthURL opens in the same browser tab instead // of a new one due to browser settings. // (See https://github.com/tailscale/tailscale/issues/11905) // We therefore set a PendingAuth flag when creating a new session, check // it here and call awaitUserAuth if we find it to be true. Once the auth // wait completes, awaitUserAuth will set PendingAuth to false. if sErr == nil && session.PendingAuth == true { if err := s.awaitUserAuth(r.Context(), session); err != nil { sErr = err } }
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 { peer := s.getPeer(ctx) if prefs.RunSSHSet && !peer.canEdit(capFeatureSSH) { return tsweb.Error(http.StatusUnauthorized, "RunSSHSet not allowed", nil) }
_, 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 { if !data.SetExitNode && !data.SetRoutes { return tsweb.Error(http.StatusBadRequest, "must specify SetExitNode or SetRoutes", nil) } peer := s.getPeer(ctx) if data.SetExitNode && !peer.canEdit(capFeatureExitNodes) { return tsweb.Error(http.StatusUnauthorized, "SetExitNode not allowed", nil) } if data.SetRoutes && !peer.canEdit(capFeatureSubnets) { return tsweb.Error(http.StatusUnauthorized, "SetRoutes not allowed", nil) }
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()) } // For each group of fields not being set, preserve the current prefs. if !data.SetExitNode { data.AdvertiseExitNode = currAdvertisingExitNode data.UseExitNode = prefs.ExitNodeID } if !data.SetRoutes { data.AdvertiseRoutes = currNonExitRoutes }
// 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 }
switch path { case "/v0/logout": if !s.getPeer(r.Context()).canEdit(capFeatureAccount) { http.Error(w, "not allowed", http.StatusUnauthorized) return } case "/v0/update/check": if r.Method == httpm.POST && !s.getPeer(r.Context()).canEdit(capFeatureAccount) { http.Error(w, "not allowed", http.StatusUnauthorized) 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 }}