mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-28 18:41:30 +02:00
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.
440 lines
12 KiB
Go
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)
|
|
}
|
|
}
|