Files
netbird/client/internal/filedrop/protocol.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

198 lines
4.3 KiB
Go

package filedrop
import (
"errors"
"fmt"
"time"
)
const Port uint16 = 41421
// HeaderReceivedBytes carries the receiver's confirmed byte count in a HEAD response.
const HeaderReceivedBytes = "Netbird-Received-Bytes"
// DefaultOfferTTL bounds how long an offer waits for the receiver's decision.
const DefaultOfferTTL = 5 * time.Minute
// MaxOfferFiles bounds the number of items a single offer may announce.
const MaxOfferFiles = 512
// MaxInlineTextSize bounds an inline text snippet, which is held in memory.
const MaxInlineTextSize = 64 * 1024
const maxOfferBodySize = 1 << 20
const (
pathOffers = "/v1/offers"
pathOffersSlash = pathOffers + "/"
segmentFiles = "files"
)
// The decisions an offer can carry. Pending is the only non-final one.
const (
DecisionPending Decision = iota
DecisionAccepted
DecisionDeclined
DecisionExpired
)
// The receiving modes a profile can be in.
const (
ModeOff Mode = iota
ModeAsk
ModeAutoAccept
)
// The payload kinds an offer can announce.
const (
KindFile Kind = iota
KindText
)
// The states a transfer moves through.
const (
StatePending State = iota
StateTransferring
StateCompleted
StateDeclined
StateExpired
StateCancelled
StateFailed
)
var (
ErrOfferNotFound = errors.New("offer not found")
ErrRefused = errors.New("offer refused by receiver")
ErrDeclined = errors.New("offer declined")
ErrExpired = errors.New("offer expired")
ErrNotAccepted = errors.New("offer not accepted")
ErrUnknownPeer = errors.New("unknown peer")
ErrInvalidOffer = errors.New("invalid offer")
)
// OfferID identifies a single transfer offer on the receiving peer.
type OfferID string
// PeerKey is the remote peer's public key, used as the identity for per-sender policy.
type PeerKey string
// Decision is the receiver's answer to an offer.
type Decision uint8
// Mode is the receiver's profile-local policy for incoming offers.
type Mode uint8
// Kind distinguishes payloads that are written to the spool from inline text snippets.
type Kind uint8
// State is the lifecycle state of a transfer, on either side.
type State uint8
// FileMeta describes one payload item announced in an offer.
type FileMeta struct {
Name string `json:"name"`
Size int64 `json:"size"`
ContentType string `json:"contentType,omitempty"`
Kind Kind `json:"kind,omitempty"`
Text string `json:"text,omitempty"`
}
// OfferRequest is the JSON body of POST /v1/offers. It carries metadata only.
type OfferRequest struct {
SenderName string `json:"senderName,omitempty"`
Files []FileMeta `json:"files"`
}
// OfferResponse is returned for an offer and for every poll of its status.
type OfferResponse struct {
ID OfferID `json:"id"`
Decision Decision `json:"decision"`
}
// String implements fmt.Stringer.
func (d Decision) String() string {
switch d {
case DecisionPending:
return "pending"
case DecisionAccepted:
return "accepted"
case DecisionDeclined:
return "declined"
case DecisionExpired:
return "expired"
default:
return fmt.Sprintf("unknown(%d)", uint8(d))
}
}
func (d Decision) valid() bool {
switch d {
case DecisionPending, DecisionAccepted, DecisionDeclined, DecisionExpired:
return true
default:
return false
}
}
// String implements fmt.Stringer.
func (m Mode) String() string {
switch m {
case ModeOff:
return "off"
case ModeAsk:
return "ask"
case ModeAutoAccept:
return "auto"
default:
return fmt.Sprintf("unknown(%d)", uint8(m))
}
}
func (m Mode) valid() bool {
switch m {
case ModeOff, ModeAsk, ModeAutoAccept:
return true
default:
return false
}
}
// String implements fmt.Stringer.
func (k Kind) String() string {
switch k {
case KindFile:
return "file"
case KindText:
return "text"
default:
return fmt.Sprintf("unknown(%d)", uint8(k))
}
}
func (k Kind) valid() bool {
return k == KindFile || k == KindText
}
// String implements fmt.Stringer.
func (s State) String() string {
switch s {
case StatePending:
return "pending"
case StateTransferring:
return "transferring"
case StateCompleted:
return "completed"
case StateDeclined:
return "declined"
case StateExpired:
return "expired"
case StateCancelled:
return "cancelled"
case StateFailed:
return "failed"
default:
return fmt.Sprintf("unknown(%d)", uint8(s))
}
}