diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 51fb220dc..b747cef3d 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -2023,6 +2023,97 @@ func (b *LocalBackend) OpenFile(name string) (rc io.ReadCloser, size int64, err return apiSrv.OpenFile(name) } +// FileTarget is a node to which files can be sent, and the PeerAPI +// URL base to do so via. +type FileTarget struct { + Node *tailcfg.Node + + // PeerAPI is the http://ip:port URL base of the node's peer API, + // without any path (not even a single slash). + PeerAPIURL string +} + +// FileTargets lists nodes that the current node can send files to. +func (b *LocalBackend) FileTargets() ([]*FileTarget, error) { + var ret []*FileTarget + + b.mu.Lock() + defer b.mu.Unlock() + nm := b.netMap + if b.state != ipn.Running || nm == nil { + return nil, errors.New("not connected") + } + now := time.Now() + for _, p := range nm.Peers { + if p.User != nm.User || p.LastSeen == nil { + continue + } + if t := *p.LastSeen; now.Sub(t) > 30*time.Minute { + continue + } + peerAPI := peerAPIBase(b.netMap, p) + if peerAPI == "" { + continue + } + ret = append(ret, &FileTarget{ + Node: p, + PeerAPIURL: peerAPI, + }) + } + // TODO: sort a different way than the netmap already is? + return ret, nil +} + +// peerAPIBase returns the "http://ip:port" URL base to reach peer's peerAPI. +// It returns the empty string if the peer doesn't support the peerapi +// or there's no matching address family based on the netmap's own addresses. +func peerAPIBase(nm *netmap.NetworkMap, peer *tailcfg.Node) string { + if nm == nil || peer == nil { + return "" + } + var have4, have6 bool + for _, a := range nm.Addresses { + if !a.IsSingleIP() { + continue + } + switch { + case a.IP.Is4(): + have4 = true + case a.IP.Is6(): + have6 = true + } + } + var p4, p6 uint16 + for _, s := range peer.Hostinfo.Services { + switch s.Proto { + case "peerapi4": + p4 = s.Port + case "peerapi6": + p6 = s.Port + } + } + var ipp netaddr.IPPort + switch { + case have4 && p4 != 0: + ipp = netaddr.IPPort{IP: nodeIP(peer, netaddr.IP.Is4), Port: p4} + case have6 && p6 != 0: + ipp = netaddr.IPPort{IP: nodeIP(peer, netaddr.IP.Is6), Port: p6} + } + if ipp.IP.IsZero() { + return "" + } + return fmt.Sprintf("http://%v", ipp) +} + +func nodeIP(n *tailcfg.Node, pred func(netaddr.IP) bool) netaddr.IP { + for _, a := range n.Addresses { + if a.IsSingleIP() && pred(a.IP) { + return a.IP + } + } + return netaddr.IP{} +} + func isBSD(s string) bool { return s == "dragonfly" || s == "freebsd" || s == "netbsd" || s == "openbsd" } diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 2a6fc9315..f70ebadf2 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -291,3 +291,131 @@ func TestPeerRoutes(t *testing.T) { } } + +func TestPeerAPIBase(t *testing.T) { + tests := []struct { + name string + nm *netmap.NetworkMap + peer *tailcfg.Node + want string + }{ + { + name: "nil_netmap", + peer: new(tailcfg.Node), + want: "", + }, + { + name: "nil_peer", + nm: new(netmap.NetworkMap), + want: "", + }, + { + name: "self_only_4_them_both", + nm: &netmap.NetworkMap{ + Addresses: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("100.64.1.1/32"), + }, + }, + peer: &tailcfg.Node{ + Addresses: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("100.64.1.2/32"), + netaddr.MustParseIPPrefix("fe70::2/128"), + }, + Hostinfo: tailcfg.Hostinfo{ + Services: []tailcfg.Service{ + {Proto: "peerapi4", Port: 444}, + {Proto: "peerapi6", Port: 666}, + }, + }, + }, + want: "http://100.64.1.2:444", + }, + { + name: "self_only_6_them_both", + nm: &netmap.NetworkMap{ + Addresses: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("fe70::1/128"), + }, + }, + peer: &tailcfg.Node{ + Addresses: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("100.64.1.2/32"), + netaddr.MustParseIPPrefix("fe70::2/128"), + }, + Hostinfo: tailcfg.Hostinfo{ + Services: []tailcfg.Service{ + {Proto: "peerapi4", Port: 444}, + {Proto: "peerapi6", Port: 666}, + }, + }, + }, + want: "http://[fe70::2]:666", + }, + { + name: "self_both_them_only_4", + nm: &netmap.NetworkMap{ + Addresses: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("100.64.1.1/32"), + netaddr.MustParseIPPrefix("fe70::1/128"), + }, + }, + peer: &tailcfg.Node{ + Addresses: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("100.64.1.2/32"), + netaddr.MustParseIPPrefix("fe70::2/128"), + }, + Hostinfo: tailcfg.Hostinfo{ + Services: []tailcfg.Service{ + {Proto: "peerapi4", Port: 444}, + }, + }, + }, + want: "http://100.64.1.2:444", + }, + { + name: "self_both_them_only_6", + nm: &netmap.NetworkMap{ + Addresses: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("100.64.1.1/32"), + netaddr.MustParseIPPrefix("fe70::1/128"), + }, + }, + peer: &tailcfg.Node{ + Addresses: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("100.64.1.2/32"), + netaddr.MustParseIPPrefix("fe70::2/128"), + }, + Hostinfo: tailcfg.Hostinfo{ + Services: []tailcfg.Service{ + {Proto: "peerapi6", Port: 666}, + }, + }, + }, + want: "http://[fe70::2]:666", + }, + { + name: "self_both_them_no_peerapi_service", + nm: &netmap.NetworkMap{ + Addresses: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("100.64.1.1/32"), + netaddr.MustParseIPPrefix("fe70::1/128"), + }, + }, + peer: &tailcfg.Node{ + Addresses: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("100.64.1.2/32"), + netaddr.MustParseIPPrefix("fe70::2/128"), + }, + }, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := peerAPIBase(tt.nm, tt.peer) + if got != tt.want { + t.Errorf("got %q; want %q", got, tt.want) + } + }) + } +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index e26bcf2ab..9a6240608 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -13,6 +13,7 @@ import ( "io" "net/http" "net/url" + "reflect" "runtime" "strconv" "strings" @@ -83,6 +84,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveCheckIPForwarding(w, r) case "/localapi/v0/bugreport": h.serveBugReport(w, r) + case "/localapi/v0/file-targets": + h.serveFileTargets(w, r) case "/": io.WriteString(w, "tailscaled\n") default: @@ -231,6 +234,25 @@ func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) { io.Copy(w, rc) } +func (h *Handler) serveFileTargets(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "file access denied", http.StatusForbidden) + return + } + if r.Method != "GET" { + http.Error(w, "want GET to list targets", 400) + return + } + wfs, err := h.b.FileTargets() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + makeNonNil(&wfs) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(wfs) +} + func defBool(a string, def bool) bool { if a == "" { return def @@ -241,3 +263,30 @@ func defBool(a string, def bool) bool { } 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())) + } +}