Files
netbird/client/internal/filedrop/client.go
Zoltán Papp 73cffdb702 Add peer-to-peer file drop
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.
2026-08-16 22:04:38 +02:00

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)
}
}