mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 20:26:47 +02:00
placeholder to be amended for moving taildrop
This commit is contained in:
parent
286c6ce27c
commit
5ee4da1597
@ -235,7 +235,7 @@ type LocalBackend struct {
|
||||
interact bool
|
||||
egg bool
|
||||
prevIfState *interfaces.State
|
||||
peerAPIServer *peerAPIServer // or nil
|
||||
peerAPIServer *PeerAPIServer // or nil
|
||||
peerAPIListeners []*peerAPIListener
|
||||
loginFlags controlclient.LoginFlags
|
||||
incomingFiles map[*incomingFile]bool
|
||||
@ -3545,7 +3545,7 @@ func (b *LocalBackend) initPeerAPIListener() {
|
||||
b.logf("peerapi starting without Taildrop directory configured")
|
||||
}
|
||||
|
||||
ps := &peerAPIServer{
|
||||
ps := &PeerAPIServer{
|
||||
b: b,
|
||||
rootDir: fileRoot,
|
||||
directFileMode: b.directFileRoot != "",
|
||||
|
||||
@ -9,18 +9,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/adler32"
|
||||
"hash/crc32"
|
||||
"html"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
@ -29,18 +24,14 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kortschak/wol"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"golang.org/x/net/http/httpguts"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/logtail/backoff"
|
||||
"tailscale.com/net/dns/resolver"
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/net/netaddr"
|
||||
@ -49,8 +40,6 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/multierr"
|
||||
"tailscale.com/version/distro"
|
||||
"tailscale.com/wgengine/filter"
|
||||
)
|
||||
|
||||
@ -60,7 +49,7 @@ var initListenConfig func(*net.ListenConfig, netip.Addr, *interfaces.State, stri
|
||||
// ("cleartext" HTTP/2) support to the peerAPI.
|
||||
var addH2C func(*http.Server)
|
||||
|
||||
type peerAPIServer struct {
|
||||
type PeerAPIServer struct {
|
||||
b *LocalBackend
|
||||
rootDir string // empty means file receiving unavailable
|
||||
knownEmpty atomic.Bool
|
||||
@ -82,373 +71,7 @@ type peerAPIServer struct {
|
||||
directFileDoFinalRename bool
|
||||
}
|
||||
|
||||
const (
|
||||
// partialSuffix is the suffix appended to files while they're
|
||||
// still in the process of being transferred.
|
||||
partialSuffix = ".partial"
|
||||
|
||||
// deletedSuffix is the suffix for a deleted marker file
|
||||
// that's placed next to a file (without the suffix) that we
|
||||
// tried to delete, but Windows wouldn't let us. These are
|
||||
// only written on Windows (and in tests), but they're not
|
||||
// permitted to be uploaded directly on any platform, like
|
||||
// partial files.
|
||||
deletedSuffix = ".deleted"
|
||||
)
|
||||
|
||||
func validFilenameRune(r rune) bool {
|
||||
switch r {
|
||||
case '/':
|
||||
return false
|
||||
case '\\', ':', '*', '"', '<', '>', '|':
|
||||
// Invalid stuff on Windows, but we reject them everywhere
|
||||
// for now.
|
||||
// TODO(bradfitz): figure out a better plan. We initially just
|
||||
// wrote things to disk URL path-escaped, but that's gross
|
||||
// when debugging, and just moves the problem to callers.
|
||||
// So now we put the UTF-8 filenames on disk directly as
|
||||
// sent.
|
||||
return false
|
||||
}
|
||||
return unicode.IsPrint(r)
|
||||
}
|
||||
|
||||
func (s *peerAPIServer) diskPath(baseName string) (fullPath string, ok bool) {
|
||||
if !utf8.ValidString(baseName) {
|
||||
return "", false
|
||||
}
|
||||
if strings.TrimSpace(baseName) != baseName {
|
||||
return "", false
|
||||
}
|
||||
if len(baseName) > 255 {
|
||||
return "", false
|
||||
}
|
||||
// TODO: validate unicode normalization form too? Varies by platform.
|
||||
clean := path.Clean(baseName)
|
||||
if clean != baseName ||
|
||||
clean == "." || clean == ".." ||
|
||||
strings.HasSuffix(clean, deletedSuffix) ||
|
||||
strings.HasSuffix(clean, partialSuffix) {
|
||||
return "", false
|
||||
}
|
||||
for _, r := range baseName {
|
||||
if !validFilenameRune(r) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
if !filepath.IsLocal(baseName) {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(s.rootDir, baseName), true
|
||||
}
|
||||
|
||||
// hasFilesWaiting reports whether any files are buffered in the
|
||||
// tailscaled daemon storage.
|
||||
func (s *peerAPIServer) hasFilesWaiting() bool {
|
||||
if s == nil || s.rootDir == "" || s.directFileMode {
|
||||
return false
|
||||
}
|
||||
if s.knownEmpty.Load() {
|
||||
// Optimization: this is usually empty, so avoid opening
|
||||
// the directory and checking. We can't cache the actual
|
||||
// has-files-or-not values as the macOS/iOS client might
|
||||
// in the future use+delete the files directly. So only
|
||||
// keep this negative cache.
|
||||
return false
|
||||
}
|
||||
f, err := os.Open(s.rootDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
for {
|
||||
des, err := f.ReadDir(10)
|
||||
for _, de := range des {
|
||||
name := de.Name()
|
||||
if strings.HasSuffix(name, partialSuffix) {
|
||||
continue
|
||||
}
|
||||
if name, ok := strings.CutSuffix(name, deletedSuffix); ok { // for Windows + tests
|
||||
// After we're done looping over files, then try
|
||||
// to delete this file. Don't do it proactively,
|
||||
// as the OS may return "foo.jpg.deleted" before "foo.jpg"
|
||||
// and we don't want to delete the ".deleted" file before
|
||||
// enumerating to the "foo.jpg" file.
|
||||
defer tryDeleteAgain(filepath.Join(s.rootDir, name))
|
||||
continue
|
||||
}
|
||||
if de.Type().IsRegular() {
|
||||
_, err := os.Stat(filepath.Join(s.rootDir, name+deletedSuffix))
|
||||
if os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
if err == nil {
|
||||
tryDeleteAgain(filepath.Join(s.rootDir, name))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
s.knownEmpty.Store(true)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WaitingFiles returns the list of files that have been sent by a
|
||||
// peer that are waiting in the buffered "pick up" directory owned by
|
||||
// the Tailscale daemon.
|
||||
//
|
||||
// As a side effect, it also does any lazy deletion of files as
|
||||
// required by Windows.
|
||||
func (s *peerAPIServer) WaitingFiles() (ret []apitype.WaitingFile, err error) {
|
||||
if s == nil {
|
||||
return nil, errNilPeerAPIServer
|
||||
}
|
||||
if s.rootDir == "" {
|
||||
return nil, errNoTaildrop
|
||||
}
|
||||
if s.directFileMode {
|
||||
return nil, nil
|
||||
}
|
||||
f, err := os.Open(s.rootDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var deleted map[string]bool // "foo.jpg" => true (if "foo.jpg.deleted" exists)
|
||||
for {
|
||||
des, err := f.ReadDir(10)
|
||||
for _, de := range des {
|
||||
name := de.Name()
|
||||
if strings.HasSuffix(name, partialSuffix) {
|
||||
continue
|
||||
}
|
||||
if name, ok := strings.CutSuffix(name, deletedSuffix); ok { // for Windows + tests
|
||||
if deleted == nil {
|
||||
deleted = map[string]bool{}
|
||||
}
|
||||
deleted[name] = true
|
||||
continue
|
||||
}
|
||||
if de.Type().IsRegular() {
|
||||
fi, err := de.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, apitype.WaitingFile{
|
||||
Name: filepath.Base(name),
|
||||
Size: fi.Size(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
// Filter out any return values "foo.jpg" where a
|
||||
// "foo.jpg.deleted" marker file exists on disk.
|
||||
all := ret
|
||||
ret = ret[:0]
|
||||
for _, wf := range all {
|
||||
if !deleted[wf.Name] {
|
||||
ret = append(ret, wf)
|
||||
}
|
||||
}
|
||||
// And do some opportunistic deleting while we're here.
|
||||
// Maybe Windows is done virus scanning the file we tried
|
||||
// to delete a long time ago and will let us delete it now.
|
||||
for name := range deleted {
|
||||
tryDeleteAgain(filepath.Join(s.rootDir, name))
|
||||
}
|
||||
}
|
||||
sort.Slice(ret, func(i, j int) bool { return ret[i].Name < ret[j].Name })
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
var (
|
||||
errNilPeerAPIServer = errors.New("peerapi unavailable; not listening")
|
||||
errNoTaildrop = errors.New("Taildrop disabled; no storage directory")
|
||||
)
|
||||
|
||||
// tryDeleteAgain tries to delete path (and path+deletedSuffix) after
|
||||
// it failed earlier. This happens on Windows when various anti-virus
|
||||
// tools hook into filesystem operations and have the file open still
|
||||
// while we're trying to delete it. In that case we instead mark it as
|
||||
// deleted (writing a "foo.jpg.deleted" marker file), but then we
|
||||
// later try to clean them up.
|
||||
//
|
||||
// fullPath is the full path to the file without the deleted suffix.
|
||||
func tryDeleteAgain(fullPath string) {
|
||||
if err := os.Remove(fullPath); err == nil || os.IsNotExist(err) {
|
||||
os.Remove(fullPath + deletedSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *peerAPIServer) DeleteFile(baseName string) error {
|
||||
if s == nil {
|
||||
return errNilPeerAPIServer
|
||||
}
|
||||
if s.rootDir == "" {
|
||||
return errNoTaildrop
|
||||
}
|
||||
if s.directFileMode {
|
||||
return errors.New("deletes not allowed in direct mode")
|
||||
}
|
||||
path, ok := s.diskPath(baseName)
|
||||
if !ok {
|
||||
return errors.New("bad filename")
|
||||
}
|
||||
var bo *backoff.Backoff
|
||||
logf := s.b.logf
|
||||
t0 := s.b.clock.Now()
|
||||
for {
|
||||
err := os.Remove(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
err = redactErr(err)
|
||||
// Put a retry loop around deletes on Windows. Windows
|
||||
// file descriptor closes are effectively asynchronous,
|
||||
// as a bunch of hooks run on/after close, and we can't
|
||||
// necessarily delete the file for a while after close,
|
||||
// as we need to wait for everybody to be done with
|
||||
// it. (on Windows, unlike Unix, a file can't be deleted
|
||||
// if it's open anywhere)
|
||||
// So try a few times but ultimately just leave a
|
||||
// "foo.jpg.deleted" marker file to note that it's
|
||||
// deleted and we clean it up later.
|
||||
if runtime.GOOS == "windows" {
|
||||
if bo == nil {
|
||||
bo = backoff.NewBackoff("delete-retry", logf, 1*time.Second)
|
||||
}
|
||||
if s.b.clock.Since(t0) < 5*time.Second {
|
||||
bo.BackOff(context.Background(), err)
|
||||
continue
|
||||
}
|
||||
if err := touchFile(path + deletedSuffix); err != nil {
|
||||
logf("peerapi: failed to leave deleted marker: %v", err)
|
||||
}
|
||||
}
|
||||
logf("peerapi: failed to DeleteFile: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// redacted is a fake path name we use in errors, to avoid
|
||||
// accidentally logging actual filenames anywhere.
|
||||
const redacted = "redacted"
|
||||
|
||||
type redactedErr struct {
|
||||
msg string
|
||||
inner error
|
||||
}
|
||||
|
||||
func (re *redactedErr) Error() string {
|
||||
return re.msg
|
||||
}
|
||||
|
||||
func (re *redactedErr) Unwrap() error {
|
||||
return re.inner
|
||||
}
|
||||
|
||||
func redactString(s string) string {
|
||||
hash := adler32.Checksum([]byte(s))
|
||||
|
||||
var buf [len(redacted) + len(".12345678")]byte
|
||||
b := append(buf[:0], []byte(redacted)...)
|
||||
b = append(b, '.')
|
||||
b = strconv.AppendUint(b, uint64(hash), 16)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func redactErr(root error) error {
|
||||
// redactStrings is a list of sensitive strings that were redacted.
|
||||
// It is not sufficient to just snub out sensitive fields in Go errors
|
||||
// since some wrapper errors like fmt.Errorf pre-cache the error string,
|
||||
// which would unfortunately remain unaffected.
|
||||
var redactStrings []string
|
||||
|
||||
// Redact sensitive fields in known Go error types.
|
||||
var unknownErrors int
|
||||
multierr.Range(root, func(err error) bool {
|
||||
switch err := err.(type) {
|
||||
case *os.PathError:
|
||||
redactStrings = append(redactStrings, err.Path)
|
||||
err.Path = redactString(err.Path)
|
||||
case *os.LinkError:
|
||||
redactStrings = append(redactStrings, err.New, err.Old)
|
||||
err.New = redactString(err.New)
|
||||
err.Old = redactString(err.Old)
|
||||
default:
|
||||
unknownErrors++
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// If there are no redacted strings or no unknown error types,
|
||||
// then we can return the possibly modified root error verbatim.
|
||||
// Otherwise, we must replace redacted strings from any wrappers.
|
||||
if len(redactStrings) == 0 || unknownErrors == 0 {
|
||||
return root
|
||||
}
|
||||
|
||||
// Stringify and replace any paths that we found above, then return
|
||||
// the error wrapped in a type that uses the newly-redacted string
|
||||
// while also allowing Unwrap()-ing to the inner error type(s).
|
||||
s := root.Error()
|
||||
for _, toRedact := range redactStrings {
|
||||
s = strings.ReplaceAll(s, toRedact, redactString(toRedact))
|
||||
}
|
||||
return &redactedErr{msg: s, inner: root}
|
||||
}
|
||||
|
||||
func touchFile(path string) error {
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
return redactErr(err)
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func (s *peerAPIServer) OpenFile(baseName string) (rc io.ReadCloser, size int64, err error) {
|
||||
if s == nil {
|
||||
return nil, 0, errNilPeerAPIServer
|
||||
}
|
||||
if s.rootDir == "" {
|
||||
return nil, 0, errNoTaildrop
|
||||
}
|
||||
if s.directFileMode {
|
||||
return nil, 0, errors.New("opens not allowed in direct mode")
|
||||
}
|
||||
path, ok := s.diskPath(baseName)
|
||||
if !ok {
|
||||
return nil, 0, errors.New("bad filename")
|
||||
}
|
||||
if fi, err := os.Stat(path + deletedSuffix); err == nil && fi.Mode().IsRegular() {
|
||||
tryDeleteAgain(path)
|
||||
return nil, 0, &fs.PathError{Op: "open", Path: redacted, Err: fs.ErrNotExist}
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, redactErr(err)
|
||||
}
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, 0, redactErr(err)
|
||||
}
|
||||
return f, fi.Size(), nil
|
||||
}
|
||||
|
||||
func (s *peerAPIServer) listen(ip netip.Addr, ifState *interfaces.State) (ln net.Listener, err error) {
|
||||
func (s *PeerAPIServer) listen(ip netip.Addr, ifState *interfaces.State) (ln net.Listener, err error) {
|
||||
// Android for whatever reason often has problems creating the peerapi listener.
|
||||
// But since we started intercepting it with netstack, it's not even important that
|
||||
// we have a real kernel-level listener. So just create a dummy listener on Android
|
||||
@ -511,7 +134,7 @@ func (s *peerAPIServer) listen(ip netip.Addr, ifState *interfaces.State) (ln net
|
||||
}
|
||||
|
||||
type peerAPIListener struct {
|
||||
ps *peerAPIServer
|
||||
ps *PeerAPIServer
|
||||
ip netip.Addr
|
||||
lb *LocalBackend
|
||||
|
||||
@ -578,7 +201,7 @@ func (pln *peerAPIListener) ServeConn(src netip.AddrPort, c net.Conn) {
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
h := &peerAPIHandler{
|
||||
h := &PeerAPIHandler{
|
||||
ps: pln.ps,
|
||||
isSelf: nm.SelfNode.User() == peerNode.User(),
|
||||
remoteAddr: src,
|
||||
@ -595,9 +218,9 @@ func (pln *peerAPIListener) ServeConn(src netip.AddrPort, c net.Conn) {
|
||||
go httpServer.Serve(netutil.NewOneConnListener(c, nil))
|
||||
}
|
||||
|
||||
// peerAPIHandler serves the PeerAPI for a source specific client.
|
||||
type peerAPIHandler struct {
|
||||
ps *peerAPIServer
|
||||
// PeerAPIHandler serves the PeerAPI for a source specific client.
|
||||
type PeerAPIHandler struct {
|
||||
ps *PeerAPIServer
|
||||
remoteAddr netip.AddrPort
|
||||
isSelf bool // whether peerNode is owned by same user as this node
|
||||
selfNode tailcfg.NodeView // this node; always non-nil
|
||||
@ -605,13 +228,13 @@ type peerAPIHandler struct {
|
||||
peerUser tailcfg.UserProfile // profile of peerNode
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) logf(format string, a ...any) {
|
||||
func (h *PeerAPIHandler) logf(format string, a ...any) {
|
||||
h.ps.b.logf("peerapi: "+format, a...)
|
||||
}
|
||||
|
||||
// isAddressValid reports whether addr is a valid destination address for this
|
||||
// node originating from the peer.
|
||||
func (h *peerAPIHandler) isAddressValid(addr netip.Addr) bool {
|
||||
func (h *PeerAPIHandler) isAddressValid(addr netip.Addr) bool {
|
||||
if v := h.peerNode.SelfNodeV4MasqAddrForThisPeer(); v != nil {
|
||||
return *v == addr
|
||||
}
|
||||
@ -622,7 +245,7 @@ func (h *peerAPIHandler) isAddressValid(addr netip.Addr) bool {
|
||||
return views.SliceContains(h.selfNode.Addresses(), pfx)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) validateHost(r *http.Request) error {
|
||||
func (h *PeerAPIHandler) validateHost(r *http.Request) error {
|
||||
if r.Host == "peer" {
|
||||
return nil
|
||||
}
|
||||
@ -636,7 +259,7 @@ func (h *peerAPIHandler) validateHost(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) validatePeerAPIRequest(r *http.Request) error {
|
||||
func (h *PeerAPIHandler) validatePeerAPIRequest(r *http.Request) error {
|
||||
if r.Referer() != "" {
|
||||
return errors.New("unexpected Referer")
|
||||
}
|
||||
@ -679,7 +302,7 @@ func peerAPIRequestShouldGetSecurityHeaders(r *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.validatePeerAPIRequest(r); err != nil {
|
||||
metricInvalidRequests.Add(1)
|
||||
h.logf("invalid request from %v: %v", h.remoteAddr, err)
|
||||
@ -693,6 +316,7 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/v0/put/") {
|
||||
metricPutCalls.Add(1)
|
||||
//TODO: fix this
|
||||
h.handlePeerPut(w, r)
|
||||
return
|
||||
}
|
||||
@ -747,7 +371,7 @@ This is my Tailscale device. Your device is %v.
|
||||
}
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Request) {
|
||||
// http.Errors only useful if hitting endpoint manually
|
||||
// otherwise rely on log lines when debugging ingress connections
|
||||
// as connection is hijacked for bidi and is encrypted tls
|
||||
@ -808,7 +432,7 @@ func (h *peerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Reque
|
||||
h.ps.b.HandleIngressTCPConn(h.peerNode, target, srcAddr, getConnOrReset, sendRST)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canDebug() {
|
||||
http.Error(w, "denied; no debug access", http.StatusForbidden)
|
||||
return
|
||||
@ -855,7 +479,7 @@ func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Re
|
||||
fmt.Fprintln(w, "</table>")
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeDoctor(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) handleServeDoctor(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canDebug() {
|
||||
http.Error(w, "denied; no debug access", http.StatusForbidden)
|
||||
return
|
||||
@ -873,7 +497,7 @@ func (h *peerAPIHandler) handleServeDoctor(w http.ResponseWriter, r *http.Reques
|
||||
fmt.Fprintln(w, "</pre>")
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canDebug() {
|
||||
http.Error(w, "denied; no debug access", http.StatusForbidden)
|
||||
return
|
||||
@ -971,76 +595,9 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req
|
||||
fmt.Fprintln(w, "</pre>")
|
||||
}
|
||||
|
||||
type incomingFile struct {
|
||||
name string // "foo.jpg"
|
||||
started time.Time
|
||||
size int64 // or -1 if unknown; never 0
|
||||
w io.Writer // underlying writer
|
||||
ph *peerAPIHandler
|
||||
partialPath string // non-empty in direct mode
|
||||
|
||||
mu sync.Mutex
|
||||
copied int64
|
||||
done bool
|
||||
lastNotify time.Time
|
||||
}
|
||||
|
||||
func (f *incomingFile) markAndNotifyDone() {
|
||||
f.mu.Lock()
|
||||
f.done = true
|
||||
f.mu.Unlock()
|
||||
b := f.ph.ps.b
|
||||
b.sendFileNotify()
|
||||
}
|
||||
|
||||
func (f *incomingFile) Write(p []byte) (n int, err error) {
|
||||
n, err = f.w.Write(p)
|
||||
|
||||
b := f.ph.ps.b
|
||||
var needNotify bool
|
||||
defer func() {
|
||||
if needNotify {
|
||||
b.sendFileNotify()
|
||||
}
|
||||
}()
|
||||
if n > 0 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.copied += int64(n)
|
||||
now := b.clock.Now()
|
||||
if f.lastNotify.IsZero() || now.Sub(f.lastNotify) > time.Second {
|
||||
f.lastNotify = now
|
||||
needNotify = true
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (f *incomingFile) PartialFile() ipn.PartialFile {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return ipn.PartialFile{
|
||||
Name: f.name,
|
||||
Started: f.started,
|
||||
DeclaredSize: f.size,
|
||||
Received: f.copied,
|
||||
PartialPath: f.partialPath,
|
||||
Done: f.done,
|
||||
}
|
||||
}
|
||||
|
||||
// canPutFile reports whether h can put a file ("Taildrop") to this node.
|
||||
func (h *peerAPIHandler) canPutFile() bool {
|
||||
if h.peerNode.UnsignedPeerAPIOnly() {
|
||||
// Unsigned peers can't send files.
|
||||
return false
|
||||
}
|
||||
return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityFileSharingSend)
|
||||
}
|
||||
|
||||
// canDebug reports whether h can debug this node (goroutines, metrics,
|
||||
// magicsock internal state, etc).
|
||||
func (h *peerAPIHandler) canDebug() bool {
|
||||
func (h *PeerAPIHandler) canDebug() bool {
|
||||
if !h.selfNode.HasCap(tailcfg.CapabilityDebug) {
|
||||
// This node does not expose debug info.
|
||||
return false
|
||||
@ -1053,7 +610,7 @@ func (h *peerAPIHandler) canDebug() bool {
|
||||
}
|
||||
|
||||
// canWakeOnLAN reports whether h can send a Wake-on-LAN packet from this node.
|
||||
func (h *peerAPIHandler) canWakeOnLAN() bool {
|
||||
func (h *PeerAPIHandler) canWakeOnLAN() bool {
|
||||
if h.peerNode.UnsignedPeerAPIOnly() {
|
||||
return false
|
||||
}
|
||||
@ -1063,150 +620,15 @@ func (h *peerAPIHandler) canWakeOnLAN() bool {
|
||||
var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS")
|
||||
|
||||
// canIngress reports whether h can send ingress requests to this node.
|
||||
func (h *peerAPIHandler) canIngress() bool {
|
||||
func (h *PeerAPIHandler) canIngress() bool {
|
||||
return h.peerHasCap(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
|
||||
func (h *PeerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
|
||||
return h.ps.b.PeerCaps(h.remoteAddr.Addr()).HasCapability(wantCap)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
|
||||
if !envknob.CanTaildrop() {
|
||||
http.Error(w, "Taildrop disabled on device", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !h.canPutFile() {
|
||||
http.Error(w, "Taildrop access denied", 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, "expected method PUT", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.ps.rootDir == "" {
|
||||
http.Error(w, errNoTaildrop.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if distro.Get() == distro.Unraid && !h.ps.directFileMode {
|
||||
http.Error(w, "Taildrop folder not configured or accessible", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rawPath := r.URL.EscapedPath()
|
||||
suffix, ok := strings.CutPrefix(rawPath, "/v0/put/")
|
||||
if !ok {
|
||||
http.Error(w, "misconfigured internals", 500)
|
||||
return
|
||||
}
|
||||
if suffix == "" {
|
||||
http.Error(w, "empty filename", 400)
|
||||
return
|
||||
}
|
||||
if strings.Contains(suffix, "/") {
|
||||
http.Error(w, "directories not supported", 400)
|
||||
return
|
||||
}
|
||||
baseName, err := url.PathUnescape(suffix)
|
||||
if err != nil {
|
||||
http.Error(w, "bad path encoding", 400)
|
||||
return
|
||||
}
|
||||
dstFile, ok := h.ps.diskPath(baseName)
|
||||
if !ok {
|
||||
http.Error(w, "bad filename", 400)
|
||||
return
|
||||
}
|
||||
t0 := h.ps.b.clock.Now()
|
||||
// TODO(bradfitz): prevent same filename being sent by two peers at once
|
||||
|
||||
// prevent same filename being sent twice
|
||||
if _, err := os.Stat(dstFile); err == nil {
|
||||
http.Error(w, "file exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
partialFile := dstFile + partialSuffix
|
||||
f, err := os.Create(partialFile)
|
||||
if err != nil {
|
||||
h.logf("put Create error: %v", redactErr(err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var success bool
|
||||
defer func() {
|
||||
if !success {
|
||||
os.Remove(partialFile)
|
||||
}
|
||||
}()
|
||||
var finalSize int64
|
||||
var inFile *incomingFile
|
||||
if r.ContentLength != 0 {
|
||||
inFile = &incomingFile{
|
||||
name: baseName,
|
||||
started: h.ps.b.clock.Now(),
|
||||
size: r.ContentLength,
|
||||
w: f,
|
||||
ph: h,
|
||||
}
|
||||
if h.ps.directFileMode {
|
||||
inFile.partialPath = partialFile
|
||||
}
|
||||
h.ps.b.registerIncomingFile(inFile, true)
|
||||
defer h.ps.b.registerIncomingFile(inFile, false)
|
||||
n, err := io.Copy(inFile, r.Body)
|
||||
if err != nil {
|
||||
err = redactErr(err)
|
||||
f.Close()
|
||||
h.logf("put Copy error: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
finalSize = n
|
||||
}
|
||||
if err := redactErr(f.Close()); err != nil {
|
||||
h.logf("put Close error: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if h.ps.directFileMode && !h.ps.directFileDoFinalRename {
|
||||
if inFile != nil { // non-zero length; TODO: notify even for zero length
|
||||
inFile.markAndNotifyDone()
|
||||
}
|
||||
} else {
|
||||
if err := os.Rename(partialFile, dstFile); err != nil {
|
||||
err = redactErr(err)
|
||||
h.logf("put final rename: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
d := h.ps.b.clock.Since(t0).Round(time.Second / 10)
|
||||
h.logf("got put of %s in %v from %v/%v", approxSize(finalSize), d, h.remoteAddr.Addr(), h.peerNode.ComputedName)
|
||||
|
||||
// TODO: set modtime
|
||||
// TODO: some real response
|
||||
success = true
|
||||
io.WriteString(w, "{}\n")
|
||||
h.ps.knownEmpty.Store(false)
|
||||
h.ps.b.sendFileNotify()
|
||||
}
|
||||
|
||||
func approxSize(n int64) string {
|
||||
if n <= 1<<10 {
|
||||
return "<=1KB"
|
||||
}
|
||||
if n <= 1<<20 {
|
||||
return "<=1MB"
|
||||
}
|
||||
return fmt.Sprintf("~%dMB", n>>20)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeGoroutines(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) handleServeGoroutines(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canDebug() {
|
||||
http.Error(w, "denied; no debug access", http.StatusForbidden)
|
||||
return
|
||||
@ -1222,7 +644,7 @@ func (h *peerAPIHandler) handleServeGoroutines(w http.ResponseWriter, r *http.Re
|
||||
w.Write(buf)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeEnv(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) handleServeEnv(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canDebug() {
|
||||
http.Error(w, "denied; no debug access", http.StatusForbidden)
|
||||
return
|
||||
@ -1242,7 +664,7 @@ func (h *peerAPIHandler) handleServeEnv(w http.ResponseWriter, r *http.Request)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeMagicsock(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) handleServeMagicsock(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canDebug() {
|
||||
http.Error(w, "denied; no debug access", http.StatusForbidden)
|
||||
return
|
||||
@ -1250,7 +672,7 @@ func (h *peerAPIHandler) handleServeMagicsock(w http.ResponseWriter, r *http.Req
|
||||
h.ps.b.magicConn().ServeHTTPDebug(w, r)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) handleServeMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canDebug() {
|
||||
http.Error(w, "denied; no debug access", http.StatusForbidden)
|
||||
return
|
||||
@ -1259,7 +681,7 @@ func (h *peerAPIHandler) handleServeMetrics(w http.ResponseWriter, r *http.Reque
|
||||
clientmetric.WritePrometheusExpositionFormat(w)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canDebug() {
|
||||
http.Error(w, "denied; no debug access", http.StatusForbidden)
|
||||
return
|
||||
@ -1272,7 +694,7 @@ func (h *peerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Reques
|
||||
dh.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleWakeOnLAN(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) handleWakeOnLAN(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canWakeOnLAN() {
|
||||
http.Error(w, "no WoL access", http.StatusForbidden)
|
||||
return
|
||||
@ -1327,7 +749,7 @@ func (h *peerAPIHandler) handleWakeOnLAN(w http.ResponseWriter, r *http.Request)
|
||||
json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) replyToDNSQueries() bool {
|
||||
func (h *PeerAPIHandler) replyToDNSQueries() bool {
|
||||
if h.isSelf {
|
||||
// If the peer is owned by the same user, just allow it
|
||||
// without further checks.
|
||||
@ -1371,7 +793,7 @@ func (h *peerAPIHandler) replyToDNSQueries() bool {
|
||||
|
||||
// handleDNSQuery implements a DoH server (RFC 8484) over the peerapi.
|
||||
// It's not over HTTPS as the spec dictates, but rather HTTP-over-WireGuard.
|
||||
func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PeerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ps.resolver == nil {
|
||||
http.Error(w, "DNS not wired up", http.StatusNotImplemented)
|
||||
return
|
||||
|
||||
@ -32,7 +32,7 @@ import (
|
||||
)
|
||||
|
||||
type peerAPITestEnv struct {
|
||||
ph *peerAPIHandler
|
||||
ph *PeerAPIHandler
|
||||
rr *httptest.ResponseRecorder
|
||||
logBuf tstest.MemLogger
|
||||
}
|
||||
@ -479,13 +479,13 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
netMap: &netmap.NetworkMap{SelfNode: selfNode.View()},
|
||||
clock: &tstest.Clock{},
|
||||
}
|
||||
e.ph = &peerAPIHandler{
|
||||
e.ph = &PeerAPIHandler{
|
||||
isSelf: tt.isSelf,
|
||||
selfNode: selfNode.View(),
|
||||
peerNode: (&tailcfg.Node{
|
||||
ComputedName: "some-peer-name",
|
||||
}).View(),
|
||||
ps: &peerAPIServer{
|
||||
ps: &PeerAPIServer{
|
||||
b: lb,
|
||||
},
|
||||
}
|
||||
@ -525,7 +525,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
// a bit. So test that we work around that sufficiently.
|
||||
func TestFileDeleteRace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := &peerAPIServer{
|
||||
ps := &PeerAPIServer{
|
||||
b: &LocalBackend{
|
||||
logf: t.Logf,
|
||||
capFileSharing: true,
|
||||
@ -533,7 +533,7 @@ func TestFileDeleteRace(t *testing.T) {
|
||||
},
|
||||
rootDir: dir,
|
||||
}
|
||||
ph := &peerAPIHandler{
|
||||
ph := &PeerAPIHandler{
|
||||
isSelf: true,
|
||||
peerNode: (&tailcfg.Node{
|
||||
ComputedName: "some-peer-name",
|
||||
@ -574,7 +574,7 @@ func TestFileDeleteRace(t *testing.T) {
|
||||
// Tests "foo.jpg.deleted" marks (for Windows).
|
||||
func TestDeletedMarkers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := &peerAPIServer{
|
||||
ps := &PeerAPIServer{
|
||||
b: &LocalBackend{
|
||||
logf: t.Logf,
|
||||
capFileSharing: true,
|
||||
@ -656,7 +656,7 @@ func TestDeletedMarkers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPeerAPIReplyToDNSQueries(t *testing.T) {
|
||||
var h peerAPIHandler
|
||||
var h PeerAPIHandler
|
||||
|
||||
h.isSelf = true
|
||||
if !h.replyToDNSQueries() {
|
||||
@ -667,7 +667,7 @@ func TestPeerAPIReplyToDNSQueries(t *testing.T) {
|
||||
|
||||
eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0)
|
||||
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
|
||||
h.ps = &peerAPIServer{
|
||||
h.ps = &PeerAPIServer{
|
||||
b: &LocalBackend{
|
||||
e: eng,
|
||||
pm: pm,
|
||||
|
||||
@ -15,7 +15,6 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"runtime"
|
||||
@ -859,7 +858,7 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
configIn := new(ipn.ServeConfig)
|
||||
if err := json.NewDecoder(r.Body).Decode(configIn); err != nil {
|
||||
writeErrorJSON(w, fmt.Errorf("decoding config: %w", err))
|
||||
WriteErrorJSON(w, fmt.Errorf("decoding config: %w", err))
|
||||
return
|
||||
}
|
||||
etag := r.Header.Get("If-Match")
|
||||
@ -868,7 +867,7 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
writeErrorJSON(w, fmt.Errorf("updating config: %w", err))
|
||||
WriteErrorJSON(w, fmt.Errorf("updating config: %w", err))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@ -1119,67 +1118,7 @@ func (h *Handler) serveCheckPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "file access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
suffix, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/files/")
|
||||
if !ok {
|
||||
http.Error(w, "misconfigured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if suffix == "" {
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "want GET to list files", 400)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if s := r.FormValue("waitsec"); s != "" && s != "0" {
|
||||
d, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid waitsec", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(d) * time.Second)
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithDeadline(ctx, deadline)
|
||||
defer cancel()
|
||||
}
|
||||
wfs, err := h.b.AwaitWaitingFiles(ctx)
|
||||
if err != nil && ctx.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))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
io.Copy(w, rc)
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, err error) {
|
||||
func WriteErrorJSON(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
err = errors.New("unexpected nil error")
|
||||
}
|
||||
@ -1191,101 +1130,6 @@ func writeErrorJSON(w http.ResponseWriter, err 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
|
||||
}
|
||||
mak.NonNilSliceForJSON(&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) {
|
||||
metricFilePutCalls.Add(1)
|
||||
|
||||
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, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/file-put/")
|
||||
if !ok {
|
||||
http.Error(w, "misconfigured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
stableIDStr, filenameEscaped, ok := strings.Cut(upath, "/")
|
||||
if !ok {
|
||||
http.Error(w, "bogus URL", 400)
|
||||
return
|
||||
}
|
||||
stableID := tailcfg.StableNodeID(stableIDStr)
|
||||
|
||||
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 = h.b.Dialer().PeerAPITransport()
|
||||
rp.ServeHTTP(w, outReq)
|
||||
}
|
||||
|
||||
func (h *Handler) serveSetDNS(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "access denied", http.StatusForbidden)
|
||||
@ -1298,7 +1142,7 @@ func (h *Handler) serveSetDNS(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
err := h.b.SetDNS(ctx, r.FormValue("name"), r.FormValue("value"))
|
||||
if err != nil {
|
||||
writeErrorJSON(w, err)
|
||||
WriteErrorJSON(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -1389,7 +1233,7 @@ func (h *Handler) servePing(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
res, err := h.b.Ping(ctx, ip, tailcfg.PingType(pingTypeStr), size)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, err)
|
||||
WriteErrorJSON(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
609
taildrop/reciever.go
Normal file
609
taildrop/reciever.go
Normal file
@ -0,0 +1,609 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package taildrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"hash/adler32"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/logtail/backoff"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/multierr"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
const (
|
||||
// partialSuffix is the suffix appended to files while they're
|
||||
// still in the process of being transferred.
|
||||
partialSuffix = ".partial"
|
||||
|
||||
// deletedSuffix is the suffix for a deleted marker file
|
||||
// that's placed next to a file (without the suffix) that we
|
||||
// tried to delete, but Windows wouldn't let us. These are
|
||||
// only written on Windows (and in tests), but they're not
|
||||
// permitted to be uploaded directly on any platform, like
|
||||
// partial files.
|
||||
deletedSuffix = ".deleted"
|
||||
)
|
||||
|
||||
func validFilenameRune(r rune) bool {
|
||||
switch r {
|
||||
case '/':
|
||||
return false
|
||||
case '\\', ':', '*', '"', '<', '>', '|':
|
||||
// Invalid stuff on Windows, but we reject them everywhere
|
||||
// for now.
|
||||
// TODO(bradfitz): figure out a better plan. We initially just
|
||||
// wrote things to disk URL path-escaped, but that's gross
|
||||
// when debugging, and just moves the problem to callers.
|
||||
// So now we put the UTF-8 filenames on disk directly as
|
||||
// sent.
|
||||
return false
|
||||
}
|
||||
return unicode.IsPrint(r)
|
||||
}
|
||||
|
||||
func approxSize(n int64) string {
|
||||
if n <= 1<<10 {
|
||||
return "<=1KB"
|
||||
}
|
||||
if n <= 1<<20 {
|
||||
return "<=1MB"
|
||||
}
|
||||
return fmt.Sprintf("~%dMB", n>>20)
|
||||
}
|
||||
|
||||
type peerAPIServer struct {
|
||||
*ipnlocal.PeerAPIServer
|
||||
}
|
||||
|
||||
func (s *peerAPIServer) diskPath(baseName string) (fullPath string, ok bool) {
|
||||
if !utf8.ValidString(baseName) {
|
||||
return "", false
|
||||
}
|
||||
if strings.TrimSpace(baseName) != baseName {
|
||||
return "", false
|
||||
}
|
||||
if len(baseName) > 255 {
|
||||
return "", false
|
||||
}
|
||||
// TODO: validate unicode normalization form too? Varies by platform.
|
||||
clean := path.Clean(baseName)
|
||||
if clean != baseName ||
|
||||
clean == "." || clean == ".." ||
|
||||
strings.HasSuffix(clean, deletedSuffix) ||
|
||||
strings.HasSuffix(clean, partialSuffix) {
|
||||
return "", false
|
||||
}
|
||||
for _, r := range baseName {
|
||||
if !validFilenameRune(r) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
if !filepath.IsLocal(baseName) {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(s.rootDir, baseName), true
|
||||
}
|
||||
|
||||
// redacted is a fake path name we use in errors, to avoid
|
||||
// accidentally logging actual filenames anywhere.
|
||||
const redacted = "redacted"
|
||||
|
||||
type redactedErr struct {
|
||||
msg string
|
||||
inner error
|
||||
}
|
||||
|
||||
func (re *redactedErr) Error() string {
|
||||
return re.msg
|
||||
}
|
||||
|
||||
func (re *redactedErr) Unwrap() error {
|
||||
return re.inner
|
||||
}
|
||||
|
||||
func redactString(s string) string {
|
||||
hash := adler32.Checksum([]byte(s))
|
||||
|
||||
var buf [len(redacted) + len(".12345678")]byte
|
||||
b := append(buf[:0], []byte(redacted)...)
|
||||
b = append(b, '.')
|
||||
b = strconv.AppendUint(b, uint64(hash), 16)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func redactErr(root error) error {
|
||||
// redactStrings is a list of sensitive strings that were redacted.
|
||||
// It is not sufficient to just snub out sensitive fields in Go errors
|
||||
// since some wrapper errors like fmt.Errorf pre-cache the error string,
|
||||
// which would unfortunately remain unaffected.
|
||||
var redactStrings []string
|
||||
|
||||
// Redact sensitive fields in known Go error types.
|
||||
var unknownErrors int
|
||||
multierr.Range(root, func(err error) bool {
|
||||
switch err := err.(type) {
|
||||
case *os.PathError:
|
||||
redactStrings = append(redactStrings, err.Path)
|
||||
err.Path = redactString(err.Path)
|
||||
case *os.LinkError:
|
||||
redactStrings = append(redactStrings, err.New, err.Old)
|
||||
err.New = redactString(err.New)
|
||||
err.Old = redactString(err.Old)
|
||||
default:
|
||||
unknownErrors++
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// If there are no redacted strings or no unknown error types,
|
||||
// then we can return the possibly modified root error verbatim.
|
||||
// Otherwise, we must replace redacted strings from any wrappers.
|
||||
if len(redactStrings) == 0 || unknownErrors == 0 {
|
||||
return root
|
||||
}
|
||||
|
||||
// Stringify and replace any paths that we found above, then return
|
||||
// the error wrapped in a type that uses the newly-redacted string
|
||||
// while also allowing Unwrap()-ing to the inner error type(s).
|
||||
s := root.Error()
|
||||
for _, toRedact := range redactStrings {
|
||||
s = strings.ReplaceAll(s, toRedact, redactString(toRedact))
|
||||
}
|
||||
return &redactedErr{msg: s, inner: root}
|
||||
}
|
||||
|
||||
type peerAPIHandler struct {
|
||||
*ipnlocal.PeerAPIHandler
|
||||
}
|
||||
|
||||
// WaitingFiles returns the list of files that have been sent by a
|
||||
// peer that are waiting in the buffered "pick up" directory owned by
|
||||
// the Tailscale daemon.
|
||||
//
|
||||
// As a side effect, it also does any lazy deletion of files as
|
||||
// required by Windows.
|
||||
func (s *peerAPIServer) WaitingFiles() (ret []apitype.WaitingFile, err error) {
|
||||
if s == nil {
|
||||
return nil, errNilPeerAPIServer
|
||||
}
|
||||
if s.rootDir == "" {
|
||||
return nil, errNoTaildrop
|
||||
}
|
||||
if s.directFileMode {
|
||||
return nil, nil
|
||||
}
|
||||
f, err := os.Open(s.rootDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var deleted map[string]bool // "foo.jpg" => true (if "foo.jpg.deleted" exists)
|
||||
for {
|
||||
des, err := f.ReadDir(10)
|
||||
for _, de := range des {
|
||||
name := de.Name()
|
||||
if strings.HasSuffix(name, partialSuffix) {
|
||||
continue
|
||||
}
|
||||
if name, ok := strings.CutSuffix(name, deletedSuffix); ok { // for Windows + tests
|
||||
if deleted == nil {
|
||||
deleted = map[string]bool{}
|
||||
}
|
||||
deleted[name] = true
|
||||
continue
|
||||
}
|
||||
if de.Type().IsRegular() {
|
||||
fi, err := de.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, apitype.WaitingFile{
|
||||
Name: filepath.Base(name),
|
||||
Size: fi.Size(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
// Filter out any return values "foo.jpg" where a
|
||||
// "foo.jpg.deleted" marker file exists on disk.
|
||||
all := ret
|
||||
ret = ret[:0]
|
||||
for _, wf := range all {
|
||||
if !deleted[wf.Name] {
|
||||
ret = append(ret, wf)
|
||||
}
|
||||
}
|
||||
// And do some opportunistic deleting while we're here.
|
||||
// Maybe Windows is done virus scanning the file we tried
|
||||
// to delete a long time ago and will let us delete it now.
|
||||
for name := range deleted {
|
||||
tryDeleteAgain(filepath.Join(s.rootDir, name))
|
||||
}
|
||||
}
|
||||
sort.Slice(ret, func(i, j int) bool { return ret[i].Name < ret[j].Name })
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
var (
|
||||
errNilPeerAPIServer = errors.New("peerapi unavailable; not listening")
|
||||
errNoTaildrop = errors.New("Taildrop disabled; no storage directory")
|
||||
)
|
||||
|
||||
// tryDeleteAgain tries to delete path (and path+deletedSuffix) after
|
||||
// it failed earlier. This happens on Windows when various anti-virus
|
||||
// tools hook into filesystem operations and have the file open still
|
||||
// while we're trying to delete it. In that case we instead mark it as
|
||||
// deleted (writing a "foo.jpg.deleted" marker file), but then we
|
||||
// later try to clean them up.
|
||||
//
|
||||
// fullPath is the full path to the file without the deleted suffix.
|
||||
func tryDeleteAgain(fullPath string) {
|
||||
if err := os.Remove(fullPath); err == nil || os.IsNotExist(err) {
|
||||
os.Remove(fullPath + deletedSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *peerAPIServer) DeleteFile(baseName string) error {
|
||||
if s == nil {
|
||||
return errNilPeerAPIServer
|
||||
}
|
||||
if s.rootDir == "" {
|
||||
return errNoTaildrop
|
||||
}
|
||||
if s.directFileMode {
|
||||
return errors.New("deletes not allowed in direct mode")
|
||||
}
|
||||
path, ok := s.diskPath(baseName)
|
||||
if !ok {
|
||||
return errors.New("bad filename")
|
||||
}
|
||||
var bo *backoff.Backoff
|
||||
logf := s.b.logf
|
||||
t0 := s.b.clock.Now()
|
||||
for {
|
||||
err := os.Remove(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
err = redactErr(err)
|
||||
// Put a retry loop around deletes on Windows. Windows
|
||||
// file descriptor closes are effectively asynchronous,
|
||||
// as a bunch of hooks run on/after close, and we can't
|
||||
// necessarily delete the file for a while after close,
|
||||
// as we need to wait for everybody to be done with
|
||||
// it. (on Windows, unlike Unix, a file can't be deleted
|
||||
// if it's open anywhere)
|
||||
// So try a few times but ultimately just leave a
|
||||
// "foo.jpg.deleted" marker file to note that it's
|
||||
// deleted and we clean it up later.
|
||||
if runtime.GOOS == "windows" {
|
||||
if bo == nil {
|
||||
bo = backoff.NewBackoff("delete-retry", logf, 1*time.Second)
|
||||
}
|
||||
if s.b.clock.Since(t0) < 5*time.Second {
|
||||
bo.BackOff(context.Background(), err)
|
||||
continue
|
||||
}
|
||||
if err := touchFile(path + deletedSuffix); err != nil {
|
||||
logf("peerapi: failed to leave deleted marker: %v", err)
|
||||
}
|
||||
}
|
||||
logf("peerapi: failed to DeleteFile: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// hasFilesWaiting reports whether any files are buffered in the
|
||||
// tailscaled daemon storage.
|
||||
func (s *peerAPIServer) hasFilesWaiting() bool {
|
||||
if s == nil || s.rootDir == "" || s.directFileMode {
|
||||
return false
|
||||
}
|
||||
if s.knownEmpty.Load() {
|
||||
// Optimization: this is usually empty, so avoid opening
|
||||
// the directory and checking. We can't cache the actual
|
||||
// has-files-or-not values as the macOS/iOS client might
|
||||
// in the future use+delete the files directly. So only
|
||||
// keep this negative cache.
|
||||
return false
|
||||
}
|
||||
f, err := os.Open(s.rootDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
for {
|
||||
des, err := f.ReadDir(10)
|
||||
for _, de := range des {
|
||||
name := de.Name()
|
||||
if strings.HasSuffix(name, partialSuffix) {
|
||||
continue
|
||||
}
|
||||
if name, ok := strings.CutSuffix(name, deletedSuffix); ok { // for Windows + tests
|
||||
// After we're done looping over files, then try
|
||||
// to delete this file. Don't do it proactively,
|
||||
// as the OS may return "foo.jpg.deleted" before "foo.jpg"
|
||||
// and we don't want to delete the ".deleted" file before
|
||||
// enumerating to the "foo.jpg" file.
|
||||
defer tryDeleteAgain(filepath.Join(s.rootDir, name))
|
||||
continue
|
||||
}
|
||||
if de.Type().IsRegular() {
|
||||
_, err := os.Stat(filepath.Join(s.rootDir, name+deletedSuffix))
|
||||
if os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
if err == nil {
|
||||
tryDeleteAgain(filepath.Join(s.rootDir, name))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
s.knownEmpty.Store(true)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func touchFile(path string) error {
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
return redactErr(err)
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func (s *peerAPIServer) OpenFile(baseName string) (rc io.ReadCloser, size int64, err error) {
|
||||
if s == nil {
|
||||
return nil, 0, errNilPeerAPIServer
|
||||
}
|
||||
if s.rootDir == "" {
|
||||
return nil, 0, errNoTaildrop
|
||||
}
|
||||
if s.directFileMode {
|
||||
return nil, 0, errors.New("opens not allowed in direct mode")
|
||||
}
|
||||
path, ok := s.diskPath(baseName)
|
||||
if !ok {
|
||||
return nil, 0, errors.New("bad filename")
|
||||
}
|
||||
if fi, err := os.Stat(path + deletedSuffix); err == nil && fi.Mode().IsRegular() {
|
||||
tryDeleteAgain(path)
|
||||
return nil, 0, &fs.PathError{Op: "open", Path: redacted, Err: fs.ErrNotExist}
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, redactErr(err)
|
||||
}
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, 0, redactErr(err)
|
||||
}
|
||||
return f, fi.Size(), nil
|
||||
}
|
||||
|
||||
type incomingFile struct {
|
||||
name string // "foo.jpg"
|
||||
started time.Time
|
||||
size int64 // or -1 if unknown; never 0
|
||||
w io.Writer // underlying writer
|
||||
ph ipnlocal.PeerAPIHandler
|
||||
partialPath string // non-empty in direct mode
|
||||
|
||||
mu sync.Mutex
|
||||
copied int64
|
||||
done bool
|
||||
lastNotify time.Time
|
||||
}
|
||||
|
||||
func (f *incomingFile) markAndNotifyDone() {
|
||||
f.mu.Lock()
|
||||
f.done = true
|
||||
f.mu.Unlock()
|
||||
b := f.ph.ps.b
|
||||
b.sendFileNotify()
|
||||
}
|
||||
|
||||
func (f *incomingFile) Write(p []byte) (n int, err error) {
|
||||
n, err = f.w.Write(p)
|
||||
|
||||
b := f.ph.ps.b
|
||||
var needNotify bool
|
||||
defer func() {
|
||||
if needNotify {
|
||||
b.sendFileNotify()
|
||||
}
|
||||
}()
|
||||
if n > 0 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.copied += int64(n)
|
||||
now := b.clock.Now()
|
||||
if f.lastNotify.IsZero() || now.Sub(f.lastNotify) > time.Second {
|
||||
f.lastNotify = now
|
||||
needNotify = true
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (f *incomingFile) PartialFile() ipn.PartialFile {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return ipn.PartialFile{
|
||||
Name: f.name,
|
||||
Started: f.started,
|
||||
DeclaredSize: f.size,
|
||||
Received: f.copied,
|
||||
PartialPath: f.partialPath,
|
||||
Done: f.done,
|
||||
}
|
||||
}
|
||||
|
||||
// canPutFile reports whether h can put a file ("Taildrop") to this node.
|
||||
func (h *peerAPIHandler) canPutFile() bool {
|
||||
if h.peerNode.UnsignedPeerAPIOnly() {
|
||||
// Unsigned peers can't send files.
|
||||
return false
|
||||
}
|
||||
return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityFileSharingSend)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
|
||||
if !envknob.CanTaildrop() {
|
||||
http.Error(w, "Taildrop disabled on device", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !h.canPutFile() {
|
||||
http.Error(w, "Taildrop access denied", 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, "expected method PUT", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.ps.rootDir == "" {
|
||||
http.Error(w, errNoTaildrop.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if distro.Get() == distro.Unraid && !h.ps.directFileMode {
|
||||
http.Error(w, "Taildrop folder not configured or accessible", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rawPath := r.URL.EscapedPath()
|
||||
suffix, ok := strings.CutPrefix(rawPath, "/v0/put/")
|
||||
if !ok {
|
||||
http.Error(w, "misconfigured internals", 500)
|
||||
return
|
||||
}
|
||||
if suffix == "" {
|
||||
http.Error(w, "empty filename", 400)
|
||||
return
|
||||
}
|
||||
if strings.Contains(suffix, "/") {
|
||||
http.Error(w, "directories not supported", 400)
|
||||
return
|
||||
}
|
||||
baseName, err := url.PathUnescape(suffix)
|
||||
if err != nil {
|
||||
http.Error(w, "bad path encoding", 400)
|
||||
return
|
||||
}
|
||||
dstFile, ok := h.ps.diskPath(baseName)
|
||||
if !ok {
|
||||
http.Error(w, "bad filename", 400)
|
||||
return
|
||||
}
|
||||
t0 := h.ps.b.clock.Now()
|
||||
// TODO(bradfitz): prevent same filename being sent by two peers at once
|
||||
|
||||
// prevent same filename being sent twice
|
||||
if _, err := os.Stat(dstFile); err == nil {
|
||||
http.Error(w, "file exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
partialFile := dstFile + partialSuffix
|
||||
f, err := os.Create(partialFile)
|
||||
if err != nil {
|
||||
h.logf("put Create error: %v", redactErr(err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var success bool
|
||||
defer func() {
|
||||
if !success {
|
||||
os.Remove(partialFile)
|
||||
}
|
||||
}()
|
||||
var finalSize int64
|
||||
var inFile *incomingFile
|
||||
if r.ContentLength != 0 {
|
||||
inFile = &incomingFile{
|
||||
name: baseName,
|
||||
started: h.ps.b.clock.Now(),
|
||||
size: r.ContentLength,
|
||||
w: f,
|
||||
ph: h,
|
||||
}
|
||||
if h.ps.directFileMode {
|
||||
inFile.partialPath = partialFile
|
||||
}
|
||||
h.ps.b.registerIncomingFile(inFile, true)
|
||||
defer h.ps.b.registerIncomingFile(inFile, false)
|
||||
n, err := io.Copy(inFile, r.Body)
|
||||
if err != nil {
|
||||
err = redactErr(err)
|
||||
f.Close()
|
||||
h.logf("put Copy error: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
finalSize = n
|
||||
}
|
||||
if err := redactErr(f.Close()); err != nil {
|
||||
h.logf("put Close error: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if h.ps.directFileMode && !h.ps.directFileDoFinalRename {
|
||||
if inFile != nil { // non-zero length; TODO: notify even for zero length
|
||||
inFile.markAndNotifyDone()
|
||||
}
|
||||
} else {
|
||||
if err := os.Rename(partialFile, dstFile); err != nil {
|
||||
err = redactErr(err)
|
||||
h.logf("put final rename: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
d := h.ps.b.clock.Since(t0).Round(time.Second / 10)
|
||||
h.logf("got put of %s in %v from %v/%v", approxSize(finalSize), d, h.remoteAddr.Addr(), h.peerNode.ComputedName)
|
||||
|
||||
// TODO: set modtime
|
||||
// TODO: some real response
|
||||
success = true
|
||||
io.WriteString(w, "{}\n")
|
||||
h.ps.knownEmpty.Store(false)
|
||||
h.ps.b.sendFileNotify()
|
||||
}
|
||||
181
taildrop/sender.go
Normal file
181
taildrop/sender.go
Normal file
@ -0,0 +1,181 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package taildrop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn/localapi"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
*localapi.Handler
|
||||
}
|
||||
|
||||
func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "file access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
suffix, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/files/")
|
||||
if !ok {
|
||||
http.Error(w, "misconfigured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if suffix == "" {
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "want GET to list files", 400)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if s := r.FormValue("waitsec"); s != "" && s != "0" {
|
||||
d, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid waitsec", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(d) * time.Second)
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithDeadline(ctx, deadline)
|
||||
defer cancel()
|
||||
}
|
||||
wfs, err := h.b.AwaitWaitingFiles(ctx)
|
||||
if err != nil && ctx.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))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
io.Copy(w, rc)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
mak.NonNilSliceForJSON(&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) {
|
||||
metricFilePutCalls.Add(1)
|
||||
|
||||
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, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/file-put/")
|
||||
if !ok {
|
||||
http.Error(w, "misconfigured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
stableIDStr, filenameEscaped, ok := strings.Cut(upath, "/")
|
||||
if !ok {
|
||||
http.Error(w, "bogus URL", 400)
|
||||
return
|
||||
}
|
||||
stableID := tailcfg.StableNodeID(stableIDStr)
|
||||
|
||||
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 = h.b.Dialer().PeerAPITransport()
|
||||
rp.ServeHTTP(w, outReq)
|
||||
}
|
||||
0
taildrop/sender_test.go
Normal file
0
taildrop/sender_test.go
Normal file
Loading…
x
Reference in New Issue
Block a user