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

263 lines
6.4 KiB
Go

package filedrop
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"sync"
"time"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/tun/netstack"
)
const (
defaultSpoolMaxAge = 24 * time.Hour
janitorInterval = 10 * time.Minute
readHeaderTimeout = 30 * time.Second
idleTimeout = 5 * time.Minute
)
// PeerResolver maps the source overlay address of a connection to the peer that owns it.
type PeerResolver interface {
ResolvePeer(addr netip.Addr) (key PeerKey, name string, ok bool)
}
// Notifier receives receiver-side transfer events for the platform layer to surface.
type Notifier interface {
OnOffer(offer Offer)
OnProgress(offer Offer, index int, received int64)
OnCompleted(offer Offer)
OnFailed(offer Offer, err error)
OnWithdrawn(offer Offer)
}
// ServerConfig configures the receiving side.
type ServerConfig struct {
SpoolDir string
Policy *PolicyStore
Resolver PeerResolver
Notifier Notifier
OfferTTL time.Duration
SpoolMaxAge time.Duration
}
// Server serves the receiver over HTTP on the overlay address and owns the
// listener and janitor lifecycle; the protocol logic itself lives in receiver.
type Server struct {
mu sync.RWMutex
httpServer *http.Server
listener net.Listener
extraListeners []net.Listener
netstackNet *netstack.Net
recv *receiver
boundPort uint16
janitorStop context.CancelFunc
janitorDone chan struct{}
}
// NewServer builds a receiving server. It does not start listening.
func NewServer(cfg ServerConfig) (*Server, error) {
if cfg.Resolver == nil {
return nil, errors.New("peer resolver is required")
}
if cfg.Policy == nil {
return nil, errors.New("receiving policy is required")
}
spool, err := NewSpool(cfg.SpoolDir)
if err != nil {
return nil, fmt.Errorf("create spool: %w", err)
}
maxAge := cfg.SpoolMaxAge
if maxAge <= 0 {
maxAge = defaultSpoolMaxAge
}
return &Server{recv: newReceiver(cfg, spool, maxAge)}, nil
}
// SetNetstackNet routes listeners through the gVisor netstack instead of host sockets.
func (s *Server) SetNetstackNet(n *netstack.Net) {
s.mu.Lock()
defer s.mu.Unlock()
s.netstackNet = n
}
// Offers returns the offer store, for the platform layer to accept, decline, and list.
func (s *Server) Offers() *OfferStore {
return s.recv.offers
}
// Spool returns the staging area, so the platform layer can deliver completed payloads.
func (s *Server) Spool() *Spool {
return s.recv.spool
}
// Policy returns the active profile's receiving policy store.
func (s *Server) Policy() *PolicyStore {
return s.recv.policy
}
// BoundPort returns the port the server actually listens on, 0 when stopped.
func (s *Server) BoundPort() uint16 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.boundPort
}
// Start binds the service to addr and serves until Stop.
func (s *Server) Start(ctx context.Context, addr netip.AddrPort) error {
s.mu.Lock()
if s.httpServer != nil {
s.mu.Unlock()
return errors.New("file drop server is already running")
}
ln, desc, err := s.createListener(ctx, addr)
if err != nil && addr.Port() != 0 {
log.Warnf("file drop port %d is unavailable, falling back to a dynamic port: %v", addr.Port(), err)
ln, desc, err = s.createListener(ctx, netip.AddrPortFrom(addr.Addr(), 0))
}
if err != nil {
s.mu.Unlock()
return fmt.Errorf("create listener: %w", err)
}
transport := &httpTransport{recv: s.recv}
httpServer := &http.Server{
Handler: transport.routes(),
ReadHeaderTimeout: readHeaderTimeout,
IdleTimeout: idleTimeout,
}
janitorCtx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
s.listener = ln
s.httpServer = httpServer
s.boundPort = listenerPort(ln, addr.Port())
s.janitorStop = cancel
s.janitorDone = done
s.mu.Unlock()
go s.runJanitor(janitorCtx, done)
go s.serve(httpServer, ln, desc)
log.Infof("file drop server started on %s", desc)
return nil
}
// AddListener serves the running service on an additional address, such as IPv6.
func (s *Server) AddListener(ctx context.Context, addr netip.AddrPort) error {
s.mu.Lock()
httpServer := s.httpServer
if httpServer == nil {
s.mu.Unlock()
return errors.New("file drop server is not running")
}
ln, desc, err := s.createListener(ctx, addr)
if err != nil {
s.mu.Unlock()
return fmt.Errorf("create listener: %w", err)
}
s.extraListeners = append(s.extraListeners, ln)
s.mu.Unlock()
go s.serve(httpServer, ln, desc)
log.Infof("file drop server also listening on %s", desc)
return nil
}
// Stop shuts the service down and releases the offers it was tracking. It is idempotent.
func (s *Server) Stop() error {
s.mu.Lock()
httpServer := s.httpServer
if httpServer == nil {
s.mu.Unlock()
return nil
}
s.httpServer = nil
s.listener = nil
s.boundPort = 0
extra := s.extraListeners
s.extraListeners = nil
stopJanitor, janitorDone := s.janitorStop, s.janitorDone
s.janitorStop, s.janitorDone = nil, nil
s.mu.Unlock()
if stopJanitor != nil {
stopJanitor()
<-janitorDone
}
err := httpServer.Close()
for _, ln := range extra {
if cerr := ln.Close(); cerr != nil {
log.Debugf("close extra file drop listener: %v", cerr)
}
}
s.recv.close()
if err != nil {
return fmt.Errorf("close: %w", err)
}
return nil
}
func (s *Server) serve(httpServer *http.Server, ln net.Listener, desc string) {
if err := httpServer.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Errorf("file drop server error on %s: %v", desc, err)
}
}
func (s *Server) createListener(ctx context.Context, addr netip.AddrPort) (net.Listener, string, error) {
if s.netstackNet != nil {
ln, err := s.netstackNet.ListenTCPAddrPort(addr)
if err != nil {
return nil, "", fmt.Errorf("listen on netstack: %w", err)
}
return ln, fmt.Sprintf("netstack %s", addr), nil
}
var lc net.ListenConfig
ln, err := lc.Listen(ctx, "tcp", net.TCPAddrFromAddrPort(addr).String())
if err != nil {
return nil, "", fmt.Errorf("listen: %w", err)
}
return ln, addr.String(), nil
}
func (s *Server) runJanitor(ctx context.Context, done chan struct{}) {
defer close(done)
ticker := time.NewTicker(janitorInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.recv.expireOverdue()
}
}
}
func listenerPort(ln net.Listener, requested uint16) uint16 {
if tcpAddr, ok := ln.Addr().(*net.TCPAddr); ok {
return uint16(tcpAddr.Port)
}
return requested
}