Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953// Copyright (c) Tailscale Inc & contributors// SPDX-License-Identifier: BSD-3-Clause
// Package localapi contains the HTTP server handlers for tailscaled's API server.package localapi
import ( "bytes" "cmp" "context" "crypto/subtle" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/netip" "net/url" "runtime" "slices" "strconv" "strings" "sync" "time"
"golang.org/x/net/dns/dnsmessage" "tailscale.com/client/tailscale/apitype" "tailscale.com/envknob" "tailscale.com/feature" "tailscale.com/feature/buildfeatures" "tailscale.com/hostinfo" "tailscale.com/ipn" "tailscale.com/ipn/ipnauth" "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnstate" "tailscale.com/logtail" "tailscale.com/net/neterror" "tailscale.com/net/netns" "tailscale.com/net/netutil" "tailscale.com/tailcfg" "tailscale.com/tstime" "tailscale.com/types/appctype" "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/logid" "tailscale.com/util/clientmetric" "tailscale.com/util/def" "tailscale.com/util/eventbus" "tailscale.com/util/httpm" "tailscale.com/util/mak" "tailscale.com/util/osdiag" "tailscale.com/util/rands" "tailscale.com/util/syspolicy/pkey" "tailscale.com/version" "tailscale.com/wgengine/magicsock")
var ( metricInvalidRequests = clientmetric.NewCounter("localapi_invalid_requests") metricDebugMetricsCalls = clientmetric.NewCounter("localapi_debugmetric_requests") metricUserMetricsCalls = clientmetric.NewCounter("localapi_usermetric_requests") metricBugReportRequests = clientmetric.NewCounter("localapi_bugreport_requests"))
type LocalAPIHandler func(*Handler, http.ResponseWriter, *http.Request)
// handler is the set of LocalAPI handlers, keyed by the part of the// Request.URL.Path after "/localapi/v0/". If the key ends with a trailing slash// then it's a prefix match.var handler = map[string]LocalAPIHandler{ // The prefix match handlers end with a slash: "profiles/": (*Handler).serveProfiles,
// The other /localapi/v0/NAME handlers are exact matches and contain only NAME // without a trailing slash: "cert-domains": (*Handler).serveCertDomains, "check-prefs": (*Handler).serveCheckPrefs, "check-so-mark-in-use": (*Handler).serveCheckSOMarkInUse, "derpmap": (*Handler).serveDERPMap, "dns-config": (*Handler).serveDNSConfig, "goroutines": (*Handler).serveGoroutines, "login-interactive": (*Handler).serveLoginInteractive, "logout": (*Handler).serveLogout, "peer-by-id": (*Handler).servePeerByID, "ping": (*Handler).servePing, "prefs": (*Handler).servePrefs, "reload-config": (*Handler).reloadConfig, "reset-auth": (*Handler).serveResetAuth, "services": (*Handler).serveServices, "set-expiry-sooner": (*Handler).serveSetExpirySooner, "shutdown": (*Handler).serveShutdown, "start": (*Handler).serveStart, "status": (*Handler).serveStatus, "user-profile": (*Handler).serveUserProfile, "whois": (*Handler).serveWhoIs,}
func init() { if buildfeatures.HasAppConnectors { Register("appc-route-info", (*Handler).serveGetAppcRouteInfo) } if buildfeatures.HasAdvertiseRoutes { Register("check-ip-forwarding", (*Handler).serveCheckIPForwarding) Register("check-udp-gro-forwarding", (*Handler).serveCheckUDPGROForwarding) Register("set-udp-gro-forwarding", (*Handler).serveSetUDPGROForwarding) } if buildfeatures.HasClientMetrics { Register("upload-client-metrics", (*Handler).serveUploadClientMetrics) } if buildfeatures.HasClientUpdate { Register("update/check", (*Handler).serveUpdateCheck) } if buildfeatures.HasUseExitNode { Register("suggest-exit-node", (*Handler).serveSuggestExitNode) Register("set-use-exit-node-enabled", (*Handler).serveSetUseExitNodeEnabled) } if buildfeatures.HasACME { Register("set-dns", (*Handler).serveSetDNS) } if buildfeatures.HasDebug { Register("bugreport", (*Handler).serveBugReport) Register("pprof", (*Handler).servePprof) } if buildfeatures.HasIPNBus { Register("watch-ipn-bus", (*Handler).serveWatchIPNBus) } if buildfeatures.HasDNS { Register("dns-osconfig", (*Handler).serveDNSOSConfig) Register("dns-query", (*Handler).serveDNSQuery) } if buildfeatures.HasUserMetrics { Register("usermetrics", (*Handler).serveUserMetrics) } if buildfeatures.HasServe { Register("query-feature", (*Handler).serveQueryFeature) } if buildfeatures.HasOutboundProxy || buildfeatures.HasSSH { Register("dial", (*Handler).serveDial) } if buildfeatures.HasClientMetrics || buildfeatures.HasDebug { Register("metrics", (*Handler).serveMetrics) } if buildfeatures.HasDebug || buildfeatures.HasAdvertiseRoutes { Register("disconnect-control", (*Handler).disconnectControl) } // Alpha/experimental/debug features. These should be moved to // their own features if/when they graduate. if buildfeatures.HasDebug { Register("id-token", (*Handler).serveIDToken) Register("alpha-set-device-attrs", (*Handler).serveSetDeviceAttrs) // see tailscale/corp#24690 Register("handle-push-message", (*Handler).serveHandlePushMessage) Register("set-push-device-token", (*Handler).serveSetPushDeviceToken) } if buildfeatures.HasDebug || runtime.GOOS == "windows" || runtime.GOOS == "darwin" { Register("set-gui-visible", (*Handler).serveSetGUIVisible) } if buildfeatures.HasLogTail { // TODO(bradfitz): separate out logtail tap functionality from upload // functionality to make this possible? But seems unlikely people would // want just this. They could "tail -f" or "journalctl -f" their logs // themselves. Register("logtap", (*Handler).serveLogTap) }}
// Register registers a new LocalAPI handler for the given name.func Register(name string, fn LocalAPIHandler) { if _, ok := handler[name]; ok { panic("duplicate LocalAPI handler registration: " + name) } handler[name] = fn}
var ( // The clientmetrics package is stateful, but we want to expose a simple // imperative API to local clients, so we need to keep track of // clientmetric.Metric instances that we've created for them. These need to // be globals because we end up creating many Handler instances for the // lifetime of a client. metricsMu sync.Mutex metrics = map[string]*clientmetric.Metric{})
// NewHandler creates a new LocalAPI HTTP handler from the given config.func NewHandler(cfg HandlerConfig) *Handler { return &Handler{ Actor: cfg.Actor, b: cfg.Backend, logf: cfg.Logf, backendLogID: cfg.LogID, clock: tstime.StdClock{}, eventBus: cfg.EventBus, }}
// HandlerConfig carries the settings for a local API handler.// All fields are required.type HandlerConfig struct { Actor ipnauth.Actor Backend *ipnlocal.LocalBackend Logf logger.Logf LogID logid.PublicID EventBus *eventbus.Bus}
type Handler struct { // RequiredPassword, if non-empty, forces all HTTP // requests to have HTTP basic auth with this password. // It's used by the sandboxed macOS sameuserproof GUI auth mechanism. RequiredPassword string
// PermitRead is whether read-only HTTP handlers are allowed. PermitRead bool
// PermitWrite is whether mutating HTTP handlers are allowed. // If PermitWrite is true, everything is allowed. // It effectively means that the user is root or the admin // (operator user). PermitWrite bool
// PermitCert is whether the client is additionally granted // cert fetching access. PermitCert bool
// Actor is the identity of the client connected to the Handler. Actor ipnauth.Actor
b *ipnlocal.LocalBackend logf logger.Logf backendLogID logid.PublicID clock tstime.Clock eventBus *eventbus.Bus // read-only after initialization}
func (h *Handler) Logf(format string, args ...any) { h.logf(format, args...)}
func (h *Handler) LocalBackend() *ipnlocal.LocalBackend { return h.b}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if h.b == nil { http.Error(w, "server has no local backend", http.StatusInternalServerError) return } if r.Referer() != "" || r.Header.Get("Origin") != "" || !h.validHost(r.Host) { metricInvalidRequests.Add(1) http.Error(w, "invalid localapi request", http.StatusForbidden) return } w.Header().Set("Tailscale-Version", version.Long()) w.Header().Set("Tailscale-Cap", strconv.Itoa(int(tailcfg.CurrentCapabilityVersion))) w.Header().Set("Content-Security-Policy", `default-src 'none'; frame-ancestors 'none'; script-src 'none'; script-src-elem 'none'; script-src-attr 'none'`) w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") if h.RequiredPassword != "" { _, pass, ok := r.BasicAuth() if !ok { metricInvalidRequests.Add(1) http.Error(w, "auth required", http.StatusUnauthorized) return } if subtle.ConstantTimeCompare([]byte(pass), []byte(h.RequiredPassword)) == 0 { metricInvalidRequests.Add(1) http.Error(w, "bad password", http.StatusForbidden) return } } defer h.b.CheckDeadlocks()() if fn, route, ok := handlerForPath(r.URL.Path); ok { h.logRequest(r.Method, route) fn(h, w, r) } else { http.NotFound(w, r) }}
// validLocalHostForTesting allows loopback handlers without RequiredPassword for testing.var validLocalHostForTesting = false
// validHost reports whether h is a valid Host header value for a LocalAPI request.func (h *Handler) validHost(hostname string) bool { // The client code sends a hostname of "local-tailscaled.sock". switch hostname { case "", apitype.LocalAPIHost: return true } if !validLocalHostForTesting && h.RequiredPassword == "" { return false // only allow localhost with basic auth or in tests } host, _, err := net.SplitHostPort(hostname) if err != nil { return false } if host == "localhost" { return true } addr, err := netip.ParseAddr(host) if err != nil { return false } return addr.IsLoopback()}
// handlerForPath returns the LocalAPI handler for the provided Request.URI.Path.// (the path doesn't include any query parameters)func handlerForPath(urlPath string) (h LocalAPIHandler, route string, ok bool) { if urlPath == "/" { return (*Handler).serveLocalAPIRoot, "/", true } suff, ok := strings.CutPrefix(urlPath, "/localapi/v0/") if !ok { // Currently all LocalAPI methods start with "/localapi/v0/" to signal // to people that they're not necessarily stable APIs. In practice we'll // probably need to keep them pretty stable anyway, but for now treat // them as an internal implementation detail. return nil, "", false } if fn, ok := handler[suff]; ok { // Here we match exact handler suffixes like "status" or ones with a // slash already in their name, like "tka/status". return fn, "/localapi/v0/" + suff, true } // Otherwise, it might be a prefix match like "files/*" which we look up // by the prefix including first trailing slash. if i := strings.IndexByte(suff, '/'); i != -1 { suff = suff[:i+1] if fn, ok := handler[suff]; ok { return fn, "/localapi/v0/" + suff, true } } return nil, "", false}
func (h *Handler) logRequest(method, route string) { switch method { case httpm.GET, httpm.HEAD, httpm.OPTIONS: // don't log safe methods default: h.Logf("localapi: [%s] %s", method, route) }}
func (*Handler) serveLocalAPIRoot(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "tailscaled\n")}
// serveIDToken handles requests to get an OIDC ID token.func (h *Handler) serveIDToken(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "id-token access denied", http.StatusForbidden) return } nm := h.b.NetMapNoPeers() if nm == nil { http.Error(w, "no netmap", http.StatusServiceUnavailable) return } aud := strings.TrimSpace(r.FormValue("aud")) if len(aud) == 0 { http.Error(w, "no audience requested", http.StatusBadRequest) return } req := &tailcfg.TokenRequest{ CapVersion: tailcfg.CurrentCapabilityVersion, Audience: aud, NodeKey: nm.NodeKey, } b, err := json.Marshal(req) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } httpReq, err := http.NewRequest(httpm.POST, "https://unused/machine/id-token", bytes.NewReader(b)) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } resp, err := h.b.DoNoiseRequest(httpReq) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer resp.Body.Close() w.WriteHeader(resp.StatusCode) if _, err := io.Copy(w, resp.Body); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }}
func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "bugreport access denied", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "only POST allowed", http.StatusMethodNotAllowed) return } defer h.b.TryFlushLogs() // kick off upload after bugreport's done logging
logMarker := func() string { return fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, h.clock.Now().UTC().Format(tstime.NumericDateTimeZ), rands.HexString(16)) } if envknob.NoLogsNoSupport() { logMarker = func() string { return "BUG-NO-LOGS-NO-SUPPORT-this-node-has-had-its-logging-disabled" } }
startMarker := logMarker() h.logf("user bugreport: %s", startMarker) if note := r.URL.Query().Get("note"); len(note) > 0 { h.logf("user bugreport note: %s", note) } hi, _ := json.Marshal(hostinfo.New()) h.logf("user bugreport hostinfo: %s", hi) if err := h.b.HealthTracker().OverallError(); err != nil { h.logf("user bugreport health: %s", err.Error()) } else { h.logf("user bugreport health: ok") }
// Information about the current node from the netmap if nm := h.b.NetMapNoPeers(); nm != nil { if self := nm.SelfNode; self.Valid() { h.logf("user bugreport node info: nodeid=%q stableid=%q expiry=%q", self.ID(), self.StableID(), self.KeyExpiry().Format(time.RFC3339)) } h.logf("user bugreport public keys: machine=%q node=%q", nm.MachineKey, nm.NodeKey) } else { h.logf("user bugreport netmap: no active netmap") }
// Print all envknobs; we otherwise only print these on startup, and // printing them here ensures we don't have to go spelunking through // logs for them. envknob.LogCurrent(logger.WithPrefix(h.logf, "user bugreport: "))
// OS-specific details h.logf.JSON(1, "UserBugReportOS", osdiag.SupportInfo(osdiag.LogSupportInfoReasonBugReport))
// Tailnet Lock details st := h.b.TailnetLockStatus() if st.Enabled { h.logf.JSON(1, "UserBugReportTailnetLockStatus", st) if st.NodeKeySignature != nil { h.logf("user bugreport tailnet lock signature: %s", st.NodeKeySignature.String()) } }
if def.Bool(r.URL.Query().Get("diagnose"), false) { if f, ok := ipnlocal.HookDoctor.GetOk(); ok { f(r.Context(), h.b, logger.WithPrefix(h.logf, "diag: ")) } } w.Header().Set("Content-Type", "text/plain") fmt.Fprintln(w, startMarker)
// Nothing else to do if we're not in record mode; we wrote the marker // above, so we can just finish our response now. if !def.Bool(r.URL.Query().Get("record"), false) { return }
until := h.clock.Now().Add(12 * time.Hour)
var changed map[string]bool for _, component := range []string{"magicsock"} { if h.b.GetComponentDebugLogging(component).IsZero() { if err := h.b.SetComponentDebugLogging(component, until); err != nil { h.logf("bugreport: error setting component %q logging: %v", component, err) continue }
mak.Set(&changed, component, true) } } defer func() { for component := range changed { h.b.SetComponentDebugLogging(component, time.Time{}) } }()
// NOTE(andrew): if we have anything else we want to do while recording // a bugreport, we can add it here.
metricBugReportRequests.Add(1)
// Read from the client; this will also return when the client closes // the connection. var buf [1]byte _, err := r.Body.Read(buf[:])
switch { case err == nil: // good case errors.Is(err, io.EOF): // good case errors.Is(err, io.ErrUnexpectedEOF): // this happens when Ctrl-C'ing the tailscale client; don't // bother logging an error default: // Log but continue anyway. h.logf("user bugreport: error reading body: %v", err) }
// Generate another log marker and return it to the client. endMarker := logMarker() h.logf("user bugreport end: %s", endMarker) fmt.Fprintln(w, endMarker)}
func (h *Handler) serveWhoIs(w http.ResponseWriter, r *http.Request) { h.serveWhoIsWithBackend(w, r, h.b)}
// serveSetDeviceAttrs is (as of 2024-12-30) an experimental LocalAPI handler to// set device attributes via the control plane.//// See tailscale/corp#24690.func (h *Handler) serveSetDeviceAttrs(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !h.PermitWrite { http.Error(w, "set-device-attrs access denied", http.StatusForbidden) return } if r.Method != httpm.PATCH { http.Error(w, "only PATCH allowed", http.StatusMethodNotAllowed) return } var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := h.b.SetDeviceAttrs(ctx, req); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") io.WriteString(w, "{}\n")}
// localBackendWhoIsMethods is the subset of ipn.LocalBackend as needed// by the localapi WhoIs method.type localBackendWhoIsMethods interface { WhoIs(string, netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) WhoIsNodeKey(key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) PeerCaps(netip.Addr) tailcfg.PeerCapMap PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap PeerCapsForService(src netip.Addr, svcName tailcfg.ServiceName) tailcfg.PeerCapMap}
func (h *Handler) serveWhoIsWithBackend(w http.ResponseWriter, r *http.Request, b localBackendWhoIsMethods) { if !h.PermitRead { http.Error(w, "whois access denied", http.StatusForbidden) return } var ( n tailcfg.NodeView u tailcfg.UserProfile ok bool ) var ipp netip.AddrPort if v := r.FormValue("addr"); v != "" { if strings.HasPrefix(v, "nodekey:") { var k key.NodePublic if err := k.UnmarshalText([]byte(v)); err != nil { http.Error(w, "invalid nodekey in 'addr' parameter", http.StatusBadRequest) return } n, u, ok = b.WhoIsNodeKey(k) } else if ip, err := netip.ParseAddr(v); err == nil { ipp = netip.AddrPortFrom(ip, 0) } else { var err error ipp, err = netip.ParseAddrPort(v) if err != nil { http.Error(w, "invalid 'addr' parameter", http.StatusBadRequest) return } } if ipp.IsValid() { n, u, ok = b.WhoIs(r.FormValue("proto"), ipp) } } else { http.Error(w, "missing 'addr' parameter", http.StatusBadRequest) return } if !ok { http.Error(w, "no match for IP:port", http.StatusNotFound) return } res := &apitype.WhoIsResponse{ Node: n.AsStruct(), // always non-nil per WhoIsResponse contract UserProfile: &u, // always non-nil per WhoIsResponse contract } if n.Addresses().Len() > 0 { src := n.Addresses().At(0).Addr() switch { case r.FormValue("svc_name") != "": svcName := tailcfg.AsServiceName(r.FormValue("svc_name")) if svcName == "" { http.Error(w, "invalid svc_name", http.StatusBadRequest) return } res.CapMap = b.PeerCapsForService(src, svcName) case r.FormValue("dst_ip") != "": svcAddr, err := netip.ParseAddr(r.FormValue("dst_ip")) if err != nil { http.Error(w, "invalid dst_ip", http.StatusBadRequest) return } res.CapMap = b.PeerCapsForIP(src, svcAddr) default: res.CapMap = b.PeerCaps(src) } } j, err := json.MarshalIndent(res, "", "\t") if err != nil { http.Error(w, "JSON encoding error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write(j)}
func (h *Handler) serveGoroutines(w http.ResponseWriter, r *http.Request) { // Require write access out of paranoia that the goroutine dump // (at least its arguments) might contain something sensitive. if !h.PermitWrite { http.Error(w, "goroutine dump access denied", http.StatusForbidden) return } buf := make([]byte, 2<<20) buf = buf[:runtime.Stack(buf, true)] w.Header().Set("Content-Type", "text/plain") w.Write(buf)}
// serveLogTap taps into the tailscaled/logtail server output and streams// it to the client.func (h *Handler) serveLogTap(w http.ResponseWriter, r *http.Request) { ctx := r.Context()
// Require write access (~root) as the logs could contain something // sensitive. if !h.PermitWrite { http.Error(w, "logtap access denied", http.StatusForbidden) return } if r.Method != httpm.GET { http.Error(w, "GET required", http.StatusMethodNotAllowed) return } f, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return }
io.WriteString(w, `{"text":"[logtap connected]\n"}`+"\n") f.Flush()
msgc := make(chan string, 16) unreg := logtail.RegisterLogTap(msgc) defer unreg()
for { select { case <-ctx.Done(): return case msg := <-msgc: io.WriteString(w, msg) f.Flush() } }}
func (h *Handler) serveMetrics(w http.ResponseWriter, r *http.Request) { metricDebugMetricsCalls.Add(1) // Require write access out of paranoia that the metrics // might contain something sensitive. if !h.PermitWrite { http.Error(w, "metric access denied", http.StatusForbidden) return } w.Header().Set("Content-Type", "text/plain") clientmetric.WritePrometheusExpositionFormat(w)}
// serveUserMetrics returns user-facing metrics in Prometheus text// exposition format.func (h *Handler) serveUserMetrics(w http.ResponseWriter, r *http.Request) { metricUserMetricsCalls.Add(1) h.b.UserMetricsRegistry().Handler(w, r)}
// servePprofFunc is the implementation of Handler.servePprof, after auth,// for platforms where we want to link it in.var servePprofFunc func(http.ResponseWriter, *http.Request)
func (h *Handler) servePprof(w http.ResponseWriter, r *http.Request) { // Require write access out of paranoia that the profile dump // might contain something sensitive. if !h.PermitWrite { http.Error(w, "profile access denied", http.StatusForbidden) return } if servePprofFunc == nil { http.Error(w, "not implemented on this platform", http.StatusServiceUnavailable) return } servePprofFunc(w, r)}
// disconnectControl is the handler for local API /disconnect-control endpoint that shuts down control client, so that// node no longer communicates with control. Doing this makes control consider this node inactive. This can be used// before shutting down a replica of HA subnet router or app connector deployments to ensure that control tells the// peers to switch over to another replica whilst still maintaining th existing peer connections.func (h *Handler) disconnectControl(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "access denied", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "use POST", http.StatusMethodNotAllowed) return } h.b.DisconnectControl()}
func (h *Handler) reloadConfig(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "access denied", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "use POST", http.StatusMethodNotAllowed) return } ok, err := h.b.ReloadConfig() var res apitype.ReloadConfigResponse res.Reloaded = ok if err != nil { res.Err = err.Error() return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(&res)}
func (h *Handler) serveResetAuth(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "reset-auth modify access denied", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "use POST", http.StatusMethodNotAllowed) return }
if err := h.b.ResetAuth(); err != nil { http.Error(w, "reset-auth failed: "+err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent)}
func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "IP forwarding check access denied", http.StatusForbidden) return } var warning string if err := h.b.CheckIPForwarding(); err != nil { warning = err.Error() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(struct { Warning string }{ Warning: warning, })}
// serveCheckSOMarkInUse reports whether SO_MARK is in use on the linux while// running without TUN. For any other OS, it reports false.func (h *Handler) serveCheckSOMarkInUse(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "SO_MARK check access denied", http.StatusForbidden) return } usingSOMark := netns.UseSocketMark() usingUserspaceNetworking := h.b.Sys().IsNetstack() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(struct { UseSOMark bool }{ UseSOMark: usingSOMark || usingUserspaceNetworking, })}
func (h *Handler) serveCheckUDPGROForwarding(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "UDP GRO forwarding check access denied", http.StatusForbidden) return } var warning string if err := h.b.CheckUDPGROForwarding(); err != nil { warning = err.Error() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(struct { Warning string }{ Warning: warning, })}
func (h *Handler) serveSetUDPGROForwarding(w http.ResponseWriter, r *http.Request) { if !buildfeatures.HasGRO { http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented) return } if !h.PermitWrite { http.Error(w, "UDP GRO forwarding set access denied", http.StatusForbidden) return } var warning string if err := h.b.SetUDPGROForwarding(); err != nil { warning = err.Error() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(struct { Warning string }{ Warning: warning, })}
func (h *Handler) serveStatus(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "status access denied", http.StatusForbidden) return } w.Header().Set("Content-Type", "application/json") var st *ipnstate.Status if def.Bool(r.FormValue("peers"), true) { st = h.b.Status() } else { st = h.b.StatusWithoutPeers() } e := json.NewEncoder(w) e.SetIndent("", "\t") e.Encode(st)}
// InUseOtherUserIPNStream reports whether r is a request for the watch-ipn-bus// handler. If so, it writes an ipn.Notify InUseOtherUser message to the user// and returns true. Otherwise it returns false, in which case it doesn't write// to w.//// Unlike the regular watch-ipn-bus handler, this one doesn't block. The caller// (in ipnserver.Server) provides the blocking until the connection is no longer// in use.func InUseOtherUserIPNStream(w http.ResponseWriter, r *http.Request, err error) (handled bool) { if r.Method != httpm.GET || r.URL.Path != "/localapi/v0/watch-ipn-bus" { return false } js, err := json.Marshal(&ipn.Notify{ Version: version.Long(), State: new(ipn.InUseOtherUser), ErrMessage: new(err.Error()), }) if err != nil { return false } js = append(js, '\n') w.Header().Set("Content-Type", "application/json") w.Write(js) return true}
func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "watch ipn bus access denied", http.StatusForbidden) return } f, ok := w.(http.Flusher) if !ok { http.Error(w, "not a flusher", http.StatusInternalServerError) return }
var mask ipn.NotifyWatchOpt if s := r.FormValue("mask"); s != "" { if err := mask.UnmarshalText([]byte(s)); err != nil { http.Error(w, "bad mask", http.StatusBadRequest) return } } if mask&ipn.NotifyInProcessNoDisconnect != 0 { http.Error(w, "NotifyInProcessNoDisconnect is only valid for in-process IPN bus subscribers", http.StatusBadRequest) return } if err := ipn.ValidateNotifyWatchOpt(mask); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return }
w.Header().Set("Content-Type", "application/json") ctx := r.Context() enc := json.NewEncoder(w) h.b.WatchNotificationsAs(ctx, h.Actor, mask, f.Flush, func(roNotify *ipn.Notify) (keepGoing bool) { err := enc.Encode(roNotify) if err != nil { if !neterror.IsClosedPipeError(err) { h.logf("json.Encode: %v", err) } return false } f.Flush() return true })}
func (h *Handler) serveLoginInteractive(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "login access denied", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "want POST", http.StatusBadRequest) return } if err := h.b.StartLoginInteractiveAs(r.Context(), h.Actor); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) return}
func (h *Handler) serveStart(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "access denied", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "want POST", http.StatusBadRequest) return } var o ipn.Options if err := json.NewDecoder(r.Body).Decode(&o); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return }
if h.b.HealthTracker().IsUnhealthy(ipn.StateStoreHealth) { http.Error(w, "cannot start backend when state store is unhealthy", http.StatusInternalServerError) return } err := h.b.Start(o) if err != nil { // TODO(bradfitz): map error to a good HTTP error http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent)}
func (h *Handler) serveLogout(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "logout access denied", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "want POST", http.StatusBadRequest) return } err := h.b.Logout(r.Context(), h.Actor) if err == nil { w.WriteHeader(http.StatusNoContent) return } http.Error(w, err.Error(), http.StatusInternalServerError)}
func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "prefs access denied", http.StatusForbidden) return } var prefs ipn.PrefsView switch r.Method { case httpm.PATCH: if !h.PermitWrite { http.Error(w, "prefs write access denied", http.StatusForbidden) return } mp := new(ipn.MaskedPrefs) if err := json.NewDecoder(r.Body).Decode(mp); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if buildfeatures.HasAppConnectors { if err := h.b.MaybeClearAppConnector(mp); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(resJSON{Error: err.Error()}) return } } var err error prefs, err = h.b.EditPrefsAs(mp, h.Actor) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(resJSON{Error: err.Error()}) return } case httpm.GET, httpm.HEAD: prefs = h.b.Prefs() default: http.Error(w, "unsupported method", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") e := json.NewEncoder(w) e.SetIndent("", "\t") e.Encode(prefs)}
type resJSON struct { Error string `json:",omitempty"`}
func (h *Handler) serveCheckPrefs(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "checkprefs access denied", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "unsupported method", http.StatusMethodNotAllowed) return } p := new(ipn.Prefs) if err := json.NewDecoder(r.Body).Decode(p); err != nil { http.Error(w, "invalid JSON body", http.StatusBadRequest) return } err := h.b.CheckPrefs(p) var res resJSON if err != nil { res.Error = err.Error() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res)}
// WriteErrorJSON writes a JSON object (with a single "error" string field) to w// with the given error. If err is nil, "unexpected nil error" is used for the// stringification instead.func WriteErrorJSON(w http.ResponseWriter, err error) { if err == nil { err = errors.New("unexpected nil error") } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) type E struct { Error string `json:"error"` } json.NewEncoder(w).Encode(E{err.Error()})}
func (h *Handler) serveSetDNS(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "access denied", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "want POST", http.StatusBadRequest) return } ctx := r.Context() err := h.b.SetDNS(ctx, r.FormValue("name"), r.FormValue("value")) if err != nil { WriteErrorJSON(w, err) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(struct{}{})}
func (h *Handler) serveDERPMap(w http.ResponseWriter, r *http.Request) { if r.Method != httpm.GET { http.Error(w, "want GET", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") e := json.NewEncoder(w) e.SetIndent("", "\t") e.Encode(h.b.DERPMap())}
// serveCertDomains returns the list of DNS.CertDomains from the current// netmap, or an empty list if no netmap has been received yet.// The returned list is sorted in ascending order.func (h *Handler) serveCertDomains(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "cert-domains access denied", http.StatusForbidden) return } var domains []string if nm := h.b.NetMapNoPeers(); nm != nil { domains = slices.Clone(nm.DNS.CertDomains) slices.Sort(domains) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(domains)}
// serveDNSConfig returns the [tailcfg.DNSConfig] from the current netmap.// It returns 503 if no netmap has been received yet.func (h *Handler) serveDNSConfig(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "dns-config access denied", http.StatusForbidden) return } nm := h.b.NetMapNoPeers() if nm == nil { http.Error(w, "no netmap", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") e := json.NewEncoder(w) e.SetIndent("", "\t") e.Encode(nm.DNS)}
// peerByIDBackend is the subset of [ipnlocal.LocalBackend] used by// [Handler.servePeerByID]. It exists so the handler can be tested with a// trivial mock without spinning up a full LocalBackend.type peerByIDBackend interface { PeerByID(tailcfg.NodeID) (tailcfg.NodeView, bool)}
// servePeerByID returns the current full [tailcfg.Node] for the peer with the// NodeID given in the "id" query parameter. It returns 404 if no such peer is// in the current netmap.//// It is intended for clients that observed a peer-mutation signal (e.g.// [ipn.Notify.PeerChangedPatch] or [ipn.Notify.PeersChanged]) and want the// latest state of the affected node without having to apply the patch// themselves.func (h *Handler) servePeerByID(w http.ResponseWriter, r *http.Request) { h.servePeerByIDWithBackend(w, r, h.b)}
func (h *Handler) servePeerByIDWithBackend(w http.ResponseWriter, r *http.Request, b peerByIDBackend) { if !h.PermitRead { http.Error(w, "peer-by-id access denied", http.StatusForbidden) return } idStr := r.FormValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id <= 0 { http.Error(w, "invalid 'id' parameter", http.StatusBadRequest) return } nv, ok := b.PeerByID(tailcfg.NodeID(id)) if !ok { http.Error(w, "no peer with that NodeID", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") e := json.NewEncoder(w) e.SetIndent("", "\t") e.Encode(nv.AsStruct())}
// userProfileBackend is the subset of [ipnlocal.LocalBackend] used by// [Handler.serveUserProfile]. It exists so the handler can be tested// with a trivial mock without spinning up a full LocalBackend.type userProfileBackend interface { UserProfile(tailcfg.UserID) (tailcfg.UserProfileView, bool)}
// serveUserProfile returns the current [tailcfg.UserProfile] for the User// with the UserID given in the "id" query parameter, in O(1) time. It// returns 404 if no such user is in the current netmap.//// It is the LocalAPI fallback for IPN-bus consumers that see a UserID// referenced by a peer Node and want to resolve it to a UserProfile.func (h *Handler) serveUserProfile(w http.ResponseWriter, r *http.Request) { h.serveUserProfileWithBackend(w, r, h.b)}
func (h *Handler) serveUserProfileWithBackend(w http.ResponseWriter, r *http.Request, b userProfileBackend) { if !h.PermitRead { http.Error(w, "user-profile access denied", http.StatusForbidden) return } idStr := r.FormValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id <= 0 { http.Error(w, "invalid 'id' parameter", http.StatusBadRequest) return } uv, ok := b.UserProfile(tailcfg.UserID(id)) if !ok { http.Error(w, "no user with that UserID", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") e := json.NewEncoder(w) e.SetIndent("", "\t") e.Encode(uv.AsStruct())}
// serveSetExpirySooner sets the expiry date on the current machine, specified// by an `expiry` unix timestamp as POST or query param.func (h *Handler) serveSetExpirySooner(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "access denied", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "POST required", http.StatusMethodNotAllowed) return }
var expiryTime time.Time if v := r.FormValue("expiry"); v != "" { expiryInt, err := strconv.ParseInt(v, 10, 64) if err != nil { http.Error(w, "can't parse expiry time, expects a unix timestamp", http.StatusBadRequest) return } expiryTime = time.Unix(expiryInt, 0) } else { http.Error(w, "missing 'expiry' parameter, a unix timestamp", http.StatusBadRequest) return } err := h.b.SetExpirySooner(r.Context(), expiryTime) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "text/plain") io.WriteString(w, "done\n")}
func (h *Handler) servePing(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if r.Method != httpm.POST { http.Error(w, "want POST", http.StatusBadRequest) return } ipStr := r.FormValue("ip") if ipStr == "" { http.Error(w, "missing 'ip' parameter", http.StatusBadRequest) return } ip, err := netip.ParseAddr(ipStr) if err != nil { http.Error(w, "invalid IP", http.StatusBadRequest) return } pingTypeStr := r.FormValue("type") if pingTypeStr == "" { http.Error(w, "missing 'type' parameter", http.StatusBadRequest) return } size := 0 sizeStr := r.FormValue("size") if sizeStr != "" { size, err = strconv.Atoi(sizeStr) if err != nil { http.Error(w, "invalid 'size' parameter", http.StatusBadRequest) return } if size != 0 && tailcfg.PingType(pingTypeStr) != tailcfg.PingDisco { http.Error(w, "'size' parameter is only supported with disco pings", http.StatusBadRequest) return } if size > magicsock.MaxDiscoPingSize { http.Error(w, fmt.Sprintf("maximum value for 'size' is %v", magicsock.MaxDiscoPingSize), http.StatusBadRequest) return } } res, err := h.b.Ping(ctx, ip, tailcfg.PingType(pingTypeStr), size) if err != nil { WriteErrorJSON(w, err) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res)}
func (h *Handler) serveDial(w http.ResponseWriter, r *http.Request) { if r.Method != httpm.POST { http.Error(w, "POST required", http.StatusMethodNotAllowed) return } const upgradeProto = "ts-dial" if !strings.Contains(r.Header.Get("Connection"), "upgrade") || r.Header.Get("Upgrade") != upgradeProto { http.Error(w, "bad ts-dial upgrade", http.StatusBadRequest) return } hostStr, portStr := r.Header.Get("Dial-Host"), r.Header.Get("Dial-Port") if hostStr == "" || portStr == "" { http.Error(w, "missing Dial-Host or Dial-Port header", http.StatusBadRequest) return } network := cmp.Or(r.Header.Get("Dial-Network"), "tcp")
addr := net.JoinHostPort(hostStr, portStr)
// Check whether the resolved address is a Tailscale route. // If not, tell the client to dial it directly so the connection // comes from the calling user's UID rather than our root-owned daemon. ipp, viaTailscale, err := h.b.Dialer().UserDialPlan(r.Context(), network, addr) if err != nil { http.Error(w, "resolve failure: "+err.Error(), http.StatusBadGateway) return } if !viaTailscale { w.Header().Set("Dial-Self", "true") w.Header().Set("Dial-Addr", ipp.String()) w.WriteHeader(http.StatusOK) return }
hijacker, ok := w.(http.Hijacker) if !ok { http.Error(w, "make request over HTTP/1", http.StatusBadRequest) return }
// Dial via Tailscale with the original hostname so UserDial can // resolve all addresses and race across families (happy eyeballs). outConn, err := h.b.Dialer().UserDial(r.Context(), network, addr) if err != nil { http.Error(w, "dial failure: "+err.Error(), http.StatusBadGateway) return } defer outConn.Close()
w.Header().Set("Upgrade", upgradeProto) w.Header().Set("Connection", "upgrade") w.WriteHeader(http.StatusSwitchingProtocols)
reqConn, brw, err := hijacker.Hijack() if err != nil { h.logf("localapi dial Hijack error: %v", err) return } defer reqConn.Close() if err := brw.Flush(); err != nil { return } reqConn = netutil.NewDrainBufConn(reqConn, brw.Reader)
errc := make(chan error, 1) go func() { _, err := io.Copy(reqConn, outConn) errc <- err }() go func() { _, err := io.Copy(outConn, reqConn) errc <- err }() <-errc}
func (h *Handler) serveSetPushDeviceToken(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "set push device token access denied", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "unsupported method", http.StatusMethodNotAllowed) return } var params apitype.SetPushDeviceTokenRequest if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { http.Error(w, "invalid JSON body", http.StatusBadRequest) return } h.b.SetPushDeviceToken(params.PushDeviceToken) w.WriteHeader(http.StatusOK)}
func (h *Handler) serveHandlePushMessage(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "handle push message not allowed", http.StatusForbidden) return } if r.Method != httpm.POST { http.Error(w, "unsupported method", http.StatusMethodNotAllowed) return } var pushMessageBody map[string]any if err := json.NewDecoder(r.Body).Decode(&pushMessageBody); err != nil { http.Error(w, "failed to decode JSON body: "+err.Error(), http.StatusBadRequest) return }
// TODO(bradfitz): do something with pushMessageBody h.logf("localapi: got push message: %v", logger.AsJSON(pushMessageBody))
w.WriteHeader(http.StatusNoContent)}
func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Request) { if r.Method != httpm.POST { http.Error(w, "unsupported method", http.StatusMethodNotAllowed) return }
var clientMetrics []clientmetric.MetricUpdate if err := json.NewDecoder(r.Body).Decode(&clientMetrics); err != nil { http.Error(w, "invalid JSON body", http.StatusBadRequest) return }
metricsMu.Lock() defer metricsMu.Unlock()
for _, m := range clientMetrics { metric, ok := metrics[m.Name] if !ok { if clientmetric.HasPublished(m.Name) { http.Error(w, "Already have a metric named "+m.Name, http.StatusBadRequest) return } switch m.Type { case "counter": metric = clientmetric.NewCounter(m.Name) case "gauge": metric = clientmetric.NewGauge(m.Name) default: http.Error(w, "Unknown metric type "+m.Type, http.StatusBadRequest) return } metrics[m.Name] = metric } switch m.Op { case "add", "": metric.Add(int64(m.Value)) case "set": metric.Set(int64(m.Value)) default: http.Error(w, "Unknown metric op "+m.Op, http.StatusBadRequest) return } }
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(struct{}{})}
func (h *Handler) serveSetGUIVisible(w http.ResponseWriter, r *http.Request) { if r.Method != httpm.POST { http.Error(w, "use POST", http.StatusMethodNotAllowed) return }
type setGUIVisibleRequest struct { IsVisible bool // whether the Tailscale client UI is now presented to the user SessionID string // the last SessionID sent to the client in ipn.Notify.SessionID } var req setGUIVisibleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON body", http.StatusBadRequest) return }
// TODO(bradfitz): use `req.IsVisible == true` to flush netmap
w.WriteHeader(http.StatusOK)}
func (h *Handler) serveSetUseExitNodeEnabled(w http.ResponseWriter, r *http.Request) { if !buildfeatures.HasUseExitNode { http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented) return } if r.Method != httpm.POST { http.Error(w, "use POST", http.StatusMethodNotAllowed) return } if !h.PermitWrite { http.Error(w, "access denied", http.StatusForbidden) return }
v, err := strconv.ParseBool(r.URL.Query().Get("enabled")) if err != nil { http.Error(w, "invalid 'enabled' parameter", http.StatusBadRequest) return } prefs, err := h.b.SetUseExitNodeEnabled(h.Actor, v) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") e := json.NewEncoder(w) e.SetIndent("", "\t") e.Encode(prefs)}
// serveProfiles serves profile switching-related endpoints. Supported methods// and paths are:// - GET /profiles/: list all profiles (JSON-encoded array of ipn.LoginProfiles)// - PUT /profiles/: add new profile (no response). A separate// StartLoginInteractive() is needed to populate and persist the new profile.// - GET /profiles/current: current profile (JSON-ecoded ipn.LoginProfile)// - GET /profiles/<id>: output profile (JSON-ecoded ipn.LoginProfile)// - POST /profiles/<id>: switch to profile (no response)// - DELETE /profiles/<id>: delete profile (no response)func (h *Handler) serveProfiles(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "profiles access denied", http.StatusForbidden) return } suffix, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/profiles/") if !ok { http.Error(w, "misconfigured", http.StatusInternalServerError) return } if suffix == "" { switch r.Method { case httpm.GET: w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(h.b.ListProfiles()) case httpm.PUT: err := h.b.NewProfile() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) default: http.Error(w, "use GET or PUT", http.StatusMethodNotAllowed) } return } suffix, err := url.PathUnescape(suffix) if err != nil { http.Error(w, "bad profile ID", http.StatusBadRequest) return } if suffix == "current" { switch r.Method { case httpm.GET: w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(h.b.CurrentProfile()) default: http.Error(w, "use GET", http.StatusMethodNotAllowed) } return }
profileID := ipn.ProfileID(suffix) switch r.Method { case httpm.GET: profiles := h.b.ListProfiles() profileIndex := slices.IndexFunc(profiles, func(p ipn.LoginProfileView) bool { return p.ID() == profileID }) if profileIndex == -1 { http.Error(w, "Profile not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(profiles[profileIndex]) case httpm.POST: err := h.b.SwitchProfile(profileID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) case httpm.DELETE: err := h.b.DeleteProfile(profileID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) default: http.Error(w, "use POST or DELETE", http.StatusMethodNotAllowed) }}
// serveQueryFeature makes a request to the "/machine/feature/query"// Noise endpoint to get instructions on how to enable a feature, such as// Funnel, for the node's tailnet.//// This request itself does not directly enable the feature on behalf of// the node, but rather returns information that can be presented to the// acting user about where/how to enable the feature. If relevant, this// includes a control URL the user can visit to explicitly consent to// using the feature.//// See tailcfg.QueryFeatureResponse for full response structure.func (h *Handler) serveQueryFeature(w http.ResponseWriter, r *http.Request) { feature := r.FormValue("feature") switch { case !h.PermitRead: http.Error(w, "access denied", http.StatusForbidden) return case r.Method != httpm.POST: http.Error(w, "use POST", http.StatusMethodNotAllowed) return case feature == "": http.Error(w, "missing feature", http.StatusInternalServerError) return } nm := h.b.NetMapNoPeers() if nm == nil { http.Error(w, "no netmap", http.StatusServiceUnavailable) return }
b, err := json.Marshal(&tailcfg.QueryFeatureRequest{ NodeKey: nm.NodeKey, Feature: feature, }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
req, err := http.NewRequestWithContext(r.Context(), httpm.POST, "https://unused/machine/feature/query", bytes.NewReader(b)) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
resp, err := h.b.DoNoiseRequest(req) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer resp.Body.Close() w.Header().Set("Content-Type", "application/json") w.WriteHeader(resp.StatusCode) if _, err := io.Copy(w, resp.Body); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }}
// serveUpdateCheck returns the ClientVersion from Status, which contains// information on whether an update is available, and if so, what version,// *if* we support auto-updates on this platform. If we don't, this endpoint// always returns a ClientVersion saying we're running the newest version.// Effectively, it tells us whether serveUpdateInstall will be able to install// an update for us.func (h *Handler) serveUpdateCheck(w http.ResponseWriter, r *http.Request) { if r.Method != httpm.GET { http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) return } cv := h.b.StatusWithoutPeers().ClientVersion // ipnstate.Status documentation notes that ClientVersion may be nil on some // platforms where this information is unavailable. In that case, return a // ClientVersion that says we're up to date, since we have no information on // whether an update is possible. if cv == nil { cv = &tailcfg.ClientVersion{RunningLatest: true} }
json.NewEncoder(w).Encode(cv)}
// serveDNSOSConfig serves the current system DNS configuration as a JSON object, if// supported by the OS.func (h *Handler) serveDNSOSConfig(w http.ResponseWriter, r *http.Request) { if !buildfeatures.HasDNS { http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented) return } if r.Method != httpm.GET { http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) return } // Require write access for privacy reasons. if !h.PermitWrite { http.Error(w, "dns-osconfig dump access denied", http.StatusForbidden) return } bCfg, err := h.b.GetDNSOSConfig() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") nameservers := make([]string, 0, len(bCfg.Nameservers)) for _, ns := range bCfg.Nameservers { nameservers = append(nameservers, ns.String()) } searchDomains := make([]string, 0, len(bCfg.SearchDomains)) for _, sd := range bCfg.SearchDomains { searchDomains = append(searchDomains, sd.WithoutTrailingDot()) } matchDomains := make([]string, 0, len(bCfg.MatchDomains)) for _, md := range bCfg.MatchDomains { matchDomains = append(matchDomains, md.WithoutTrailingDot()) } response := apitype.DNSOSConfig{ Nameservers: nameservers, SearchDomains: searchDomains, MatchDomains: matchDomains, } json.NewEncoder(w).Encode(response)}
// serveDNSQuery provides the ability to perform DNS queries using the internal// DNS forwarder. This is useful for debugging and testing purposes.// URL parameters:// - name: the domain name to query// - type: the DNS record type to query as a number (default if empty: A = '1')//// The response if successful is a DNSQueryResponse JSON object.func (h *Handler) serveDNSQuery(w http.ResponseWriter, r *http.Request) { if !buildfeatures.HasDNS { http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented) return } if r.Method != httpm.GET { http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) return } // Require write access for privacy reasons. if !h.PermitWrite { http.Error(w, "dns-query access denied", http.StatusForbidden) return } q := r.URL.Query() name := q.Get("name") queryType := q.Get("type") qt := dnsmessage.TypeA if queryType != "" { t, err := dnsMessageTypeForString(queryType) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } qt = t }
res, rrs, err := h.b.QueryDNS(name, qt) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(&apitype.DNSQueryResponse{ Bytes: res, Resolvers: rrs, })}
// dnsMessageTypeForString returns the dnsmessage.Type for the given string.// For example, DNSMessageTypeForString("A") returns dnsmessage.TypeA.func dnsMessageTypeForString(s string) (t dnsmessage.Type, err error) { s = strings.TrimSpace(strings.ToUpper(s)) switch s { case "AAAA": return dnsmessage.TypeAAAA, nil case "ALL": return dnsmessage.TypeALL, nil case "A": return dnsmessage.TypeA, nil case "CNAME": return dnsmessage.TypeCNAME, nil case "HINFO": return dnsmessage.TypeHINFO, nil case "MINFO": return dnsmessage.TypeMINFO, nil case "MX": return dnsmessage.TypeMX, nil case "NS": return dnsmessage.TypeNS, nil case "OPT": return dnsmessage.TypeOPT, nil case "PTR": return dnsmessage.TypePTR, nil case "SOA": return dnsmessage.TypeSOA, nil case "SRV": return dnsmessage.TypeSRV, nil case "TXT": return dnsmessage.TypeTXT, nil case "WKS": return dnsmessage.TypeWKS, nil } return 0, errors.New("unknown DNS message type: " + s)}
// HookRouteCheckRefresh is the hook to request a refresh of the routecheck Report// for this LocalBackend.// It is used by serveSuggestExitNode to probe for reachable exit nodes// before it makes a suggestion.var HookRouteCheckRefresh feature.Hook[func(*ipnlocal.LocalBackend, context.Context, time.Duration) error]
// serveSuggestExitNode serves a POST endpoint for returning a suggested exit node.// If the probe query parameter is true,// then a new routecheck report will be probed// so that the suggested exit node isn’t based on stale reachability data.// Combined with probe=true:// if the timeout query parameter is 0, any probes will immediately timeout;// if the timeout is positive, probes will take that duration before timing out;// if the timeout is negative, probes will use the default routecheck timeout.func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) { if !buildfeatures.HasUseExitNode { http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented) return }
switch r.Method { case httpm.GET: // The GET method is inappropriate because it isn’t cacheable. // However, we retain it for backwards compatibility. // // The original implementation used to only allow GET requests, // but since this endpoint powers the `tailscale exit-node suggest` command // whose results can change based on the shape of the network, // it was never a proper GET method in the first place. // // Now that the suggestions are backed by routecheck // and we provide the user the ability to trigger a probe, // this is obviously a POST method because it has side-effects. // However, we don’t want to break existing clients, // so we silently support the old GET method // without the extra query parameters. // // This is also why the default case returns a "want POST" error // and not an "only POST allowed" like the other endpoints. // We still accept GET requests but we don’t want them. case httpm.POST: if !def.Bool(r.FormValue("probe"), false) { break } timeout := def.Duration(r.FormValue("timeout"), -1)
// Force routecheck to probe for a new report // and use it to suggest an exit node. routecheckRefresh := HookRouteCheckRefresh.GetOrNil() if routecheckRefresh == nil { break } if err := routecheckRefresh(h.b, r.Context(), timeout); err != nil { WriteErrorJSON(w, err) return } default: // Discourage the GET method: http.Error(w, "want POST", http.StatusMethodNotAllowed) return }
res, err := h.b.SuggestExitNode() if err != nil { WriteErrorJSON(w, err) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res)}
// Shutdown is an eventbus value published when tailscaled shutdown// is requested via LocalAPI. Its only consumer is [ipnserver.Server].type Shutdown struct{}
// serveShutdown shuts down tailscaled. It requires write access// and the [pkey.AllowTailscaledRestart] policy to be enabled.// See tailscale/corp#32674.func (h *Handler) serveShutdown(w http.ResponseWriter, r *http.Request) { if r.Method != httpm.POST { http.Error(w, "only POST allowed", http.StatusMethodNotAllowed) return }
if !h.PermitWrite { http.Error(w, "shutdown access denied", http.StatusForbidden) return }
polc := h.b.Sys().PolicyClientOrDefault() if permitShutdown, _ := polc.GetBoolean(pkey.AllowTailscaledRestart, false); !permitShutdown { http.Error(w, "shutdown access denied by policy", http.StatusForbidden) return }
ec := h.eventBus.Client("localapi.Handler") defer ec.Close()
w.WriteHeader(http.StatusOK) if f, ok := w.(http.Flusher); ok { f.Flush() }
eventbus.Publish[Shutdown](ec).Publish(Shutdown{})}
func (h *Handler) serveServices(w http.ResponseWriter, r *http.Request) { if r.Method != httpm.GET { http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) return } nm := h.b.NetMapNoPeers() if nm == nil { http.Error(w, "no netmap", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(nm.Services())}
func (h *Handler) serveGetAppcRouteInfo(w http.ResponseWriter, r *http.Request) { if !buildfeatures.HasAppConnectors { http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented) return } if r.Method != httpm.GET { http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) return } res, err := h.b.ReadRouteInfo() if err != nil { if errors.Is(err, ipn.ErrStateNotExist) { res = &appctype.RouteInfo{} } else { WriteErrorJSON(w, err) return } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res)}