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

661 lines
16 KiB
Go

package filedrop
import (
"context"
"errors"
"fmt"
"net/netip"
"net/url"
"os"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/tun/netstack"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
// The event kinds. Progress is not an event: live transfers are polled.
const (
EventOffer EventKind = iota
EventCompleted
EventFailed
EventWithdrawn
)
// portSignalGrace bounds how long a failed attempt waits for one signal message
// that may advertise the receiver's actual port before giving up.
const portSignalGrace = 3 * time.Second
// ErrNotConnected indicates the operation needs a running tunnel.
var ErrNotConnected = errors.New("not connected")
// EventKind classifies the events the manager surfaces to the platform layer.
type EventKind uint8
// EventSink receives transfer events. Calls may come from server goroutines.
type EventSink func(kind EventKind, transfer Transfer)
// ManagerConfig configures a per-profile file drop manager. Policy and history
// live in Store; DataDir only holds the spool of partially received files,
// which is disposable and never outlives an offer's TTL.
type ManagerConfig struct {
Profile profilemanager.ID
DataDir string
Store Store
Events EventSink
OfferTTL time.Duration
}
type sendHandle struct {
cancel context.CancelFunc
ip netip.Addr
addr netip.AddrPort
remoteID OfferID
}
// Manager owns one profile's file drop state.
type Manager struct {
mu sync.Mutex
profile profilemanager.ID
dataDir string
store Store
policy *PolicyStore
history *History
events EventSink
offerTTL time.Duration
server *Server
ports *PortRegistry
dial DialFunc
senderName string
sends map[OfferID]*sendHandle
sendWg sync.WaitGroup
}
// NewManager loads or initializes the file drop state for one profile.
func NewManager(cfg ManagerConfig) (*Manager, error) {
if cfg.DataDir == "" {
return nil, errors.New("data dir is required")
}
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
return nil, fmt.Errorf("create file drop dir: %w", err)
}
m := &Manager{
profile: cfg.Profile,
dataDir: cfg.DataDir,
store: cfg.Store,
policy: LoadPolicyStore(cfg.Profile, cfg.Store),
history: LoadHistory(cfg.Store),
events: cfg.Events,
offerTTL: cfg.OfferTTL,
ports: NewPortRegistry(),
sends: make(map[OfferID]*sendHandle),
}
return m, nil
}
// Profile returns the profile this manager belongs to.
func (m *Manager) Profile() profilemanager.ID {
return m.profile
}
// Policy returns the receiving policy store.
func (m *Manager) Policy() *PolicyStore {
return m.policy
}
// Ports returns the registry of peer-advertised listen ports; the engine feeds it
// from incoming signal messages.
func (m *Manager) Ports() *PortRegistry {
return m.ports
}
// ReceiverPort returns the port the receiver is actually bound to, 0 when stopped.
func (m *Manager) ReceiverPort() uint16 {
m.mu.Lock()
server := m.server
m.mu.Unlock()
if server == nil {
return 0
}
return server.BoundPort()
}
// Transfers returns the history entries, newest first, with pending offers included.
func (m *Manager) Transfers() []Transfer {
return m.history.List()
}
// DeleteTransfer removes a history entry. A live transfer is cancelled first.
func (m *Manager) DeleteTransfer(id OfferID) {
if t, ok := m.history.Get(id); ok && !t.terminal() {
m.Cancel(id)
}
m.history.Delete(id)
}
// DestinationDir returns the directory received files are delivered to.
func (m *Manager) DestinationDir() string {
return m.policy.DestinationDir()
}
// SetDestinationDir persists the delivery directory.
func (m *Manager) SetDestinationDir(dir string) error {
return m.policy.SetDestinationDir(dir)
}
// StartReceiver binds the receiving server on addr.
func (m *Manager) StartReceiver(ctx context.Context, addr netip.AddrPort, netstackNet *netstack.Net, resolver PeerResolver) error {
m.mu.Lock()
if m.server != nil {
m.mu.Unlock()
return errors.New("receiver is already running")
}
m.mu.Unlock()
server, err := NewServer(ServerConfig{
SpoolDir: filepath.Join(m.dataDir, "spool"),
Policy: m.policy,
Resolver: resolver,
Notifier: m,
OfferTTL: m.offerTTL,
})
if err != nil {
return fmt.Errorf("create receiver: %w", err)
}
if netstackNet != nil {
server.SetNetstackNet(netstackNet)
}
if err := server.Start(ctx, addr); err != nil {
return fmt.Errorf("start receiver: %w", err)
}
m.mu.Lock()
m.server = server
m.mu.Unlock()
return nil
}
// AddReceiverListener serves the receiver on an additional address, such as IPv6.
func (m *Manager) AddReceiverListener(ctx context.Context, addr netip.AddrPort) error {
m.mu.Lock()
server := m.server
m.mu.Unlock()
if server == nil {
return errors.New("receiver is not running")
}
return server.AddListener(ctx, addr)
}
// StopReceiver shuts the receiving server down and drops the tunnel dialer.
func (m *Manager) StopReceiver() error {
m.mu.Lock()
server := m.server
m.server = nil
m.dial = nil
m.mu.Unlock()
if server == nil {
return nil
}
return server.Stop()
}
// SetTunnel gives the manager the tunnel dialer and the local sender name.
func (m *Manager) SetTunnel(dial DialFunc, senderName string) {
m.mu.Lock()
defer m.mu.Unlock()
m.dial = dial
m.senderName = senderName
}
// Close stops the receiver and aborts every outgoing transfer.
func (m *Manager) Close() error {
err := m.StopReceiver()
m.mu.Lock()
for _, h := range m.sends {
h.cancel()
}
m.mu.Unlock()
m.sendWg.Wait()
return err
}
// Send starts an asynchronous transfer and returns its local transfer ID.
func (m *Manager) Send(peer PeerKey, peerName string, addr netip.Addr, payloads []Payload) (OfferID, error) {
if len(payloads) == 0 {
return "", fmt.Errorf("%w: no payloads", ErrInvalidOffer)
}
m.mu.Lock()
dial, senderName := m.dial, m.senderName
m.mu.Unlock()
if dial == nil {
return "", ErrNotConnected
}
client, err := NewClient(ClientConfig{Dial: dial, SenderName: senderName, OfferTimeout: m.offerTTL})
if err != nil {
return "", err
}
id := OfferID(uuid.NewString())
ctx, cancel := context.WithCancel(context.Background())
handle := &sendHandle{cancel: cancel, ip: addr}
m.mu.Lock()
m.sends[id] = handle
m.mu.Unlock()
transfer := Transfer{
ID: id,
Direction: DirectionSent,
PeerKey: peer,
PeerName: peerName,
Files: payloadMetas(payloads),
State: StatePending,
TotalSize: payloadTotal(payloads),
CreatedAt: time.Now(),
}
m.history.Upsert(transfer)
m.sendWg.Add(1)
go func() {
defer m.sendWg.Done()
defer cancel()
m.runSend(ctx, client, handle, transfer, payloads)
m.mu.Lock()
delete(m.sends, id)
m.mu.Unlock()
}()
return id, nil
}
// Cancel aborts a transfer in either direction.
func (m *Manager) Cancel(id OfferID) {
m.mu.Lock()
handle := m.sends[id]
server := m.server
var remoteAddr netip.AddrPort
var remoteID OfferID
if handle != nil {
remoteAddr, remoteID = handle.addr, handle.remoteID
}
m.mu.Unlock()
if handle != nil {
handle.cancel()
if remoteID != "" && remoteAddr.IsValid() {
m.withdrawRemote(remoteAddr, remoteID)
}
m.finishTransfer(id, StateCancelled, "")
return
}
if server != nil {
if offer, ok := server.Offers().Decide(id, DecisionDeclined); ok {
server.Spool().Remove(offer.ID)
}
}
m.finishTransfer(id, StateCancelled, "")
}
// Accept releases a pending incoming offer for upload.
func (m *Manager) Accept(id OfferID) error {
m.mu.Lock()
server := m.server
m.mu.Unlock()
if server == nil {
return ErrNotConnected
}
offer, ok := server.Offers().Decide(id, DecisionAccepted)
if !ok {
return ErrOfferNotFound
}
if offer.State == StateCompleted {
m.OnCompleted(offer)
return nil
}
m.history.SetProgress(id, 0)
return nil
}
// Decline refuses a pending incoming offer.
func (m *Manager) Decline(id OfferID) error {
m.mu.Lock()
server := m.server
m.mu.Unlock()
if server == nil {
return ErrNotConnected
}
offer, ok := server.Offers().Decide(id, DecisionDeclined)
if !ok {
return ErrOfferNotFound
}
server.Spool().Remove(offer.ID)
m.finishTransfer(id, StateDeclined, "")
return nil
}
// SetSenderRule records a per-sender exception.
func (m *Manager) SetSenderRule(peer PeerKey, rule SenderRule) error {
if err := m.policy.SetSenderRule(peer, rule); err != nil {
return err
}
if rule != SenderRuleBlock {
return nil
}
m.mu.Lock()
server := m.server
m.mu.Unlock()
if server == nil {
return nil
}
for _, offer := range server.Offers().List() {
if offer.Sender != peer || offer.Decision != DecisionPending {
continue
}
if declined, ok := server.Offers().Decide(offer.ID, DecisionDeclined); ok {
server.Spool().Remove(declined.ID)
m.finishTransfer(declined.ID, StateDeclined, "")
m.emit(EventWithdrawn, m.transferOf(declined.ID))
}
}
return nil
}
func (m *Manager) runSend(ctx context.Context, client *Client, handle *sendHandle, transfer Transfer, payloads []Payload) {
addr, remoteID, decision, err := m.offerWithPortRetry(ctx, client, handle, transfer.PeerKey, payloads)
if err != nil {
m.failSend(ctx, transfer.ID, err)
return
}
decision, err = client.AwaitDecision(ctx, addr, remoteID, decision)
if err != nil {
m.failSend(ctx, transfer.ID, err)
return
}
if err := decisionError(decision); err != nil {
m.failSend(ctx, transfer.ID, err)
return
}
m.history.SetProgress(transfer.ID, 0)
completed := make([]int64, len(payloads))
progress := func(index int, sent, _ int64) {
completed[index] = sent
var total int64
for _, n := range completed {
total += n
}
m.history.SetProgress(transfer.ID, total)
}
if err := client.Upload(ctx, addr, remoteID, payloads, progress); err != nil {
m.failSend(ctx, transfer.ID, err)
return
}
m.history.SetProgress(transfer.ID, transfer.TotalSize)
m.finishTransfer(transfer.ID, StateCompleted, "")
m.emit(EventCompleted, m.transferOf(transfer.ID))
}
// offerWithPortRetry places the offer on the last advertised port, falling back to
// the default. When the attempt fails on the transport, it waits out one signal
// message that may carry the receiver's actual port and retries there once. A port
// learned mid-attempt aborts the attempt immediately instead of letting it hang.
func (m *Manager) offerWithPortRetry(ctx context.Context, client *Client, handle *sendHandle, key PeerKey, payloads []Payload) (netip.AddrPort, OfferID, Decision, error) {
used := m.ports.Port(key)
addr := netip.AddrPortFrom(handle.ip, effectivePort(used))
remoteID, decision, err := m.offerWatchingPorts(ctx, client, key, used, addr, payloads)
if err == nil {
m.storeRemote(handle, addr, remoteID)
return addr, remoteID, decision, nil
}
if ctx.Err() != nil || !transportFailure(err) {
return addr, remoteID, decision, err
}
graceCtx, cancel := context.WithTimeout(ctx, portSignalGrace)
port, changed := m.ports.Await(graceCtx, key, used)
cancel()
if !changed {
return addr, remoteID, decision, err
}
addr = netip.AddrPortFrom(handle.ip, effectivePort(port))
remoteID, decision, err = client.Offer(ctx, addr, payloads)
if err != nil {
return addr, remoteID, decision, err
}
m.storeRemote(handle, addr, remoteID)
return addr, remoteID, decision, nil
}
// offerWatchingPorts runs the offer while watching for a port advertisement that
// differs from the one in use; such an advertisement aborts the in-flight attempt.
func (m *Manager) offerWatchingPorts(ctx context.Context, client *Client, key PeerKey, used uint16, addr netip.AddrPort, payloads []Payload) (OfferID, Decision, error) {
watchCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
if _, changed := m.ports.Await(watchCtx, key, used); changed {
cancel()
}
}()
return client.Offer(watchCtx, addr, payloads)
}
func (m *Manager) storeRemote(handle *sendHandle, addr netip.AddrPort, remoteID OfferID) {
m.mu.Lock()
defer m.mu.Unlock()
handle.addr = addr
handle.remoteID = remoteID
}
func (m *Manager) failSend(ctx context.Context, id OfferID, err error) {
if ctx.Err() != nil {
m.finishTransfer(id, StateCancelled, "")
return
}
state := StateFailed
switch {
case errors.Is(err, ErrDeclined):
state = StateDeclined
case errors.Is(err, ErrExpired):
state = StateExpired
}
message := ""
reason := ReasonNone
if state == StateFailed {
message = err.Error()
if transportFailure(err) {
reason = ReasonUnreachable
}
}
m.finishTransferReason(id, state, message, reason)
m.emit(EventFailed, m.transferOf(id))
}
func (m *Manager) withdrawRemote(addr netip.AddrPort, remoteID OfferID) {
m.mu.Lock()
dial, senderName := m.dial, m.senderName
m.mu.Unlock()
if dial == nil {
return
}
client, err := NewClient(ClientConfig{Dial: dial, SenderName: senderName})
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := client.Cancel(ctx, addr, remoteID); err != nil {
log.Debugf("failed to withdraw file drop offer: %v", err)
}
}
// OnOffer implements Notifier for the receiver server.
func (m *Manager) OnOffer(offer Offer) {
transfer := Transfer{
ID: offer.ID,
Direction: DirectionReceived,
PeerKey: offer.Sender,
PeerName: offer.SenderName,
Files: offer.Files,
State: offer.State,
TotalSize: offer.TotalSize(),
CreatedAt: offer.CreatedAt,
}
m.history.Upsert(transfer)
if offer.Decision == DecisionPending {
m.emit(EventOffer, transfer)
}
if offer.Decision == DecisionAccepted && offer.State == StateCompleted {
m.OnCompleted(offer)
}
}
// OnProgress implements Notifier.
func (m *Manager) OnProgress(offer Offer, index int, received int64) {
var total int64
for i, n := range offer.Progress {
if i == index {
n = received
}
total += n
}
m.history.SetProgress(offer.ID, total)
}
// OnCompleted implements Notifier.
func (m *Manager) OnCompleted(offer Offer) {
m.mu.Lock()
server := m.server
m.mu.Unlock()
if server == nil {
return
}
transfer, ok := m.history.Get(offer.ID)
if !ok || transfer.State == StateCompleted {
return
}
delivered, err := deliver(server.Spool(), offer, m.policy.DestinationDir())
if err != nil {
log.Errorf("failed to deliver file drop payloads: %v", err)
m.finishTransfer(offer.ID, StateFailed, err.Error())
m.emit(EventFailed, m.transferOf(offer.ID))
return
}
transfer.State = StateCompleted
transfer.Transferred = transfer.TotalSize
transfer.DeliveredPaths = delivered
transfer.Error = ""
m.history.Upsert(transfer)
m.emit(EventCompleted, transfer)
}
// OnFailed implements Notifier.
func (m *Manager) OnFailed(offer Offer, err error) {
if errors.Is(err, ErrExpired) {
m.finishTransfer(offer.ID, StateExpired, "")
m.emit(EventWithdrawn, m.transferOf(offer.ID))
return
}
m.finishTransfer(offer.ID, StateFailed, err.Error())
m.emit(EventFailed, m.transferOf(offer.ID))
}
// OnWithdrawn implements Notifier: the sender cancelled, so the consent prompt goes away.
func (m *Manager) OnWithdrawn(offer Offer) {
m.finishTransfer(offer.ID, StateCancelled, "")
m.emit(EventWithdrawn, m.transferOf(offer.ID))
}
func (m *Manager) finishTransfer(id OfferID, state State, message string) {
m.finishTransferReason(id, state, message, ReasonNone)
}
func (m *Manager) finishTransferReason(id OfferID, state State, message string, reason FailureReason) {
transfer, ok := m.history.Get(id)
if !ok || transfer.terminal() {
return
}
transfer.State = state
transfer.Error = message
transfer.Reason = reason
m.history.Upsert(transfer)
}
func (m *Manager) transferOf(id OfferID) Transfer {
t, _ := m.history.Get(id)
return t
}
func (m *Manager) emit(kind EventKind, transfer Transfer) {
if m.events != nil && transfer.ID != "" {
m.events(kind, transfer)
}
}
func payloadMetas(payloads []Payload) []FileMeta {
metas := make([]FileMeta, len(payloads))
for i, p := range payloads {
metas[i] = p.Meta
}
return metas
}
func payloadTotal(payloads []Payload) int64 {
var total int64
for _, p := range payloads {
total += p.Meta.Size
}
return total
}
func effectivePort(advertised uint16) uint16 {
if advertised == 0 {
return Port
}
return advertised
}
// transportFailure reports whether the offer never reached the receiver; any HTTP
// response, refusal included, proves the port right and is not retried elsewhere.
func transportFailure(err error) bool {
var urlErr *url.Error
return errors.As(err, &urlErr)
}