diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go index ba59a060c..ba706aa44 100644 --- a/client/tailscale/tailscale.go +++ b/client/tailscale/tailscale.go @@ -66,6 +66,20 @@ func DoLocalRequest(req *http.Request) (*http.Response, error) { return tsClient.Do(req) } +type errorJSON struct { + Error string +} + +// bestError returns either err, or if body contains a valid JSON +// object of type errorJSON, its non-empty error body. +func bestError(err error, body []byte) error { + var j errorJSON + if err := json.Unmarshal(body, &j); err == nil && j.Error != "" { + return errors.New(j.Error) + } + return err +} + func send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, method, "http://local-tailscaled.sock"+path, body) if err != nil { @@ -81,7 +95,8 @@ func send(ctx context.Context, method, path string, wantStatus int, body io.Read return nil, err } if res.StatusCode != wantStatus { - return nil, fmt.Errorf("HTTP %s: %s (expected %v)", res.Status, slurp, wantStatus) + err := fmt.Errorf("HTTP %s: %s (expected %v)", res.Status, slurp, wantStatus) + return nil, bestError(err, slurp) } return slurp, nil } diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 5598a7eed..eb4816ff6 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -829,6 +829,9 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm p.Endpoints = []string{"127.9.9.9:456"} } } + if Debug.StripCaps { + resp.Node.Capabilities = nil + } if pf := resp.PacketFilter; pf != nil { lastParsedPacketFilter = c.parsePacketFilter(pf) @@ -1072,6 +1075,7 @@ type debug struct { OnlyDisco bool Disco bool StripEndpoints bool // strip endpoints from control (only use disco messages) + StripCaps bool // strip all local node's control-provided capabilities } func initDebug() debug { @@ -1080,6 +1084,7 @@ func initDebug() debug { NetMap: envBool("TS_DEBUG_NETMAP"), ProxyDNS: envBool("TS_DEBUG_PROXY_DNS"), StripEndpoints: envBool("TS_DEBUG_STRIP_ENDPOINTS"), + StripCaps: envBool("TS_DEBUG_STRIP_CAPS"), OnlyDisco: use == "only", Disco: use == "only" || use == "" || envBool("TS_DEBUG_USE_DISCO"), } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 51761bc19..18d8b310f 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -2215,12 +2215,36 @@ func (b *LocalBackend) OpenFile(name string) (rc io.ReadCloser, size int64, err return apiSrv.OpenFile(name) } +// hasCapFileSharing reports whether the current node has the file +// sharing capability enabled. +func (b *LocalBackend) hasCapFileSharing() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.hasCapFileSharingLocked() +} + +func (b *LocalBackend) hasCapFileSharingLocked() bool { + nm := b.netMap + if nm == nil || nm.SelfNode == nil { + return false + } + for _, c := range nm.SelfNode.Capabilities { + if c == tailcfg.CapabilityFileSharing { + return true + } + } + return false +} + // FileTargets lists nodes that the current node can send files to. func (b *LocalBackend) FileTargets() ([]*apitype.FileTarget, error) { var ret []*apitype.FileTarget b.mu.Lock() defer b.mu.Unlock() + if !b.hasCapFileSharingLocked() { + return nil, errors.New("file sharing not enabled by Tailscale admin") + } nm := b.netMap if b.state != ipn.Running || nm == nil { return nil, errors.New("not connected") diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index 2af91f234..b1a43b3c2 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -410,6 +410,10 @@ func (h *peerAPIHandler) put(w http.ResponseWriter, r *http.Request) { http.Error(w, "not owner", http.StatusForbidden) return } + if !h.ps.b.hasCapFileSharing() { + http.Error(w, "file sharing not enabled by Tailscale admin", http.StatusForbidden) + return + } if r.Method != "PUT" { http.Error(w, "not method PUT", http.StatusMethodNotAllowed) return diff --git a/ipn/ipnstate/ipnstate.go b/ipn/ipnstate/ipnstate.go index a748f3271..cbc3413ca 100644 --- a/ipn/ipnstate/ipnstate.go +++ b/ipn/ipnstate/ipnstate.go @@ -88,7 +88,8 @@ type PeerStatus struct { KeepAlive bool ExitNode bool // true if this is the currently selected exit node. - PeerAPIURL []string + PeerAPIURL []string + Capabilities []string `json:",omitempty"` // ShareeNode indicates this node exists in the netmap because // it's owned by a shared-to user and that node might connect diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 8e57f85e1..f8adb188b 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -9,6 +9,7 @@ "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net" @@ -299,6 +300,18 @@ func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) { 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) @@ -310,7 +323,7 @@ func (h *Handler) serveFileTargets(w http.ResponseWriter, r *http.Request) { } fts, err := h.b.FileTargets() if err != nil { - http.Error(w, err.Error(), 500) + writeErrorJSON(w, err) return } makeNonNil(&fts) diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 8b099532e..dfd44fe0f 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -177,7 +177,7 @@ type Node struct { // They're free-form strings, but should be in the form of URLs/URIs // such as: // "https://tailscale.com/cap/is-admin" - // "https://tailscale.com/cap/recv-file" + // "https://tailscale.com/cap/file-sharing" Capabilities []string `json:",omitempty"` // The following three computed fields hold the various names that can @@ -1140,3 +1140,8 @@ type Oauth2Token struct { // mechanisms for that TokenSource will not be used. Expiry time.Time `json:"expiry,omitempty"` } + +const ( + CapabilityFileSharing = "https://tailscale.com/cap/file-sharing" + CapabilityAdmin = "https://tailscale.com/cap/is-admin" +) diff --git a/wgengine/magicsock/magicsock.go b/wgengine/magicsock/magicsock.go index 417f04dae..04ad53b22 100644 --- a/wgengine/magicsock/magicsock.go +++ b/wgengine/magicsock/magicsock.go @@ -2984,6 +2984,11 @@ func (c *Conn) UpdateStatus(sb *ipnstate.StatusBuilder) { ss.HostName = c.netMap.Hostinfo.Hostname ss.DNSName = c.netMap.Name ss.UserID = c.netMap.User + if c.netMap.SelfNode != nil { + if c := c.netMap.SelfNode.Capabilities; len(c) > 0 { + ss.Capabilities = append([]string(nil), c...) + } + } } else { ss.HostName, _ = os.Hostname() }