Files
netbird/client/internal/filedrop/client.go
Zoltan Papp b165e0126e [client] Report file drop progress, and stop a withdrawn transfer
The receiver reported progress once per file, after the whole body had
been staged: spool.Write drains its reader before returning, so a large
file sat at nothing until it jumped to done. It now stages through a
reader that reports as the bytes land, the same way the sender already
did, and both sides thin their reports to one per percent and one per
200ms so a fast transfer cannot flood the history or the UI.

Progress also becomes an event of its own. It was left out because a
report per byte would have been unusable; thinned, it costs a handful of
events a second and spares every UI a poll. The daemon's event bridge
ignores the new kind, so nothing is published where a notification would
be noise.

Withdrawing consent mid-transfer did nothing. Cancel routed through
Decide, which only moves an offer out of Pending, so an accepted offer
kept its decision, kept its spool, and kept being uploaded into: the
receiver's list went quiet while the sender ran to completion. Revoke
takes an offer back whatever it has already answered, the staging reader
gives up as soon as consent is gone, and the sender stops rather than
retrying a refusal three times over. A transfer withdrawn this way reads
as declined on both ends, which is what it is, rather than as a failure.
2026-08-18 14:34:44 +02:00

440 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
}
// 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
}
// 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()
}
// A refusal and a vanished offer are answers, not transport
// hiccups: the receiver has withdrawn, and retrying only keeps
// pushing bytes at someone who said no.
if errors.Is(err, ErrNotAccepted) || errors.Is(err, ErrOfferNotFound) {
return 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.StatusNotFound {
return ErrOfferNotFound
}
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)
}
}