Files
netbird/client/internal/filedrop/manager.go
T
Zoltán Papp 41906d4a1a [client] Pin the file drop port over the tunnel
The file drop server listened on 41421, which falls inside the ephemeral
port range on Linux (32768-60999) and Windows (49152-65535), so any
outbound connection could take it after boot. The receiver then bound a
dynamic port and advertised it over signaling, and the sender waited for
that advertisement before retrying.

That coupled a data plane feature to signaling traffic: once a peer
connection is established there is no reason for another offer or answer
to go out, so a sender could wait out the grace period for an
advertisement that never came.

Move the port to 22042, next to the SSH (22022) and DNS forwarder (22054)
ports and clear of both ephemeral ranges, and keep the tunnel side fixed
the way SSH does. A receiver that cannot bind it falls back to a dynamic
port and redirects 22042 to it with an inbound DNAT rule, so senders
always dial the well known port and never negotiate. NB_FILEDROP_PORT
overrides the local bind only.

The DNAT runs ahead of the filter on every backend (nftables prerouting
at NAT dest priority, iptables nat/PREROUTING, and the userspace filter's
translate-then-redecode path), so the netstack service registry keeps
taking the bound port.

This drops the port registry, the retry that waited on it, and the
signaling plumbing that fed it.
2026-08-25 18:51:21 +02:00

615 lines
15 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 one of them, thinned to a few reports a second
// by the reader that raises it; see progressInterval.
const (
EventOffer EventKind = iota
EventCompleted
EventFailed
EventWithdrawn
EventProgress
)
// 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
Sink Sink
}
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
sink Sink
server *Server
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,
sink: cfg.Sink,
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
}
// 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 where received files are delivered: the platform
// sink's own name for it when one stages payloads, otherwise the configured
// directory.
func (m *Manager) DestinationDir() string {
if labeller, ok := m.sink.(interface{ DestinationLabel() string }); ok {
return labeller.DestinationLabel()
}
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"),
Sink: m.sink,
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().Revoke(id); 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 := netip.AddrPortFrom(handle.ip, Port)
remoteID, decision, err := client.Offer(ctx, addr, payloads)
if err != nil {
m.failSend(ctx, transfer.ID, err)
return
}
m.storeRemote(handle, addr, remoteID)
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)
m.emit(EventProgress, m.transferOf(transfer.ID))
}
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))
}
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 {
// Withdrawn mid-transfer reads the same way as refused up front: the
// receiver said no, which is an answer rather than a failure.
case errors.Is(err, ErrDeclined), errors.Is(err, ErrNotAccepted),
errors.Is(err, ErrOfferNotFound):
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)
m.emit(EventProgress, m.transferOf(offer.ID))
}
// 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 := server.Spool().Deliver(offer, m.policy.DestinationDir())
if err != nil {
log.Errorf("failed to deliver file drop payloads: %v", err)
// Nothing will ask for these payloads again, and a sink that cannot
// address them by path has no sweep of its own to fall back on.
server.Spool().Remove(offer.ID)
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
}
// transportFailure reports whether the offer never reached the receiver; any HTTP
// response, refusal included, means the receiver answered.
func transportFailure(err error) bool {
var urlErr *url.Error
return errors.As(err, &urlErr)
}