| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535 |
- // Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
- // Use of this source code is governed by a BSD-style
- // license that can be found in the LICENSE file.
- // Package localapi contains the HTTP server handlers for tailscaled's API server.
- package localapi
- import (
- "crypto/rand"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/http/httputil"
- "net/url"
- "reflect"
- "runtime"
- "strconv"
- "strings"
- "sync"
- "time"
- "inet.af/netaddr"
- "tailscale.com/client/tailscale/apitype"
- "tailscale.com/ipn"
- "tailscale.com/ipn/ipnlocal"
- "tailscale.com/ipn/ipnstate"
- "tailscale.com/net/netknob"
- "tailscale.com/tailcfg"
- "tailscale.com/types/logger"
- "tailscale.com/util/clientmetric"
- "tailscale.com/version"
- )
- func randHex(n int) string {
- b := make([]byte, n)
- rand.Read(b)
- return hex.EncodeToString(b)
- }
- func NewHandler(b *ipnlocal.LocalBackend, logf logger.Logf, logID string) *Handler {
- return &Handler{b: b, logf: logf, backendLogID: logID}
- }
- 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.
- PermitWrite bool
- b *ipnlocal.LocalBackend
- logf logger.Logf
- backendLogID string
- }
- 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
- }
- w.Header().Set("Tailscale-Version", version.Long)
- if h.RequiredPassword != "" {
- _, pass, ok := r.BasicAuth()
- if !ok {
- http.Error(w, "auth required", http.StatusUnauthorized)
- return
- }
- if pass != h.RequiredPassword {
- http.Error(w, "bad password", http.StatusForbidden)
- return
- }
- }
- if strings.HasPrefix(r.URL.Path, "/localapi/v0/files/") {
- h.serveFiles(w, r)
- return
- }
- if strings.HasPrefix(r.URL.Path, "/localapi/v0/file-put/") {
- h.serveFilePut(w, r)
- return
- }
- if strings.HasPrefix(r.URL.Path, "/localapi/v0/cert/") {
- h.serveCert(w, r)
- return
- }
- switch r.URL.Path {
- case "/localapi/v0/whois":
- h.serveWhoIs(w, r)
- case "/localapi/v0/goroutines":
- h.serveGoroutines(w, r)
- case "/localapi/v0/profile":
- h.serveProfile(w, r)
- case "/localapi/v0/status":
- h.serveStatus(w, r)
- case "/localapi/v0/logout":
- h.serveLogout(w, r)
- case "/localapi/v0/prefs":
- h.servePrefs(w, r)
- case "/localapi/v0/check-ip-forwarding":
- h.serveCheckIPForwarding(w, r)
- case "/localapi/v0/bugreport":
- h.serveBugReport(w, r)
- case "/localapi/v0/file-targets":
- h.serveFileTargets(w, r)
- case "/localapi/v0/set-dns":
- h.serveSetDNS(w, r)
- case "/localapi/v0/derpmap":
- h.serveDERPMap(w, r)
- case "/localapi/v0/metrics":
- h.serveMetrics(w, r)
- case "/":
- io.WriteString(w, "tailscaled\n")
- default:
- http.Error(w, "404 not found", 404)
- }
- }
- func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
- if !h.PermitRead {
- http.Error(w, "bugreport access denied", http.StatusForbidden)
- return
- }
- logMarker := fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, time.Now().UTC().Format("20060102150405Z"), randHex(8))
- h.logf("user bugreport: %s", logMarker)
- if note := r.FormValue("note"); len(note) > 0 {
- h.logf("user bugreport note: %s", note)
- }
- w.Header().Set("Content-Type", "text/plain")
- fmt.Fprintln(w, logMarker)
- }
- func (h *Handler) serveWhoIs(w http.ResponseWriter, r *http.Request) {
- if !h.PermitRead {
- http.Error(w, "whois access denied", http.StatusForbidden)
- return
- }
- b := h.b
- var ipp netaddr.IPPort
- if v := r.FormValue("addr"); v != "" {
- var err error
- ipp, err = netaddr.ParseIPPort(v)
- if err != nil {
- http.Error(w, "invalid 'addr' parameter", 400)
- return
- }
- } else {
- http.Error(w, "missing 'addr' parameter", 400)
- return
- }
- n, u, ok := b.WhoIs(ipp)
- if !ok {
- http.Error(w, "no match for IP:port", 404)
- return
- }
- res := &apitype.WhoIsResponse{
- Node: n,
- UserProfile: &u,
- }
- j, err := json.MarshalIndent(res, "", "\t")
- if err != nil {
- http.Error(w, "JSON encoding error", 500)
- 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)
- }
- func (h *Handler) serveMetrics(w http.ResponseWriter, r *http.Request) {
- // 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)
- }
- // serveProfileFunc is the implementation of Handler.serveProfile, after auth,
- // for platforms where we want to link it in.
- var serveProfileFunc func(http.ResponseWriter, *http.Request)
- func (h *Handler) serveProfile(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 serveProfileFunc == nil {
- http.Error(w, "not implemented on this platform", http.StatusServiceUnavailable)
- return
- }
- serveProfileFunc(w, r)
- }
- 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,
- })
- }
- 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 defBool(r.FormValue("peers"), true) {
- st = h.b.Status()
- } else {
- st = h.b.StatusWithoutPeers()
- }
- e := json.NewEncoder(w)
- e.SetIndent("", "\t")
- e.Encode(st)
- }
- 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 != "POST" {
- http.Error(w, "want POST", 400)
- return
- }
- err := h.b.LogoutSync(r.Context())
- if err == nil {
- w.WriteHeader(http.StatusNoContent)
- return
- }
- http.Error(w, err.Error(), 500)
- }
- 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.Prefs
- switch r.Method {
- case "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(), 400)
- return
- }
- var err error
- prefs, err = h.b.EditPrefs(mp)
- if err != nil {
- http.Error(w, err.Error(), 400)
- return
- }
- case "GET", "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)
- }
- func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) {
- if !h.PermitWrite {
- http.Error(w, "file access denied", http.StatusForbidden)
- return
- }
- suffix := strings.TrimPrefix(r.URL.EscapedPath(), "/localapi/v0/files/")
- if suffix == "" {
- if r.Method != "GET" {
- http.Error(w, "want GET to list files", 400)
- return
- }
- wfs, err := h.b.WaitingFiles()
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(wfs)
- return
- }
- name, err := url.PathUnescape(suffix)
- if err != nil {
- http.Error(w, "bad filename", 400)
- return
- }
- if r.Method == "DELETE" {
- if err := h.b.DeleteFile(name); err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- w.WriteHeader(http.StatusNoContent)
- return
- }
- rc, size, err := h.b.OpenFile(name)
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- defer rc.Close()
- w.Header().Set("Content-Length", fmt.Sprint(size))
- io.Copy(w, rc)
- }
- 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(500)
- type E struct {
- Error string `json:"error"`
- }
- json.NewEncoder(w).Encode(E{err.Error()})
- }
- func (h *Handler) serveFileTargets(w http.ResponseWriter, r *http.Request) {
- if !h.PermitRead {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- if r.Method != "GET" {
- http.Error(w, "want GET to list targets", 400)
- return
- }
- fts, err := h.b.FileTargets()
- if err != nil {
- writeErrorJSON(w, err)
- return
- }
- makeNonNil(&fts)
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(fts)
- }
- // serveFilePut sends a file to another node.
- //
- // It's sometimes possible for clients to do this themselves, without
- // tailscaled, except in the case of tailscaled running in
- // userspace-networking ("netstack") mode, in which case tailscaled
- // needs to a do a netstack dial out.
- //
- // Instead, the CLI also goes through tailscaled so it doesn't need to be
- // aware of the network mode in use.
- //
- // macOS/iOS have always used this localapi method to simplify the GUI
- // clients.
- //
- // The Windows client currently (2021-11-30) uses the peerapi (/v0/put/)
- // directly, as the Windows GUI always runs in tun mode anyway.
- //
- // URL format:
- //
- // * PUT /localapi/v0/file-put/:stableID/:escaped-filename
- func (h *Handler) serveFilePut(w http.ResponseWriter, r *http.Request) {
- if !h.PermitWrite {
- http.Error(w, "file access denied", http.StatusForbidden)
- return
- }
- if r.Method != "PUT" {
- http.Error(w, "want PUT to put file", 400)
- return
- }
- fts, err := h.b.FileTargets()
- if err != nil {
- http.Error(w, err.Error(), 500)
- return
- }
- upath := strings.TrimPrefix(r.URL.EscapedPath(), "/localapi/v0/file-put/")
- slash := strings.Index(upath, "/")
- if slash == -1 {
- http.Error(w, "bogus URL", 400)
- return
- }
- stableID, filenameEscaped := tailcfg.StableNodeID(upath[:slash]), upath[slash+1:]
- var ft *apitype.FileTarget
- for _, x := range fts {
- if x.Node.StableID == stableID {
- ft = x
- break
- }
- }
- if ft == nil {
- http.Error(w, "node not found", 404)
- return
- }
- dstURL, err := url.Parse(ft.PeerAPIURL)
- if err != nil {
- http.Error(w, "bogus peer URL", 500)
- return
- }
- outReq, err := http.NewRequestWithContext(r.Context(), "PUT", "http://peer/v0/put/"+filenameEscaped, r.Body)
- if err != nil {
- http.Error(w, "bogus outreq", 500)
- return
- }
- outReq.ContentLength = r.ContentLength
- rp := httputil.NewSingleHostReverseProxy(dstURL)
- rp.Transport = getDialPeerTransport(h.b)
- rp.ServeHTTP(w, outReq)
- }
- func (h *Handler) serveSetDNS(w http.ResponseWriter, r *http.Request) {
- if !h.PermitWrite {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- if r.Method != "POST" {
- http.Error(w, "want POST", 400)
- 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 != "GET" {
- http.Error(w, "want GET", 400)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- e := json.NewEncoder(w)
- e.SetIndent("", "\t")
- e.Encode(h.b.DERPMap())
- }
- var dialPeerTransportOnce struct {
- sync.Once
- v *http.Transport
- }
- func getDialPeerTransport(b *ipnlocal.LocalBackend) *http.Transport {
- dialPeerTransportOnce.Do(func() {
- t := http.DefaultTransport.(*http.Transport).Clone()
- t.Dial = nil
- dialer := net.Dialer{
- Timeout: 30 * time.Second,
- KeepAlive: netknob.PlatformTCPKeepAlive(),
- Control: b.PeerDialControlFunc(),
- }
- t.DialContext = dialer.DialContext
- dialPeerTransportOnce.v = t
- })
- return dialPeerTransportOnce.v
- }
- func defBool(a string, def bool) bool {
- if a == "" {
- return def
- }
- v, err := strconv.ParseBool(a)
- if err != nil {
- return def
- }
- return v
- }
- // makeNonNil takes a pointer to a Go data structure
- // (currently only a slice or a map) and makes sure it's non-nil for
- // JSON serialization. (In particular, JavaScript clients usually want
- // the field to be defined after they decode the JSON.)
- func makeNonNil(ptr interface{}) {
- if ptr == nil {
- panic("nil interface")
- }
- rv := reflect.ValueOf(ptr)
- if rv.Kind() != reflect.Ptr {
- panic(fmt.Sprintf("kind %v, not Ptr", rv.Kind()))
- }
- if rv.Pointer() == 0 {
- panic("nil pointer")
- }
- rv = rv.Elem()
- if rv.Pointer() != 0 {
- return
- }
- switch rv.Type().Kind() {
- case reflect.Slice:
- rv.Set(reflect.MakeSlice(rv.Type(), 0, 0))
- case reflect.Map:
- rv.Set(reflect.MakeMap(rv.Type()))
- }
- }
|