mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-24 16:41:30 +02:00
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.
413 lines
12 KiB
Go
413 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/netip"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/filedrop"
|
|
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
)
|
|
|
|
func (s *Server) fileDropManager() (*filedrop.Manager, error) {
|
|
activeProf, err := s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get active profile: %w", err)
|
|
}
|
|
if !profilemanager.IsValidProfileFilenameStem(activeProf.ID) {
|
|
return nil, fmt.Errorf("invalid profile ID %q", activeProf.ID)
|
|
}
|
|
|
|
s.mutex.Lock()
|
|
if s.fileDrop != nil && s.fileDrop.Profile() == activeProf.ID {
|
|
mgr := s.fileDrop
|
|
s.mutex.Unlock()
|
|
return mgr, nil
|
|
}
|
|
old := s.fileDrop
|
|
s.fileDrop = nil
|
|
s.mutex.Unlock()
|
|
|
|
if old != nil {
|
|
if err := old.Close(); err != nil {
|
|
log.Warnf("failed to close previous file drop manager: %v", err)
|
|
}
|
|
}
|
|
|
|
prefs, err := s.profileManager.ProfilePrefs(activeProf.ID, activeProf.Username)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve profile prefs: %w", err)
|
|
}
|
|
|
|
mgr, err := filedrop.NewManager(filedrop.ManagerConfig{
|
|
Profile: activeProf.ID,
|
|
DataDir: filepath.Join(profilemanager.DefaultConfigPathDir, "filedrop", activeProf.ID.String()),
|
|
Store: filedrop.NewProfileStore(prefs),
|
|
Events: s.publishFileDropEvent,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create file drop manager: %w", err)
|
|
}
|
|
|
|
seedFileDropDestination(mgr, activeProf.Username)
|
|
|
|
s.mutex.Lock()
|
|
if s.fileDrop != nil && s.fileDrop.Profile() == activeProf.ID {
|
|
winner := s.fileDrop
|
|
s.mutex.Unlock()
|
|
_ = mgr.Close()
|
|
return winner, nil
|
|
}
|
|
s.fileDrop = mgr
|
|
s.mutex.Unlock()
|
|
return mgr, nil
|
|
}
|
|
|
|
func (s *Server) publishFileDropEvent(kind filedrop.EventKind, transfer filedrop.Transfer) {
|
|
s.mutex.Lock()
|
|
statusRecorder := s.statusRecorder
|
|
s.mutex.Unlock()
|
|
if statusRecorder == nil {
|
|
return
|
|
}
|
|
|
|
var metaKind, userMsg string
|
|
switch kind {
|
|
case filedrop.EventOffer:
|
|
metaKind = proto.MetadataKindFileDropOffer
|
|
userMsg = fmt.Sprintf("%s wants to send you %s", transfer.PeerName, transferLabel(transfer))
|
|
case filedrop.EventCompleted:
|
|
metaKind = proto.MetadataKindFileDropCompleted
|
|
if transfer.Direction == filedrop.DirectionSent {
|
|
userMsg = fmt.Sprintf("Sent %s to %s", transferLabel(transfer), transfer.PeerName)
|
|
} else {
|
|
userMsg = fmt.Sprintf("Received %s from %s", transferLabel(transfer), transfer.PeerName)
|
|
}
|
|
case filedrop.EventFailed:
|
|
metaKind = proto.MetadataKindFileDropFailed
|
|
userMsg = fmt.Sprintf("Transfer of %s failed", transferLabel(transfer))
|
|
case filedrop.EventWithdrawn:
|
|
metaKind = proto.MetadataKindFileDropWithdrawn
|
|
default:
|
|
return
|
|
}
|
|
|
|
statusRecorder.PublishEvent(
|
|
proto.SystemEvent_INFO,
|
|
proto.SystemEvent_SYSTEM,
|
|
"file drop transfer update",
|
|
userMsg,
|
|
map[string]string{
|
|
proto.MetadataKindKey: metaKind,
|
|
proto.MetadataFileDropTransferKey: string(transfer.ID),
|
|
proto.MetadataFileDropPeerKey: string(transfer.PeerKey),
|
|
},
|
|
)
|
|
}
|
|
|
|
// FileDropSend starts an asynchronous transfer to a peer.
|
|
func (s *Server) FileDropSend(ctx context.Context, req *proto.FileDropSendRequest) (*proto.FileDropSendResponse, error) {
|
|
mgr, err := s.fileDropManager()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mutex.Lock()
|
|
statusRecorder := s.statusRecorder
|
|
s.mutex.Unlock()
|
|
if statusRecorder == nil {
|
|
return nil, errors.New("not connected")
|
|
}
|
|
|
|
peerState, err := statusRecorder.GetPeer(req.GetPeerKey())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unknown peer: %w", err)
|
|
}
|
|
addr, err := netip.ParseAddr(peerState.IP)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse peer address: %w", err)
|
|
}
|
|
|
|
payloads, err := s.buildFileDropPayloads(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id, err := mgr.Send(filedrop.PeerKey(req.GetPeerKey()), peerState.FQDN, addr.Unmap(), payloads)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &proto.FileDropSendResponse{TransferId: string(id)}, nil
|
|
}
|
|
|
|
func (s *Server) buildFileDropPayloads(ctx context.Context, req *proto.FileDropSendRequest) ([]filedrop.Payload, error) {
|
|
var payloads []filedrop.Payload
|
|
|
|
if len(req.GetPaths()) > 0 {
|
|
caller, ok := ipcauth.CallerIdentity(ctx)
|
|
if !ok {
|
|
return nil, errors.New("caller identity required to send files")
|
|
}
|
|
for _, path := range req.GetPaths() {
|
|
payload, err := fileDropPayload(caller, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payloads = append(payloads, payload)
|
|
}
|
|
}
|
|
|
|
if text := req.GetText(); text != "" {
|
|
if len(text) > filedrop.MaxInlineTextSize {
|
|
return nil, fmt.Errorf("text exceeds %d bytes", filedrop.MaxInlineTextSize)
|
|
}
|
|
payloads = append(payloads, filedrop.TextPayload("text", text))
|
|
}
|
|
|
|
if len(payloads) == 0 {
|
|
return nil, errors.New("nothing to send")
|
|
}
|
|
return payloads, nil
|
|
}
|
|
|
|
// FileDropDecide accepts or declines a pending incoming offer.
|
|
func (s *Server) FileDropDecide(_ context.Context, req *proto.FileDropDecideRequest) (*proto.FileDropDecideResponse, error) {
|
|
mgr, err := s.fileDropManager()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id := filedrop.OfferID(req.GetTransferId())
|
|
if req.GetAccept() {
|
|
err = mgr.Accept(id)
|
|
} else {
|
|
err = mgr.Decline(id)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &proto.FileDropDecideResponse{}, nil
|
|
}
|
|
|
|
// FileDropCancel aborts a transfer in either direction.
|
|
func (s *Server) FileDropCancel(_ context.Context, req *proto.FileDropCancelRequest) (*proto.FileDropCancelResponse, error) {
|
|
mgr, err := s.fileDropManager()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mgr.Cancel(filedrop.OfferID(req.GetTransferId()))
|
|
return &proto.FileDropCancelResponse{}, nil
|
|
}
|
|
|
|
// FileDropListTransfers returns the transfer history, newest first.
|
|
func (s *Server) FileDropListTransfers(context.Context, *proto.FileDropListTransfersRequest) (*proto.FileDropListTransfersResponse, error) {
|
|
mgr, err := s.fileDropManager()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
transfers := mgr.Transfers()
|
|
resp := &proto.FileDropListTransfersResponse{
|
|
Transfers: make([]*proto.FileDropTransfer, 0, len(transfers)),
|
|
}
|
|
for _, t := range transfers {
|
|
resp.Transfers = append(resp.Transfers, fileDropTransferToProto(t))
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// FileDropDeleteTransfer removes one entry from the transfer history.
|
|
func (s *Server) FileDropDeleteTransfer(_ context.Context, req *proto.FileDropDeleteTransferRequest) (*proto.FileDropDeleteTransferResponse, error) {
|
|
mgr, err := s.fileDropManager()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mgr.DeleteTransfer(filedrop.OfferID(req.GetTransferId()))
|
|
return &proto.FileDropDeleteTransferResponse{}, nil
|
|
}
|
|
|
|
// FileDropGetSettings returns the active profile's receiving policy.
|
|
func (s *Server) FileDropGetSettings(context.Context, *proto.FileDropGetSettingsRequest) (*proto.FileDropGetSettingsResponse, error) {
|
|
mgr, err := s.fileDropManager()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
policy := mgr.Policy().Get()
|
|
rules := make(map[string]proto.FileDropRule, len(policy.Senders))
|
|
for key, rule := range policy.Senders {
|
|
rules[string(key)] = proto.FileDropRule(rule)
|
|
}
|
|
|
|
return &proto.FileDropGetSettingsResponse{
|
|
Mode: proto.FileDropMode(policy.Mode),
|
|
DestinationDir: mgr.DestinationDir(),
|
|
PeerRules: rules,
|
|
}, nil
|
|
}
|
|
|
|
// FileDropSetSettings updates the active profile's receiving policy.
|
|
func (s *Server) FileDropSetSettings(ctx context.Context, req *proto.FileDropSetSettingsRequest) (*proto.FileDropSetSettingsResponse, error) {
|
|
mgr, err := s.fileDropManager()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
policy := mgr.Policy().Get()
|
|
policy.Mode = filedrop.Mode(req.GetMode())
|
|
if err := mgr.Policy().Set(policy); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if dir := req.GetDestinationDir(); dir != mgr.DestinationDir() {
|
|
if err := s.validateFileDropDestination(ctx, dir); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := mgr.SetDestinationDir(dir); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &proto.FileDropSetSettingsResponse{}, nil
|
|
}
|
|
|
|
func (s *Server) validateFileDropDestination(ctx context.Context, dir string) error {
|
|
if dir == "" {
|
|
return nil
|
|
}
|
|
if !filepath.IsAbs(dir) {
|
|
return errors.New("destination must be an absolute path")
|
|
}
|
|
|
|
caller, ok := ipcauth.CallerIdentity(ctx)
|
|
if !ok {
|
|
return errors.New("caller identity required to change the destination")
|
|
}
|
|
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
return fmt.Errorf("destination: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return errors.New("destination is not a directory")
|
|
}
|
|
return checkDirOwnership(caller, dir, info)
|
|
}
|
|
|
|
// FileDropSetPeerRule sets or clears a per-sender exception.
|
|
func (s *Server) FileDropSetPeerRule(_ context.Context, req *proto.FileDropSetPeerRuleRequest) (*proto.FileDropSetPeerRuleResponse, error) {
|
|
mgr, err := s.fileDropManager()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := mgr.SetSenderRule(filedrop.PeerKey(req.GetPeerKey()), filedrop.SenderRule(req.GetRule())); err != nil {
|
|
return nil, err
|
|
}
|
|
return &proto.FileDropSetPeerRuleResponse{}, nil
|
|
}
|
|
|
|
func fileDropTransferToProto(t filedrop.Transfer) *proto.FileDropTransfer {
|
|
files := make([]*proto.FileDropFile, 0, len(t.Files))
|
|
for _, f := range t.Files {
|
|
files = append(files, &proto.FileDropFile{
|
|
Name: f.Name,
|
|
Size: f.Size,
|
|
ContentType: f.ContentType,
|
|
IsText: f.Kind == filedrop.KindText,
|
|
Text: f.Text,
|
|
})
|
|
}
|
|
|
|
return &proto.FileDropTransfer{
|
|
Id: string(t.ID),
|
|
Outgoing: t.Direction == filedrop.DirectionSent,
|
|
PeerKey: string(t.PeerKey),
|
|
PeerName: t.PeerName,
|
|
Files: files,
|
|
State: proto.FileDropState(t.State),
|
|
Transferred: t.Transferred,
|
|
TotalSize: t.TotalSize,
|
|
CreatedAt: timestamppb.New(t.CreatedAt),
|
|
UpdatedAt: timestamppb.New(t.UpdatedAt),
|
|
DeliveredPaths: t.DeliveredPaths,
|
|
Error: t.Error,
|
|
Reason: proto.FileDropReason(t.Reason),
|
|
}
|
|
}
|
|
|
|
func transferLabel(t filedrop.Transfer) string {
|
|
if len(t.Files) == 1 {
|
|
return t.Files[0].Name
|
|
}
|
|
return fmt.Sprintf("%d files", len(t.Files))
|
|
}
|
|
|
|
func fileDropPayload(caller ipcauth.Identity, path string) (filedrop.Payload, error) {
|
|
f, err := ipcauth.OpenOwnedFile(caller, path)
|
|
if err != nil {
|
|
return filedrop.Payload{}, fmt.Errorf("open %s: %w", path, err)
|
|
}
|
|
info, err := f.Stat()
|
|
closeErr := f.Close()
|
|
if err != nil {
|
|
return filedrop.Payload{}, fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
if closeErr != nil {
|
|
return filedrop.Payload{}, fmt.Errorf("close %s: %w", path, closeErr)
|
|
}
|
|
|
|
return filedrop.Payload{
|
|
Meta: filedrop.FileMeta{
|
|
Name: filepath.Base(path),
|
|
Size: info.Size(),
|
|
ContentType: mime.TypeByExtension(filepath.Ext(path)),
|
|
},
|
|
Open: func(offset int64) (io.ReadCloser, error) {
|
|
f, err := ipcauth.OpenOwnedFile(caller, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
|
_ = f.Close()
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// seedFileDropDestination gives a profile a delivery directory on first use, so
|
|
// a received file always has somewhere to land. Resolved from the profile's own
|
|
// user rather than the process: the daemon runs as root, whose home is not where
|
|
// the user would look for their downloads.
|
|
func seedFileDropDestination(mgr *filedrop.Manager, username string) {
|
|
if mgr.DestinationDir() != "" {
|
|
return
|
|
}
|
|
|
|
u, err := user.Lookup(username)
|
|
if err != nil {
|
|
log.Warnf("cannot resolve home of %s for the file drop destination: %v", username, err)
|
|
return
|
|
}
|
|
if u.HomeDir == "" {
|
|
log.Warnf("user %s has no home directory for the file drop destination", username)
|
|
return
|
|
}
|
|
|
|
dir := filepath.Join(u.HomeDir, "Downloads", "NetBird")
|
|
if err := mgr.SetDestinationDir(dir); err != nil {
|
|
log.Warnf("failed to set the default file drop destination: %v", err)
|
|
}
|
|
}
|