mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-27 10:01:28 +02:00
Files move directly between peers over the overlay, with no server in the path. The receiver listens on the WireGuard address only, so the port is unreachable from outside the tunnel, and every offer is matched to a known peer before anything is read. Consent is the default: an offer carries metadata alone, and no payload moves until the receiver accepts. Policy is per profile and device-local — off, ask, or auto-accept, with per-sender exceptions on top. Policy and history live in the profile's preferences, so removing a profile takes its file drop state with it. Transfers interrupted by a restart are settled on load; nothing survives to finish them, and left alone they would sit in the log as permanently pending. The Android bindings pull payload bytes through a chunk-returning stream: gomobile copies a []byte argument into a fresh Java array and never copies it back, so a fill-my-buffer method would hand back the right length with no data.
447 lines
12 KiB
Go
447 lines
12 KiB
Go
package filedrop
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"strconv"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const (
|
|
defaultPollTimeout = 60 * time.Second
|
|
defaultOfferTimeout = DefaultOfferTTL
|
|
uploadRetryDelay = 2 * time.Second
|
|
maxUploadAttempts = 3
|
|
)
|
|
|
|
// DialFunc opens a connection to the receiving peer over the tunnel.
|
|
type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
|
|
|
// Payload is one item to send; Open is called per attempt starting at an offset.
|
|
type Payload struct {
|
|
Meta FileMeta
|
|
Open func(offset int64) (io.ReadCloser, error)
|
|
}
|
|
|
|
// ProgressFunc reports staged bytes for one item as the upload streams.
|
|
type ProgressFunc func(index int, sent int64, total int64)
|
|
|
|
// ClientConfig configures the sending side.
|
|
type ClientConfig struct {
|
|
Dial DialFunc
|
|
SenderName string
|
|
PollTimeout time.Duration
|
|
OfferTimeout time.Duration
|
|
}
|
|
|
|
type progressReader struct {
|
|
r io.Reader
|
|
sent int64
|
|
total int64
|
|
report func(sent int64)
|
|
}
|
|
|
|
// Client sends offers and payloads to a peer's file drop service.
|
|
type Client struct {
|
|
http *http.Client
|
|
senderName string
|
|
pollTimeout time.Duration
|
|
offerTimeout time.Duration
|
|
}
|
|
|
|
func (p *progressReader) Read(b []byte) (int, error) {
|
|
n, err := p.r.Read(b)
|
|
if n > 0 {
|
|
p.sent += int64(n)
|
|
p.report(p.sent)
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
// NewClient builds a sending client over the given dialer.
|
|
func NewClient(cfg ClientConfig) (*Client, error) {
|
|
if cfg.Dial == nil {
|
|
return nil, errors.New("dial function is required")
|
|
}
|
|
|
|
pollTimeout := cfg.PollTimeout
|
|
if pollTimeout <= 0 {
|
|
pollTimeout = defaultPollTimeout
|
|
}
|
|
offerTimeout := cfg.OfferTimeout
|
|
if offerTimeout <= 0 {
|
|
offerTimeout = defaultOfferTimeout
|
|
}
|
|
|
|
transport := &http.Transport{
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return cfg.Dial(ctx, network, addr) },
|
|
MaxIdleConnsPerHost: 2,
|
|
ResponseHeaderTimeout: pollTimeout + 30*time.Second,
|
|
}
|
|
|
|
return &Client{
|
|
http: &http.Client{Transport: transport},
|
|
senderName: cfg.SenderName,
|
|
pollTimeout: pollTimeout,
|
|
offerTimeout: offerTimeout,
|
|
}, nil
|
|
}
|
|
|
|
// TextPayload builds an inline text payload, which is carried in the offer itself.
|
|
func TextPayload(name, text string) Payload {
|
|
return Payload{
|
|
Meta: FileMeta{
|
|
Name: name,
|
|
Size: int64(len(text)),
|
|
ContentType: "text/plain",
|
|
Kind: KindText,
|
|
Text: text,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Send offers the payloads to the peer at addr and uploads them once accepted.
|
|
func (c *Client) Send(ctx context.Context, addr netip.AddrPort, payloads []Payload, progress ProgressFunc) (OfferID, error) {
|
|
id, decision, err := c.Offer(ctx, addr, payloads)
|
|
if err != nil {
|
|
return id, err
|
|
}
|
|
|
|
decision, err = c.AwaitDecision(ctx, addr, id, decision)
|
|
if err != nil {
|
|
return id, err
|
|
}
|
|
if err := decisionError(decision); err != nil {
|
|
return id, err
|
|
}
|
|
|
|
return id, c.Upload(ctx, addr, id, payloads, progress)
|
|
}
|
|
|
|
// Offer announces the payloads and returns the offer ID with its initial decision.
|
|
func (c *Client) Offer(ctx context.Context, addr netip.AddrPort, payloads []Payload) (OfferID, Decision, error) {
|
|
if len(payloads) == 0 {
|
|
return "", DecisionPending, fmt.Errorf("%w: no payloads", ErrInvalidOffer)
|
|
}
|
|
return c.postOffer(ctx, baseURL(addr), payloads)
|
|
}
|
|
|
|
// AwaitDecision resolves a pending decision by long-polling the receiver.
|
|
func (c *Client) AwaitDecision(ctx context.Context, addr netip.AddrPort, id OfferID, decision Decision) (Decision, error) {
|
|
if decision != DecisionPending {
|
|
return decision, nil
|
|
}
|
|
return c.awaitDecision(ctx, baseURL(addr), id)
|
|
}
|
|
|
|
// Upload streams every non-inline payload of an accepted offer.
|
|
func (c *Client) Upload(ctx context.Context, addr netip.AddrPort, id OfferID, payloads []Payload, progress ProgressFunc) error {
|
|
base := baseURL(addr)
|
|
for i, p := range payloads {
|
|
if p.Meta.Kind == KindText {
|
|
continue
|
|
}
|
|
if err := c.uploadFile(ctx, base, id, i, p, progress); err != nil {
|
|
return fmt.Errorf("upload %s: %w", p.Meta.Name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Cancel withdraws an offer, taking the receiver's consent prompt with it.
|
|
func (c *Client) Cancel(ctx context.Context, addr netip.AddrPort, id OfferID) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, offerURL(baseURL(addr), id), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("build cancel request: %w", err)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("send cancel: %w", err)
|
|
}
|
|
defer drainAndClose(resp)
|
|
|
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
|
|
return statusError(resp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) postOffer(ctx context.Context, base string, payloads []Payload) (OfferID, Decision, error) {
|
|
files := make([]FileMeta, len(payloads))
|
|
for i, p := range payloads {
|
|
files[i] = p.Meta
|
|
}
|
|
|
|
body, err := json.Marshal(OfferRequest{SenderName: c.senderName, Files: files})
|
|
if err != nil {
|
|
return "", DecisionPending, fmt.Errorf("encode offer: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+pathOffers, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", DecisionPending, fmt.Errorf("build offer request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return "", DecisionPending, fmt.Errorf("send offer: %w", err)
|
|
}
|
|
defer drainAndClose(resp)
|
|
|
|
switch resp.StatusCode {
|
|
case http.StatusCreated, http.StatusAccepted:
|
|
case http.StatusForbidden:
|
|
return "", DecisionPending, ErrRefused
|
|
default:
|
|
return "", DecisionPending, statusError(resp)
|
|
}
|
|
|
|
var offer OfferResponse
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, maxOfferBodySize)).Decode(&offer); err != nil {
|
|
return "", DecisionPending, fmt.Errorf("decode offer response: %w", err)
|
|
}
|
|
if offer.ID == "" {
|
|
return "", DecisionPending, fmt.Errorf("%w: receiver returned no offer id", ErrInvalidOffer)
|
|
}
|
|
if !offer.Decision.valid() {
|
|
return "", DecisionPending, fmt.Errorf("%w: receiver returned decision %s", ErrInvalidOffer, offer.Decision)
|
|
}
|
|
|
|
return offer.ID, offer.Decision, nil
|
|
}
|
|
|
|
func (c *Client) awaitDecision(ctx context.Context, base string, id OfferID) (Decision, error) {
|
|
deadline := time.Now().Add(c.offerTimeout)
|
|
|
|
for time.Now().Before(deadline) {
|
|
decision, err := c.pollDecision(ctx, base, id)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return DecisionPending, ctx.Err()
|
|
}
|
|
log.Debugf("poll file drop decision: %v", err)
|
|
if !sleepCtx(ctx, uploadRetryDelay) {
|
|
return DecisionPending, ctx.Err()
|
|
}
|
|
continue
|
|
}
|
|
if decision != DecisionPending {
|
|
return decision, nil
|
|
}
|
|
}
|
|
|
|
return DecisionExpired, nil
|
|
}
|
|
|
|
func (c *Client) pollDecision(ctx context.Context, base string, id OfferID) (Decision, error) {
|
|
pollCtx, cancel := context.WithTimeout(ctx, c.pollTimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(pollCtx, http.MethodGet, offerURL(base, id), nil)
|
|
if err != nil {
|
|
return DecisionPending, fmt.Errorf("build status request: %w", err)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return DecisionPending, fmt.Errorf("poll status: %w", err)
|
|
}
|
|
defer drainAndClose(resp)
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return DecisionPending, ErrOfferNotFound
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return DecisionPending, statusError(resp)
|
|
}
|
|
|
|
var offer OfferResponse
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, maxOfferBodySize)).Decode(&offer); err != nil {
|
|
return DecisionPending, fmt.Errorf("decode status response: %w", err)
|
|
}
|
|
if !offer.Decision.valid() {
|
|
return DecisionPending, fmt.Errorf("%w: receiver returned decision %s", ErrInvalidOffer, offer.Decision)
|
|
}
|
|
return offer.Decision, nil
|
|
}
|
|
|
|
func (c *Client) uploadFile(ctx context.Context, base string, id OfferID, index int, p Payload, progress ProgressFunc) error {
|
|
var lastErr error
|
|
|
|
for attempt := range maxUploadAttempts {
|
|
offset := int64(0)
|
|
if attempt > 0 {
|
|
if !sleepCtx(ctx, uploadRetryDelay) {
|
|
return ctx.Err()
|
|
}
|
|
confirmed, err := c.confirmedOffset(ctx, base, id, index)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
offset = confirmed
|
|
}
|
|
|
|
if offset >= p.Meta.Size {
|
|
return nil
|
|
}
|
|
|
|
if err := c.putFile(ctx, base, id, index, p, offset, progress); err != nil {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
lastErr = err
|
|
log.Debugf("upload attempt %d for %s: %v", attempt+1, p.Meta.Name, err)
|
|
continue
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
func (c *Client) putFile(ctx context.Context, base string, id OfferID, index int, p Payload, offset int64, progress ProgressFunc) error {
|
|
if p.Open == nil {
|
|
return fmt.Errorf("payload %s has no reader", p.Meta.Name)
|
|
}
|
|
|
|
body, err := p.Open(offset)
|
|
if err != nil {
|
|
return fmt.Errorf("open payload: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := body.Close(); err != nil {
|
|
log.Debugf("close payload reader: %v", err)
|
|
}
|
|
}()
|
|
|
|
var reader io.Reader = body
|
|
if progress != nil {
|
|
reader = &progressReader{
|
|
r: body,
|
|
sent: offset,
|
|
total: p.Meta.Size,
|
|
report: func(sent int64) {
|
|
progress(index, sent, p.Meta.Size)
|
|
},
|
|
}
|
|
}
|
|
|
|
url := fileURL(base, id, index) + "?offset=" + strconv.FormatInt(offset, 10)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, reader)
|
|
if err != nil {
|
|
return fmt.Errorf("build upload request: %w", err)
|
|
}
|
|
req.ContentLength = p.Meta.Size - offset
|
|
req.Header.Set("Content-Type", contentTypeOrDefault(p.Meta.ContentType))
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("send payload: %w", err)
|
|
}
|
|
defer drainAndClose(resp)
|
|
|
|
if resp.StatusCode == http.StatusForbidden {
|
|
return ErrNotAccepted
|
|
}
|
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
|
return statusError(resp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) confirmedOffset(ctx context.Context, base string, id OfferID, index int) (int64, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodHead, fileURL(base, id, index), nil)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("build probe request: %w", err)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("probe upload: %w", err)
|
|
}
|
|
defer drainAndClose(resp)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return 0, statusError(resp)
|
|
}
|
|
|
|
raw := resp.Header.Get(HeaderReceivedBytes)
|
|
if raw == "" {
|
|
return 0, nil
|
|
}
|
|
offset, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil || offset < 0 {
|
|
return 0, fmt.Errorf("invalid %s header %q", HeaderReceivedBytes, raw)
|
|
}
|
|
return offset, nil
|
|
}
|
|
|
|
func baseURL(addr netip.AddrPort) string {
|
|
return "http://" + net.JoinHostPort(addr.Addr().Unmap().String(), strconv.Itoa(int(addr.Port())))
|
|
}
|
|
|
|
func offerURL(base string, id OfferID) string {
|
|
return base + pathOffersSlash + string(id)
|
|
}
|
|
|
|
func fileURL(base string, id OfferID, index int) string {
|
|
return offerURL(base, id) + "/" + segmentFiles + "/" + strconv.Itoa(index)
|
|
}
|
|
|
|
func contentTypeOrDefault(ct string) string {
|
|
if ct == "" {
|
|
return "application/octet-stream"
|
|
}
|
|
return ct
|
|
}
|
|
|
|
func statusError(resp *http.Response) error {
|
|
return fmt.Errorf("receiver returned %s", resp.Status)
|
|
}
|
|
|
|
func drainAndClose(resp *http.Response) {
|
|
if _, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxOfferBodySize)); err != nil {
|
|
log.Tracef("drain response body: %v", err)
|
|
}
|
|
if err := resp.Body.Close(); err != nil {
|
|
log.Debugf("close response body: %v", err)
|
|
}
|
|
}
|
|
|
|
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case <-timer.C:
|
|
return true
|
|
case <-ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
|
|
func decisionError(decision Decision) error {
|
|
switch decision {
|
|
case DecisionAccepted:
|
|
return nil
|
|
case DecisionDeclined:
|
|
return ErrDeclined
|
|
case DecisionExpired:
|
|
return ErrExpired
|
|
default:
|
|
return fmt.Errorf("unexpected decision %s", decision)
|
|
}
|
|
}
|